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
@@ -0,0 +1,85 @@
|
||||
// conditions.js — declarative, serialisable conditions. No eval, ever:
|
||||
// game specs are JSON, saves are JSON, and both must stay inspectable.
|
||||
//
|
||||
// Condition shapes (all JSON):
|
||||
// { "flag": "metOtto" } flag is truthy
|
||||
// { "not": <cond> }
|
||||
// { "all": [<cond>...] } { "any": [<cond>...] }
|
||||
// { "var": "gold", "eq": 3 } eq|ne|lt|lte|gt|gte
|
||||
// { "has": "mug" } object id in inventory
|
||||
// { "at": "tavern" } player is in room id
|
||||
// { "here": "otto" } character is in current room
|
||||
// { "dialogue": "otto1", "node": "start" } dialogue progress
|
||||
// { "seen": "intro" } scene has completed
|
||||
//
|
||||
// null / undefined condition === always true.
|
||||
// Companion keys allowed alongside the type key: var -> op/value,
|
||||
// dialogue -> node. Anything else next to a type key is an error.
|
||||
const COND_TYPES = {
|
||||
flag: [], not: [], all: [], any: [], var: ["op", "value"], has: [], at: [],
|
||||
here: [], dialogue: ["node"], seen: [],
|
||||
};
|
||||
|
||||
export function check(cond, world) {
|
||||
if (cond == null) return true;
|
||||
if (typeof cond !== "object" || Array.isArray(cond)) {
|
||||
throw new TypeError(`bad condition: ${JSON.stringify(cond)}`);
|
||||
}
|
||||
const keys = Object.keys(cond);
|
||||
const types = keys.filter((k) => k in COND_TYPES);
|
||||
if (types.length === 0) {
|
||||
throw new TypeError(`unknown condition key: ${keys.join(",")}`);
|
||||
}
|
||||
if (types.length !== 1) {
|
||||
throw new TypeError(`condition must have exactly one key, got: ${keys.join(",")}`);
|
||||
}
|
||||
const k = types[0];
|
||||
const extras = keys.filter((x) => x !== k && !COND_TYPES[k].includes(x));
|
||||
if (extras.length) {
|
||||
throw new TypeError(`condition '${k}' got unexpected key(s): ${extras.join(",")}`);
|
||||
}
|
||||
const v = cond[k];
|
||||
switch (k) {
|
||||
case "flag": return Boolean(world.flags[v]);
|
||||
case "not": return !check(v, world);
|
||||
case "all": return v.every((c) => check(c, world));
|
||||
case "any": return v.some((c) => check(c, world));
|
||||
case "var": {
|
||||
const have = world.vars[cond.var] ?? 0;
|
||||
const want = cond.value;
|
||||
switch (cond.op ?? "eq") {
|
||||
case "eq": return have === want;
|
||||
case "ne": return have !== want;
|
||||
case "lt": return have < want;
|
||||
case "lte": return have <= want;
|
||||
case "gt": return have > want;
|
||||
case "gte": return have >= want;
|
||||
default: throw new TypeError(`unknown var op: ${cond.op}`);
|
||||
}
|
||||
}
|
||||
case "has": return world.inventory.includes(v);
|
||||
case "at": return world.room === v;
|
||||
case "here": return world.characters[v]?.room === world.room;
|
||||
case "dialogue": {
|
||||
const st = world.dialogueState[cond.dialogue];
|
||||
return st != null && (cond.node == null || st.node === cond.node);
|
||||
}
|
||||
case "seen": return Boolean(world.seenScenes[v]);
|
||||
default: throw new TypeError(`unknown condition key: ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect referenced ids for loader validation (dangling-reference checks).
|
||||
export function conditionRefs(cond, refs = { flags: [], vars: [], objects: [], rooms: [], characters: [], dialogues: [], scenes: [] }) {
|
||||
if (cond == null) return refs;
|
||||
if (cond.flag) refs.flags.push(cond.flag);
|
||||
else if (cond.not) conditionRefs(cond.not, refs);
|
||||
else if (cond.all || cond.any) (cond.all ?? cond.any).forEach((c) => conditionRefs(c, refs));
|
||||
else if (cond.var) refs.vars.push(cond.var);
|
||||
else if (cond.has) refs.objects.push(cond.has);
|
||||
else if (cond.at) refs.rooms.push(cond.at);
|
||||
else if (cond.here) refs.characters.push(cond.here);
|
||||
else if (cond.dialogue) refs.dialogues.push(cond.dialogue);
|
||||
else if (cond.seen) refs.scenes.push(cond.seen);
|
||||
return refs;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// dialogue.js — branching dialogue trees, SCUMM conversation squares.
|
||||
//
|
||||
// Spec shape:
|
||||
// "dialogues": {
|
||||
// "otto_intro": {
|
||||
// "start": "greet",
|
||||
// "nodes": {
|
||||
// "greet": {
|
||||
// "speaker": "otto",
|
||||
// "text": "You again. What do you want?",
|
||||
// "choices": [
|
||||
// { "text": "A job.", "to": "job", "conditions": {...}, "effects": [...] },
|
||||
// { "text": "Nothing.", "end": true }
|
||||
// ]
|
||||
// },
|
||||
// "job": { "speaker": "otto", "text": "...", "next": "greet" }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Nodes with `choices` wait for the player; nodes with `next` auto-advance
|
||||
// (engine times them); nodes with neither end the dialogue. State lives in
|
||||
// world.dialogState so saves preserve mid-conversation position.
|
||||
|
||||
import { check } from "./conditions.js";
|
||||
import { applyAll } from "./effects.js";
|
||||
|
||||
export class DialogueRunner {
|
||||
constructor(compiled, world, emitter) {
|
||||
this.compiled = compiled;
|
||||
this.world = world;
|
||||
this.emitter = emitter;
|
||||
this.active = null; // dialogue id
|
||||
this.pending = null; // auto-advance deadline (engine clock ms)
|
||||
}
|
||||
|
||||
start(id) {
|
||||
const d = this.compiled.dialogues[id];
|
||||
if (!d) throw new Error(`unknown dialogue: ${id}`);
|
||||
this.active = id;
|
||||
this.world.dialogueState[id] = { node: d.start, visited: [] };
|
||||
this._enter(d.start);
|
||||
}
|
||||
|
||||
current() {
|
||||
if (!this.active) return null;
|
||||
const st = this.world.dialogueState[this.active];
|
||||
const node = this.compiled.dialogues[this.active].nodes[st.node];
|
||||
return { id: this.active, node: st.node, def: node };
|
||||
}
|
||||
|
||||
/** Visible choices, conditions applied. */
|
||||
choices() {
|
||||
const cur = this.current();
|
||||
if (!cur || !cur.def.choices) return [];
|
||||
return cur.def.choices
|
||||
.map((c, i) => ({ ...c, index: i }))
|
||||
.filter((c) => check(c.conditions, this.world));
|
||||
}
|
||||
|
||||
choose(index) {
|
||||
const cur = this.current();
|
||||
if (!cur?.def.choices) throw new Error("no choices at current node");
|
||||
const visible = this.choices();
|
||||
const choice = visible.find((c) => c.index === index);
|
||||
if (!choice) throw new Error(`choice ${index} not available`);
|
||||
const events = applyAll(choice.effects, this.world);
|
||||
for (const ev of events) this.emitter.emit(ev.type, ev);
|
||||
if (choice.end || !choice.to) return this.end();
|
||||
this._goto(choice.to);
|
||||
return this.current();
|
||||
}
|
||||
|
||||
/** Called by engine tick: advance timed nodes. now = clock ms. */
|
||||
tick(now) {
|
||||
if (!this.active || !this.pending || now < this.pending) return;
|
||||
const cur = this.current();
|
||||
if (!cur) return;
|
||||
if (cur.def.next) this._goto(cur.def.next);
|
||||
else this.end();
|
||||
}
|
||||
|
||||
end() {
|
||||
const id = this.active;
|
||||
this.active = null;
|
||||
this.pending = null;
|
||||
// mark done so saves don't resume a finished conversation, but keep the
|
||||
// node history for {dialogue, node} conditions
|
||||
const st = this.world.dialogueState[id];
|
||||
if (st) st.done = true;
|
||||
this.emitter.emit("dialogueEnd", { id });
|
||||
}
|
||||
|
||||
_goto(nodeId) {
|
||||
const d = this.compiled.dialogues[this.active];
|
||||
if (!d.nodes[nodeId]) throw new Error(`unknown node ${nodeId} in ${this.active}`);
|
||||
this.world.dialogueState[this.active] = {
|
||||
node: nodeId,
|
||||
visited: [...(this.world.dialogueState[this.active]?.visited ?? []), nodeId],
|
||||
};
|
||||
this._enter(nodeId);
|
||||
}
|
||||
|
||||
_enter(nodeId) {
|
||||
const def = this.compiled.dialogues[this.active].nodes[nodeId];
|
||||
const events = applyAll(def.onEnter, this.world);
|
||||
for (const ev of events) this.emitter.emit(ev.type, ev);
|
||||
this.emitter.emit("dialogueLine", {
|
||||
id: this.active, node: nodeId,
|
||||
speaker: def.speaker, text: def.text,
|
||||
});
|
||||
if (!def.choices) {
|
||||
const hold = def.holdMs ?? Math.max(1500, (def.text ?? "").length * 45);
|
||||
this.pending = this._clock() + hold;
|
||||
} else {
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
_clock() { return this.clock ? this.clock() : 0; }
|
||||
}
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
// effects.js — declarative world mutations. Like conditions: pure JSON,
|
||||
// no eval, validatable against the compiled spec, safe to save.
|
||||
//
|
||||
// Effect shapes:
|
||||
// { "setFlag": "x", "value": true } value defaults to true
|
||||
// { "setVar": "gold", "value": 5 }
|
||||
// { "addVar": "gold", "value": -2 }
|
||||
// { "give": "mug" } { "remove": "mug" }
|
||||
// { "goTo": "tavern" } player changes room
|
||||
// { "moveTo": "otto", "room": "deck" } character changes room
|
||||
// { "walkTo": "otto", "x": 100, "y": 200 } character walks (engine schedules)
|
||||
// { "say": { "who": "otto", "text": "..." } } queued subtitle line
|
||||
// { "playMusic": "calypso" } { "stopMusic": true } { "playSfx": "click" }
|
||||
// { "startDialogue": "otto_intro" }
|
||||
// { "startScene": "intro" }
|
||||
// { "animate": { "who": "otto", "anim": "wave" } }
|
||||
// { "endGame": "win" }
|
||||
|
||||
export const EFFECT_KEYS = new Set([
|
||||
"setFlag", "setVar", "addVar", "give", "remove", "goTo", "moveTo", "walkTo",
|
||||
"say", "playMusic", "stopMusic", "playSfx", "startDialogue", "startScene",
|
||||
"animate", "endGame",
|
||||
]);
|
||||
|
||||
// Companion keys allowed alongside the effect key.
|
||||
const EFFECT_COMPANIONS = { setFlag: ["value"], setVar: ["value"], addVar: ["value"] };
|
||||
|
||||
// Apply one effect to the world. Returns an event (or null) for the engine
|
||||
// to emit; side effects that need engine machinery (walk scheduling,
|
||||
// dialogue start, audio) are returned as events, not executed here —
|
||||
// keeping world mutation testable in isolation.
|
||||
export function apply(effect, world) {
|
||||
const keys = Object.keys(effect);
|
||||
const types = keys.filter((k) => EFFECT_KEYS.has(k));
|
||||
if (types.length === 0) throw new TypeError(`unknown effect key: ${keys.join(",")}`);
|
||||
if (types.length !== 1) throw new TypeError(`effect needs exactly one key, got: ${keys.join(",")}`);
|
||||
const k = types[0];
|
||||
const extras = keys.filter((x) => x !== k && !(EFFECT_COMPANIONS[k] ?? []).includes(x));
|
||||
if (extras.length) throw new TypeError(`effect '${k}' got unexpected key(s): ${extras.join(",")}`);
|
||||
const v = effect[k];
|
||||
switch (k) {
|
||||
case "setFlag": world.flags[v] = effect.value ?? true; return { type: "flag", flag: v, value: world.flags[v] };
|
||||
case "setVar":
|
||||
if (!Number.isFinite(effect.value)) throw new TypeError(`setVar '${v}' needs numeric value`);
|
||||
world.vars[v] = effect.value; return { type: "var", var: v, value: world.vars[v] };
|
||||
case "addVar":
|
||||
if (!Number.isFinite(effect.value)) throw new TypeError(`addVar '${v}' needs numeric value`);
|
||||
world.vars[v] = (world.vars[v] ?? 0) + effect.value; return { type: "var", var: v, value: world.vars[v] };
|
||||
case "give": if (!world.inventory.includes(v)) world.inventory.push(v); return { type: "give", object: v };
|
||||
case "remove": world.inventory = world.inventory.filter((o) => o !== v); return { type: "remove", object: v };
|
||||
case "goTo": return { type: "goTo", room: v };
|
||||
case "moveTo": world.characters[v.character].room = v.room; return { type: "moveTo", ...v };
|
||||
case "walkTo": return { type: "walkTo", ...v };
|
||||
case "say": return { type: "say", ...v };
|
||||
case "playMusic": return { type: "playMusic", track: v };
|
||||
case "stopMusic": return { type: "stopMusic" };
|
||||
case "playSfx": return { type: "playSfx", sfx: v };
|
||||
case "startDialogue": return { type: "startDialogue", dialogue: v };
|
||||
case "startScene": return { type: "startScene", scene: v };
|
||||
case "animate": return { type: "animate", ...v };
|
||||
case "endGame": return { type: "endGame", ending: v };
|
||||
default: throw new TypeError(`unknown effect key: ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAll(effects, world) {
|
||||
return (effects ?? []).filter((e) => e != null).map((e) => apply(e, world)).filter(Boolean);
|
||||
}
|
||||
|
||||
// Referenced ids for loader validation.
|
||||
export function effectsRefs(effects, refs = { flags: [], vars: [], objects: [], rooms: [], characters: [], dialogues: [], scenes: [], music: [], sfx: [] }) {
|
||||
for (const e of effects ?? []) {
|
||||
const k = Object.keys(e).find((x) => EFFECT_KEYS.has(x));
|
||||
if (!k) continue;
|
||||
const v = e[k];
|
||||
if (k === "setFlag") refs.flags.push(v);
|
||||
else if (k === "setVar" || k === "addVar") refs.vars.push(v);
|
||||
else if (k === "give" || k === "remove") refs.objects.push(v);
|
||||
else if (k === "goTo") refs.rooms.push(v);
|
||||
else if (k === "moveTo") { refs.characters.push(v.character); refs.rooms.push(v.room); }
|
||||
else if (k === "walkTo") refs.characters.push(v.character);
|
||||
else if (k === "playMusic") refs.music.push(v);
|
||||
else if (k === "playSfx") refs.sfx.push(v);
|
||||
else if (k === "startDialogue") refs.dialogues.push(v);
|
||||
else if (k === "startScene") refs.scenes.push(v);
|
||||
else if (k === "animate") refs.characters.push(v.who);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// engine.js — the orchestrator. Headless: takes a clock function, emits
|
||||
// events; the renderer/audio/DOM layers subscribe. Everything a browser
|
||||
// needs is here, nothing a browser has is assumed.
|
||||
//
|
||||
// Phases: boot -> title -> menu -> playing (with scene/dialogue overlays)
|
||||
// -> ended. The intro is just a scene; menus are the web layer's business
|
||||
// (the engine exposes newGame/save/load/startScene and stays agnostic).
|
||||
|
||||
import { Emitter } from "./events.js";
|
||||
import { compile } from "./loader.js";
|
||||
import { GameWorld } from "./world.js";
|
||||
import { VerbResolver } from "./verbs.js";
|
||||
import { DialogueRunner } from "./dialogue.js";
|
||||
import { WalkArea } from "./walkable.js";
|
||||
import { applyAll } from "./effects.js";
|
||||
import { serialize, deserialize } from "./save.js";
|
||||
|
||||
export class Engine {
|
||||
/** @param {object} spec raw game JSON. @param {object} opts {clock: ()=>ms} */
|
||||
constructor(spec, opts = {}) {
|
||||
this.compiled = compile(spec);
|
||||
this.clock = opts.clock ?? (() => Date.now());
|
||||
this.emitter = new Emitter();
|
||||
this.world = new GameWorld(this.compiled);
|
||||
this.resolver = new VerbResolver(this.compiled);
|
||||
this.resolver.bindWorld(this.world);
|
||||
this.dialogue = new DialogueRunner(this.compiled, this.world, this.emitter);
|
||||
this.dialogue.clock = () => this.timeMs;
|
||||
|
||||
this.phase = "boot";
|
||||
this.timeMs = 0;
|
||||
this.selectedVerb = null;
|
||||
this.walkAreaCache = new Map();
|
||||
this.walk = null; // {character, path[], idx, speed}
|
||||
this.scene = null; // {id, steps, i, until, done}
|
||||
this.walkSpeed = this.compiled.game.walkSpeed ?? 60; // px/s
|
||||
}
|
||||
|
||||
// ---- lifecycle -------------------------------------------------------
|
||||
newGame() {
|
||||
this.world.reset(this.compiled.game.startRoom, this.compiled.game.startInventory ?? []);
|
||||
this.dialogue.active = null;
|
||||
this.walk = null;
|
||||
this.scene = null;
|
||||
this.selectedVerb = null;
|
||||
this.phase = "playing";
|
||||
this._enterRoom(this.world.room, true);
|
||||
this.emitter.emit("newGame", {});
|
||||
}
|
||||
|
||||
endGame(ending) {
|
||||
this.world.ended = ending;
|
||||
this.phase = "ended";
|
||||
this.emitter.emit("endGame", { ending });
|
||||
}
|
||||
|
||||
// ---- save/load (storage is the caller's business) --------------------
|
||||
save() { return serialize(this); }
|
||||
load(json) { deserialize(this, json); this.emitter.emit("loaded", {}); }
|
||||
onRestore() {
|
||||
this.walk = null;
|
||||
this.scene = null;
|
||||
this.dialogue.active = null;
|
||||
// resume a mid-dialogue save at its saved node (skip finished ones)
|
||||
for (const [id, st] of Object.entries(this.world.dialogueState)) {
|
||||
if (st?.node && !st.done) { this.dialogue.active = id; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- verbs & clicking -------------------------------------------------
|
||||
selectVerb(verbId) {
|
||||
if (verbId !== null && !this.compiled.verbs[verbId])
|
||||
throw new Error(`unknown verb: ${verbId}`);
|
||||
this.selectedVerb = verbId;
|
||||
this.emitter.emit("verbSelect", { verb: verbId });
|
||||
}
|
||||
|
||||
/** Hit-test a point in the current room. Returns a target descriptor. */
|
||||
hitTest(x, y) {
|
||||
for (const c of this.world.charactersHere()) {
|
||||
if (c.id === "player") continue;
|
||||
const def = this.compiled.characters[c.id];
|
||||
const hs = def.hotspot ?? { x: c.x - 20, y: c.y - 60, w: 40, h: 60 };
|
||||
if (x >= hs.x && x <= hs.x + hs.w && y >= hs.y && y <= hs.y + hs.h)
|
||||
return { kind: "character", id: c.id, tags: def.tags ?? ["character"], x, y };
|
||||
}
|
||||
for (const o of this.world.objectsHere()) {
|
||||
const hs = o.hotspot;
|
||||
if (hs && x >= hs.x && x <= hs.x + hs.w && y >= hs.y && y <= hs.y + hs.h)
|
||||
return { kind: "object", id: o.id, tags: o.tags ?? [] };
|
||||
}
|
||||
for (const ex of this.world.roomDef.exits ?? []) {
|
||||
const at = ex.at;
|
||||
if (Math.hypot(x - at[0], y - at[1]) <= (ex.radius ?? 30))
|
||||
return { kind: "exit", id: ex.to, tags: ["exit"], label: ex.label };
|
||||
}
|
||||
return { kind: "walkable", id: "room", tags: ["room"], x, y };
|
||||
}
|
||||
|
||||
/** The core interaction entry point: verb + clicked target (+ inventory
|
||||
* item held for "give/use-with"). Returns the response text or null. */
|
||||
click(target, withId = null) {
|
||||
if (this.phase !== "playing" || this.world.ended) return null;
|
||||
if (this.dialogue.active) return null; // clicks are swallowed in dialogue
|
||||
|
||||
const verb = this.selectedVerb ?? this.compiled.game.defaultVerb ?? "look";
|
||||
|
||||
if (target.kind === "exit" && verb === (this.compiled.game.moveVerb ?? "walk")) {
|
||||
this.goTo(target.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const it = this.resolver.resolve(verb, target, withId);
|
||||
if (it) {
|
||||
const events = applyAll(it.effects, this.world);
|
||||
this._dispatch(events);
|
||||
const response = it.response ?? null;
|
||||
if (response) this._say(null, response);
|
||||
this.emitter.emit("interaction", { verb, target: target.id, with: withId, interaction: it });
|
||||
return response;
|
||||
}
|
||||
const fallback = this.resolver.defaultResponse(verb, target);
|
||||
this._say(null, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Click without a selected verb: walk (or use the spec's defaultVerb). */
|
||||
clickPoint(x, y) {
|
||||
if (this.phase !== "playing" || this.world.ended) return;
|
||||
if (this.dialogue.active) return;
|
||||
const target = this.hitTest(x, y);
|
||||
if (target.kind === "walkable") {
|
||||
this.walkTo("player", target.x, target.y);
|
||||
} else {
|
||||
this.click(target);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- movement ---------------------------------------------------------
|
||||
walkAreaFor(roomId) {
|
||||
if (!this.walkAreaCache.has(roomId)) {
|
||||
this.walkAreaCache.set(roomId, new WalkArea(this.compiled.rooms[roomId].walkable));
|
||||
}
|
||||
return this.walkAreaCache.get(roomId);
|
||||
}
|
||||
|
||||
walkTo(character, x, y) {
|
||||
const ch = this.world.characters[character];
|
||||
if (!ch) return;
|
||||
const area = this.walkAreaFor(ch.room);
|
||||
const path = area.path([ch.x, ch.y], [x, y]);
|
||||
if (!path) { this._say(null, "You can't get there from here."); return; }
|
||||
this.walk = { character, path, idx: 1, speed: this.walkSpeed };
|
||||
this.emitter.emit("walkStart", { character, path });
|
||||
}
|
||||
|
||||
goTo(roomId) {
|
||||
if (!this.compiled.rooms[roomId]) throw new Error(`unknown room: ${roomId}`);
|
||||
this.world.room = roomId;
|
||||
const p = this.world.characters.player;
|
||||
p.room = roomId;
|
||||
const entry = this.compiled.rooms[roomId].entry ?? [100, 300];
|
||||
p.x = entry[0]; p.y = entry[1];
|
||||
this.walk = null;
|
||||
this._enterRoom(roomId, false);
|
||||
}
|
||||
|
||||
_enterRoom(roomId, initial) {
|
||||
const room = this.compiled.rooms[roomId];
|
||||
const events = applyAll(room.onEnter, this.world);
|
||||
this._dispatch(events);
|
||||
this.emitter.emit("roomEnter", { room: roomId, initial });
|
||||
}
|
||||
|
||||
// ---- scenes (intro/title cutscenes) -----------------------------------
|
||||
startScene(id) {
|
||||
const steps = this.compiled.scenes[id];
|
||||
if (!steps) throw new Error(`unknown scene: ${id}`);
|
||||
this.scene = { id, steps, i: 0, until: null };
|
||||
this.emitter.emit("sceneStart", { id });
|
||||
this._sceneStep();
|
||||
}
|
||||
|
||||
skipScene() {
|
||||
if (!this.scene) return;
|
||||
// jump to the final goto step if there is one, else end the scene
|
||||
const lastGoto = this.scene.steps.reduce((acc, s) => (s.goto ? s : acc), null);
|
||||
this.scene.steps = lastGoto ? [lastGoto] : [];
|
||||
this.scene.i = 0;
|
||||
this._sceneStep();
|
||||
}
|
||||
|
||||
_sceneStep() {
|
||||
const sc = this.scene;
|
||||
if (!sc) return;
|
||||
if (sc.i >= sc.steps.length) {
|
||||
this.world.seenScenes[sc.id] = true;
|
||||
const id = sc.id;
|
||||
this.scene = null;
|
||||
this.emitter.emit("sceneEnd", { id });
|
||||
return;
|
||||
}
|
||||
const step = sc.steps[sc.i];
|
||||
const [k, v] = [Object.keys(step)[0], Object.values(step)[0]];
|
||||
sc.until = null;
|
||||
switch (k) {
|
||||
case "music": this.emitter.emit("playMusic", { track: v }); break;
|
||||
case "stopMusic": this.emitter.emit("stopMusic", {}); break;
|
||||
case "sfx": this.emitter.emit("playSfx", { sfx: v }); break;
|
||||
case "slide": this.emitter.emit("slide", { asset: v }); sc.until = this.timeMs + (step.ms ?? 4000); break;
|
||||
case "text": this.emitter.emit("sceneText", { text: v }); sc.until = this.timeMs + (step.ms ?? Math.max(2000, v.length * 55)); break;
|
||||
case "wait": sc.until = this.timeMs + v; break;
|
||||
case "setFlag": this.world.flags[v] = step.value ?? true; break;
|
||||
case "startDialogue": this.dialogue.start(v); sc.until = this.timeMs + 1e12; break; // waits for dialogue end
|
||||
case "goto": this.goTo(v); break;
|
||||
default: throw new Error(`unknown scene step: ${k}`);
|
||||
}
|
||||
// instant steps (music/sfx/goto/setFlag) fall through immediately;
|
||||
// timed ones set sc.until and wait for the tick
|
||||
if (sc.until === null) { sc.i++; this._sceneStep(); }
|
||||
}
|
||||
|
||||
// ---- dialogue API ------------------------------------------------------
|
||||
startDialogue(id) { this.dialogue.start(id); }
|
||||
dialogueChoices() { return this.dialogue.choices(); }
|
||||
chooseDialogue(index) {
|
||||
const r = this.dialogue.choose(index);
|
||||
if (this.scene && this.scene.until > 1e11 && !this.dialogue.active) {
|
||||
this.scene.i++; this.scene.until = null; this._sceneStep();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---- ticking -----------------------------------------------------------
|
||||
/** Advance time by dt ms (call from rAF or a fake clock in tests). */
|
||||
tick(dt) {
|
||||
this.timeMs += dt;
|
||||
// walking
|
||||
if (this.walk) {
|
||||
const ch = this.world.characters[this.walk.character];
|
||||
let remaining = (this.walk.speed * dt) / 1000;
|
||||
while (remaining > 0 && this.walk.idx < this.walk.path.length) {
|
||||
const target = this.walk.path[this.walk.idx];
|
||||
const d = Math.hypot(target[0] - ch.x, target[1] - ch.y);
|
||||
if (d <= remaining) { ch.x = target[0]; ch.y = target[1]; remaining -= d; this.walk.idx++; }
|
||||
else {
|
||||
const t = remaining / d;
|
||||
ch.x += (target[0] - ch.x) * t; ch.y += (target[1] - ch.y) * t;
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
if (this.walk.idx >= this.walk.path.length) {
|
||||
this.emitter.emit("walkEnd", { character: this.walk.character });
|
||||
this.walk = null;
|
||||
}
|
||||
}
|
||||
// dialogue auto-advance
|
||||
this.dialogue.tick(this.timeMs);
|
||||
if (this.scene && this.scene.until !== null && this.timeMs >= this.scene.until &&
|
||||
!(this.dialogue.active && this.scene.until > 1e11)) {
|
||||
this.scene.i++; this.scene.until = null; this._sceneStep();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internals ---------------------------------------------------------
|
||||
_dispatch(events) {
|
||||
for (const ev of events) {
|
||||
switch (ev.type) {
|
||||
case "goTo": this.goTo(ev.room); break;
|
||||
case "walkTo": this.walkTo(ev.character, ev.x, ev.y); break;
|
||||
case "startDialogue": this.startDialogue(ev.dialogue); break;
|
||||
case "startScene": this.startScene(ev.scene); break;
|
||||
case "endGame": this.endGame(ev.ending); break;
|
||||
case "say": this._say(ev.who, ev.text); break;
|
||||
default: this.emitter.emit(ev.type, ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_say(who, text) {
|
||||
this.emitter.emit("say", { who, text });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// events.js — tiny pub/sub. The core emits events; renderers/audio subscribe.
|
||||
// No DOM, no deps: the core must run headless under node --test.
|
||||
|
||||
export class Emitter {
|
||||
constructor() { this.listeners = new Map(); }
|
||||
|
||||
on(event, fn) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, []);
|
||||
this.listeners.get(event).push(fn);
|
||||
return () => this.off(event, fn);
|
||||
}
|
||||
|
||||
off(event, fn) {
|
||||
const arr = this.listeners.get(event);
|
||||
if (!arr) return;
|
||||
const i = arr.indexOf(fn);
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
}
|
||||
|
||||
emit(event, payload) {
|
||||
for (const fn of this.listeners.get(event) ?? []) fn(payload);
|
||||
for (const fn of this.listeners.get("*") ?? []) fn({ event, ...payload });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// loader.js — validate + compile a game spec. House rule: report EVERY
|
||||
// problem in one pass (agents iterate on full error lists, not one crash
|
||||
// at a time), and never let a malformed spec reach the engine.
|
||||
|
||||
import { check, conditionRefs } from "./conditions.js";
|
||||
import { EFFECT_KEYS, effectsRefs } from "./effects.js";
|
||||
|
||||
export class SpecError extends Error {
|
||||
constructor(errors, warnings = []) {
|
||||
super("game spec invalid:\n - " + errors.join("\n - "));
|
||||
this.errors = errors;
|
||||
this.warnings = warnings;
|
||||
}
|
||||
}
|
||||
|
||||
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
const COND_TYPES = {
|
||||
flag: [], not: [], all: [], any: [], var: ["op", "value"], has: [], at: [],
|
||||
here: [], dialogue: ["node"], seen: [],
|
||||
};
|
||||
|
||||
function checkConditionShape(c, where, errors) {
|
||||
if (c == null) return;
|
||||
if (typeof c !== "object" || Array.isArray(c)) {
|
||||
errors.push(`${where}: condition must be an object, got ${JSON.stringify(c)}`);
|
||||
return;
|
||||
}
|
||||
const keys = Object.keys(c);
|
||||
const types = keys.filter((k) => k in COND_TYPES);
|
||||
if (types.length !== 1) {
|
||||
errors.push(`${where}: condition needs exactly one key, got [${keys.join(",")}]`);
|
||||
return;
|
||||
}
|
||||
const k = types[0];
|
||||
for (const x of keys)
|
||||
if (x !== k && !COND_TYPES[k].includes(x))
|
||||
errors.push(`${where}: unexpected key '${x}' on condition '${k}'`);
|
||||
if (k === "not") checkConditionShape(c.not, where, errors);
|
||||
else if (k === "all" || k === "any") {
|
||||
if (!Array.isArray(c[k])) errors.push(`${where}: ${k} needs an array`);
|
||||
else c[k].forEach((sub, i) => checkConditionShape(sub, `${where}.${k}[${i}]`, errors));
|
||||
} else if (k === "var") {
|
||||
if (typeof c.var !== "string") errors.push(`${where}: var condition needs a var name`);
|
||||
if (!["eq", "ne", "lt", "lte", "gt", "gte"].includes(c.op ?? "eq"))
|
||||
errors.push(`${where}: bad var op ${JSON.stringify(c.op)}`);
|
||||
if (!isNum(c.value)) errors.push(`${where}: var condition needs numeric value`);
|
||||
}
|
||||
}
|
||||
|
||||
const EFFECT_COMPANIONS = { setFlag: ["value"], setVar: ["value"], addVar: ["value"] };
|
||||
|
||||
function checkEffectShape(e, where, errors) {
|
||||
if (typeof e !== "object" || Array.isArray(e)) {
|
||||
errors.push(`${where}: effect must be an object, got ${JSON.stringify(e)}`);
|
||||
return;
|
||||
}
|
||||
const keys = Object.keys(e);
|
||||
const types = keys.filter((k) => EFFECT_KEYS.has(k));
|
||||
if (types.length !== 1) {
|
||||
errors.push(`${where}: effect needs exactly one key, got [${keys.join(",")}]`);
|
||||
return;
|
||||
}
|
||||
const k = types[0];
|
||||
for (const x of keys)
|
||||
if (x !== k && !(EFFECT_COMPANIONS[k] ?? []).includes(x))
|
||||
errors.push(`${where}: unexpected key '${x}' on effect '${k}'`);
|
||||
if ((k === "setVar" || k === "addVar") && !isNum(e.value))
|
||||
errors.push(`${where}: ${k} needs numeric value`);
|
||||
if (k === "moveTo" && (!e.moveTo || typeof e.moveTo.character !== "string" || typeof e.moveTo.room !== "string"))
|
||||
errors.push(`${where}: moveTo needs {character, room}`);
|
||||
if (k === "walkTo" && (!e.walkTo || typeof e.walkTo.character !== "string" || !isNum(e.walkTo.x) || !isNum(e.walkTo.y)))
|
||||
errors.push(`${where}: walkTo needs {character, x, y}`);
|
||||
if (k === "say" && (!e.say || typeof e.say.text !== "string"))
|
||||
errors.push(`${where}: say needs {who?, text}`);
|
||||
if (k === "animate" && (!e.animate || typeof e.animate.who !== "string" || typeof e.animate.anim !== "string"))
|
||||
errors.push(`${where}: animate needs {who, anim}`);
|
||||
}
|
||||
|
||||
function checkEffectsShape(list, where, errors) {
|
||||
if (list == null) return;
|
||||
if (!Array.isArray(list)) { errors.push(`${where}: effects must be an array`); return; }
|
||||
list.forEach((e, i) => checkEffectShape(e, `${where}[${i}]`, errors));
|
||||
}
|
||||
|
||||
export function validate(spec) {
|
||||
const errors = [], warnings = [];
|
||||
if (typeof spec !== "object" || spec == null) {
|
||||
return { errors: ["spec must be a JSON object"], warnings };
|
||||
}
|
||||
|
||||
// meta
|
||||
if (!spec.meta || typeof spec.meta.name !== "string" || !spec.meta.name)
|
||||
errors.push("meta.name is required");
|
||||
|
||||
// verbs
|
||||
const verbIds = new Set();
|
||||
if (!Array.isArray(spec.verbs) || spec.verbs.length === 0) {
|
||||
errors.push("verbs: non-empty array required");
|
||||
} else {
|
||||
for (const v of spec.verbs) {
|
||||
if (!v || typeof v.id !== "string" || typeof v.label !== "string") {
|
||||
errors.push(`verbs: each needs id + label, got ${JSON.stringify(v)}`);
|
||||
continue;
|
||||
}
|
||||
if (verbIds.has(v.id)) errors.push(`verbs: duplicate id '${v.id}'`);
|
||||
verbIds.add(v.id);
|
||||
}
|
||||
}
|
||||
|
||||
// rooms
|
||||
const roomIds = new Set();
|
||||
if (!Array.isArray(spec.rooms) || spec.rooms.length === 0) {
|
||||
errors.push("rooms: non-empty array required");
|
||||
} else {
|
||||
for (const r of spec.rooms) {
|
||||
if (!r || typeof r.id !== "string") { errors.push(`rooms: needs id, got ${JSON.stringify(r)}`); continue; }
|
||||
if (roomIds.has(r.id)) errors.push(`rooms: duplicate id '${r.id}'`);
|
||||
roomIds.add(r.id);
|
||||
if (!Array.isArray(r.walkable) || r.walkable.length === 0) {
|
||||
errors.push(`rooms.${r.id}: walkable needs at least one polygon`);
|
||||
} else {
|
||||
r.walkable.forEach((poly, pi) => {
|
||||
if (!Array.isArray(poly) || poly.length < 3)
|
||||
errors.push(`rooms.${r.id}.walkable[${pi}]: polygon needs >=3 points`);
|
||||
else poly.forEach((p, i) => {
|
||||
if (!Array.isArray(p) || !isNum(p[0]) || !Array.isArray(p) || !isNum(p[1]))
|
||||
errors.push(`rooms.${r.id}.walkable[${pi}][${i}]: point needs [x, y] numbers`);
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const [ei, ex] of (r.exits ?? []).entries()) {
|
||||
if (!ex || typeof ex.to !== "string") errors.push(`rooms.${r.id}.exits[${ei}]: needs 'to'`);
|
||||
else if (!roomIds.has(ex.to) && !(spec.rooms.some((x) => x.id === ex.to)))
|
||||
errors.push(`rooms.${r.id}.exits[${ei}]: unknown room '${ex.to}'`);
|
||||
if (ex && (!Array.isArray(ex.at) || !isNum(ex.at[0]) || !isNum(ex.at[1])))
|
||||
errors.push(`rooms.${r.id}.exits[${ei}]: 'at' needs [x, y]`);
|
||||
}
|
||||
checkEffectsShape(r.onEnter, `rooms.${r.id}.onEnter`, errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof spec.startRoom !== "string") errors.push("startRoom required");
|
||||
else if (roomIds.size && !roomIds.has(spec.startRoom))
|
||||
errors.push(`startRoom '${spec.startRoom}' is not a defined room`);
|
||||
|
||||
// objects
|
||||
const objectIds = new Set();
|
||||
for (const o of spec.objects ?? []) {
|
||||
if (!o || typeof o.id !== "string") { errors.push(`objects: needs id, got ${JSON.stringify(o)}`); continue; }
|
||||
if (objectIds.has(o.id)) errors.push(`objects: duplicate id '${o.id}'`);
|
||||
objectIds.add(o.id);
|
||||
if (o.room !== "*" && !roomIds.has(o.room))
|
||||
errors.push(`objects.${o.id}: room '${o.room}' unknown (use '*' for everywhere)`);
|
||||
if (o.hotspot && !(isNum(o.hotspot.x) && isNum(o.hotspot.y) && isNum(o.hotspot.w) && isNum(o.hotspot.h)))
|
||||
errors.push(`objects.${o.id}: hotspot needs {x,y,w,h} numbers`);
|
||||
if (o.tags && !Array.isArray(o.tags)) errors.push(`objects.${o.id}: tags must be an array`);
|
||||
}
|
||||
|
||||
// characters (player required)
|
||||
const charIds = new Set();
|
||||
if (!Array.isArray(spec.characters) || !spec.characters.some((c) => c?.id === "player"))
|
||||
errors.push("characters: must include one with id 'player' (the walker)");
|
||||
for (const c of spec.characters ?? []) {
|
||||
if (!c || typeof c.id !== "string") { errors.push(`characters: needs id, got ${JSON.stringify(c)}`); continue; }
|
||||
if (charIds.has(c.id)) errors.push(`characters: duplicate id '${c.id}'`);
|
||||
charIds.add(c.id);
|
||||
if (c.id !== "player" && !roomIds.has(c.room))
|
||||
errors.push(`characters.${c.id}: room '${c.room}' unknown`);
|
||||
if (c.sprite && !(spec.assets ?? []).some((a) => a.id === c.sprite))
|
||||
errors.push(`characters.${c.id}: sprite asset '${c.sprite}' not in assets`);
|
||||
}
|
||||
|
||||
// interactions
|
||||
(spec.interactions ?? []).forEach((it, i) => {
|
||||
const where = `interactions[${i}]`;
|
||||
if (!it || typeof it !== "object") { errors.push(`${where}: must be an object`); return; }
|
||||
if (it.verb !== "*" && !verbIds.has(it.verb)) errors.push(`${where}: unknown verb '${it.verb}'`);
|
||||
if (!it.noun && !it.tag) errors.push(`${where}: needs 'noun' or 'tag'`);
|
||||
if (it.noun && it.noun !== "*" && !objectIds.has(it.noun) && !charIds.has(it.noun))
|
||||
errors.push(`${where}: noun '${it.noun}' is not a known object or character`);
|
||||
if (it.with && !objectIds.has(it.with)) errors.push(`${where}: with '${it.with}' is not a known object`);
|
||||
if (!it.response && !it.effects) errors.push(`${where}: needs response and/or effects`);
|
||||
checkConditionShape(it.conditions, `${where}.conditions`, errors);
|
||||
checkEffectsShape(it.effects, `${where}.effects`, errors);
|
||||
});
|
||||
|
||||
// dialogues
|
||||
for (const [did, d] of Object.entries(spec.dialogues ?? {})) {
|
||||
if (!d || typeof d.start !== "string" || !d.nodes) { errors.push(`dialogues.${did}: needs start + nodes`); continue; }
|
||||
if (!d.nodes[d.start]) errors.push(`dialogues.${did}: start node '${d.start}' missing`);
|
||||
for (const [nid, node] of Object.entries(d.nodes)) {
|
||||
const where = `dialogues.${did}.${nid}`;
|
||||
if (!node || typeof node !== "object") { errors.push(`${where}: must be an object`); continue; }
|
||||
if (!node.text && !node.choices) errors.push(`${where}: needs text and/or choices`);
|
||||
if (node.next && !d.nodes[node.next]) errors.push(`${where}: next '${node.next}' missing`);
|
||||
checkEffectsShape(node.onEnter, `${where}.onEnter`, errors);
|
||||
for (const [ci, c] of (node.choices ?? []).entries()) {
|
||||
if (!c || typeof c.text !== "string") { errors.push(`${where}.choices[${ci}]: needs text`); continue; }
|
||||
if (c.to && !d.nodes[c.to]) errors.push(`${where}.choices[${ci}]: to '${c.to}' missing`);
|
||||
if (!c.to && !c.end) errors.push(`${where}.choices[${ci}]: needs 'to' or 'end'`);
|
||||
checkConditionShape(c.conditions, `${where}.choices[${ci}].conditions`, errors);
|
||||
checkEffectsShape(c.effects, `${where}.choices[${ci}].effects`, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scenes (intro/title sequences)
|
||||
for (const [sid, steps] of Object.entries(spec.scenes ?? {})) {
|
||||
if (!Array.isArray(steps)) { errors.push(`scenes.${sid}: must be an array of steps`); continue; }
|
||||
steps.forEach((st, i) => {
|
||||
const where = `scenes.${sid}[${i}]`;
|
||||
if (!st || typeof st !== "object") { errors.push(`${where}: step must be an object`); return; }
|
||||
const keys = Object.keys(st);
|
||||
const SCENE_TYPES = {
|
||||
music: [], stopMusic: [], sfx: [], slide: ["ms"], text: ["ms"], wait: ["ms"],
|
||||
startDialogue: [], goto: [], setFlag: ["value"],
|
||||
};
|
||||
const types = keys.filter((k) => k in SCENE_TYPES);
|
||||
if (types.length !== 1) { errors.push(`${where}: step needs exactly one key, got [${keys.join(",")}]`); return; }
|
||||
const k = types[0];
|
||||
for (const x of keys)
|
||||
if (x !== k && !SCENE_TYPES[k].includes(x))
|
||||
errors.push(`${where}: unexpected key '${x}' on step '${k}'`);
|
||||
if ((k === "slide") && !(spec.assets ?? []).some((a) => a.id === st.slide))
|
||||
errors.push(`${where}: slide asset '${st.slide}' not in assets`);
|
||||
if (k === "startDialogue" && !(spec.dialogues ?? {})[st.startDialogue])
|
||||
errors.push(`${where}: dialogue '${st.startDialogue}' unknown`);
|
||||
if (k === "goto" && !roomIds.has(st.goto))
|
||||
errors.push(`${where}: goto room '${st.goto}' unknown`);
|
||||
if ((k === "wait" || k === "text" || k === "slide") && st.ms != null && !isNum(st.ms))
|
||||
errors.push(`${where}: ms must be a number`);
|
||||
});
|
||||
}
|
||||
|
||||
// audio + asset manifests
|
||||
const musicIds = new Set((spec.music ?? []).map((m) => m.id));
|
||||
const sfxIds = new Set((spec.sfx ?? []).map((s) => s.id));
|
||||
const assetIds = new Set();
|
||||
for (const a of spec.assets ?? []) {
|
||||
if (!a || typeof a.id !== "string" || typeof a.file !== "string")
|
||||
errors.push(`assets: each needs id + file, got ${JSON.stringify(a)}`);
|
||||
else if (assetIds.has(a.id)) errors.push(`assets: duplicate id '${a.id}'`);
|
||||
else assetIds.add(a.id);
|
||||
}
|
||||
for (const r of spec.rooms ?? [])
|
||||
if (r.bg && !assetIds.has(r.bg)) errors.push(`rooms.${r.id}: bg asset '${r.bg}' not in assets`);
|
||||
|
||||
// room exit symmetry warning (adventure convention: back exits)
|
||||
for (const r of spec.rooms ?? []) {
|
||||
for (const ex of r.exits ?? []) {
|
||||
const back = (spec.rooms.find((x) => x.id === ex.to)?.exits ?? []).some((e2) => e2.to === r.id);
|
||||
if (!back) warnings.push(`rooms.${r.id}: exit to '${ex.to}' has no return exit from '${ex.to}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// dangling condition/effect refs (flags & vars may be created at runtime,
|
||||
// so those are warnings; structural ids are errors)
|
||||
const refs = { flags: [], vars: [], objects: [], rooms: [], characters: [], dialogues: [], scenes: [], music: [], sfx: [] };
|
||||
const walk = (list) => list && (Array.isArray(list) ? list : [list]);
|
||||
for (const it of spec.interactions ?? []) {
|
||||
conditionRefs(it.conditions, refs); effectsRefs(it.effects, refs);
|
||||
}
|
||||
for (const r of spec.rooms ?? []) effectsRefs(r.onEnter, refs);
|
||||
for (const d of Object.values(spec.dialogues ?? {}))
|
||||
for (const n of Object.values(d.nodes ?? {})) {
|
||||
effectsRefs(n.onEnter, refs);
|
||||
for (const c of n.choices ?? []) { conditionRefs(c.conditions, refs); effectsRefs(c.effects, refs); }
|
||||
}
|
||||
for (const f of refs.objects) if (!objectIds.has(f)) errors.push(`condition/effect references unknown object '${f}'`);
|
||||
for (const f of refs.rooms) if (!roomIds.has(f)) errors.push(`condition/effect references unknown room '${f}'`);
|
||||
for (const f of refs.characters) if (!charIds.has(f)) errors.push(`condition/effect references unknown character '${f}'`);
|
||||
for (const f of refs.dialogues) if (!(spec.dialogues ?? {})[f]) errors.push(`condition/effect references unknown dialogue '${f}'`);
|
||||
for (const f of refs.music) if (!musicIds.has(f)) errors.push(`effect references unknown music track '${f}'`);
|
||||
for (const f of refs.sfx) if (!sfxIds.has(f)) errors.push(`effect references unknown sfx '${f}'`);
|
||||
for (const f of refs.scenes) if (!(spec.scenes ?? {})[f]) errors.push(`condition references unknown scene '${f}'`);
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
export function compile(spec) {
|
||||
const { errors, warnings } = validate(spec);
|
||||
if (errors.length) throw new SpecError(errors, warnings);
|
||||
const index = (arr, key = "id") => Object.fromEntries((arr ?? []).map((x) => [x[key], x]));
|
||||
const compiled = {
|
||||
meta: spec.meta,
|
||||
game: spec,
|
||||
verbs: index(spec.verbs),
|
||||
rooms: index(spec.rooms),
|
||||
objects: index(spec.objects),
|
||||
characters: index(spec.characters),
|
||||
dialogues: spec.dialogues ?? {},
|
||||
scenes: spec.scenes ?? {},
|
||||
music: index(spec.music),
|
||||
sfx: index(spec.sfx),
|
||||
assets: index(spec.assets),
|
||||
interactions: (spec.interactions ?? []).map((it, i) => ({ ...it, _order: i })),
|
||||
};
|
||||
compiled.warnings = warnings;
|
||||
return compiled;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// save.js — versioned save snapshots. Saves are plain JSON strings so the
|
||||
// web layer can park them in localStorage; the core never touches storage.
|
||||
|
||||
export const SAVE_VERSION = 1;
|
||||
|
||||
export function serialize(engine) {
|
||||
return JSON.stringify({
|
||||
version: SAVE_VERSION,
|
||||
game: engine.compiled.meta.name,
|
||||
savedAt: new Date().toISOString(),
|
||||
world: engine.world.snapshot(),
|
||||
engine: {
|
||||
selectedVerb: engine.selectedVerb,
|
||||
timeMs: engine.timeMs,
|
||||
phase: engine.phase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const migrations = {
|
||||
// 1: initial format
|
||||
};
|
||||
|
||||
export function deserialize(engine, json) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(json);
|
||||
} catch (e) {
|
||||
throw new Error("save is not valid JSON");
|
||||
}
|
||||
if (typeof data !== "object" || data === null) throw new Error("save must be an object");
|
||||
if (typeof data.version !== "number") throw new Error("save has no version");
|
||||
if (data.version > SAVE_VERSION)
|
||||
throw new Error(`save version ${data.version} is newer than engine supports (${SAVE_VERSION})`);
|
||||
if (data.game !== engine.compiled.meta.name)
|
||||
throw new Error(`save is for game '${data.game}', not '${engine.compiled.meta.name}'`);
|
||||
let v = data.version;
|
||||
while (v < SAVE_VERSION) {
|
||||
const mig = migrations[v + 1];
|
||||
if (!mig) throw new Error(`no migration path from save version ${v} to ${v + 1}`);
|
||||
mig(data);
|
||||
v++;
|
||||
}
|
||||
engine.world.restore(data.world);
|
||||
engine.selectedVerb = data.engine?.selectedVerb ?? null;
|
||||
engine.timeMs = data.engine?.timeMs ?? 0;
|
||||
engine.phase = data.engine?.phase ?? "playing";
|
||||
engine.onRestore?.();
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// verbs.js — the verb interface: SCUMM's beating heart.
|
||||
//
|
||||
// The spec declares a verb table (classic nine-coin or modern two-verb —
|
||||
// the engine doesn't care) and a list of interactions:
|
||||
//
|
||||
// { "verb": "look", "noun": "mug", // exact object/character id
|
||||
// "conditions": <cond>, "effects": [<effect>...],
|
||||
// "response": "A suspicious mug.", "priority": 10 }
|
||||
// { "verb": "use", "noun": "*", ... } // any target
|
||||
// { "verb": "use", "tag": "container", ... } // objects carrying the tag
|
||||
// { "verb": "give", "with": "mug", "noun": "otto", ... } // item-with inventory
|
||||
//
|
||||
// Resolution order: most specific noun wins (exact id > tag > "*"), then
|
||||
// higher priority, then spec order. Conditions gate each match; the first
|
||||
// fully-matching interaction wins. If nothing matches, the verb's default
|
||||
// response fires ("You get nothing useful out of that.") — the engine
|
||||
// always answers, like a good adventure game does.
|
||||
|
||||
import { check } from "./conditions.js";
|
||||
|
||||
export class VerbResolver {
|
||||
/** @param {object} compiled indexed spec */
|
||||
constructor(compiled) {
|
||||
this.compiled = compiled;
|
||||
}
|
||||
|
||||
/** Find the best interaction for (verbId, target, withId).
|
||||
* target: { kind: "object"|"character"|"room"|"exit", id, tags[] }
|
||||
* Returns { interaction, response } or null. */
|
||||
resolve(verbId, target, withId = null) {
|
||||
const candidates = [];
|
||||
for (const it of this.compiled.interactions) {
|
||||
if (it.verb !== "*" && it.verb !== verbId) continue;
|
||||
if (it.with && it.with !== withId) continue;
|
||||
if (withId && !it.with) continue; // item-with interactions need the with
|
||||
let specificity = 0;
|
||||
if (it.noun) {
|
||||
if (it.noun === "*") specificity = 0;
|
||||
else if (it.noun === target.id) specificity = 2;
|
||||
else continue;
|
||||
} else if (it.tag) {
|
||||
if (!(target.tags ?? []).includes(it.tag)) continue;
|
||||
specificity = 1;
|
||||
} else {
|
||||
continue; // neither noun nor tag: malformed, loader should have caught
|
||||
}
|
||||
candidates.push({ it, specificity });
|
||||
}
|
||||
candidates.sort((a, b) =>
|
||||
b.specificity - a.specificity ||
|
||||
(b.it.priority ?? 0) - (a.it.priority ?? 0) ||
|
||||
a.it._order - b.it._order);
|
||||
for (const { it } of candidates) {
|
||||
if (check(it.conditions, this._world)) return it;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Attach the live world for condition checks (engine does this once). */
|
||||
bindWorld(world) { this._world = world; }
|
||||
|
||||
defaultResponse(verbId, target) {
|
||||
const v = this.compiled.verbs[verbId];
|
||||
const name = target?.id ?? "that";
|
||||
return (v?.defaults ?? {})[target?.kind] ?? v?.default ??
|
||||
`You get nothing useful out of ${name}.`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// walkable.js — walkable polygons, hit tests and pathfinding.
|
||||
//
|
||||
// A room's walkable area is a list of polygons ([[x,y],...] closed by
|
||||
// convention). Characters walk on them; clicks outside are clamped to the
|
||||
// nearest walkable point (classic adventure feel: you can always go where
|
||||
// you clicked, roughly).
|
||||
//
|
||||
// Pathfinding: boundary waypoints + Dijkstra. Waypoints are polygon corners
|
||||
// plus sampled boundary points; two waypoints on *different* polygons within
|
||||
// `portalEpsilon` are connected (shared edge = doorway between areas).
|
||||
// Simple, deterministic, testable — plenty for adventure-game walking.
|
||||
|
||||
export function pointInPolygon([x, y], poly) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [xi, yi] = poly[i], [xj, yj] = poly[j];
|
||||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function dist(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1]); }
|
||||
|
||||
export function nearestPointInPolygon(p, polys) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const poly of polys) {
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [x1, y1] = poly[j], [x2, y2] = poly[i];
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy || 1;
|
||||
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const q = [x1 + t * dx, y1 + t * dy];
|
||||
const d = dist(p, q);
|
||||
if (d < bestD) { bestD = d; best = q; }
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export class WalkArea {
|
||||
/** @param {number[][][]} polys array of polygons */
|
||||
constructor(polys, { sampleStep = 40, portalEpsilon = 24 } = {}) {
|
||||
this.polys = polys;
|
||||
this.sampleStep = sampleStep;
|
||||
this.portalEpsilon = portalEpsilon;
|
||||
this.nodes = this._buildNodes();
|
||||
this.adj = this._buildAdj();
|
||||
}
|
||||
|
||||
contains(p) { return this.polys.some((poly) => pointInPolygon(p, poly)); }
|
||||
|
||||
clamp(p) {
|
||||
return this.contains(p) ? p : nearestPointInPolygon(p, this.polys);
|
||||
}
|
||||
|
||||
_buildNodes() {
|
||||
const nodes = [];
|
||||
for (let pi = 0; pi < this.polys.length; pi++) {
|
||||
const poly = this.polys[pi];
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
nodes.push({ p: poly[i], poly: pi });
|
||||
const a = poly[j], b = poly[i];
|
||||
const len = dist(a, b);
|
||||
const steps = Math.max(1, Math.floor(len / this.sampleStep));
|
||||
for (let s = 1; s < steps; s++) {
|
||||
const t = s / steps;
|
||||
nodes.push({ p: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], poly: pi });
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
_buildAdj() {
|
||||
const adj = this.nodes.map(() => []);
|
||||
// connect node pairs on the same polygon within 1.6*sampleStep (boundary
|
||||
// chain), and cross-polygon pairs within portalEpsilon (shared edge =
|
||||
// doorway between areas)
|
||||
for (let a = 0; a < this.nodes.length; a++) {
|
||||
for (let b = a + 1; b < this.nodes.length; b++) {
|
||||
const na = this.nodes[a], nb = this.nodes[b];
|
||||
const d = dist(na.p, nb.p);
|
||||
if (na.poly === nb.poly) {
|
||||
if (d <= this.sampleStep * 1.6) { adj[a].push(b); adj[b].push(a); }
|
||||
} else if (d <= this.portalEpsilon) {
|
||||
adj[a].push(b); adj[b].push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
/** Shortest walking path from a to b (both clamped into the area).
|
||||
* Returns array of points ending exactly at b, or null if unreachable. */
|
||||
path(from, to) {
|
||||
const start = this.clamp(from), goal = this.clamp(to);
|
||||
if (this.contains(from) && this.contains(to) && this._segmentClear(start, goal)) {
|
||||
return [start, goal];
|
||||
}
|
||||
// attach start/goal to nearest nodes, run Dijkstra, then walk the chain
|
||||
const a = this._nearestNode(start), b = this._nearestNode(goal);
|
||||
if (a < 0 || b < 0) return null;
|
||||
const n = this.nodes.length;
|
||||
const d = new Array(n).fill(Infinity), prev = new Array(n).fill(-1);
|
||||
const seen = new Array(n).fill(false);
|
||||
d[a] = 0;
|
||||
for (;;) {
|
||||
let u = -1;
|
||||
for (let i = 0; i < n; i++) if (!seen[i] && (u < 0 || d[i] < d[u])) u = i;
|
||||
if (u < 0 || d[u] === Infinity) break;
|
||||
if (u === b) break;
|
||||
seen[u] = true;
|
||||
for (const v of this.adj[u]) {
|
||||
const nd = d[u] + dist(this.nodes[u].p, this.nodes[v].p);
|
||||
if (nd < d[v]) { d[v] = nd; prev[v] = u; }
|
||||
}
|
||||
}
|
||||
if (d[b] === Infinity) return null;
|
||||
const chain = [];
|
||||
for (let u = b; u >= 0; u = prev[u]) chain.push(this.nodes[u].p);
|
||||
chain.reverse();
|
||||
return [start, ...chain.filter((p) => dist(p, start) > 1), goal];
|
||||
}
|
||||
|
||||
_nearestNode(p) {
|
||||
let best = -1, bestD = Infinity;
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
const d = dist(this.nodes[i].p, p);
|
||||
if (d < bestD) { bestD = d; best = i; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** straight line stays inside one polygon? (cheap shortcut) */
|
||||
_segmentClear(a, b) {
|
||||
const steps = Math.ceil(dist(a, b) / 16) || 1;
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const p = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
if (!this.contains(p)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// world.js — the game world: compiled static definition + mutable state.
|
||||
// Pure data + methods; no DOM, no timers. The Engine drives it; save.js
|
||||
// snapshots it.
|
||||
|
||||
export class GameWorld {
|
||||
/** @param {object} compiled loader.compile() output: indexed rooms,
|
||||
* objects, characters, verbs, interactions, dialogues, scenes, assets. */
|
||||
constructor(compiled) {
|
||||
this.compiled = compiled;
|
||||
this.reset(compiled.game.startRoom, compiled.game.startInventory ?? []);
|
||||
}
|
||||
|
||||
reset(room, inventory = []) {
|
||||
this.room = room;
|
||||
this.inventory = [...inventory];
|
||||
this.flags = {};
|
||||
this.vars = {};
|
||||
this.seenScenes = {};
|
||||
this.dialogueState = {}; // dialogueId -> { node, visited[] }
|
||||
this.characters = {}; // charId -> { id, room, x, y, anim }
|
||||
for (const c of Object.values(this.compiled.characters)) {
|
||||
this.characters[c.id] = { id: c.id, room: c.room, x: c.x ?? 0, y: c.y ?? 0, anim: "idle" };
|
||||
}
|
||||
this.ended = null; // set by endGame effect
|
||||
}
|
||||
|
||||
// --- queries used by conditions/effects ---
|
||||
get roomDef() { return this.compiled.rooms[this.room]; }
|
||||
|
||||
objectsHere() {
|
||||
return Object.values(this.compiled.objects).filter((o) => {
|
||||
if (o.room === "*") return true;
|
||||
if (o.room !== this.room) return false;
|
||||
return !o.takes || !this.inventory.includes(o.id); // taken objects leave the room
|
||||
});
|
||||
}
|
||||
|
||||
charactersHere() {
|
||||
return Object.values(this.characters).filter((c) => c.room === this.room);
|
||||
}
|
||||
|
||||
// --- mutations (effects.js calls these via apply(); kept for tests) ---
|
||||
setFlag(name, value = true) { this.flags[name] = value; }
|
||||
setVar(name, value) { this.vars[name] = value; }
|
||||
addVar(name, value) { this.vars[name] = (this.vars[name] ?? 0) + value; }
|
||||
has(objectId) { return this.inventory.includes(objectId); }
|
||||
give(objectId) { if (!this.has(objectId)) this.inventory.push(objectId); }
|
||||
remove(objectId) { this.inventory = this.inventory.filter((o) => o !== objectId); }
|
||||
|
||||
// --- save snapshot (plain JSON, versioned by save.js) ---
|
||||
snapshot() {
|
||||
return {
|
||||
room: this.room,
|
||||
inventory: [...this.inventory],
|
||||
flags: structuredClone(this.flags),
|
||||
vars: structuredClone(this.vars),
|
||||
seenScenes: structuredClone(this.seenScenes),
|
||||
dialogueState: structuredClone(this.dialogueState),
|
||||
characters: structuredClone(this.characters),
|
||||
ended: this.ended,
|
||||
};
|
||||
}
|
||||
|
||||
restore(snap) {
|
||||
this.room = snap.room;
|
||||
this.inventory = [...snap.inventory];
|
||||
this.flags = structuredClone(snap.flags);
|
||||
this.vars = structuredClone(snap.vars);
|
||||
this.seenScenes = structuredClone(snap.seenScenes);
|
||||
this.dialogueState = structuredClone(snap.dialogueState);
|
||||
this.characters = structuredClone(snap.characters);
|
||||
this.ended = snap.ended ?? null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user