123 lines
5.5 KiB
JavaScript
123 lines
5.5 KiB
JavaScript
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");
|
||
|
|
});
|