165 lines
6.2 KiB
JavaScript
165 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);
|