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,123 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { AssetManifest } from "../src/render/assets.js";
|
||||
import { HeadlessAudio } from "../src/render/audio.js";
|
||||
import { RoomRenderer } from "../src/render/canvas.js";
|
||||
import { HudModel } from "../src/render/hud.js";
|
||||
import { Engine } from "../src/core/engine.js";
|
||||
|
||||
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
|
||||
|
||||
// ---- assets ---------------------------------------------------------------
|
||||
test("asset manifest resolves and degrades gracefully", () => {
|
||||
const am = new AssetManifest(example.assets);
|
||||
assert.equal(am.get("bg_jetty").file, "assets/img/jetty.png");
|
||||
const missing = am.get("nope");
|
||||
assert.equal(missing.missing, true);
|
||||
assert.equal(am.get("nope").missing, true); // second call: no crash
|
||||
});
|
||||
|
||||
test("animation frame cycling", () => {
|
||||
const am = new AssetManifest([{ id: "p", file: "p.png", frames: { walk: [0, 1, 1, 0] }, anim: { walk: 100 } }]);
|
||||
const a = am.get("p");
|
||||
assert.equal(am.frameFor(a, "walk", 0), 0);
|
||||
assert.equal(am.frameFor(a, "walk", 100), 1);
|
||||
assert.equal(am.frameFor(a, "walk", 300), 0);
|
||||
assert.equal(am.frameFor(a, "unknownAnim", 0), 0); // falls back
|
||||
});
|
||||
|
||||
// ---- audio ----------------------------------------------------------------
|
||||
test("headless audio records calls and preserves loop points", () => {
|
||||
const au = new HeadlessAudio();
|
||||
au.playMusic({ id: "calypso", file: "c.ogg", loopStartMs: 8460, loopEndMs: 76920 });
|
||||
au.playSfx({ id: "click", file: "k.ogg" });
|
||||
au.setVolume(2); // clamped
|
||||
assert.deepEqual(au.calls[0], { op: "playMusic", id: "calypso", file: "c.ogg",
|
||||
loopStartMs: 8460, loopEndMs: 76920 });
|
||||
assert.equal(au.calls[1].op, "playSfx");
|
||||
assert.equal(au.volume, 1);
|
||||
au.stopMusic();
|
||||
assert.equal(au.current, null);
|
||||
});
|
||||
|
||||
// ---- canvas renderer (fake recording context) ------------------------------
|
||||
function fakeCtx() {
|
||||
const ops = [];
|
||||
const noop = (name) => (...args) => ops.push({ op: name, args });
|
||||
return { ops, drawImage: noop("drawImage"), fillRect: noop("fillRect"),
|
||||
fillText: noop("fillText"), beginPath: noop("beginPath"),
|
||||
lineTo: noop("lineTo"), moveTo: noop("moveTo"), closePath: noop("closePath"),
|
||||
fill: noop("fill"), save: noop("save"), restore: noop("restore") };
|
||||
}
|
||||
|
||||
test("renderer draws bg, placeholders, and exit labels", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const am = new AssetManifest(example.assets);
|
||||
const rr = new RoomRenderer(e.compiled, am, new Map()); // no images loaded
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0 });
|
||||
assert.ok(ctx.ops.some((o) => o.op === "fillRect")); // bg placeholder
|
||||
assert.ok(ctx.ops.some((o) => o.op === "fillText" && o.args[0] === "Tavern"));
|
||||
// player + objects drawn as labelled placeholders
|
||||
const labels = ctx.ops.filter((o) => o.op === "fillText").map((o) => o.args[0]);
|
||||
assert.ok(labels.includes("player"));
|
||||
assert.ok(labels.includes("post"));
|
||||
assert.ok(labels.includes("mug"));
|
||||
});
|
||||
|
||||
test("renderer uses loaded images when present", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const am = new AssetManifest(example.assets);
|
||||
const images = new Map([["bg_jetty", { width: 640, height: 480 }]]);
|
||||
const rr = new RoomRenderer(e.compiled, am, images);
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0 });
|
||||
assert.ok(ctx.ops.some((o) => o.op === "drawImage"));
|
||||
});
|
||||
|
||||
test("object placeholder boxes match hotspot rects (see-what-you-click)", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0 });
|
||||
const rects = ctx.ops.filter((o) => o.op === "fillRect").map((o) => o.args);
|
||||
// post hotspot {480,300,40,90} and mug {200,360,40,30} drawn exactly
|
||||
assert.ok(rects.some((r) => r[0] === 480 && r[1] === 300 && r[2] === 40 && r[3] === 90),
|
||||
`post box not at hotspot: ${JSON.stringify(rects)}`);
|
||||
assert.ok(rects.some((r) => r[0] === 200 && r[1] === 360 && r[2] === 40 && r[3] === 30),
|
||||
`mug box not at hotspot: ${JSON.stringify(rects)}`);
|
||||
});
|
||||
|
||||
test("walkable debug draws polygons", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0, debugWalkable: true });
|
||||
assert.ok(ctx.ops.some((o) => o.op === "closePath"));
|
||||
});
|
||||
|
||||
// ---- HUD model --------------------------------------------------------------
|
||||
test("hud exposes verbs, inventory, dialogue choices, save gate", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const hud = new HudModel(e);
|
||||
assert.equal(hud.verbs().length, 5);
|
||||
e.selectVerb("look");
|
||||
assert.equal(hud.verbs().find((v) => v.id === "look").selected, true);
|
||||
assert.deepEqual(hud.inventory(), []);
|
||||
e.selectVerb("use");
|
||||
e.click({ kind: "object", id: "mug", tags: ["container"] });
|
||||
assert.deepEqual(hud.inventory(), [{ id: "mug", name: "a suspicious mug" }]);
|
||||
assert.equal(hud.canSave(), true);
|
||||
e.goTo("tavern");
|
||||
e.selectVerb("talk");
|
||||
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
|
||||
assert.equal(hud.inDialogue(), true);
|
||||
assert.equal(hud.dialogueChoices().length, 2);
|
||||
assert.equal(hud.canSave(), false, "no saving mid-dialogue");
|
||||
});
|
||||
Reference in new issue
Block a user