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
+165
@@ -0,0 +1,165 @@
|
||||
// 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);
|
||||
Reference in new issue
Block a user