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

+85
View File
@@ -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;
}