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:
Kevin Hermes committed 2026-10-18 00:29:41 +00:00
1 parent ea0df1d475
commit a4a2348a54
32 files changed
+2849 -1

No files matched your search

+165
View File
@@ -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);
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Moonlit Engine — skeleton demo</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; background: #0b0b12; color: #ddd; font: 14px/1.4 monospace;
display: flex; flex-direction: column; align-items: center; }
#stage { position: relative; margin-top: 12px; }
canvas { image-rendering: pixelated; border: 1px solid #333; cursor: crosshair; }
#verbs { display: flex; gap: 6px; margin: 10px 0 4px; flex-wrap: wrap; justify-content: center; }
#verbs button, #squares button { font: inherit; padding: 6px 12px; background: #1d2438;
color: #cfd8ff; border: 1px solid #3a4a70; border-radius: 4px; cursor: pointer; }
#verbs button.selected { background: #3a4a70; color: #fff; }
#textbox { min-height: 3.2em; width: 640px; max-width: 95vw; background: #11131c;
border: 1px solid #333; padding: 8px 10px; margin-top: 6px; }
#squares { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; width: 640px; max-width: 95vw; }
#squares button { text-align: left; flex: 1 1 45%; }
#sys { margin: 8px 0; display: flex; gap: 8px; }
#sys button { font: inherit; padding: 4px 10px; background: #141824; color: #9aa; border: 1px solid #2c3446; border-radius: 4px; cursor: pointer; }
#title { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(5,5,10,.92); font-size: 18px; text-align: center; }
kbd { background: #222; border: 1px solid #444; border-radius: 3px; padding: 1px 5px; }
</style>
</head>
<body>
<div id="stage">
<canvas id="cv" width="640" height="480"></canvas>
<div id="title">MOONLIT ENGINE — skeleton demo<br><br>Press any key / click to begin</div>
</div>
<div id="verbs"></div>
<div id="textbox"></div>
<div id="squares"></div>
<div id="sys">
<button id="save">Save</button>
<button id="load">Load</button>
<button id="skip">Skip intro</button>
<label>vol <input id="vol" type="range" min="0" max="1" step="0.05" value="0.8"></label>
</div>
<script type="module" src="boot.js"></script>
</body>
</html>