SCUMM-style web adventure engine v0.1
Headless, data-driven core (no DOM, no eval): loader with one-pass validation, verb-coin interaction resolution, polygon walkables with Dijkstra pathfinding, branching dialogue with mid-conversation saves, declarative JSON conditions/effects, versioned JSON saves. Thin integration layer: asset manifest with graceful placeholders, WebAudio music loop points (midi-playlist convention), canvas renderer (hotspot boxes drawn where hitTest clicks), HUD model. Web harness (web/) + skeleton example game exercising every feature. 69 tests (node --test), tools/run_all.sh gate, docs/SPEC.md design spec. Verified end-to-end in a real browser: intro scene, walking, look/take, room exits, dialogue choices.
This commit is contained in:
1 parent
ea0df1d475
commit
a4a2348a54
32 files changed
+2849
-1
No files matched your search
@@ -0,0 +1,150 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Engine } from "../src/core/engine.js";
|
||||
|
||||
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
|
||||
|
||||
function fresh() {
|
||||
let now = 0;
|
||||
const e = new Engine(structuredClone(example), { clock: () => now });
|
||||
const events = [];
|
||||
e.emitter.on("*", (ev) => events.push(ev));
|
||||
e.newGame();
|
||||
const advance = (ms) => { now += ms; e.tick(ms); };
|
||||
return { e, events, advance };
|
||||
}
|
||||
|
||||
test("intro scene runs on the clock and lands the player in the start room", () => {
|
||||
const { e, events, advance } = fresh();
|
||||
e.startScene("intro");
|
||||
assert.ok(events.some((ev) => ev.event === "playMusic" && ev.track === "calypso"));
|
||||
assert.ok(events.some((ev) => ev.event === "slide"));
|
||||
advance(4000); // slide done
|
||||
advance(3500); // text done -> goto jetty
|
||||
assert.equal(e.scene, null);
|
||||
assert.equal(e.world.room, "jetty");
|
||||
assert.equal(e.world.seenScenes.intro, true);
|
||||
});
|
||||
|
||||
test("scene skip jumps to the final goto", () => {
|
||||
const { e } = fresh();
|
||||
e.startScene("intro");
|
||||
e.skipScene();
|
||||
assert.equal(e.scene, null);
|
||||
assert.equal(e.world.room, "jetty");
|
||||
});
|
||||
|
||||
test("clicking a walkable point schedules a walk that completes over time", () => {
|
||||
const { e, advance } = fresh();
|
||||
const p = e.world.characters.player;
|
||||
const startX = p.x;
|
||||
e.clickPoint(400, 380);
|
||||
assert.ok(e.walk, "walk scheduled");
|
||||
advance(1000); // 80 px/s -> 80px
|
||||
assert.ok(p.x > startX);
|
||||
advance(5000);
|
||||
assert.equal(e.walk, null);
|
||||
assert.ok(Math.abs(p.x - 400) < 1, `arrived at x=400, got ${p.x}`);
|
||||
});
|
||||
|
||||
test("click outside the walkable area clamps to the nearest edge", () => {
|
||||
const { e, advance } = fresh();
|
||||
e.clickPoint(700, 380); // beyond the jetty polygon (x<=600)
|
||||
advance(10000);
|
||||
const p = e.world.characters.player;
|
||||
assert.ok(p.x <= 600 && p.x >= 590, `clamped near x=600, got ${p.x}`);
|
||||
});
|
||||
|
||||
test("verb + object click: take the mug, then it leaves the room", () => {
|
||||
const { e } = fresh();
|
||||
e.selectVerb("use");
|
||||
const resp = e.click({ kind: "object", id: "mug", tags: ["container"] });
|
||||
assert.ok(resp.includes("pocket"));
|
||||
assert.deepEqual(e.world.inventory, ["mug"]);
|
||||
assert.ok(!e.world.objectsHere().some((o) => o.id === "mug"), "taken object leaves the room");
|
||||
});
|
||||
|
||||
test("unmatched verb gets the default response — the engine always answers", () => {
|
||||
const { e } = fresh();
|
||||
e.selectVerb("give");
|
||||
const resp = e.click({ kind: "object", id: "post", tags: [] });
|
||||
assert.equal(resp, "They're not interested.");
|
||||
});
|
||||
|
||||
test("walk verb + exit click changes rooms and fires onEnter (music)", () => {
|
||||
const { e, events } = fresh();
|
||||
e.selectVerb("walk");
|
||||
e.click({ kind: "exit", id: "tavern", tags: ["exit"] });
|
||||
assert.equal(e.world.room, "tavern");
|
||||
assert.equal(e.world.characters.player.room, "tavern");
|
||||
assert.ok(events.some((ev) => ev.event === "roomEnter" && ev.room === "tavern"));
|
||||
});
|
||||
|
||||
test("talk verb starts the dialogue; clicks are swallowed mid-dialogue", () => {
|
||||
const { e } = fresh();
|
||||
e.goTo("tavern");
|
||||
e.selectVerb("talk");
|
||||
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
|
||||
assert.equal(e.dialogue.active, "barkeep_intro");
|
||||
const before = e.world.flags;
|
||||
const resp = e.click({ kind: "object", id: "post", tags: [] });
|
||||
assert.equal(resp, null, "clicks ignored during dialogue");
|
||||
assert.deepEqual(e.world.flags, before);
|
||||
});
|
||||
|
||||
test("dialogue choice with give-mug chain: full interaction flow", () => {
|
||||
const { e, advance } = fresh();
|
||||
e.selectVerb("use");
|
||||
e.click({ kind: "object", id: "mug", tags: ["container"] }); // take mug
|
||||
e.goTo("tavern");
|
||||
e.selectVerb("give");
|
||||
const resp = e.click({ kind: "character", id: "barkeep", tags: ["talker"] }, "mug");
|
||||
assert.ok(resp.includes("very quiet"));
|
||||
assert.deepEqual(e.world.inventory, []);
|
||||
assert.equal(e.world.flags.gaveMug, true);
|
||||
assert.equal(e.world.vars.barkeepTrust, 1);
|
||||
});
|
||||
|
||||
test("hitTest finds characters, objects, exits, then walkable", () => {
|
||||
const { e } = fresh();
|
||||
assert.equal(e.hitTest(500, 340).id, "post");
|
||||
assert.equal(e.hitTest(220, 375).id, "mug");
|
||||
assert.equal(e.hitTest(580, 340).kind, "exit");
|
||||
assert.equal(e.hitTest(300, 420).kind, "walkable");
|
||||
e.goTo("tavern");
|
||||
assert.equal(e.hitTest(400, 300).id, "barkeep");
|
||||
});
|
||||
|
||||
test("endGame effect ends the run and blocks further clicks", () => {
|
||||
const spec = structuredClone(example);
|
||||
spec.interactions.push({ verb: "use", noun: "post", response: "The world ends.",
|
||||
effects: [{ endGame: "win" }] });
|
||||
const e = new Engine(spec, { clock: () => 0 });
|
||||
e.newGame();
|
||||
e.selectVerb("use");
|
||||
e.click({ kind: "object", id: "post", tags: [] });
|
||||
assert.equal(e.phase, "ended");
|
||||
assert.equal(e.world.ended, "win");
|
||||
assert.equal(e.click({ kind: "object", id: "post", tags: [] }), null);
|
||||
});
|
||||
|
||||
test("room onEnter effects fire on entry", () => {
|
||||
const spec = structuredClone(example);
|
||||
spec.rooms[1].onEnter = [{ setFlag: "visitedTavern" }];
|
||||
const e = new Engine(spec, { clock: () => 0 });
|
||||
e.newGame();
|
||||
e.goTo("tavern");
|
||||
assert.equal(e.world.flags.visitedTavern, true);
|
||||
});
|
||||
|
||||
test("newGame resets everything", () => {
|
||||
const { e } = fresh();
|
||||
e.selectVerb("use");
|
||||
e.click({ kind: "object", id: "mug", tags: ["container"] });
|
||||
e.goTo("tavern");
|
||||
e.newGame();
|
||||
assert.equal(e.world.room, "jetty");
|
||||
assert.deepEqual(e.world.inventory, []);
|
||||
assert.equal(e.world.flags.tookMug, undefined);
|
||||
});
|
||||
Reference in new issue
Block a user