Files
Kevin Hermes a4a2348a54 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.
2026-10-18 00:29:41 +00:00

166 lines
6.2 KiB
JavaScript

// boot.js — the web layer: DOM, canvas, WebAudio. All game logic lives in
// src/; this file only binds engine events to browser things.
import { Engine } from "../src/core/engine.js";
import { AssetManifest } from "../src/render/assets.js";
import { RoomRenderer } from "../src/render/canvas.js";
import { HudModel } from "../src/render/hud.js";
const spec = await (await fetch("/example/game.json")).json();
const engine = new Engine(spec);
window.__engine = engine; // dev/debug hook
const assets = new AssetManifest(spec.assets ?? []);
// ---- images ---------------------------------------------------------------
const images = new Map();
await Promise.all((spec.assets ?? [])
.filter((a) => /\.(png|jpe?g|webp|gif)$/i.test(a.file))
.map((a) => new Promise((res) => {
const img = new Image();
img.onload = () => { images.set(a.id, img); res(); };
img.onerror = () => res(); // placeholder boxes cover missing art
img.src = "/" + a.file;
})));
const renderer = new RoomRenderer(engine.compiled, assets, images);
const hud = new HudModel(engine);
// ---- audio (WebAudio, with loop points like the MIDI convention) ---------
let actx = null, musicNodes = [], master = null;
const gain = () => {
if (!master) { master = actx.createGain(); master.connect(actx.destination); }
master.gain.value = +volEl.value;
return master;
};
const buffers = new Map();
async function bufferFor(def) {
if (buffers.has(def.id)) return buffers.get(def.id);
const r = await fetch("/" + def.file);
if (!r.ok) { console.warn("[audio] missing file:", def.file); buffers.set(def.id, null); return null; }
const buf = await actx.decodeAudioData(await r.arrayBuffer());
buffers.set(def.id, buf);
return buf;
}
function stopMusic() { for (const n of musicNodes) { try { n.stop(); } catch {} } musicNodes = []; }
async function playMusic(def) {
stopMusic();
const buf = await bufferFor(def);
if (!buf) return;
const src = actx.createBufferSource();
src.buffer = buf;
if (def.loopStartMs != null && def.loopEndMs != null) {
src.loop = true; src.loopStart = def.loopStartMs / 1000; src.loopEnd = def.loopEndMs / 1000;
src.start(0, 0, def.loopStartMs / 1000); // play intro head once
const later = actx.createBufferSource(); // then the loop body forever
later.buffer = buf; later.loop = true;
later.loopStart = def.loopStartMs / 1000; later.loopEnd = def.loopEndMs / 1000;
later.start(actx.currentTime + def.loopStartMs / 1000, def.loopStartMs / 1000);
const g = gain(); src.connect(g); later.connect(g);
musicNodes.push(src, later);
} else {
src.loop = true;
const g = gain(); src.connect(g); src.start();
musicNodes.push(src);
}
}
async function playSfx(def) {
const buf = await bufferFor(def);
if (!buf) return;
const src = actx.createBufferSource(); src.buffer = buf;
src.connect(gain()); src.start();
}
engine.emitter.on("playMusic", async (ev) => {
const def = (spec.music ?? []).find((m) => m.id === ev.track);
if (def && actx) await playMusic(def);
});
engine.emitter.on("stopMusic", stopMusic);
engine.emitter.on("playSfx", async (ev) => {
const def = (spec.sfx ?? []).find((s) => s.id === ev.sfx);
if (def && actx) await playSfx(def);
});
// ---- DOM binding ----------------------------------------------------------
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
const verbsEl = document.getElementById("verbs");
const textbox = document.getElementById("textbox");
const squares = document.getElementById("squares");
const titleEl = document.getElementById("title");
const volEl = document.getElementById("vol");
function drawVerbs() {
verbsEl.innerHTML = "";
for (const v of hud.verbs()) {
const b = document.createElement("button");
b.textContent = v.label;
b.className = v.selected ? "selected" : "";
b.onclick = () => { engine.selectVerb(v.id); drawVerbs(); };
verbsEl.appendChild(b);
}
}
function drawSquares() {
squares.innerHTML = "";
for (const c of hud.dialogueChoices()) {
const b = document.createElement("button");
b.textContent = c.text;
b.onclick = () => { engine.chooseDialogue(c.index); drawSquares(); };
squares.appendChild(b);
}
}
engine.emitter.on("say", (ev) => { textbox.textContent = ev.text; });
engine.emitter.on("dialogueLine", (ev) => {
textbox.textContent = (ev.speaker ? ev.speaker.toUpperCase() + ": " : "") + ev.text;
drawSquares();
});
engine.emitter.on("dialogueEnd", () => drawSquares());
engine.emitter.on("roomEnter", () => { textbox.textContent = ""; drawSquares(); });
cv.addEventListener("click", (e) => {
const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) * (cv.width / r.width);
const y = (e.clientY - r.top) * (cv.height / r.height);
if (engine.selectedVerb) {
const t = engine.hitTest(x, y);
if (t.kind !== "walkable") engine.click(t);
else engine.clickPoint(x, y);
} else {
engine.clickPoint(x, y);
}
});
// keyboard: number keys pick verbs, Esc clears, Space skips
addEventListener("keydown", (e) => {
if (titleEl.style.display !== "none") return begin();
const n = +e.key;
if (n >= 1 && n <= hud.verbs().length) { engine.selectVerb(hud.verbs()[n - 1].id); drawVerbs(); }
if (e.key === "Escape") { engine.selectVerb(null); drawVerbs(); }
if (e.key === " " && engine.scene) engine.skipScene();
});
document.getElementById("skip").onclick = () => engine.scene && engine.skipScene();
document.getElementById("save").onclick = () => {
if (hud.canSave()) localStorage.setItem("moonlit-save", engine.save());
};
document.getElementById("load").onclick = () => {
const s = localStorage.getItem("moonlit-save");
if (s) { engine.load(s); titleEl.style.display = "none"; }
};
volEl.oninput = () => { if (master) master.gain.value = +volEl.value; };
function begin() {
titleEl.style.display = "none";
actx ??= new AudioContext();
engine.newGame();
engine.startScene("intro");
}
titleEl.onclick = begin;
// ---- main loop ------------------------------------------------------------
let last = performance.now();
function frame(now) {
engine.tick(now - last); last = now;
renderer.draw(engine, ctx, { timeMs: now });
requestAnimationFrame(frame);
}
drawVerbs();
requestAnimationFrame(frame);