diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/README.md b/README.md index e7fb882..124f5d1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ # 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. \ No newline at end of file +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. diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..a989a1f --- /dev/null +++ b/docs/SPEC.md @@ -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. diff --git a/example/game.json b/example/game.json new file mode 100644 index 0000000..4943634 --- /dev/null +++ b/example/game.json @@ -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 } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aac4da9 --- /dev/null +++ b/package.json @@ -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" } +} diff --git a/src/core/conditions.js b/src/core/conditions.js new file mode 100644 index 0000000..a023071 --- /dev/null +++ b/src/core/conditions.js @@ -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": } +// { "all": [...] } { "any": [...] } +// { "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; +} diff --git a/src/core/dialogue.js b/src/core/dialogue.js new file mode 100644 index 0000000..575be25 --- /dev/null +++ b/src/core/dialogue.js @@ -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; } +} diff --git a/src/core/effects.js b/src/core/effects.js new file mode 100644 index 0000000..92af102 --- /dev/null +++ b/src/core/effects.js @@ -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; +} diff --git a/src/core/engine.js b/src/core/engine.js new file mode 100644 index 0000000..ff3f8cc --- /dev/null +++ b/src/core/engine.js @@ -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 }); + } +} diff --git a/src/core/events.js b/src/core/events.js new file mode 100644 index 0000000..cb7d581 --- /dev/null +++ b/src/core/events.js @@ -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 }); + } +} diff --git a/src/core/loader.js b/src/core/loader.js new file mode 100644 index 0000000..5afe7c4 --- /dev/null +++ b/src/core/loader.js @@ -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; +} diff --git a/src/core/save.js b/src/core/save.js new file mode 100644 index 0000000..542b4f2 --- /dev/null +++ b/src/core/save.js @@ -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; +} diff --git a/src/core/verbs.js b/src/core/verbs.js new file mode 100644 index 0000000..6e25971 --- /dev/null +++ b/src/core/verbs.js @@ -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": , "effects": [...], +// "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}.`; + } +} diff --git a/src/core/walkable.js b/src/core/walkable.js new file mode 100644 index 0000000..33614e9 --- /dev/null +++ b/src/core/walkable.js @@ -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; + } +} diff --git a/src/core/world.js b/src/core/world.js new file mode 100644 index 0000000..2b95e5f --- /dev/null +++ b/src/core/world.js @@ -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; + } +} diff --git a/src/render/assets.js b/src/render/assets.js new file mode 100644 index 0000000..35ac926 --- /dev/null +++ b/src/render/assets.js @@ -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]; + } +} diff --git a/src/render/audio.js b/src/render/audio.js new file mode 100644 index 0000000..ff8da91 --- /dev/null +++ b/src/render/audio.js @@ -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 }); } +} diff --git a/src/render/canvas.js b/src/render/canvas.js new file mode 100644 index 0000000..bac21a4 --- /dev/null +++ b/src/render/canvas.js @@ -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); + } + } + } +} diff --git a/src/render/hud.js b/src/render/hud.js new file mode 100644 index 0000000..879432e --- /dev/null +++ b/src/render/hud.js @@ -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; } +} diff --git a/tests/conditions.test.js b/tests/conditions.test.js new file mode 100644 index 0000000..bf7b532 --- /dev/null +++ b/tests/conditions.test.js @@ -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"]); +}); diff --git a/tests/dialogue.test.js b/tests/dialogue.test.js new file mode 100644 index 0000000..76ab116 --- /dev/null +++ b/tests/dialogue.test.js @@ -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/); +}); diff --git a/tests/effects.test.js b/tests/effects.test.js new file mode 100644 index 0000000..312da6c --- /dev/null +++ b/tests/effects.test.js @@ -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"]); +}); diff --git a/tests/engine.test.js b/tests/engine.test.js new file mode 100644 index 0000000..63d7c6f --- /dev/null +++ b/tests/engine.test.js @@ -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); +}); diff --git a/tests/loader.test.js b/tests/loader.test.js new file mode 100644 index 0000000..e8b737a --- /dev/null +++ b/tests/loader.test.js @@ -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"))); +}); diff --git a/tests/render.test.js b/tests/render.test.js new file mode 100644 index 0000000..30e0a3a --- /dev/null +++ b/tests/render.test.js @@ -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"); +}); diff --git a/tests/save.test.js b/tests/save.test.js new file mode 100644 index 0000000..1dbeae4 --- /dev/null +++ b/tests/save.test.js @@ -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); +}); diff --git a/tests/verbs.test.js b/tests/verbs.test.js new file mode 100644 index 0000000..cbab81d --- /dev/null +++ b/tests/verbs.test.js @@ -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"); +}); diff --git a/tests/walkable.test.js b/tests/walkable.test.js new file mode 100644 index 0000000..c70e224 --- /dev/null +++ b/tests/walkable.test.js @@ -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])); +}); diff --git a/tools/run_all.sh b/tools/run_all.sh new file mode 100755 index 0000000..77a749d --- /dev/null +++ b/tools/run_all.sh @@ -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" diff --git a/tools/validate_game.js b/tools/validate_game.js new file mode 100644 index 0000000..12456ed --- /dev/null +++ b/tools/validate_game.js @@ -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 "); + 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)`); diff --git a/web/boot.js b/web/boot.js new file mode 100644 index 0000000..b281c10 --- /dev/null +++ b/web/boot.js @@ -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); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..df541ac --- /dev/null +++ b/web/index.html @@ -0,0 +1,44 @@ + + + + + +Moonlit Engine — skeleton demo + + + +
+ +
MOONLIT ENGINE — skeleton demo

Press any key / click to begin
+
+
+
+
+
+ + + + +
+ + +