// 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);