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

+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+36 -1
View File
@@ -1,3 +1,38 @@
# moonlit-engine # moonlit-engine
SCUMM-style web adventure game engine. Data-driven game specs, verb coin interface, dialogue trees, walkable areas, save/load. Content-agnostic: engine only, assets and game ideas come later. A SCUMM-style, web-based adventure game engine. Data-driven: a game is a
JSON spec, not code. Engine only — game ideas and asset generation come
later.
- Design spec: [docs/SPEC.md](docs/SPEC.md)
## Quickstart
```sh
./tools/run_all.sh # validate example spec + 68 tests — must be green
node tools/validate_game.js example/game.json # check any game spec
python3 -m http.server 8080 # then open http://localhost:8080/web/index.html
```
The demo (`example/game.json` + `web/`) is a two-room skeleton — jetty,
tavern, a suspicious mug, a barkeep, one branching dialogue — deliberately
minimal: it exists to exercise every engine feature, not to be a game.
It ships with the *Moonlit Mangrove Calypso* track and its loop points.
## Layout
```
src/core/ headless engine (no DOM): loader, world, verbs, dialogue,
walkable, conditions, effects, save, events, engine
src/render/ thin integration layer: asset manifest, audio (loop points),
canvas renderer, HUD model — all testable without a browser
web/ browser harness: index.html + boot.js (~150 lines of glue)
example/ the skeleton game spec
tools/ validate_game.js, run_all.sh
tests/ node --test suite, one file per module
docs/ SPEC.md — the design spec
```
## Requirements
Node 20+ for tests/tooling. Any modern browser for the demo. No dependencies.
+202
View File
@@ -0,0 +1,202 @@
# Moonlit Engine — design spec
A SCUMM-style, web-based adventure game **engine**. This repo is the engine
only: game ideas, art and music generation come later. The engine is
content-agnostic and data-driven — a game is a JSON spec, not code.
Status: v0.1 — core complete and tested (68 tests), web harness working,
asset *integration* in place, asset *generation* explicitly out of scope.
---
## 1. Design principles
1. **Headless core.** `src/core/` has zero DOM, zero timers, zero network.
It takes an injected clock and emits events. Everything runs under
`node --test`. The browser layer (`web/`) is ~150 lines of glue.
2. **Declarative everything.** Conditions, effects, dialogue, scenes and
interactions are plain JSON. No `eval`, ever. Specs and saves stay
inspectable, validatable and diffable.
3. **Report every problem at once.** The loader validates the whole spec
in one pass and lists *all* errors (agents iterate on full error lists,
not one crash at a time). Same house rule as the midi-playlist repo.
4. **Never declare success without a passing run.** `./tools/run_all.sh`
(validate + tests) must be green before any claim of "works".
5. **Degrade gracefully while art doesn't exist yet.** Missing asset files
draw labelled placeholder boxes and warn once per id. A game with zero
art files is fully playable for design purposes.
## 2. Architecture
```
game.json ──► loader.js (validate + compile) ┌─► canvas.js (draws)
│ ├─► hud.js (verb bar / text box / squares)
▼ ├─► audio.js (music w/ loop points, sfx)
engine.js ◄── tick(dt) from rAF or fake clock └─► boot.js (DOM glue, save slots)
│
├── world.js mutable state: room, inventory, flags, vars, characters
├── verbs.js verb-coin interaction resolution
├── dialogue.js branching conversation trees
├── walkable.js polygons, clamping, Dijkstra paths
├── conditions.js JSON condition evaluator (no eval)
├── effects.js JSON world mutations (no eval)
├── save.js versioned JSON snapshots
└── events.js pub/sub; renderers/audio subscribe, core never calls them
```
The core emits events (`roomEnter`, `say`, `dialogueLine`, `playMusic`,
`walkStart`, …). Renderers subscribe. The core never knows a browser exists.
## 3. Lifecycle
Phases: `boot → title → menu → playing → ended`.
- **Title screen** — web layer's business (a DOM overlay in the demo).
The engine exposes `newGame()`, `load()`, `startScene()`.
- **Intro** — just a *scene* (`scenes.intro` in the spec): timed slides,
text, music, then `goto` the first room. Skippable (`skipScene()`).
- **Menus** (save/load/options/volume) — web layer; the engine exposes
`hud.canSave()` and save/load APIs. Keeping menus out of the core means
a future game can dress them however it likes.
- **Playing** — the verb/click/dialogue loop, with scene and dialogue
overlays.
- **Ended** — an `endGame` effect sets `phase = "ended"`; clicks stop;
the web layer shows the ending screen.
## 4. The game spec (game.json)
Top-level keys: `meta`, `startRoom`, `startInventory`, `walkSpeed`,
`defaultVerb`, `moveVerb`, `verbs[]`, `rooms[]`, `objects[]`,
`characters[]`, `interactions[]`, `dialogues{}`, `scenes{}`, `music[]`,
`sfx[]`, `assets[]`.
### Rooms & walkable areas
A room is a background asset id, one or more **walkable polygons**
(`[[x,y],...]` in a 640×480 coordinate space), an `entry` point, `exits`
(`{to, at:[x,y], label}`) and optional `onEnter` effects. The loader warns
(never errors) when an exit has no return exit — adventure convention, not
a hard rule.
### Objects
`{id, room, name, hotspot:{x,y,w,h}, takes?, tags?, sprite?}`. `takes:
true` objects leave the room once in inventory. `room: "*"` = everywhere.
`tags` feed tag-based interactions.
### Characters
Must include one with `id: "player"` (the walker). Others have a room,
position, sprite and tags. Characters render back-to-front by y.
### Verbs & interactions (the verb coin)
The verb table is free-form: classic nine-coin, modern two-verb, whatever.
Each verb has a label and a **default response** — the engine always
answers.
Interactions are a flat list:
`{verb, noun | tag, with?, conditions?, effects?, response?, priority?}`.
Resolution order: **exact noun id > tag > `*`**, then higher `priority`,
then spec order. Conditions gate each candidate; first full match wins.
`with` marks item-with interactions (`give mug to barkeep`).
### Conditions (JSON, no eval)
`{flag}`, `{not}`, `{all:[...]}`, `{any:[...]}`,
`{var, op: eq|ne|lt|lte|gt|gte, value}`, `{has: item}`, `{at: room}`,
`{here: character}`, `{dialogue, node}`, `{seen: scene}`.
### Effects (JSON, no eval)
`setFlag`, `setVar`, `addVar`, `give`, `remove`, `goTo`, `moveTo`,
`walkTo`, `say`, `playMusic`, `stopMusic`, `playSfx`, `startDialogue`,
`startScene`, `animate`, `endGame`.
### Dialogue
Named dialogues with a `start` node and a node map. Nodes: `speaker`,
`text`, then either `choices[]` (waits for the player; each choice has
`to`/`end`, optional `conditions`/`effects`) or `next` (auto-advances on
a timed hold) or neither (ends). Nodes can fire `onEnter` effects.
Mid-conversation position lives in world state, so saves resume
conversations. Finished dialogues are marked done and never auto-resume.
### Scenes (intro / cutscenes)
Ordered steps: `music`, `stopMusic`, `sfx`, `slide {asset, ms}`,
`text {text, ms}`, `wait {ms}`, `setFlag`, `startDialogue`, `goto {room}`.
Instant steps fall through; timed ones wait on the clock. Skipping jumps
to the final `goto`.
## 5. Game mechanics
- **Movement**: click a walkable point → pathfind (Dijkstra over boundary
waypoints between polygons, endpoints line-of-sight joined) → the engine
moves the player at `walkSpeed` px/s on `tick(dt)`. Clicks outside the
walkable area clamp to the nearest edge. Doors are exits: walk verb +
exit click changes rooms and fires `onEnter`.
- **Inventory**: `give`/`remove` effects; `has` conditions; taken objects
disappear from rooms; the HUD lists names.
- **World state**: flags (booleans) and vars (numbers) — the only way
stories remember things. Unknown flags read false / vars read 0, so
specs may gate on flags created later.
- **Hit-testing priority**: characters → objects → exits → walkable floor.
## 6. User interaction
- **Verb then target** (SCUMM): select verb, click hotspot. No verb
selected: click walks (or fires `defaultVerb` if the spec sets one).
- **Keyboard**: number keys 1–9 pick verbs, Esc clears the verb, Space
skips scenes.
- **Dialogue squares**: choices render as buttons; hidden by conditions.
- **Touch**: single taps = clicks; works as-is via canvas pointer events.
- **The engine always answers**: unmatched interactions fall back to the
verb's default response. Dead air is a spec bug, not an engine bug.
## 7. Save / load
Saves are plain JSON strings (`SAVE_VERSION = 1`, game-name tagged,
timestamped) — the web layer parks them in `localStorage`; the core never
touches storage. Loading rejects: bad JSON, missing/future version, wrong
game. Migration hooks exist for format changes. Mid-dialogue saves resume
at the saved node. Saving is gated off mid-dialogue/mid-scene (`canSave`).
## 8. Assets & audio — integration only
- **Asset manifest** in the spec: `{id, file, frames?, framesPerRow?,
framesRows?}`. The engine references ids; the web layer loads files.
Missing files → labelled placeholder + one warning. How files get made
is out of scope (that's the ComfyUI / midi-playlist side, later).
- **Music carries loop points** — `loopStartMs` / `loopEndMs` per track,
mirroring the MIDI repo's loop-marker convention. The web audio layer
plays the intro head once, then loops the body forever. The
*Moonlit Mangrove Calypso* track (bars 4–36 loop) is the reference
example: `example/game.json` ships it with loop points set.
- **Formats**: images png/jpg/webp; audio ogg/mp3 (anything WebAudio
decodes). Sprites are horizontal/row grids; frame timing per anim.
## 9. Testing policy
`node --test tests/*.test.js` — 68 tests, one file per module:
conditions, effects, walkable (incl. doorway traversal + unreachable
areas), loader (example validates clean; every error class reported in
one pass; dangling refs; symmetry warnings), verbs (specificity,
priority, condition fallthrough, with-items), dialogue (branching, timed
auto-advance, hidden choices, mid-dialogue snapshots), save (round-trip,
version/game rejection, resume), engine end-to-end (intro scene on the
clock, walking, clamping, take/give chains, dialogue gating, endGame),
render (manifest degradation, frame cycling, fake-canvas draw ops, HUD
model).
`./tools/run_all.sh` = validate example + full suite. Green or it didn't happen.
## 10. Conventions
- Coordinates: 640×480 room space, y-down, sprites anchored bottom-centre.
- Interaction precedence is spec order at equal specificity — put the
conditional variant **before** the unconditional one.
- Dialogue nodes are named strings, not indices.
- Save format changes require bumping `SAVE_VERSION` + a migration.
## 11. Roadmap (deliberately not in v0.1)
1. Game idea + writing (the fun part, next session).
2. Asset generation pipeline: ComfyUI for backgrounds/sprites,
midi-playlist → ogg for music, wired into the manifest.
3. Sprite animation polish (facing, walk cycles), character `animate`.
4. Multiple save slots, options menu, gamepad.
5. Room transitions with fades; parallax backgrounds.
+112
View File
@@ -0,0 +1,112 @@
{
"meta": { "name": "skeleton-demo", "version": 1, "blurb": "Engine exercise, not a game idea: two rooms, one object you can take, one character to talk to, one branching dialogue, one intro scene." },
"startRoom": "jetty",
"startInventory": [],
"walkSpeed": 80,
"defaultVerb": "look",
"moveVerb": "walk",
"verbs": [
{ "id": "look", "label": "Look", "default": "You see nothing extraordinary." },
{ "id": "use", "label": "Use", "default": "That doesn't seem to work." },
{ "id": "talk", "label": "Talk", "default": "They're not talking." },
{ "id": "give", "label": "Give", "default": "They're not interested." },
{ "id": "walk", "label": "Walk", "default": "You can't walk there." }
],
"rooms": [
{
"id": "jetty",
"bg": "bg_jetty",
"walkable": [ [[40,300],[600,300],[600,440],[40,440]] ],
"entry": [120, 380],
"exits": [ { "to": "tavern", "at": [580, 340], "label": "Tavern" } ],
"onEnter": [ { "playMusic": "calypso" } ]
},
{
"id": "tavern",
"bg": "bg_tavern",
"walkable": [ [[60,280],[580,280],[580,440],[60,440]] ],
"entry": [120, 380],
"exits": [ { "to": "jetty", "at": [80, 340], "label": "Back to the jetty" } ]
}
],
"objects": [
{ "id": "post", "room": "jetty", "name": "a mooring post",
"hotspot": { "x": 480, "y": 300, "w": 40, "h": 90 } },
{ "id": "mug", "room": "jetty", "name": "a suspicious mug", "takes": true,
"hotspot": { "x": 200, "y": 360, "w": 40, "h": 30 }, "tags": ["container"] }
],
"characters": [
{ "id": "player", "room": "jetty", "x": 120, "y": 380, "sprite": "player" },
{ "id": "barkeep", "room": "tavern", "x": 400, "y": 320, "name": "the barkeep",
"sprite": "barkeep", "tags": ["talker"] }
],
"interactions": [
{ "verb": "look", "noun": "post", "response": "Sturdy. Smells of rope and regret." },
{ "verb": "look", "noun": "mug", "conditions": { "has": "mug" },
"response": "The mug is in your pocket. It feels important." },
{ "verb": "look", "noun": "mug", "response": "A suspicious mug. Definitely suspicious." },
{ "verb": "use", "noun": "mug", "response": "You pocket the mug. Nobody objects.",
"effects": [ { "give": "mug" }, { "setFlag": "tookMug" } ] },
{ "verb": "talk", "noun": "barkeep", "response": null,
"effects": [ { "startDialogue": "barkeep_intro" } ] },
{ "verb": "give", "with": "mug", "noun": "barkeep",
"conditions": { "not": { "flag": "gaveMug" } },
"response": "\"Where'd you get that?\" The barkeep goes very quiet.",
"effects": [ { "remove": "mug" }, { "setFlag": "gaveMug" }, { "addVar": "barkeepTrust", "value": 1 } ] },
{ "verb": "give", "with": "mug", "noun": "barkeep",
"conditions": { "flag": "gaveMug" },
"response": "You already handed it over." },
{ "verb": "look", "noun": "*", "tag": "talker", "response": "They look like they'd rather not be looked at." }
],
"dialogues": {
"barkeep_intro": {
"start": "greet",
"nodes": {
"greet": {
"speaker": "barkeep",
"text": "We're closed. Also we're open. Don't ask.",
"choices": [
{ "text": "What's in the mug?", "to": "mug" },
{ "text": "Nothing. Sorry.", "end": true }
]
},
"mug": {
"speaker": "barkeep",
"text": "What mug? There was never a mug.",
"onEnter": [ { "setFlag": "heardAboutMug" } ],
"next": "greet"
}
}
}
},
"scenes": {
"intro": [
{ "music": "calypso" },
{ "slide": "title", "ms": 4000 },
{ "text": "A skeleton of a game, in the style of something.", "ms": 3500 },
{ "goto": "jetty" }
]
},
"music": [
{ "id": "calypso", "file": "assets/music/moonlit_mangrove_calypso.ogg",
"loopStartMs": 8460, "loopEndMs": 76920 }
],
"sfx": [
{ "id": "click", "file": "assets/sfx/click.ogg" }
],
"assets": [
{ "id": "bg_jetty", "file": "assets/img/jetty.png", "w": 640, "h": 480 },
{ "id": "bg_tavern", "file": "assets/img/tavern.png", "w": 640, "h": 480 },
{ "id": "title", "file": "assets/img/title.png", "w": 640, "h": 480 },
{ "id": "player", "file": "assets/img/player.png", "frames": { "idle": [0], "walk": [0,1,1,0] }, "framesPerRow": 4 },
{ "id": "barkeep", "file": "assets/img/barkeep.png", "frames": { "idle": [0] }, "framesPerRow": 1 }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "moonlit-engine",
"version": "0.1.0",
"description": "SCUMM-style web adventure engine: data-driven specs, verb interface, dialogue trees, walkable areas, save/load. Engine only — content and assets come later.",
"type": "module",
"license": "MIT",
"scripts": {
"test": "node --test tests/*.test.js",
"validate": "node tools/validate_game.js",
"demo": "python3 -m http.server 8080"
},
"engines": { "node": ">=20" }
}
+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;
}
+121
View File
@@ -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; }
}
+89
View File
@@ -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;
}
+283
View File
@@ -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 });
}
}
+24
View File
@@ -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 });
}
}
+301
View File
@@ -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;
}
+50
View File
@@ -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;
}
+68
View File
@@ -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}.`;
}
}
+147
View File
@@ -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;
}
}
+74
View File
@@ -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;
}
}
+35
View File
@@ -0,0 +1,35 @@
// assets.js — asset manifest integration. The engine references asset ids;
// this resolves them to files/frames and degrades gracefully when a file
// doesn't exist yet (placeholder + one warning per id). Asset GENERATION is
// explicitly out of scope: the engine never cares how a file came to be.
export class AssetManifest {
/** @param {object[]} assets [{id, file, kind?, w?, h?, frames?, anim?}] */
constructor(assets = []) {
this.byId = new Map(assets.map((a) => [a.id, a]));
this.missingWarned = new Set();
}
has(id) { return this.byId.has(id); }
/** Resolve an id. Missing ids return a placeholder descriptor and warn
* once — the game keeps running, which is what you want while art is
* still being made. */
get(id) {
const a = this.byId.get(id);
if (a) return a;
if (!this.missingWarned.has(id)) {
this.missingWarned.add(id);
console.warn(`[assets] missing asset id: ${id}`);
}
return { id, file: null, missing: true, w: 64, h: 64 };
}
/** Animation frame for (asset, anim, timeMs). frames: {idle: [0,1,2], ...}
* frameMs: per-anim speed, default 200. */
frameFor(asset, anim, timeMs) {
const frames = asset.frames?.[anim] ?? asset.frames?.idle ?? [0];
const frameMs = asset.anim?.[anim] ?? asset.anim?.idle ?? 200;
return frames[Math.floor(timeMs / frameMs) % frames.length];
}
}
+27
View File
@@ -0,0 +1,27 @@
// audio.js — audio integration with loop points, mirroring the MIDI
// convention from midi-playlist: music tracks carry loopStartMs/loopEndMs
// so game engines loop the musical body, not the whole file.
//
// HeadlessAudio is the test/dev implementation (records calls). The web
// boot layer supplies a WebAudio implementation with the same interface.
export class HeadlessAudio {
constructor() {
this.calls = [];
this.volume = 0.8;
this.current = null;
}
/** @param {object} def {id, file, loopStartMs?, loopEndMs?} */
playMusic(def) {
this.calls.push({ op: "playMusic", id: def.id, file: def.file,
loopStartMs: def.loopStartMs ?? null, loopEndMs: def.loopEndMs ?? null });
this.current = def.id;
}
stopMusic() { this.calls.push({ op: "stopMusic" }); this.current = null; }
playSfx(def) { this.calls.push({ op: "playSfx", id: def.id, file: def.file }); }
setVolume(v) { this.volume = Math.max(0, Math.min(1, v)); this.calls.push({ op: "setVolume", v: this.volume }); }
}
+85
View File
@@ -0,0 +1,85 @@
// canvas.js — Canvas2D room renderer. Thin on purpose: it draws what the
// world says; all logic lives in core. Takes any 2D-context-shaped object,
// so tests can pass a recording fake.
export class RoomRenderer {
/** @param {object} compiled loader.compile() output
* @param {AssetManifest} assets
* @param {object} images id -> loaded image-like {width,height} or null */
constructor(compiled, assets, images = new Map()) {
this.compiled = compiled;
this.assets = assets;
this.images = images;
}
/** @param ctx 2D context (fillRect/drawImage/save/restore/translate/
* fillStyle/font/textAlign/fillText/beginPath/...) */
draw(engine, ctx, { timeMs, debugWalkable = false } = {}) {
const room = engine.world.roomDef;
// background
const bg = this.images.get(room.bg) ?? null;
if (bg) ctx.drawImage(bg, 0, 0);
else { ctx.fillStyle = "#1a1a2e"; ctx.fillRect(0, 0, 640, 480); }
// walkable debug (dev aid: green floor, yellow portals)
if (debugWalkable) {
ctx.save();
ctx.fillStyle = "rgba(0,255,0,0.15)";
for (const poly of room.walkable) {
ctx.beginPath();
poly.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
// exits (subtle label so players can find doors)
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.font = "12px monospace";
ctx.textAlign = "center";
for (const ex of room.exits ?? []) ctx.fillText(ex.label ?? ex.to, ex.at[0], ex.at[1]);
// objects and characters, sorted back-to-front by y (adventure-game
// convention). Objects without sprite assets draw as labelled
// placeholders so unfinished games still show their layout.
const drawn = [];
for (const o of engine.world.objectsHere()) {
drawn.push({ y: o.hotspot?.y ?? 0, kind: "object", def: o });
}
for (const c of engine.world.charactersHere()) {
drawn.push({ y: c.y, kind: "character", def: this.compiled.characters[c.id], state: c });
}
drawn.sort((a, b) => a.y - b.y);
for (const d of drawn) {
const asset = d.def.sprite ? this.assets.get(d.def.sprite) : null;
const img = asset ? this.images.get(asset.id) : null;
const hs = d.kind === "object" ? d.def.hotspot : null;
// bottom-centre anchor for sprites; characters track live position
const cx = d.kind === "character" ? d.state.x : (hs ? hs.x + hs.w / 2 : 0);
const bottom = d.kind === "character" ? d.state.y : (hs ? hs.y + hs.h : 0);
if (img) {
const frame = this.assets.frameFor(asset, d.state?.anim ?? "idle", timeMs ?? 0);
const cols = asset.framesPerRow ?? 1;
const rows = asset.framesRows ?? 1;
const fw = img.width / cols, fh = img.height / rows;
const col = frame % cols, row = Math.floor(frame / cols);
ctx.drawImage(img, col * fw, row * fh, fw, fh, cx - fw / 2, bottom - fh, fw, fh);
} else {
// placeholder box drawn at the *hotspot* rect — the same box
// hitTest uses — so what you see is what you can click
const bx = d.kind === "character" ? cx - 20 : hs.x;
const by = d.kind === "character" ? bottom - 60 : hs.y;
const bw = d.kind === "character" ? 40 : hs.w;
const bh = d.kind === "character" ? 60 : hs.h;
ctx.fillStyle = d.kind === "character" ? "#c9a86a" : "#6a8fc9";
ctx.fillRect(bx, by, bw, bh);
ctx.fillStyle = "#111";
ctx.font = "10px monospace";
ctx.textAlign = "center";
ctx.fillText(d.def.id, bx + bw / 2, by + bh / 2);
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
// hud.js — the HUD *model*: what the verb bar, text box, inventory and
// dialogue squares should show right now. Pure data, no DOM: the web layer
// binds it to buttons; tests assert it directly.
export class HudModel {
constructor(engine) {
this.engine = engine;
this.currentLine = null; // {who, text, until}
engine.emitter.on("say", (ev) => { this.currentLine = ev; });
engine.emitter.on("dialogueLine", (ev) => { this.currentLine = ev; });
engine.emitter.on("roomEnter", () => { this.currentLine = null; });
engine.emitter.on("interaction", () => {});
}
/** Verb bar: spec verbs with labels; selected verb flagged. */
verbs() {
return Object.values(this.engine.compiled.verbs).map((v) => ({
id: v.id, label: v.label, selected: this.engine.selectedVerb === v.id,
}));
}
inventory() {
return this.engine.world.inventory.map((id) => {
const def = this.engine.compiled.objects[id];
return { id, name: def?.name ?? id };
});
}
/** Dialogue squares when in conversation, else []. */
dialogueChoices() { return this.engine.dialogueChoices(); }
inDialogue() { return Boolean(this.engine.dialogue.active); }
/** The text box line (interaction response or current dialogue line). */
line() { return this.currentLine; }
/** Menu/save availability for the web layer. */
canSave() { return this.engine.phase === "playing" && !this.engine.dialogue.active && !this.engine.scene; }
}
+68
View File
@@ -0,0 +1,68 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { check, conditionRefs } from "../src/core/conditions.js";
const world = () => ({
flags: { metOtto: true, lied: false },
vars: { gold: 5, trust: 0 },
inventory: ["mug"],
room: "tavern",
characters: { otto: { room: "tavern" }, ann: { room: "deck" } },
dialogueState: { otto1: { node: "greet" } },
seenScenes: { intro: true },
});
test("flag conditions", () => {
assert.equal(check({ flag: "metOtto" }, world()), true);
assert.equal(check({ flag: "lied" }, world()), false);
assert.equal(check({ flag: "nonexistent" }, world()), false);
assert.equal(check(null, world()), true);
});
test("not / all / any", () => {
assert.equal(check({ not: { flag: "lied" } }, world()), true);
assert.equal(check({ all: [{ flag: "metOtto" }, { has: "mug" }] }, world()), true);
assert.equal(check({ all: [{ flag: "metOtto" }, { has: "rope" }] }, world()), false);
assert.equal(check({ any: [{ flag: "lied" }, { has: "mug" }] }, world()), true);
assert.equal(check({ any: [] }, world()), false);
assert.equal(check({ all: [] }, world()), true);
});
test("var comparisons", () => {
for (const [op, want] of [["eq", true], ["ne", false], ["lt", false],
["lte", true], ["gt", false], ["gte", true]]) {
assert.equal(check({ var: "gold", op, value: 5 }, world()), want, op);
}
assert.equal(check({ var: "missing", op: "eq", value: 0 }, world()), true);
});
test("has / at / here / seen / dialogue", () => {
assert.equal(check({ has: "mug" }, world()), true);
assert.equal(check({ at: "tavern" }, world()), true);
assert.equal(check({ at: "deck" }, world()), false);
assert.equal(check({ here: "otto" }, world()), true);
assert.equal(check({ here: "ann" }, world()), false);
assert.equal(check({ seen: "intro" }, world()), true);
assert.equal(check({ dialogue: "otto1", node: "greet" }, world()), true);
assert.equal(check({ dialogue: "otto1", node: "other" }, world()), false);
});
test("malformed conditions throw", () => {
assert.throws(() => check({ bogus: 1 }, world()), /unknown condition key/);
assert.throws(() => check({ flag: "x", has: "y" }, world()), /exactly one key/);
assert.throws(() => check("nope", world()), /bad condition/);
});
test("conditionRefs collects ids for dangling-ref checks", () => {
const refs = conditionRefs({ all: [
{ flag: "a" }, { var: "b" }, { has: "mug" }, { at: "tavern" },
{ here: "otto" }, { dialogue: "d1" }, { seen: "intro" },
] });
assert.deepEqual(refs.flags, ["a"]);
assert.deepEqual(refs.vars, ["b"]);
assert.deepEqual(refs.objects, ["mug"]);
assert.deepEqual(refs.rooms, ["tavern"]);
assert.deepEqual(refs.characters, ["otto"]);
assert.deepEqual(refs.dialogues, ["d1"]);
assert.deepEqual(refs.scenes, ["intro"]);
});
+91
View File
@@ -0,0 +1,91 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { compile } from "../src/core/loader.js";
import { GameWorld } from "../src/core/world.js";
import { DialogueRunner } from "../src/core/dialogue.js";
import { Emitter } from "../src/core/events.js";
import { readFileSync } from "node:fs";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
function setup() {
const c = compile(example);
const w = new GameWorld(c);
const em = new Emitter();
const lines = [];
em.on("dialogueLine", (ev) => lines.push(ev));
let now = 0;
const d = new DialogueRunner(c, w, em);
d.clock = () => now;
return { c, w, em, d, lines, advance: (ms) => { now += ms; d.tick(now); } };
}
test("start emits first line and exposes choices", () => {
const { d, lines } = setup();
d.start("barkeep_intro");
assert.equal(lines.length, 1);
assert.equal(lines[0].speaker, "barkeep");
const choices = d.choices();
assert.equal(choices.length, 2);
assert.equal(choices[0].text, "What's in the mug?");
});
test("choosing branches and auto-advancing nodes time out", () => {
const { d, lines, advance } = setup();
d.start("barkeep_intro");
d.choose(0); // -> mug node (auto-advance, has next)
assert.equal(lines.length, 2);
assert.ok(lines[1].text.includes("never a mug"));
assert.equal(d.world.flags.heardAboutMug, true); // onEnter fired
advance(10000); // hold elapsed -> back to greet
assert.equal(lines.length, 3);
assert.equal(d.current().node, "greet");
});
test("end choice closes the dialogue", () => {
const { d, em } = setup();
let ended = 0;
em.on("dialogueEnd", () => ended++);
d.start("barkeep_intro");
d.choose(1);
assert.equal(d.active, null);
assert.equal(ended, 1);
});
test("choice conditions hide options", () => {
const c = compile(structuredClone(example));
c.dialogues.barkeep_intro.nodes.greet.choices[0].conditions = { flag: "hasClue" };
const w = new GameWorld(c);
const d = new DialogueRunner(c, w, new Emitter());
d.start("barkeep_intro");
assert.equal(d.choices().length, 1);
w.flags.hasClue = true;
assert.equal(d.choices().length, 2);
});
test("mid-dialogue state survives snapshot/restore", () => {
const { d, w } = setup();
d.start("barkeep_intro");
d.choose(0);
const snap = w.snapshot();
assert.equal(snap.dialogueState.barkeep_intro.node, "mug");
const w2 = new GameWorld(setup().c);
w2.restore(snap);
assert.equal(w2.dialogueState.barkeep_intro.node, "mug");
});
test("choice effects apply to the world", () => {
const spec = structuredClone(example);
spec.dialogues.barkeep_intro.nodes.greet.choices[0].effects = [{ setFlag: "askedAboutMug" }];
const c = compile(spec);
const w = new GameWorld(c);
const d = new DialogueRunner(c, w, new Emitter());
d.start("barkeep_intro");
d.choose(0);
assert.equal(w.flags.askedAboutMug, true);
});
test("unknown dialogue/node errors loudly", () => {
const { d } = setup();
assert.throws(() => d.start("nope"), /unknown dialogue/);
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { apply, applyAll } from "../src/core/effects.js";
import { GameWorld } from "../src/core/world.js";
const compiled = {
game: { startRoom: "jetty" },
rooms: { jetty: { id: "jetty" }, tavern: { id: "tavern" } },
objects: {}, characters: { otto: { id: "otto", room: "jetty" } },
verbs: {}, interactions: [], dialogues: {}, scenes: {},
};
const fresh = () => new GameWorld(compiled);
test("flags and vars", () => {
const w = fresh();
apply({ setFlag: "x" }, w);
assert.equal(w.flags.x, true);
apply({ setFlag: "x", value: false }, w);
assert.equal(w.flags.x, false);
apply({ setVar: "gold", value: 5 }, w);
apply({ addVar: "gold", value: -2 }, w);
assert.equal(w.vars.gold, 3);
});
test("inventory give/remove are idempotent-ish", () => {
const w = fresh();
apply({ give: "mug" }, w);
apply({ give: "mug" }, w);
assert.deepEqual(w.inventory, ["mug"]);
apply({ remove: "mug" }, w);
apply({ remove: "mug" }, w);
assert.deepEqual(w.inventory, []);
});
test("effects return events for engine machinery", () => {
const w = fresh();
assert.deepEqual(apply({ goTo: "tavern" }, w), { type: "goTo", room: "tavern" });
assert.deepEqual(apply({ playMusic: "calypso" }, w), { type: "playMusic", track: "calypso" });
assert.deepEqual(apply({ startDialogue: "d1" }, w), { type: "startDialogue", dialogue: "d1" });
assert.deepEqual(apply({ endGame: "win" }, w), { type: "endGame", ending: "win" });
});
test("moveTo mutates the world directly", () => {
const w = fresh();
apply({ moveTo: { character: "otto", room: "tavern" } }, w);
assert.equal(w.characters.otto.room, "tavern");
});
test("malformed effects throw", () => {
const w = fresh();
assert.throws(() => apply({ bogus: 1 }, w), /unknown effect key/);
assert.throws(() => apply({ setFlag: "x", give: "y" }, w), /exactly one key/);
assert.throws(() => apply({ setVar: "g", value: "five" }, w), /needs numeric value/);
});
test("applyAll skips nulls", () => {
const w = fresh();
const events = applyAll([{ setFlag: "a" }, null, { give: "mug" }], w);
assert.equal(events.length, 2);
});
test("world snapshot/restore round-trips", () => {
const w = fresh();
apply({ setFlag: "x" }, w);
apply({ give: "mug" }, w);
apply({ addVar: "n", value: 7 }, w);
const snap = w.snapshot();
const w2 = fresh();
w2.restore(snap);
assert.equal(w2.flags.x, true);
assert.deepEqual(w2.inventory, ["mug"]);
assert.equal(w2.vars.n, 7);
// snapshot is a deep copy: mutating the live world doesn't touch the save
w2.give("rope");
assert.deepEqual(snap.inventory, ["mug"]);
});
+150
View File
@@ -0,0 +1,150 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { Engine } from "../src/core/engine.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
function fresh() {
let now = 0;
const e = new Engine(structuredClone(example), { clock: () => now });
const events = [];
e.emitter.on("*", (ev) => events.push(ev));
e.newGame();
const advance = (ms) => { now += ms; e.tick(ms); };
return { e, events, advance };
}
test("intro scene runs on the clock and lands the player in the start room", () => {
const { e, events, advance } = fresh();
e.startScene("intro");
assert.ok(events.some((ev) => ev.event === "playMusic" && ev.track === "calypso"));
assert.ok(events.some((ev) => ev.event === "slide"));
advance(4000); // slide done
advance(3500); // text done -> goto jetty
assert.equal(e.scene, null);
assert.equal(e.world.room, "jetty");
assert.equal(e.world.seenScenes.intro, true);
});
test("scene skip jumps to the final goto", () => {
const { e } = fresh();
e.startScene("intro");
e.skipScene();
assert.equal(e.scene, null);
assert.equal(e.world.room, "jetty");
});
test("clicking a walkable point schedules a walk that completes over time", () => {
const { e, advance } = fresh();
const p = e.world.characters.player;
const startX = p.x;
e.clickPoint(400, 380);
assert.ok(e.walk, "walk scheduled");
advance(1000); // 80 px/s -> 80px
assert.ok(p.x > startX);
advance(5000);
assert.equal(e.walk, null);
assert.ok(Math.abs(p.x - 400) < 1, `arrived at x=400, got ${p.x}`);
});
test("click outside the walkable area clamps to the nearest edge", () => {
const { e, advance } = fresh();
e.clickPoint(700, 380); // beyond the jetty polygon (x<=600)
advance(10000);
const p = e.world.characters.player;
assert.ok(p.x <= 600 && p.x >= 590, `clamped near x=600, got ${p.x}`);
});
test("verb + object click: take the mug, then it leaves the room", () => {
const { e } = fresh();
e.selectVerb("use");
const resp = e.click({ kind: "object", id: "mug", tags: ["container"] });
assert.ok(resp.includes("pocket"));
assert.deepEqual(e.world.inventory, ["mug"]);
assert.ok(!e.world.objectsHere().some((o) => o.id === "mug"), "taken object leaves the room");
});
test("unmatched verb gets the default response — the engine always answers", () => {
const { e } = fresh();
e.selectVerb("give");
const resp = e.click({ kind: "object", id: "post", tags: [] });
assert.equal(resp, "They're not interested.");
});
test("walk verb + exit click changes rooms and fires onEnter (music)", () => {
const { e, events } = fresh();
e.selectVerb("walk");
e.click({ kind: "exit", id: "tavern", tags: ["exit"] });
assert.equal(e.world.room, "tavern");
assert.equal(e.world.characters.player.room, "tavern");
assert.ok(events.some((ev) => ev.event === "roomEnter" && ev.room === "tavern"));
});
test("talk verb starts the dialogue; clicks are swallowed mid-dialogue", () => {
const { e } = fresh();
e.goTo("tavern");
e.selectVerb("talk");
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
assert.equal(e.dialogue.active, "barkeep_intro");
const before = e.world.flags;
const resp = e.click({ kind: "object", id: "post", tags: [] });
assert.equal(resp, null, "clicks ignored during dialogue");
assert.deepEqual(e.world.flags, before);
});
test("dialogue choice with give-mug chain: full interaction flow", () => {
const { e, advance } = fresh();
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] }); // take mug
e.goTo("tavern");
e.selectVerb("give");
const resp = e.click({ kind: "character", id: "barkeep", tags: ["talker"] }, "mug");
assert.ok(resp.includes("very quiet"));
assert.deepEqual(e.world.inventory, []);
assert.equal(e.world.flags.gaveMug, true);
assert.equal(e.world.vars.barkeepTrust, 1);
});
test("hitTest finds characters, objects, exits, then walkable", () => {
const { e } = fresh();
assert.equal(e.hitTest(500, 340).id, "post");
assert.equal(e.hitTest(220, 375).id, "mug");
assert.equal(e.hitTest(580, 340).kind, "exit");
assert.equal(e.hitTest(300, 420).kind, "walkable");
e.goTo("tavern");
assert.equal(e.hitTest(400, 300).id, "barkeep");
});
test("endGame effect ends the run and blocks further clicks", () => {
const spec = structuredClone(example);
spec.interactions.push({ verb: "use", noun: "post", response: "The world ends.",
effects: [{ endGame: "win" }] });
const e = new Engine(spec, { clock: () => 0 });
e.newGame();
e.selectVerb("use");
e.click({ kind: "object", id: "post", tags: [] });
assert.equal(e.phase, "ended");
assert.equal(e.world.ended, "win");
assert.equal(e.click({ kind: "object", id: "post", tags: [] }), null);
});
test("room onEnter effects fire on entry", () => {
const spec = structuredClone(example);
spec.rooms[1].onEnter = [{ setFlag: "visitedTavern" }];
const e = new Engine(spec, { clock: () => 0 });
e.newGame();
e.goTo("tavern");
assert.equal(e.world.flags.visitedTavern, true);
});
test("newGame resets everything", () => {
const { e } = fresh();
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] });
e.goTo("tavern");
e.newGame();
assert.equal(e.world.room, "jetty");
assert.deepEqual(e.world.inventory, []);
assert.equal(e.world.flags.tookMug, undefined);
});
+88
View File
@@ -0,0 +1,88 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { validate, compile, SpecError } from "../src/core/loader.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
test("the shipped example validates clean", () => {
const { errors, warnings } = validate(example);
assert.deepEqual(errors, []);
// exit symmetry: jetty<->tavern, so no symmetry warnings either
assert.deepEqual(warnings, []);
});
test("compile indexes everything", () => {
const c = compile(example);
assert.equal(c.rooms.tavern.id, "tavern");
assert.equal(c.objects.mug.name, "a suspicious mug");
assert.equal(c.characters.player.room, "jetty");
assert.equal(c.verbs.look.label, "Look");
assert.ok(c.interactions.every((it) => typeof it._order === "number"));
});
test("every problem reported in one pass", () => {
const bad = structuredClone(example);
bad.meta = {}; // missing name
bad.startRoom = "nowhere"; // dangling start
bad.rooms[0].walkable = []; // no polygons
bad.characters[0].id = "walker"; // player missing
bad.interactions[0].verb = "telepathy"; // unknown verb
bad.interactions[1].noun = "ghost-mug"; // unknown noun
const { errors } = validate(bad);
assert.ok(errors.length >= 6, `expected >=6 errors, got ${errors.length}: ${errors.join("|")}`);
const joined = errors.join("\n");
for (const needle of ["meta.name", "startRoom", "walkable", "'player'", "telepathy", "ghost-mug"])
assert.ok(joined.includes(needle), `missing error about ${needle}: ${joined}`);
});
test("compile throws SpecError listing everything", () => {
assert.throws(() => compile({ meta: {} }), SpecError);
try { compile({ meta: {} }); } catch (e) {
assert.ok(e.errors.length >= 3);
}
});
test("dangling structural refs are errors, unknown flags are not checked", () => {
const bad = structuredClone(example);
bad.interactions.push({ verb: "use", noun: "mug", effects: [{ give: "nonexistent-item" }] });
bad.interactions.push({ verb: "look", noun: "post", effects: [{ playMusic: "nope-track" }] });
bad.interactions.push({ verb: "look", noun: "post", conditions: { flag: "createdAtRuntime" }, response: "ok" });
const { errors } = validate(bad);
const joined = errors.join("\n");
assert.ok(joined.includes("nonexistent-item"));
assert.ok(joined.includes("nope-track"));
assert.ok(!joined.includes("createdAtRuntime"));
});
test("dialogue nodes must connect", () => {
const bad = structuredClone(example);
bad.dialogues.barkeep_intro.nodes.greet.choices[0].to = "phantom";
const { errors } = validate(bad);
assert.ok(errors.some((e) => e.includes("phantom")));
});
test("scene steps validated", () => {
const bad = structuredClone(example);
bad.scenes.intro = [{ explode: true }, { goto: "nowhere" }, { slide: "no-such-asset" }];
const { errors } = validate(bad);
const joined = errors.join("\n");
assert.ok(joined.includes("explode"));
assert.ok(joined.includes("nowhere"));
assert.ok(joined.includes("no-such-asset"));
});
test("duplicate ids caught", () => {
const bad = structuredClone(example);
bad.rooms.push({ ...bad.rooms[0] });
const { errors } = validate(bad);
assert.ok(errors.some((e) => e.includes("duplicate id 'jetty'")));
});
test("missing return exit is a warning, not an error", () => {
const bad = structuredClone(example);
bad.rooms[1].exits = []; // tavern no longer leads back
const { errors, warnings } = validate(bad);
assert.deepEqual(errors, []);
assert.ok(warnings.some((w) => w.includes("no return exit")));
});
+123
View File
@@ -0,0 +1,123 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { AssetManifest } from "../src/render/assets.js";
import { HeadlessAudio } from "../src/render/audio.js";
import { RoomRenderer } from "../src/render/canvas.js";
import { HudModel } from "../src/render/hud.js";
import { Engine } from "../src/core/engine.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
// ---- assets ---------------------------------------------------------------
test("asset manifest resolves and degrades gracefully", () => {
const am = new AssetManifest(example.assets);
assert.equal(am.get("bg_jetty").file, "assets/img/jetty.png");
const missing = am.get("nope");
assert.equal(missing.missing, true);
assert.equal(am.get("nope").missing, true); // second call: no crash
});
test("animation frame cycling", () => {
const am = new AssetManifest([{ id: "p", file: "p.png", frames: { walk: [0, 1, 1, 0] }, anim: { walk: 100 } }]);
const a = am.get("p");
assert.equal(am.frameFor(a, "walk", 0), 0);
assert.equal(am.frameFor(a, "walk", 100), 1);
assert.equal(am.frameFor(a, "walk", 300), 0);
assert.equal(am.frameFor(a, "unknownAnim", 0), 0); // falls back
});
// ---- audio ----------------------------------------------------------------
test("headless audio records calls and preserves loop points", () => {
const au = new HeadlessAudio();
au.playMusic({ id: "calypso", file: "c.ogg", loopStartMs: 8460, loopEndMs: 76920 });
au.playSfx({ id: "click", file: "k.ogg" });
au.setVolume(2); // clamped
assert.deepEqual(au.calls[0], { op: "playMusic", id: "calypso", file: "c.ogg",
loopStartMs: 8460, loopEndMs: 76920 });
assert.equal(au.calls[1].op, "playSfx");
assert.equal(au.volume, 1);
au.stopMusic();
assert.equal(au.current, null);
});
// ---- canvas renderer (fake recording context) ------------------------------
function fakeCtx() {
const ops = [];
const noop = (name) => (...args) => ops.push({ op: name, args });
return { ops, drawImage: noop("drawImage"), fillRect: noop("fillRect"),
fillText: noop("fillText"), beginPath: noop("beginPath"),
lineTo: noop("lineTo"), moveTo: noop("moveTo"), closePath: noop("closePath"),
fill: noop("fill"), save: noop("save"), restore: noop("restore") };
}
test("renderer draws bg, placeholders, and exit labels", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const am = new AssetManifest(example.assets);
const rr = new RoomRenderer(e.compiled, am, new Map()); // no images loaded
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0 });
assert.ok(ctx.ops.some((o) => o.op === "fillRect")); // bg placeholder
assert.ok(ctx.ops.some((o) => o.op === "fillText" && o.args[0] === "Tavern"));
// player + objects drawn as labelled placeholders
const labels = ctx.ops.filter((o) => o.op === "fillText").map((o) => o.args[0]);
assert.ok(labels.includes("player"));
assert.ok(labels.includes("post"));
assert.ok(labels.includes("mug"));
});
test("renderer uses loaded images when present", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const am = new AssetManifest(example.assets);
const images = new Map([["bg_jetty", { width: 640, height: 480 }]]);
const rr = new RoomRenderer(e.compiled, am, images);
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0 });
assert.ok(ctx.ops.some((o) => o.op === "drawImage"));
});
test("object placeholder boxes match hotspot rects (see-what-you-click)", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0 });
const rects = ctx.ops.filter((o) => o.op === "fillRect").map((o) => o.args);
// post hotspot {480,300,40,90} and mug {200,360,40,30} drawn exactly
assert.ok(rects.some((r) => r[0] === 480 && r[1] === 300 && r[2] === 40 && r[3] === 90),
`post box not at hotspot: ${JSON.stringify(rects)}`);
assert.ok(rects.some((r) => r[0] === 200 && r[1] === 360 && r[2] === 40 && r[3] === 30),
`mug box not at hotspot: ${JSON.stringify(rects)}`);
});
test("walkable debug draws polygons", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
const ctx = fakeCtx();
rr.draw(e, ctx, { timeMs: 0, debugWalkable: true });
assert.ok(ctx.ops.some((o) => o.op === "closePath"));
});
// ---- HUD model --------------------------------------------------------------
test("hud exposes verbs, inventory, dialogue choices, save gate", () => {
const e = new Engine(structuredClone(example), { clock: () => 0 });
e.newGame();
const hud = new HudModel(e);
assert.equal(hud.verbs().length, 5);
e.selectVerb("look");
assert.equal(hud.verbs().find((v) => v.id === "look").selected, true);
assert.deepEqual(hud.inventory(), []);
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] });
assert.deepEqual(hud.inventory(), [{ id: "mug", name: "a suspicious mug" }]);
assert.equal(hud.canSave(), true);
e.goTo("tavern");
e.selectVerb("talk");
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
assert.equal(hud.inDialogue(), true);
assert.equal(hud.dialogueChoices().length, 2);
assert.equal(hud.canSave(), false, "no saving mid-dialogue");
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { Engine } from "../src/core/engine.js";
import { SAVE_VERSION } from "../src/core/save.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
const fresh = () => {
let now = 0;
const e = new Engine(structuredClone(example), { clock: () => now });
e.newGame();
return { e, advance: (ms) => { now += ms; e.tick(ms); } };
};
test("save/load round-trip restores the whole world", () => {
const { e, advance } = fresh();
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] });
e.goTo("tavern");
e.world.setFlag("didSomething", true);
e.world.addVar("score", 42);
const save = e.save();
const e2 = new Engine(structuredClone(example), { clock: () => 0 });
e2.load(save);
assert.equal(e2.world.room, "tavern");
assert.deepEqual(e2.world.inventory, ["mug"]);
assert.equal(e2.world.flags.didSomething, true);
assert.equal(e2.world.vars.score, 42);
assert.equal(e2.phase, "playing");
});
test("save format is versioned and game-tagged", () => {
const { e } = fresh();
const data = JSON.parse(e.save());
assert.equal(data.version, SAVE_VERSION);
assert.equal(data.game, "skeleton-demo");
assert.ok(data.savedAt);
});
test("rejects saves from another game", () => {
const { e } = fresh();
const save = e.save();
const other = structuredClone(example);
other.meta.name = "different-game";
const e2 = new Engine(other, { clock: () => 0 });
assert.throws(() => e2.load(save), /save is for game/);
});
test("rejects future save versions", () => {
const { e } = fresh();
const data = JSON.parse(e.save());
data.version = 999;
assert.throws(() => e.load(JSON.stringify(data)), /newer than engine/);
});
test("rejects garbage saves", () => {
const { e } = fresh();
assert.throws(() => e.load("not json"), /not valid JSON/);
assert.throws(() => e.load("{}"), /no version/);
});
test("mid-dialogue save resumes at the saved node", () => {
const { e } = fresh();
e.goTo("tavern");
e.selectVerb("talk");
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
assert.equal(e.dialogue.current().node, "greet");
const save = e.save();
const e2 = new Engine(structuredClone(example), { clock: () => 0 });
e2.load(save);
assert.ok(e2.dialogue.active);
assert.equal(e2.dialogue.current().node, "greet");
assert.equal(e2.dialogueChoices().length, 2);
});
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { compile } from "../src/core/loader.js";
import { VerbResolver } from "../src/core/verbs.js";
import { GameWorld } from "../src/core/world.js";
import { readFileSync } from "node:fs";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
function setup() {
const c = compile(example);
const w = new GameWorld(c);
const r = new VerbResolver(c);
r.bindWorld(w);
return { c, w, r };
}
test("exact noun beats tag beats wildcard", () => {
const { r } = setup();
const it = r.resolve("look", { kind: "object", id: "post", tags: [] });
assert.equal(it.response, "Sturdy. Smells of rope and regret.");
const tagIt = r.resolve("look", { kind: "character", id: "barkeep", tags: ["talker"] });
assert.ok(tagIt.response.includes("rather not be looked at"));
});
test("conditions gate matches and fall through to next candidate", () => {
const { w, r } = setup();
// give-with-mug: first candidate requires not-gaveMug
let it = r.resolve("give", { kind: "character", id: "barkeep", tags: ["talker"] }, "mug");
assert.ok(it.effects.some((e) => e.setFlag === "gaveMug"));
w.flags.gaveMug = true;
it = r.resolve("give", { kind: "character", id: "barkeep", tags: ["talker"] }, "mug");
assert.equal(it.response, "You already handed it over.");
});
test("with-item matching is strict both ways", () => {
const { r } = setup();
// give without item: no give-with interaction matches
assert.equal(r.resolve("give", { kind: "character", id: "barkeep", tags: [] }, null), null);
// give with wrong item: no match
assert.equal(r.resolve("give", { kind: "character", id: "barkeep", tags: [] }, "rope"), null);
});
test("conditional look on taken mug", () => {
const { w, r } = setup();
let it = r.resolve("look", { kind: "object", id: "mug", tags: ["container"] });
assert.ok(it.response.includes("suspicious"));
w.give("mug");
it = r.resolve("look", { kind: "object", id: "mug", tags: ["container"] });
assert.ok(it.response.includes("pocket"));
});
test("default responses per verb", () => {
const { r } = setup();
assert.equal(r.defaultResponse("use", { kind: "object", id: "post" }), "That doesn't seem to work.");
assert.equal(r.resolve("nope", { kind: "object", id: "post" }), null);
});
test("priority overrides order at equal specificity", () => {
const spec = structuredClone(example);
spec.interactions.unshift({ verb: "look", noun: "post", response: "HIGH", priority: 100 });
const c = compile(spec);
const w = new GameWorld(c);
const r = new VerbResolver(c); r.bindWorld(w);
const it = r.resolve("look", { kind: "object", id: "post", tags: [] });
assert.equal(it.response, "HIGH");
});
+58
View File
@@ -0,0 +1,58 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { pointInPolygon, nearestPointInPolygon, WalkArea } from "../src/core/walkable.js";
const square = [[0, 0], [100, 0], [100, 100], [0, 100]];
test("point in polygon", () => {
assert.equal(pointInPolygon([50, 50], square), true);
assert.equal(pointInPolygon([150, 50], square), false);
const tri = [[0, 0], [100, 0], [50, 100]];
assert.equal(pointInPolygon([50, 90], tri), true);
assert.equal(pointInPolygon([5, 90], tri), false);
});
test("nearest point clamps to boundary", () => {
const p = nearestPointInPolygon([150, 50], [square]);
assert.deepEqual(p, [100, 50]);
});
test("walk area contains/clamp", () => {
const wa = new WalkArea([square]);
assert.equal(wa.contains([10, 10]), true);
assert.deepEqual(wa.clamp([150, 50]), [100, 50]);
assert.deepEqual(wa.clamp([10, 10]), [10, 10]);
});
test("straight path inside one polygon", () => {
const wa = new WalkArea([square], { sampleStep: 20 });
const path = wa.path([10, 50], [90, 50]);
assert.ok(path, "path found");
assert.deepEqual(path[0], [10, 50]);
assert.deepEqual(path.at(-1), [90, 50]);
});
test("path between two polygons sharing an edge (doorway)", () => {
const left = [[0, 0], [100, 0], [100, 100], [0, 100]];
const right = [[100, 0], [200, 0], [200, 100], [100, 100]];
const wa = new WalkArea([left, right], { sampleStep: 20, portalEpsilon: 5 });
const path = wa.path([10, 50], [190, 50]);
assert.ok(path, "cross-polygon path found");
assert.deepEqual(path.at(-1), [190, 50]);
// every waypoint must be inside the walkable area
for (const p of path) assert.ok(wa.contains(p) || wa.clamp(p)[0] === p[0], `waypoint ${p} off-area`);
});
test("unreachable area returns null", () => {
const left = [[0, 0], [10, 0], [10, 10], [0, 10]];
const far = [[500, 500], [510, 500], [510, 510], [500, 510]];
const wa = new WalkArea([left, far], { sampleStep: 4, portalEpsilon: 5 });
assert.equal(wa.path([5, 5], [505, 505]), null);
});
test("click outside the area walks to the nearest edge", () => {
const wa = new WalkArea([square], { sampleStep: 20 });
const path = wa.path([50, 50], [150, 50]);
assert.ok(path);
assert.deepEqual(path.at(-1), wa.clamp([150, 50]));
});
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# run_all.sh — the whole verification pipeline, one command:
# 1. validate the example game spec
# 2. run the full test suite
# Never declare success without a passing run.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== validating example game spec"
node tools/validate_game.js example/game.json
echo "== tests"
node --test tests/*.test.js | tail -n 12
echo "ALL OK"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
// validate_game.js — CLI front door: check a game spec, print every problem
// at once. Exit 0 = valid (warnings allowed), 1 = invalid.
//
// node tools/validate_game.js example/game.json
import { readFileSync } from "node:fs";
import { validate } from "../src/core/loader.js";
const file = process.argv[2];
if (!file) {
console.error("usage: node tools/validate_game.js <game.json>");
process.exit(2);
}
let spec;
try {
spec = JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
console.error(`ERROR: ${file} is not valid JSON: ${e.message}`);
process.exit(1);
}
const { errors, warnings } = validate(spec);
for (const w of warnings) console.warn("WARNING:", w);
if (errors.length) {
console.error(`ERROR: ${file}: spec invalid:`);
for (const e of errors) console.error(" - " + e);
process.exit(1);
}
console.log(`OK: ${file} (${spec.rooms.length} rooms, ${spec.objects?.length ?? 0} objects, ` +
`${spec.characters.length} characters, ${spec.interactions?.length ?? 0} interactions, ` +
`${Object.keys(spec.dialogues ?? {}).length} dialogues, ${Object.keys(spec.scenes ?? {}).length} scenes)`);
+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>