From f15c16d82ae0c2e23e457099e43028aed52fb71b Mon Sep 17 00:00:00 2001 From: datagutt Date: Tue, 22 Sep 2026 22:18:35 +0200 Subject: [PATCH 001/153] docs(game): design record, plan, handoff and session hooks for Fjord Town Records the decisions from the design session, the milestone plan with task checkboxes, and a handoff file. A SessionStart hook injects the handoff and open tasks; a Stop hook asks for a handoff update when code commits are newer than the last one. --- .claude/hooks/game-session-start.sh | 46 +++++ .claude/hooks/game-stop-check.sh | 31 +++ .claude/settings.json | 26 +++ .gitignore | 4 +- docs/game/DESIGN.md | 217 ++++++++++++++++++++ docs/game/HANDOFF.md | 37 ++++ docs/game/PLAN.md | 297 ++++++++++++++++++++++++++++ docs/game/README.md | 44 +++++ 8 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 .claude/hooks/game-session-start.sh create mode 100644 .claude/hooks/game-stop-check.sh create mode 100644 .claude/settings.json create mode 100644 docs/game/DESIGN.md create mode 100644 docs/game/HANDOFF.md create mode 100644 docs/game/PLAN.md create mode 100644 docs/game/README.md diff --git a/.claude/hooks/game-session-start.sh b/.claude/hooks/game-session-start.sh new file mode 100644 index 0000000..d6c2699 --- /dev/null +++ b/.claude/hooks/game-session-start.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SessionStart hook: puts the Fjord Town handoff, the open tasks of the current +# milestone and recent git state into the session context. Plain stdout from a +# SessionStart hook is added to Claude's context. +set -u + +root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +docs="$root/docs/game" +[ -f "$docs/HANDOFF.md" ] || exit 0 + +branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)" + +echo "## Fjord Town game project: session context" +echo +echo "Branch: $branch. Read docs/game/README.md for the workflow; decisions are in" +echo "docs/game/DESIGN.md and must not be re-litigated. Update docs/game/HANDOFF.md and" +echo "tick docs/game/PLAN.md boxes in the same commit as the work." +echo +cat "$docs/HANDOFF.md" +echo + +if [ -f "$docs/PLAN.md" ]; then + # Print the first milestone section that still has unticked tasks, open tasks only. + awk ' + /^## M[0-9]+:/ { if (found) exit; heading = $0; open = 0; next } + /^## / { if (found) exit; heading = ""; next } + heading != "" && /^- \[ \] \*\*/ { + if (!found) { print "## Open tasks in current milestone"; print heading; found = 1 } + print; inTask = 1; next + } + /^- \[/ { inTask = 0; next } + inTask && /^ / { print; next } + { inTask = 0 } + ' "$docs/PLAN.md" + echo +fi + +echo "## Git state" +git -C "$root" log --oneline -5 2>/dev/null +status="$(git -C "$root" status --short 2>/dev/null | head -20)" +if [ -n "$status" ]; then + echo + echo "Uncommitted changes (possibly unfinished work from the last session):" + echo "$status" +fi +exit 0 diff --git a/.claude/hooks/game-stop-check.sh b/.claude/hooks/game-stop-check.sh new file mode 100644 index 0000000..533522d --- /dev/null +++ b/.claude/hooks/game-stop-check.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Stop hook: on the game branches, blocks the session from ending once when there +# are code commits newer than the last commit that touched docs/game/HANDOFF.md. +set -u + +input="$(cat)" +# Already continuing because of this hook: let the session stop, never loop. +if printf '%s' "$input" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then + exit 0 +fi + +root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +[ -f "$root/docs/game/HANDOFF.md" ] || exit 0 + +branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo)" +case "$branch" in + game | game/*) ;; + *) exit 0 ;; +esac + +last_code="$(git -C "$root" log -1 --format=%ct -- . ':(exclude)docs/game' 2>/dev/null)" +last_handoff="$(git -C "$root" log -1 --format=%ct -- docs/game/HANDOFF.md 2>/dev/null)" +[ -n "$last_code" ] || exit 0 +[ -n "$last_handoff" ] || last_handoff=0 + +if [ "$last_code" -gt "$last_handoff" ]; then + cat <<'JSON' +{"decision":"block","reason":"There are commits on the game branch newer than the last docs/game/HANDOFF.md update. Before stopping: rewrite docs/game/HANDOFF.md (current state, next step, blockers, gotchas), make sure finished tasks are ticked in docs/game/PLAN.md, and commit both. If this turn only answered a question and the handoff is already accurate, say so and stop."} +JSON +fi +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5374a5c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/game-session-start.sh\"", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/game-stop-check.sh\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index c0a898c..b3eedaf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -.claude/ +.claude/* +!.claude/settings.json +!.claude/hooks/ diff --git a/docs/game/DESIGN.md b/docs/game/DESIGN.md new file mode 100644 index 0000000..d1edd86 --- /dev/null +++ b/docs/game/DESIGN.md @@ -0,0 +1,217 @@ +# Fjord Town: design record + +The portfolio at datagutt.no is being rebuilt as a top-down pixel-art game. This file +records every decision made during the design grilling session on 2026-09-22, so later +sessions do not re-litigate them. If a decision changes, edit it here and add a line to +the changelog at the bottom. + +Related files: [PLAN.md](./PLAN.md) (tasks and progress), [HANDOFF.md](./HANDOFF.md) +(session-to-session state), [README.md](./README.md) (workflow rules). + +## 1. Audience and access + +- The game is the site. Built for curious visitors (devs, streamers) who explore. +- Visitors in a hurry are never locked out. The **Journal** is a plain, server-rendered + page at `/journal` with all content in boring normal HTML. It doubles as the SEO and + screen-reader path. It is linked from the title screen, a persistent corner button, + and the in-game START menu. +- Content is spread over several areas, buildings and NPCs. Talking to an NPC gives the + details. + +## 2. Genre and camera + +- Top-down, grid-based movement on a 16×16 tile grid (Pokémon, Undertale, Stardew). +- Celeste contributes game feel only: particles, screen shake, tight input, mood. +- A Terraria-style side-view mine is planned for v2 (tech stack moves there). + +## 3. Player and the datagutt NPC + +- The visitor is a newcomer to town. datagutt (Thomas) is an NPC who lives there. +- The datagutt NPC is live: location, animation and emote bubble are driven by Lanyard + presence (coding at the PC, headphones outside when Spotify plays, asleep when offline). +- **The datagutt sprite and portrait are based on `public/images/avatar.png`**: short + blond (yellow) hair, pale skin, thick black square glasses, light stubble, light-blue + top. Built from the closest character-generator and portrait-generator layers, then + hand-edited where the stock parts differ (the chunky glasses in particular). The + original avatar also appears as-is on the title screen, in the Journal and as the + framed photo in the house. + +## 4. Setting + +- A small Norwegian fjord town: red wooden houses, ferry dock, pine forest, hills, + aurora at night. A light tech twist: the buildings are the work. +- The site's green palette survives in UI accents and the aurora. +- Day and night follow the **visitor's local clock**. +- **Seasons follow the Norwegian calendar** (the town is in Norway): winter Dec to Feb + with snow, spring Mar to May, summer Jun to Aug, autumn Sep to Nov. Seasons ship in v1. + +## 5. Content map + +| Content | Place | Presenter | +|---|---|---| +| Arrival, controls, Journal | Ferry dock | Ferryman gives passport and explains controls | +| Hero (name, tagline, avatar) | datagutt's red house | datagutt NPC (live) and his PC | +| About | datagutt's house: bookshelf, wall photos, fridge notes | datagutt NPC and inspectable objects | +| Guac.tv | Boathouse studio at the harbour | Streamer NPC, wall of screens | +| IRLServer | Radio tower on the hill | Technician NPC, bonded cables | +| Donate.chat | Kiosk in the town square | Shopkeeper NPC, tip jar | +| Portfolio (this site) | Scale model of the town in datagutt's house | Meta joke: "you're standing in it" | +| Nettbureau (current job) | Office building, town centre | Coworker NPCs | +| Indre Østfold Data IKS | Town hall basement server room | Old sysadmin NPC | +| Tech stack (6 categories) | Smithy/workshop, tools on 6 racks | Smith NPC (moves to the mine in v2) | +| Open source (pinned repos, live) | Public library, one book per repo | Librarian NPC | +| GitHub stats and contributions (live) | Farm field, one tile per day, crop height = commits | Farmer NPC | +| Contact (email, socials) | Post office: write a letter, noticeboard | Postmaster NPC | + +## 6. Progression + +- **Fjord Passport**: every building's main NPC stamps it. Nothing is gated. +- A full passport unlocks the finale: night falls, aurora, datagutt on the pier, a short + credits roll built from the real stack, ending on the contact prompt. +- Progress lives in `localStorage` (versioned save). The Journal shows stamp progress. + +## 7. Engine and architecture + +- **Phaser 4** (4.2.x at time of writing), WebGL, custom shaders and filters. +- The game lives in `game/` and imports nothing from Next.js or React, so it can be + lifted out into a standalone game later (Tauri, Electron, Capacitor). +- `/` is a thin Next.js page: server-rendered HTML title screen, client-only dynamic + import of the game. Server-fetched world data is embedded as JSON in the page. +- `/journal` is a fully server-rendered page built from the same `content/` module. +- In-game UI (dialogue, menus, passport) is drawn in-engine with bitmap pixel fonts. + +## 8. Art + +- LimeZu packs, 16×16, one artist and one palette: **Modern Exteriors**, **Modern + Interiors** (includes the character generator), **Modern User Interface** (includes the + portrait generator). **Modern Office** is optional and not bought yet. +- Licence: commercial use and edits allowed, **credit to LimeZu required** + (https://limezu.itch.io/), **no redistribution**. The game needs a visible credit. +- **Mana Seed is banned for this project**: its licence forbids use alongside + AI-generated code. +- Custom pieces: red Norwegian house recolours, fjord cliffs. Snow, frost, water, + aurora, weather and lighting are shader or overlay work. +- NPC sprites and portraits are composited in code at build time from the generator + layer PNGs (body, eyes, outfit, hair, accessory). No GUI clicking. + +## 9. Licensed asset storage + +- Raw packs live in a **private repo `datagutt/datagutt-assets`** (local checkout at + `../datagutt-assets`). Only extracted 16×16 files and generator layers go in it, not + the zips, 32/48 px copies or executables. +- The public repo never contains licensed pixels. That includes **packed atlases**: + build output under `public/game/` is gitignored and produced at build time. +- Vercel cannot pull private git submodules, so the build clones the assets repo with a + read-only token (`ASSETS_REPO_TOKEN`). Locally the pipeline reads `ASSETS_DIR` + (default `../datagutt-assets`). +- Without the assets repo the build still works in **placeholder mode** (coloured + rectangles), so forks and clones stay buildable. + +## 10. Maps + +- Claude paints the maps; the user does small touch-ups in Tiled. +- **The generator is the source of truth.** Layout as code, prefabs, autotiling and + seeded scatter rules produce Tiled `.tmj` files that Phaser loads natively. +- Every map has `manual_*` layers (for example `manual_decor`, `manual_erase`, + `manual_collision`) that the generator never overwrites and merges last. +- A render-to-PNG step lets Claude look at each map and iterate visually. +- A validator checks doors, NPC ids, stamp ids, collision and edits to generated layers. + +## 11. Dialogue + +- Voice: warm Stardew base, Undertale seasoning. Facts are stated plainly; jokes live in + optional lines, repeat conversations and inspectable objects (`* ` narration). + Light Norwegian flavour. +- Claude drafts every line; the user edits. +- Written in **Ink** (`inkjs` runtime, compiled at build time). Facts come from the + `content/` module and live data through Ink external functions such as + `project_desc("irlserver")`. A validator fails the build on unknown ids. +- Portraits with talk, nod and head-shake animations come from the portrait generator. + Portraits are optional per NPC. + +## 12. Content source of truth + +- One typed `content/` module holds all copy: what is now in `data/*.ts` plus the text + inlined in `Hero`, `About` and `Contact`. The Journal and the game read only from it. +- Live data (`lib/github.ts`: pinned repos, stats, contributions) keeps its current + server-side caching and feeds both. + +## 13. Controls and viewport + +- Touch: **tap to move** with A* pathfinding; tap an NPC or door to walk there and + interact; tap anywhere to advance dialogue. No virtual D-pad for now. +- Desktop: arrows/WASD, E/Space/Z interact, X/Esc back, Enter START menu, click to + move, gamepad via Phaser. +- Fixed tile height, variable width, integer zoom, camera clamped to map bounds. +- The Journal button is always visible on touch devices. + +## 14. Multiplayer + +- **Ghost visitors**: other visitors appear as translucent, randomly tinted sprites in + real time, with emote bubbles. No chat. +- Presence is sent only on tile change. One room per map. At most about 20 ghosts + drawn per map. Ghosts fade after 30 s idle. Random names such as "Traveller from Bergen". +- Transport stays on the existing Vercel WebSocket route (`/api/reactions/ws`, to be + renamed). The single-instance fan-out limit is accepted; upgrade path is Upstash Redis + pub/sub or a Cloudflare Durable Object per room. +- Kill switch `rx_off` stays, plus a "Show other visitors" setting. + +## 15. Audio + +- v1: layered ambience (waves, gulls, wind, rain, indoor fire) from CC0 sources, sound + effects synthesised in code (jsfxr-style), per-NPC Undertale-style dialogue blips. +- Music is **not** in v1, but the audio system supports per-area music layers, day/night + variants and crossfades from day one. Music is added in v1.1. +- Audio starts on the title screen's user gesture. Mute is persisted. + +## 16. First 10 seconds + +1. Server-rendered HTML title screen (under 50 KB): fjord at dusk, logo, **Start** and + **Journal**. +2. Phaser, atlases, Ink and the first map stream in behind it. Start shows progress. +3. First visit: skippable ferry arrival (about 10 s). Returning visit: Continue or New + game. +4. `?at=` deep links skip the title and spawn outside that place. +- Budget: about 1 MB gzipped for the game payload, interiors lazy-loaded, 60 fps on a + mid-range phone, shader quality tiers. + +## 17. Fate of the current site + +- The 5 canvases become arcade cabinets, the PC screensaver and the hill telescope + (v1.1). The dungeon generator may lay out the v2 mine. +- GSAP ScrollTrigger sections, ScrollText, pixel dividers, glitch CSS and the section + components are deleted. +- The Lanyard card is replaced by the live NPC and a status screen in the START menu. + `react-use-lanyard` is replaced by a small plain WebSocket client. +- The reactions overlay is replaced by ghosts and emotes. + +## 18. Scope + +- **v1 (launch)**: overworld and every interior on the content map, all NPCs with Ink + dialogue and portraits, live datagutt NPC, passport and finale, day/night, **seasons**, + water and aurora shaders, particles and screen shake, ghosts and emotes, ambience and + effects and blips, title screen and ferry intro and Continue and deep links, `/journal`, + tap-to-move and keyboard and gamepad, LimeZu credit. +- **v1.1**: arcade cabinets, secrets and achievements, music. +- **v2**: Terraria-style mine, weather. + +## 19. Rollout + +- Long-lived `game` branch with Vercel preview deployments. +- Production stays on the current site until v1 is complete, then one switch-over merge + to `master`. + +## Open questions (defaults in force until the user says otherwise) + +| Question | Default | +|---|---| +| Internal resolution | 16 px tiles, integer zoom chosen so the short screen axis shows at least 11 tiles | +| NPC names and personalities | Claude drafts them in M2 alongside the Ink scripts, user edits | +| Lanyard activity to NPC location mapping | Coding app → PC in house; Spotify → bench by the fjord with headphones; online idle → wandering town square; offline → asleep in bed; custom status text → speech bubble | +| Ghost name format | "Traveller from ", random per session | +| Analytics | None added; existing setup untouched | +| Language | English, with occasional Norwegian words | + +## Changelog + +- 2026-09-22: initial record from the grilling session. diff --git a/docs/game/HANDOFF.md b/docs/game/HANDOFF.md new file mode 100644 index 0000000..4da1234 --- /dev/null +++ b/docs/game/HANDOFF.md @@ -0,0 +1,37 @@ +# Handoff + +Last updated: 2026-09-22 (session 1: design grilling and planning) + +## Current state + +- Branch `game` created from `master` at `bc7041e`. +- Design settled in a grilling session; everything is recorded in DESIGN.md. +- Planning docs, session hooks and milestone issues are in place (M0.1 to M0.4 done). +- No game code yet. The live site on `master` is untouched. +- The user bought and downloaded LimeZu Modern Exteriors, Modern Interiors (with the + character generator) and Modern User Interface (with the portrait generator). The zips + sit unextracted in `../datagutt-assets/`, which is not yet a git repo. + +## Next step + +**M0.5**: extract the 16×16 folders and generator layers in `../datagutt-assets/`, add +`LICENSES.md`, `git init`, and ask the user before creating the private GitHub repo +`datagutt/datagutt-assets`. Then M0.6 to M0.11. + +## Blockers and things waiting on the user + +- Creating the private GitHub repo `datagutt/datagutt-assets` (M0.5). +- A fine-grained read-only token for that repo, stored in Vercel as `ASSETS_REPO_TOKEN` + (M0.11). +- Review of the NPC roster and dialogue drafts later in M2. + +## Gotchas learned so far + +- Vercel cannot pull private git submodules, so the build clones the assets repo with a + token instead (DESIGN §9). +- The Mana Seed licence forbids use alongside AI-generated code. Do not use it. +- `.gitignore` tracks only `.claude/settings.json` and `.claude/hooks/`; everything else in + `.claude/` (such as `settings.local.json`) stays ignored. +- `jq` is not installed on this machine; hook scripts use plain shell and node. +- The datagutt NPC must match `public/images/avatar.png` (blond hair, chunky black square + glasses, stubble, light-blue top). diff --git a/docs/game/PLAN.md b/docs/game/PLAN.md new file mode 100644 index 0000000..39953dc --- /dev/null +++ b/docs/game/PLAN.md @@ -0,0 +1,297 @@ +# Fjord Town: implementation plan + +This file is the **single source of truth for task progress**. Tick a box in the same +commit as the work that completes it. The GitHub issues (one per milestone) only hold the +goal and discussion; they link here and close when the milestone's exit criteria pass. + +Decisions live in [DESIGN.md](./DESIGN.md). Session state lives in +[HANDOFF.md](./HANDOFF.md). Workflow rules are in [README.md](./README.md). + +Task format: `- [ ] **M1.3** Title: what to do. *Done when* the observable check.` +Tasks are ordered by dependency within a milestone. Milestones can overlap once their +dependencies are done (see the graph). + +``` +M0 Foundations ─┬─> M1 Engine core ─┬─> M3 World ──┬─> M5 Atmosphere ─┐ + └─> M2 Content ─────┴─> M4 Live ───┴───────────────────┴─> M6 Journal & launch +``` + +--- + +## M0: Foundations and tooling + +Goal: repo, assets and toolchain ready so game code can start. +Issue: see README.md (issue links table). + +- [x] **M0.1** Create the `game` branch. *Done when* the branch exists. +- [x] **M0.2** Write DESIGN, PLAN, HANDOFF and README under `docs/game/`. *Done when* + committed. +- [x] **M0.3** Session continuity: SessionStart hook injects HANDOFF and open tasks; Stop + hook blocks when code commits are newer than the last HANDOFF commit. *Done when* + both hooks are in `.claude/settings.json` and pipe-tested. +- [x] **M0.4** GitHub issues, one per milestone, labelled `game`. *Done when* the issue + links table in README.md is filled in. +- [ ] **M0.5** Private assets repo. In `../datagutt-assets`: extract the 16×16 folders + from the Exteriors, Interiors and UI zips plus the character and portrait generator + layer PNGs into `limezu/{exteriors,interiors,ui,characters,portraits}/`; add + `LICENSES.md` quoting each pack's licence; `git init`; create the private GitHub + repo `datagutt/datagutt-assets` and push. Zips, `.exe` and 32/48 px copies stay + out (`.gitignore`). *Done when* the private repo exists and a clone is under 300 MB. + **Needs the user** to approve creating the GitHub repo. +- [ ] **M0.6** Asset fetch step `scripts/assets/fetch.mjs`: use `ASSETS_DIR` if set + (default `../datagutt-assets`); else if `ASSETS_REPO_TOKEN` is set, shallow-clone + the private repo into `.assets-cache/`; else placeholder mode with a clear log + line. *Done when* all three paths are tested locally. +- [ ] **M0.7** Gitignore licensed output: `public/game/`, `.assets-cache/`, + `game/generated/`. (The `.claude/` rules were already fixed in M0.3.) *Done when* + `git status` is clean after a full asset build. +- [ ] **M0.8** Dependencies: `phaser@^4`, `inkjs`, a texture packer + (`free-tex-packer-core` or own maxrects), `pngjs` or `sharp` for compositing, + `vitest`, `@playwright/test`. Verify Phaser 4 API docs from `node_modules` rather + than memory. *Done when* `pnpm build` passes with the new deps. +- [ ] **M0.9** Boundary rule: ESLint `no-restricted-imports` so `game/**` cannot import + `next`, `react` or `@/app`, `@/components`. *Done when* a deliberate bad import + fails `pnpm lint`. +- [ ] **M0.10** Test setup: `pnpm test` (vitest) for pure logic and `pnpm test:e2e` + (Playwright) for smoke tests of `/` and `/journal`. *Done when* one trivial test of + each kind passes. +- [ ] **M0.11** Vercel: add `ASSETS_REPO_TOKEN` (fine-grained, read-only, single repo) + for Preview and Production; wire `prebuild` to run fetch and the asset pipeline. + **Needs the user** to create the token. *Done when* a preview deployment of `game` + builds with real art. + +## M1: Engine core (greybox) + +Goal: walk around a greybox town, enter a building, talk to a stub NPC, on desktop and +phone. + +- [ ] **M1.1** Next.js shell: `app/page.tsx` renders the HTML title screen (static, + under 50 KB) and a client-only `GameMount` that dynamically imports `game/boot.ts`. + The old page moves to `app/_legacy/` until M6 so it can be compared. *Done when* + `/` shows the title and Start boots Phaser. +- [ ] **M1.2** Phaser config: WebGL, `pixelArt: true`, `roundPixels`, integer zoom so the + short axis shows at least 11 tiles, resize handling, DPR-aware. Scenes: `Boot`, + `Preload`, `World`, `UI` (overlay), `Cutscene`. *Done when* the canvas is crisp at + 1×, 2× and 3× DPR with no subpixel shimmer while the camera moves. +- [ ] **M1.3** Asset pipeline v1 `scripts/assets/build.mjs`: read the manifest + `game/assets.manifest.ts` (which sprites and tiles are used), pack atlases into + `public/game/atlas/*.png|json`, emit a typed key list `game/generated/keys.ts`. + Placeholder mode emits coloured rectangles with the same keys. *Done when* both + modes produce loadable atlases. +- [ ] **M1.4** Tiled loader: load `.tmj` maps, tile layers, object layers (`door`, `npc`, + `sign`, `stamp`, `spawn`, `trigger`), collision from a `collision` layer and tile + properties. *Done when* a hand-made test map renders with collision. +- [ ] **M1.5** Grid movement: 4-direction, tile-locked, walk and run speeds, facing, input + buffering so turning feels tight (Celeste standard: no dropped inputs). *Done when* + unit tests cover the movement state machine and it feels good by hand. +- [ ] **M1.6** Interaction: face a tile and press interact; objects dispatch by type. + *Done when* a sign shows text. +- [ ] **M1.7** Doors and warps: fade transition, target map and spawn id, lazy-load + interior maps and atlases on first entry. *Done when* entering and leaving a + building keeps facing and position correct. +- [ ] **M1.8** Tap and click to move: A* on the collision grid (pure function, unit + tested), path preview dots, tap on NPC or door walks adjacent then interacts. + *Done when* it works on a real phone. +- [ ] **M1.9** Input layer: keyboard, gamepad, pointer unified into actions + (`move`, `interact`, `back`, `menu`). *Done when* all three drive the same actions. +- [ ] **M1.10** Save system: versioned `localStorage` blob (position, map, stamps, flags, + visited dialogue, settings) with migration and try/catch everywhere. *Done when* + reload restores state and corrupt data falls back to a new game. +- [ ] **M1.11** Deep links: `?at=` spawns outside that place and skips the + title. *Done when* every place id in the content map works. +- [ ] **M1.12** Greybox maps: overworld plus one interior, generated by a minimal + version of the generator (M3.1) or hand-written, enough to exercise M1.4–M1.11. + *Done when* the whole loop runs in placeholder mode. +- [ ] **M1.13** Perf harness: an FPS and frame-time overlay behind `?debug`, plus a + Playwright check that the game boots without console errors. *Done when* in CI. + +## M2: Content, dialogue and UI + +Goal: every piece of site content is reachable through NPC dialogue with portraits, and +the passport works. + +- [ ] **M2.1** `content/` module: `profile.ts` (name, tagline, about paragraphs, quick + facts, email, socials), `projects.ts`, `experience.ts`, `skills.ts`, `places.ts` + (place ids, names, map positions, stamp ids). Move `data/*.ts` and the copy inlined + in `Hero`, `About`, `Contact`, `Socials`. *Done when* no copy is left in components + and types compile. +- [ ] **M2.2** WorldState payload: a server function combining `content/` with live + GitHub data (repos, stats, contributions) into one typed JSON object, embedded in + `/` as ` + + + +`, +); + +const ctx = await esbuild.context({ + entryPoints: { harness: "game/dev/harness.ts" }, + bundle: true, + format: "esm", + outdir: outDir, + sourcemap: true, + target: "es2022", + define: { "process.env.NODE_ENV": '"development"' }, + logLevel: "info", +}); + +await ctx.watch(); +const { port: actualPort } = await ctx.serve({ servedir: path.join(root, "public"), port }); +console.log(`[game-dev] http://localhost:${actualPort}/game/dev.html`); From ae820aec9a0d1c6241f381fb14fc46f59da979da Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 23 Sep 2026 00:24:56 +0200 Subject: [PATCH 006/153] Relicense under GPL-3.0-or-later The repository previously had no licence. Code is now GPL-3.0-or-later; the README lists what is not covered (Pangram Pangram fonts, images, LimeZu art, Geist fonts under OFL). --- LICENSE | 674 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 19 ++ package.json | 12 +- 3 files changed, 701 insertions(+), 4 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 1f7605f..5684d87 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,22 @@ Why sleep when you can create a portfolio? ```bash pnpm dev ``` + +## License + +Copyright (C) 2023-2026 Thomas Lekanger (datagutt). + +The code in this repository is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. See +[LICENSE](./LICENSE). + +Not covered by the GPL: + +- `fonts/`: PP Mondwest and PP NeueBit by Pangram Pangram, under their own + [licence](./fonts/EULA-PangramPangram-FreeForPersonalUse-MAY2021.pdf). +- `public/images/`: the avatar and project screenshots. All rights reserved. +- Game art: the LimeZu tilesets and characters are licensed separately, live in a private + repository and are never committed here (see `docs/game/DESIGN.md` §9). +- Geist and Geist Pixel fonts come from the `geist` package under the SIL Open Font License. + diff --git a/package.json b/package.json index 2e9c59d..b0fbdab 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,10 @@ "name": "datagutt", "version": "0.1.0", "private": true, + "license": "GPL-3.0-or-later", "scripts": { "dev": "next dev", - "build": "node scripts/assets/fetch.mjs && next build", + "build": "pnpm assets && next build", "start": "next start", "lint": "eslint . --ext .ts,.tsx,.js,.mjs", "format": "prettier '**/*.{cjs,mjs,ts,tsx,md,json}' --ignore-path ../.gitignore --ignore-unknown --no-error-on-unmatched-pattern --write", @@ -12,8 +13,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "assets:fetch": "node scripts/assets/fetch.mjs", - "game:dev": "node scripts/game-dev.mjs" + "game:dev": "pnpm assets && node scripts/game-dev.mjs", + "assets": "node scripts/assets/fetch.mjs && node scripts/assets/build.mjs" }, "dependencies": { "@spissvinkel/simplex-noise": "^1.0.1", @@ -47,13 +48,16 @@ "esbuild": "^0.28.2", "eslint": "^8.57.1", "eslint-config-next": "15.2.2", + "opentype.js": "^2.0.0", "pngjs": "^7", "postcss": "^8.5.6", "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.5.14", + "sharp": "^0.35.4", "tailwindcss": "^3.4.18", "typescript": "^5.9.3", - "vitest": "^5" + "vitest": "^5", + "wawoff2": "^2.0.1" }, "packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903" } From b8f56115e52182c633205ed9e608ac1522772e3b Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 23 Sep 2026 00:48:50 +0200 Subject: [PATCH 007/153] feat(game): asset pipeline and greybox maps (M1.3, M1.12) - scripts/assets/build.mjs writes public/game/ (gitignored): characters composited from LimeZu generator layers with colour swaps (datagutt matches the avatar), or placeholder figures without the art repo; a generated greybox tileset; Tiled .tmj maps; Geist Pixel as a 1-bit bitmap font. - world/: a small map-drawing API and the greybox town and house, written as code (the seed of the M3 generator). - Lockfile for sharp, opentype.js and wawoff2 (the previous commit's package.json change landed without it). --- game/assets/manifest.ts | 59 ++++++++++++++ game/characters/sheet.ts | 33 ++++++++ game/package.json | 3 + game/world/objects.ts | 79 ++++++++++++++++++ pnpm-lock.yaml | 51 ++++++------ scripts/assets/build.mjs | 164 ++++++++++++++++++++++++++++++++++++++ scripts/assets/font.mjs | 125 +++++++++++++++++++++++++++++ scripts/assets/raster.mjs | 25 ++++++ tsconfig.json | 1 + world/greybox/maps.ts | 120 ++++++++++++++++++++++++++++ world/greybox/tiles.ts | 47 +++++++++++ world/grid.ts | 77 ++++++++++++++++++ world/package.json | 3 + world/tiled.ts | 91 +++++++++++++++++++++ 14 files changed, 855 insertions(+), 23 deletions(-) create mode 100644 game/assets/manifest.ts create mode 100644 game/characters/sheet.ts create mode 100644 game/package.json create mode 100644 game/world/objects.ts create mode 100644 scripts/assets/build.mjs create mode 100644 scripts/assets/font.mjs create mode 100644 scripts/assets/raster.mjs create mode 100644 world/greybox/maps.ts create mode 100644 world/greybox/tiles.ts create mode 100644 world/grid.ts create mode 100644 world/package.json create mode 100644 world/tiled.ts diff --git a/game/assets/manifest.ts b/game/assets/manifest.ts new file mode 100644 index 0000000..96287b8 --- /dev/null +++ b/game/assets/manifest.ts @@ -0,0 +1,59 @@ +// What the asset build produces. Read at build time by scripts/assets/build.mjs (through +// Node's type stripping, so no imports and only erasable TypeScript here) and at runtime +// by the game. Paths in recipes are relative to limezu/characters/ in datagutt-assets. + +export type CharacterRecipe = { + /** Layers in LimeZu's stacking order: body, eyes, outfit, hair, then accessories. */ + layers: string[]; + /** Exact colour swaps (hex without #) applied to every layer, for custom hair and the like. */ + recolor?: Record; + /** Main colour of the stand-in sprite drawn in placeholder mode. */ + placeholder: string; +}; + +export const CHARACTERS = { + /** The visitor: a traveller with a backpack. */ + player: { + layers: [ + "Bodies/Body_03.png", + "Eyes/Eyes_01.png", + "Outfits/Outfit_01_05.png", + "Hairstyles/Hairstyle_05_03.png", + "Accessories/Accessory_03_Backpack_01.png", + ], + placeholder: "4f8a5b", + }, + /** + * datagutt, matched to public/images/avatar.png: pale skin, short flat yellow hair, + * black glasses, light-blue sweater. Hair and glasses are recoloured from stock. + */ + datagutt: { + layers: [ + "Bodies/Body_02.png", + "Eyes/Eyes_01.png", + "Outfits/Outfit_14_04.png", + "Hairstyles/Hairstyle_20_01.png", + "Accessories/Accessory_15_Glasses_01.png", + ], + recolor: { + cc9659: "f7d768", + b37b3f: "e6bb3a", + ab6736: "c4952a", + "58616f": "1b1b24", + }, + placeholder: "e6bb3a", + }, + ferryman: { + layers: [ + "Bodies/Body_04.png", + "Eyes/Eyes_02.png", + "Outfits/Outfit_17_01.png", + "Hairstyles/Hairstyle_08_05.png", + "Accessories/Accessory_11_Beanie_01.png", + "Accessories/Accessory_13_Beard_05.png", + ], + placeholder: "3d6f8e", + }, +} satisfies Record; + +export type CharacterId = keyof typeof CHARACTERS; diff --git a/game/characters/sheet.ts b/game/characters/sheet.ts new file mode 100644 index 0000000..eb9ff2d --- /dev/null +++ b/game/characters/sheet.ts @@ -0,0 +1,33 @@ +// Frame layout of a LimeZu character sheet (16x16 pack): 16×32 frames, 56 per row, +// directions always in the order right, up, left, down. The build keeps rows 0..6. + +export const FRAME_WIDTH = 16; +export const FRAME_HEIGHT = 32; +export const SHEET_COLUMNS = 56; +/** Rows copied into our built sheets: stand, idle, walk, sleep, sit, sit, phone. */ +export const SHEET_ROWS = 7; + +export const DIRECTIONS = ["right", "up", "left", "down"] as const; +export type Direction = (typeof DIRECTIONS)[number]; + +type AnimSpec = { row: number; framesPerDirection: number; frameRate: number; repeat: number }; + +export const ANIMS = { + stand: { row: 0, framesPerDirection: 1, frameRate: 1, repeat: 0 }, + idle: { row: 1, framesPerDirection: 6, frameRate: 6, repeat: -1 }, + walk: { row: 2, framesPerDirection: 6, frameRate: 12, repeat: -1 }, + phone: { row: 6, framesPerDirection: 3, frameRate: 4, repeat: -1 }, +} satisfies Record; +export type AnimName = keyof typeof ANIMS; + +/** Frame indices for one animation facing one direction. */ +export function animFrames(anim: AnimName, dir: Direction): number[] { + const spec = ANIMS[anim]; + const start = spec.row * SHEET_COLUMNS + DIRECTIONS.indexOf(dir) * spec.framesPerDirection; + return Array.from({ length: spec.framesPerDirection }, (_, i) => start + i); +} + +/** Animation key, unique per character, animation and direction. */ +export function animKey(character: string, anim: AnimName, dir: Direction): string { + return `${character}:${anim}:${dir}`; +} diff --git a/game/package.json b/game/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/game/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/game/world/objects.ts b/game/world/objects.ts new file mode 100644 index 0000000..3783f4d --- /dev/null +++ b/game/world/objects.ts @@ -0,0 +1,79 @@ +// Things placed on a map's object layer. The map build writes them as Tiled objects +// (`type` plus custom properties) and the game reads them back with parseMapObject(). +// Coordinates are in tiles. + +export type Facing = "right" | "up" | "left" | "down"; + +export type MapObject = + | { type: "spawn"; id: string; x: number; y: number; facing: Facing } + | { type: "door"; x: number; y: number; toMap: string; toSpawn: string } + | { type: "sign"; x: number; y: number; text: string } + | { type: "npc"; id: string; character: string; x: number; y: number; facing: Facing; text: string }; + +export type TiledProperty = { name: string; type: "string" | "int" | "bool"; value: string | number | boolean }; + +export type TiledObject = { + id: number; + name: string; + type: string; + x: number; + y: number; + width: number; + height: number; + rotation: number; + visible: boolean; + properties?: TiledProperty[]; +}; + +const FACINGS: readonly Facing[] = ["right", "up", "left", "down"]; + +/** Reads a Tiled object back into a MapObject, or explains what is wrong with it. */ +export function parseMapObject(obj: TiledObject, tileSize: number): MapObject { + const props = new Map((obj.properties ?? []).map((p) => [p.name, p.value])); + const str = (name: string): string => { + const v = props.get(name); + if (typeof v !== "string" || v === "") throw new Error(`${obj.type} object ${obj.id} needs a "${name}" string property`); + return v; + }; + const facing = (): Facing => { + const v = str("facing"); + if (!FACINGS.includes(v as Facing)) throw new Error(`${obj.type} object ${obj.id} has invalid facing "${v}"`); + return v as Facing; + }; + const x = Math.floor(obj.x / tileSize); + const y = Math.floor(obj.y / tileSize); + + switch (obj.type) { + case "spawn": + return { type: "spawn", id: obj.name || str("id"), x, y, facing: facing() }; + case "door": + return { type: "door", x, y, toMap: str("toMap"), toSpawn: str("toSpawn") }; + case "sign": + return { type: "sign", x, y, text: str("text") }; + case "npc": + return { type: "npc", id: obj.name || str("id"), character: str("character"), x, y, facing: facing(), text: str("text") }; + default: + throw new Error(`Unknown map object type "${obj.type}" (object ${obj.id})`); + } +} + +/** The reverse of parseMapObject, used by the map build. */ +export function toTiledObject(obj: MapObject, id: number, tileSize: number): TiledObject { + const { type, x, y, ...rest } = obj; + const name = "id" in rest ? rest.id : ""; + const properties: TiledProperty[] = Object.entries(rest) + .filter(([key]) => key !== "id") + .map(([key, value]) => ({ name: key, type: "string", value: String(value) })); + return { + id, + name, + type, + x: x * tileSize, + y: y * tileSize, + width: tileSize, + height: tileSize, + rotation: 0, + visible: true, + ...(properties.length ? { properties } : {}), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02f4f6c..3bad802 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: eslint-config-next: specifier: 15.2.2 version: 15.2.2(eslint@8.57.1)(typescript@5.9.3) + opentype.js: + specifier: ^2.0.0 + version: 2.0.0 pngjs: specifier: ^7 version: 7.0.0 @@ -108,6 +111,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.5.14 version: 0.5.14(prettier@3.7.4) + sharp: + specifier: ^0.35.4 + version: 0.35.4(@types/node@24.13.6) tailwindcss: specifier: ^3.4.18 version: 3.4.18(yaml@2.6.0) @@ -117,6 +123,9 @@ importers: vitest: specifier: ^5 version: 5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@1.21.7)(yaml@2.6.0)) + wawoff2: + specifier: ^2.0.1 + version: 2.0.1 packages: @@ -148,9 +157,6 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -2030,6 +2036,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + opentype.js@2.0.0: + resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2335,11 +2345,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2684,6 +2689,10 @@ packages: jsdom: optional: true + wawoff2@2.0.1: + resolution: {integrity: sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==} + hasBin: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2775,11 +2784,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -2923,8 +2927,7 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: @@ -3049,7 +3052,7 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.1 optional: true @@ -3274,7 +3277,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.48.1 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.8.5 tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -4309,7 +4312,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.8.5 is-callable@1.2.7: {} @@ -4667,6 +4670,8 @@ snapshots: dependencies: mimic-fn: 2.1.0 + opentype.js@2.0.0: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4926,10 +4931,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} - - semver@7.8.5: - optional: true + semver@7.8.5: {} set-function-length@1.2.2: dependencies: @@ -4985,7 +4987,6 @@ snapshots: '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 '@types/node': 24.13.6 - optional: true shebang-command@2.0.0: dependencies: @@ -5329,6 +5330,10 @@ snapshots: transitivePeerDependencies: - msw + wawoff2@2.0.1: + dependencies: + argparse: 2.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/scripts/assets/build.mjs b/scripts/assets/build.mjs new file mode 100644 index 0000000..f05ccd7 --- /dev/null +++ b/scripts/assets/build.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +// Builds everything the game loads into public/game/ (gitignored: it contains licensed +// pixels when real art is available). Run scripts/assets/fetch.mjs first; `pnpm build` +// does both. Placeholder mode produces files of the same shape without LimeZu art. +import fs from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; +import { CHARACTERS } from "../../game/assets/manifest.ts"; +import { FRAME_HEIGHT, FRAME_WIDTH, SHEET_COLUMNS, SHEET_ROWS, DIRECTIONS, ANIMS } from "../../game/characters/sheet.ts"; +import { GREYBOX_MAPS } from "../../world/greybox/maps.ts"; +import { GREYBOX_TILES } from "../../world/greybox/tiles.ts"; +import { toTmj } from "../../world/tiled.ts"; +import { buildBitmapFont } from "./font.mjs"; +import { Raster, hex } from "./raster.mjs"; + +const TILE = 16; +const root = process.cwd(); +const outDir = path.join(root, "public/game"); +const sourceFile = path.join(root, ".assets-cache/source.json"); + +if (!fs.existsSync(sourceFile)) { + console.error("[assets] .assets-cache/source.json is missing: run `pnpm assets` (fetch + build) instead."); + process.exit(1); +} +const source = JSON.parse(fs.readFileSync(sourceFile, "utf8")); +const charactersDir = source.dir && path.join(source.dir, "limezu/characters"); + +for (const sub of ["characters", "tilesets", "maps", "fonts"]) { + fs.rmSync(path.join(outDir, sub), { recursive: true, force: true }); + fs.mkdirSync(path.join(outDir, sub), { recursive: true }); +} + +// --- Greybox tileset ------------------------------------------------------------- + +const TILESET_COLUMNS = 8; +async function buildGreyboxTileset() { + const rows = Math.ceil(GREYBOX_TILES.length / TILESET_COLUMNS); + const img = new Raster(TILESET_COLUMNS * TILE, rows * TILE); + GREYBOX_TILES.forEach((tile, i) => { + const ox = (i % TILESET_COLUMNS) * TILE; + const oy = Math.floor(i / TILESET_COLUMNS) * TILE; + const base = hex(tile.base); + const accent = hex(tile.accent); + img.rect(ox, oy, TILE, TILE, base); + for (let y = 0; y < TILE; y++) { + for (let x = 0; x < TILE; x++) { + let on = false; + switch (tile.pattern) { + case "speckle": on = ((x * 7 + y * 13 + i * 5) % 11 === 0); break; + case "dots": on = x % 4 === 1 && y % 4 === (x % 8 === 1 ? 1 : 3); break; + case "stripes": on = y % 4 === 3; break; + case "waves": on = (y % 6 === 2 && (x + y) % 8 < 4) || (y % 6 === 5 && (x + y + 4) % 8 < 3); break; + case "bricks": on = y % 4 === 3 || (x + (Math.floor(y / 4) % 2) * 4) % 8 === 0; break; + case "planks": on = y % 5 === 4 || (x === (y < 8 ? 5 : 11)); break; + case "block": on = x >= 3 && x <= 12 && y >= 3 && y <= 12 && (x === 3 || x === 12 || y === 3 || y === 12); break; + case "cross": on = x === 0 || y === 0; break; + case "flat": break; + } + if (on) img.px(ox + x, oy + y, accent); + } + } + }); + await sharp(await img.toPng()).toFile(path.join(outDir, "tilesets/greybox.png")); + return { + name: "greybox", + image: "../tilesets/greybox.png", + columns: TILESET_COLUMNS, + tileCount: GREYBOX_TILES.length, + tileSize: TILE, + colliding: GREYBOX_TILES.flatMap((t, i) => (t.collides ? [i] : [])), + }; +} + +// --- Characters -------------------------------------------------------------------- + +const SHEET_W = SHEET_COLUMNS * FRAME_WIDTH; +const SHEET_H = SHEET_ROWS * FRAME_HEIGHT; + +async function recolor(buffer, map) { + const { data, info } = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const swaps = new Map(Object.entries(map).map(([from, to]) => [from.toLowerCase(), hex(to)])); + for (let i = 0; i < data.length; i += 4) { + if (!data[i + 3]) continue; + const key = ((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]).toString(16).padStart(6, "0"); + const to = swaps.get(key); + if (to) data.set(to.slice(0, 3), i); + } + return sharp(data, { raw: info }).png().toBuffer(); +} + +async function composeCharacter(id, recipe) { + const layers = []; + for (const layer of recipe.layers) { + const file = path.join(charactersDir, layer); + if (!fs.existsSync(file)) throw new Error(`Character "${id}": layer ${layer} does not exist in the assets repo`); + // Some sheets are wider than the grid (Body_01 is 927 px); crop to the frames we use. + let buf = await sharp(file).extract({ left: 0, top: 0, width: SHEET_W, height: SHEET_H }).png().toBuffer(); + if (recipe.recolor) buf = await recolor(buf, recipe.recolor); + layers.push({ input: buf }); + } + return sharp({ create: { width: SHEET_W, height: SHEET_H, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) + .composite(layers) + .png() + .toBuffer(); +} + +/** A stand-in figure with the same frame layout: body colour, lighter head, eyes that face. */ +async function placeholderCharacter(recipe) { + const img = new Raster(SHEET_W, SHEET_H); + const body = hex(recipe.placeholder); + const head = [...body.slice(0, 3).map((c) => Math.min(255, c + 70)), 255]; + const outline = hex("1b1b24"); + for (const spec of Object.values(ANIMS)) { + DIRECTIONS.forEach((dir, d) => { + for (let f = 0; f < spec.framesPerDirection; f++) { + const ox = (d * spec.framesPerDirection + f) * FRAME_WIDTH; + const oy = spec.row * FRAME_HEIGHT; + const bob = spec === ANIMS.walk ? f % 2 : 0; + img.rect(ox + 3, oy + 12 + bob, 10, 9, outline); + img.rect(ox + 4, oy + 13 + bob, 8, 7, head); + img.rect(ox + 4, oy + 21 + bob, 8, 7, body); + const step = spec === ANIMS.walk ? (f % 3) - 1 : 0; + img.rect(ox + 5 + step, oy + 28, 2, 3, outline); + img.rect(ox + 9 - step, oy + 28, 2, 3, outline); + const eyes = { right: [9, 11], left: [4, 6], down: [5, 9], up: null }[dir]; + if (eyes) for (const ex of eyes) img.rect(ox + ex, oy + 16 + bob, 1, 2, outline); + } + }); + } + return img.toPng(); +} + +// --- Run --------------------------------------------------------------------------- + +const started = Date.now(); +const tileset = await buildGreyboxTileset(); + +for (const [id, recipe] of Object.entries(CHARACTERS)) { + const png = source.mode === "placeholder" ? await placeholderCharacter(recipe) : await composeCharacter(id, recipe); + fs.writeFileSync(path.join(outDir, `characters/${id}.png`), png); +} + +for (const spec of GREYBOX_MAPS) { + fs.writeFileSync(path.join(outDir, `maps/${spec.id}.tmj`), JSON.stringify(toTmj(spec, tileset))); +} + +// Geist Pixel (OFL, from the geist package) as a 1-bit bitmap font for in-game text. +const font = await buildBitmapFont("node_modules/geist/dist/fonts/geist-pixel/GeistPixel-Square.woff2", 76, "pixel"); +fs.writeFileSync(path.join(outDir, "fonts/pixel.png"), font.png); +fs.writeFileSync(path.join(outDir, "fonts/pixel.xml"), font.xml); + +const manifest = { + mode: source.mode, + builtAt: new Date().toISOString(), + characters: Object.keys(CHARACTERS), + maps: GREYBOX_MAPS.map((m) => m.id), + tilesets: [tileset.name], + fonts: ["pixel"], +}; +fs.writeFileSync(path.join(outDir, "assets.json"), JSON.stringify(manifest, null, "\t") + "\n"); +console.log( + `[assets] Built ${manifest.characters.length} characters, ${manifest.maps.length} maps, ` + + `${manifest.tilesets.length} tileset (${source.mode} art) in ${Date.now() - started} ms`, +); diff --git a/scripts/assets/font.mjs b/scripts/assets/font.mjs new file mode 100644 index 0000000..5a3bd50 --- /dev/null +++ b/scripts/assets/font.mjs @@ -0,0 +1,125 @@ +// Turns a pixel-grid outline font (Geist Pixel, OFL) into a BMFont that Phaser can +// render crisply: each output pixel is on when its centre falls inside the glyph. +import fs from "node:fs"; +import opentype from "opentype.js"; +import wawoff from "wawoff2"; +import { Raster, hex } from "./raster.mjs"; + +export const FONT_CHARS = + " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + + "ÆØÅæøåéèëüöäÉ—–…•’‘“”"; + +/** Straight-edge contours of a glyph path in font units (y up). */ +function contoursOf(path) { + const contours = []; + let current = []; + for (const c of path.commands) { + if (c.type === "M") { + if (current.length) contours.push(current); + current = [[c.x, c.y]]; + } else if (c.type === "L") { + current.push([c.x, c.y]); + } else if (c.type === "Z") { + if (current.length) contours.push(current); + current = []; + } else { + // Curves would need flattening; pixel fonts do not use them. + current.push([c.x, c.y]); + } + } + if (current.length) contours.push(current); + return contours; +} + +/** Non-zero winding test. */ +function inside(contours, x, y) { + let winding = 0; + for (const pts of contours) { + for (let i = 0; i < pts.length; i++) { + const [x0, y0] = pts[i]; + const [x1, y1] = pts[(i + 1) % pts.length]; + if (y0 <= y) { + if (y1 > y && (x1 - x0) * (y - y0) - (x - x0) * (y1 - y0) > 0) winding++; + } else if (y1 <= y && (x1 - x0) * (y - y0) - (x - x0) * (y1 - y0) < 0) { + winding--; + } + } + } + return winding !== 0; +} + +/** Where inside each output pixel to sample; a quarter keeps half-pixel details like the arm of "r". */ +const SAMPLE_PHASE = 0.25; + +/** + * @param {string} woff2Path + * @param {number} unit font units per output pixel + * @returns {Promise<{ png: Buffer, xml: string, lineHeight: number }>} + */ +export async function buildBitmapFont(woff2Path, unit, face) { + const ttf = await wawoff.decompress(fs.readFileSync(woff2Path)); + const font = opentype.parse(Buffer.from(ttf).buffer); + const ascent = Math.round(font.ascender / unit); + const descent = Math.round(-font.descender / unit); + const cellHeight = ascent + descent; + + const glyphs = []; + for (const ch of FONT_CHARS) { + const g = font.charToGlyph(ch); + if (!g || g.index === 0) continue; + const advance = Math.round(g.advanceWidth / unit); + const contours = contoursOf(g.path); + const bits = []; + for (let py = 0; py < cellHeight; py++) { + for (let px = 0; px < advance; px++) { + const fx = (px + SAMPLE_PHASE) * unit; + const fy = (ascent - py - SAMPLE_PHASE) * unit; + if (inside(contours, fx, fy)) bits.push([px, py]); + } + } + glyphs.push({ ch, code: ch.codePointAt(0), advance, bits }); + } + + // The font's ascender leaves empty rows above even accented capitals: trim them. + const top = Math.min(...glyphs.flatMap((g) => g.bits.map(([, py]) => py))); + const bottom = Math.max(...glyphs.flatMap((g) => g.bits.map(([, py]) => py))); + for (const g of glyphs) g.bits = g.bits.map(([px, py]) => [px, py - top]); + const lineHeight = bottom - top + 1; + const base = ascent - top; + + // Pack glyph cells in rows on a 256-wide sheet, 1 px apart. + const sheetWidth = 256; + let x = 0; + let y = 0; + for (const g of glyphs) { + if (x + g.advance > sheetWidth) { + x = 0; + y += lineHeight + 1; + } + g.x = x; + g.y = y; + x += g.advance + 1; + } + const sheetHeight = y + lineHeight; + const img = new Raster(sheetWidth, sheetHeight); + const white = hex("ffffff"); + for (const g of glyphs) for (const [px, py] of g.bits) img.px(g.x + px, g.y + py, white); + + const chars = glyphs + .map( + (g) => + ``, + ) + .join("\n\t\t"); + const xml = ` + + + + + + ${chars} + + +`; + return { png: await img.toPng(), xml, lineHeight }; +} diff --git a/scripts/assets/raster.mjs b/scripts/assets/raster.mjs new file mode 100644 index 0000000..f7c3b5e --- /dev/null +++ b/scripts/assets/raster.mjs @@ -0,0 +1,25 @@ +// Tiny RGBA raster for drawing generated art (greybox tiles, placeholder sprites). +import sharp from "sharp"; + +export const hex = (h) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 255]; + +export class Raster { + constructor(width, height) { + this.width = width; + this.height = height; + this.data = new Uint8Array(width * height * 4); + } + + px(x, y, rgba) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + this.data.set(rgba, (y * this.width + x) * 4); + } + + rect(x, y, w, h, rgba) { + for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) this.px(xx, yy, rgba); + } + + toPng() { + return sharp(Buffer.from(this.data), { raw: { width: this.width, height: this.height, channels: 4 } }).png().toBuffer(); + } +} diff --git a/tsconfig.json b/tsconfig.json index 803331c..e8b0b79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "skipLibCheck": true, "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", diff --git a/world/greybox/maps.ts b/world/greybox/maps.ts new file mode 100644 index 0000000..1f64537 --- /dev/null +++ b/world/greybox/maps.ts @@ -0,0 +1,120 @@ +// Greybox maps for the engine core (docs/game/PLAN.md M1.12): enough town to test +// walking, doors, signs and NPCs before the real layout in M3. +import type { MapSpec } from "../tiled.ts"; +import { CharGrid, seeded } from "../grid.ts"; +import { GREYBOX_TILES } from "./tiles.ts"; + +const legend = Object.fromEntries(GREYBOX_TILES.map((t, i) => [t.char, i])); + +/** A red house: two roof rows, then a wall row with windows and a door row. */ +function redHouse(g: CharGrid, x: number, y: number, w: number, doorOffset: number) { + g.rect(x, y, w, 2, "R"); + g.rect(x, y + 2, w, 2, "H"); + for (let i = 1; i < w - 1; i += 3) g.set(x + i, y + 2, "O"); + g.set(x + doorOffset, y + 3, "D"); +} + +function town(): MapSpec { + const W = 40; + const H = 34; + const g = new CharGrid(W, H, "."); + const rand = seeded(7); + + // Forest edge along the top and sides. + g.rect(0, 0, W, 2, "t"); + g.rect(0, 0, 2, 24, "t"); + g.rect(W - 2, 0, 2, 24, "t"); + for (let x = 2; x < W - 2; x++) if (rand() < 0.35) g.set(x, 2, "t"); + + // The fjord: beach, water, deep water. + g.rect(0, 23, W, 2, ":"); + g.rect(0, 25, W, H - 25, "~"); + g.rect(0, 29, W, H - 29, "w"); + g.rect(0, 20, 2, 5, "^").rect(W - 2, 20, 2, 5, "^"); + + // Ferry dock reaching into the water. + g.rect(18, 23, 3, 8, "d"); + g.rect(15, 29, 9, 2, "d"); + + // Paths: dock up to the square, square to each door. + g.line(19, 12, 19, 22, "="); + g.rect(14, 9, 12, 4, "="); + g.line(9, 7, 9, 10, "=").line(9, 10, 14, 10, "="); + g.line(30, 7, 30, 10, "=").line(25, 10, 30, 10, "="); + + redHouse(g, 6, 3, 7, 3); // datagutt's house, door at (9, 6) + redHouse(g, 26, 3, 9, 4); // office, door at (30, 6) + + // Square details and scattered nature, kept off the paths. + g.stamp(15, 13, ["* *"]).stamp(22, 13, ["* *"]); + for (let i = 0; i < 40; i++) { + const x = 2 + Math.floor(rand() * (W - 4)); + const y = 13 + Math.floor(rand() * 9); + g.paintOver(x, y, rand() < 0.5 ? "u" : rand() < 0.5 ? "t" : rand() < 0.5 ? "o" : "*", "."); + } + // Keep a clear verge either side of the main path. + for (let y = 12; y < 23; y++) g.set(18, y, ".").set(20, y, "."); + g.set(22, 11, "S").set(31, 7, "S"); + + return { + id: "town", + width: W, + height: H, + legend, + layers: [{ name: "ground", rows: g.rows() }], + properties: { name: "Fjord Town" }, + objects: [ + { type: "spawn", id: "ferry", x: 19, y: 28, facing: "up" }, + { type: "spawn", id: "house_door", x: 9, y: 7, facing: "down" }, + { type: "spawn", id: "office_door", x: 30, y: 7, facing: "down" }, + { type: "door", x: 9, y: 6, toMap: "house", toSpawn: "entrance" }, + { type: "sign", x: 22, y: 11, text: "Welcome to Fjord Town. Population: small, but opinionated." }, + { type: "sign", x: 31, y: 7, text: "Nettbureau. Closed for the greybox. Come back when the art arrives." }, + { + type: "npc", + id: "ferryman", + character: "ferryman", + x: 21, + y: 29, + facing: "up", + text: "Hei! Welcome ashore. Walk with the arrow keys or WASD, or tap where you want to go.", + }, + ], + }; +} + +function houseInterior(): MapSpec { + const W = 12; + const H = 9; + const g = new CharGrid(W, H, "_"); + g.frame(0, 0, W, H, "X"); + g.rect(1, 1, W - 2, 2, "|"); + g.stamp(1, 3, ["BB P b "]); + g.stamp(1, 4, [" b "]); + g.stamp(4, 5, ["rrr"]).stamp(4, 6, ["rTr"]); + g.set(6, H - 1, "_"); // the doorway + return { + id: "house", + width: W, + height: H, + legend, + layers: [{ name: "ground", rows: g.rows() }], + properties: { name: "datagutt's house" }, + objects: [ + { type: "spawn", id: "entrance", x: 6, y: 7, facing: "up" }, + { type: "door", x: 6, y: 8, toMap: "town", toSpawn: "house_door" }, + { + type: "npc", + id: "datagutt", + character: "datagutt", + x: 4, + y: 4, + facing: "down", + text: "Oh, hi! I'm Thomas. This is still a greybox, but it's going to be a whole town.", + }, + { type: "sign", x: 4, y: 3, text: "* It's a PC. Several terminals are open. Something is compiling." }, + ], + }; +} + +export const GREYBOX_MAPS: MapSpec[] = [town(), houseInterior()]; diff --git a/world/greybox/tiles.ts b/world/greybox/tiles.ts new file mode 100644 index 0000000..ce89c51 --- /dev/null +++ b/world/greybox/tiles.ts @@ -0,0 +1,47 @@ +// Greybox tileset: simple original tiles drawn by the build, used to lay out and test +// maps before the LimeZu art pass (docs/game/PLAN.md M1.12). One ASCII character each. + +export type GreyboxPattern = "flat" | "speckle" | "stripes" | "waves" | "bricks" | "planks" | "block" | "dots" | "cross"; + +export type GreyboxTile = { + name: string; + char: string; + base: string; + accent: string; + pattern: GreyboxPattern; + collides?: boolean; +}; + +export const GREYBOX_TILES: GreyboxTile[] = [ + { name: "grass", char: ".", base: "5c9e4f", accent: "6fb35f", pattern: "speckle" }, + { name: "grass_dark", char: ",", base: "4b8a42", accent: "5c9e4f", pattern: "speckle" }, + { name: "path", char: "=", base: "c9b28a", accent: "b89f76", pattern: "speckle" }, + { name: "sand", char: ":", base: "e3d1a1", accent: "d4bf88", pattern: "dots" }, + { name: "water", char: "~", base: "3b6fa0", accent: "5a8fc0", pattern: "waves", collides: true }, + { name: "deep_water", char: "w", base: "294f78", accent: "3b6fa0", pattern: "waves", collides: true }, + { name: "wall_red", char: "H", base: "9a3328", accent: "7c2820", pattern: "planks", collides: true }, + { name: "roof", char: "R", base: "3a2a30", accent: "4c3840", pattern: "stripes", collides: true }, + { name: "door", char: "D", base: "5a3a22", accent: "d9a441", pattern: "block" }, + { name: "window", char: "O", base: "9a3328", accent: "ffd27a", pattern: "block", collides: true }, + { name: "floor_wood", char: "_", base: "a8784a", accent: "946640", pattern: "planks" }, + { name: "floor_tile", char: "'", base: "cfcabc", accent: "bab4a4", pattern: "cross" }, + { name: "rug", char: "r", base: "3d7a5e", accent: "4f9474", pattern: "dots" }, + { name: "table", char: "T", base: "7a5230", accent: "8f6238", pattern: "block", collides: true }, + { name: "counter", char: "C", base: "8a8f99", accent: "a3a9b3", pattern: "block", collides: true }, + { name: "bookshelf", char: "B", base: "5a3a22", accent: "c75c4a", pattern: "stripes", collides: true }, + { name: "bed", char: "b", base: "6d8fc7", accent: "e8eef7", pattern: "block", collides: true }, + { name: "pc", char: "P", base: "2b2f3a", accent: "1dc672", pattern: "block", collides: true }, + { name: "tree", char: "t", base: "2f6b3f", accent: "3f8551", pattern: "dots", collides: true }, + { name: "bush", char: "u", base: "3f8551", accent: "4f9a60", pattern: "speckle", collides: true }, + { name: "flowers", char: "*", base: "5c9e4f", accent: "f2a3c7", pattern: "dots" }, + { name: "rock", char: "o", base: "8a8f99", accent: "6b7078", pattern: "speckle", collides: true }, + { name: "fence", char: "#", base: "5c9e4f", accent: "8f6238", pattern: "stripes", collides: true }, + { name: "dock", char: "d", base: "8f6238", accent: "6b4a2a", pattern: "planks" }, + { name: "sign", char: "S", base: "5c9e4f", accent: "b58a55", pattern: "block", collides: true }, + { name: "interior_wall", char: "|", base: "d8d2c4", accent: "bdb6a6", pattern: "bricks", collides: true }, + { name: "void", char: "X", base: "0b1320", accent: "0b1320", pattern: "flat", collides: true }, + { name: "cliff", char: "^", base: "6b6f7a", accent: "555964", pattern: "bricks", collides: true }, +]; + +/** Tile id (0-based index in the tileset) by name. */ +export const TILE_ID = Object.fromEntries(GREYBOX_TILES.map((t, i) => [t.name, i])) as Record; diff --git a/world/grid.ts b/world/grid.ts new file mode 100644 index 0000000..da6bae5 --- /dev/null +++ b/world/grid.ts @@ -0,0 +1,77 @@ +// A mutable character grid with drawing helpers, used to lay maps out in code. +// Each character is a tile from a legend (see world/greybox/tiles.ts). + +export class CharGrid { + readonly width: number; + readonly height: number; + private cells: string[]; + + constructor(width: number, height: number, fill: string) { + this.width = width; + this.height = height; + this.cells = Array.from({ length: width * height }, () => fill); + } + + inBounds(x: number, y: number): boolean { + return x >= 0 && y >= 0 && x < this.width && y < this.height; + } + + get(x: number, y: number): string { + if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}×${this.height} grid`); + return this.cells[y * this.width + x]; + } + + set(x: number, y: number, ch: string): this { + if (this.inBounds(x, y)) this.cells[y * this.width + x] = ch; + return this; + } + + rect(x: number, y: number, w: number, h: number, ch: string): this { + for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) this.set(xx, yy, ch); + return this; + } + + /** Outline only. */ + frame(x: number, y: number, w: number, h: number, ch: string): this { + for (let xx = x; xx < x + w; xx++) this.set(xx, y, ch).set(xx, y + h - 1, ch); + for (let yy = y; yy < y + h; yy++) this.set(x, yy, ch).set(x + w - 1, yy, ch); + return this; + } + + /** Horizontal or vertical run of tiles, inclusive at both ends. */ + line(x0: number, y0: number, x1: number, y1: number, ch: string): this { + if (x0 !== x1 && y0 !== y1) throw new Error("line() draws straight lines only"); + const [ax, bx] = [Math.min(x0, x1), Math.max(x0, x1)]; + const [ay, by] = [Math.min(y0, y1), Math.max(y0, y1)]; + for (let y = ay; y <= by; y++) for (let x = ax; x <= bx; x++) this.set(x, y, ch); + return this; + } + + /** Stamp rows of characters; spaces in the stamp are transparent. */ + stamp(x: number, y: number, rows: string[]): this { + rows.forEach((row, dy) => [...row].forEach((ch, dx) => ch !== " " && this.set(x + dx, y + dy, ch))); + return this; + } + + /** Replace only where the current tile is one of `onlyOn`. */ + paintOver(x: number, y: number, ch: string, onlyOn: string): this { + if (this.inBounds(x, y) && onlyOn.includes(this.get(x, y))) this.set(x, y, ch); + return this; + } + + rows(): string[] { + return Array.from({ length: this.height }, (_, y) => this.cells.slice(y * this.width, (y + 1) * this.width).join("")); + } +} + +/** Small deterministic PRNG (mulberry32) so generated maps are identical on every build. */ +export function seeded(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/world/package.json b/world/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/world/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/world/tiled.ts b/world/tiled.ts new file mode 100644 index 0000000..9064e42 --- /dev/null +++ b/world/tiled.ts @@ -0,0 +1,91 @@ +// Writes maps in Tiled's JSON format (.tmj) so Phaser loads them natively and they can +// be opened in Tiled for touch-ups (docs/game/DESIGN.md §10). +import { toTiledObject, type MapObject } from "../game/world/objects.ts"; + +export type TilesetRef = { + name: string; + /** Image path relative to the map file. */ + image: string; + columns: number; + tileCount: number; + tileSize: number; + /** Tile ids (0-based) that block movement. */ + colliding: number[]; +}; + +export type MapSpec = { + id: string; + width: number; + height: number; + /** Tile layers, bottom to top, each as rows of legend characters. */ + layers: { name: string; rows: string[] }[]; + objects: MapObject[]; + /** Legend character to 0-based tile id. Characters not in the legend are empty. */ + legend: Record; + properties?: Record; +}; + +export function toTmj(spec: MapSpec, tileset: TilesetRef) { + const { width, height, tileSize } = { ...spec, tileSize: tileset.tileSize }; + let nextId = 1; + + const layers: object[] = spec.layers.map((layer) => { + if (layer.rows.length !== height) { + throw new Error(`${spec.id}/${layer.name}: ${layer.rows.length} rows, expected ${height}`); + } + const data: number[] = []; + layer.rows.forEach((row, y) => { + const chars = [...row]; + if (chars.length !== width) { + throw new Error(`${spec.id}/${layer.name} row ${y}: ${chars.length} tiles, expected ${width}`); + } + for (const ch of chars) { + const id = spec.legend[ch]; + data.push(id === undefined ? 0 : id + 1); + } + }); + return { id: nextId++, name: layer.name, type: "tilelayer", x: 0, y: 0, width, height, opacity: 1, visible: true, data }; + }); + + for (const obj of spec.objects) { + if (obj.x < 0 || obj.y < 0 || obj.x >= width || obj.y >= height) { + throw new Error(`${spec.id}: ${obj.type} at (${obj.x}, ${obj.y}) is outside the map`); + } + } + const layerId = nextId++; + const objects = spec.objects.map((obj, i) => toTiledObject(obj, i + 1, tileSize)); + layers.push({ id: layerId, name: "objects", type: "objectgroup", x: 0, y: 0, opacity: 1, visible: true, draworder: "topdown", objects }); + + return { + type: "map", + version: "1.10", + tiledversion: "1.11.0", + orientation: "orthogonal", + renderorder: "right-down", + infinite: false, + width, + height, + tilewidth: tileSize, + tileheight: tileSize, + nextlayerid: nextId, + nextobjectid: objects.length + 1, + properties: Object.entries(spec.properties ?? {}).map(([name, value]) => ({ name, type: "string", value })), + layers, + tilesets: [ + { + firstgid: 1, + name: tileset.name, + image: tileset.image, + imagewidth: tileset.columns * tileSize, + imageheight: Math.ceil(tileset.tileCount / tileset.columns) * tileSize, + tilewidth: tileSize, + tileheight: tileSize, + tilecount: tileset.tileCount, + columns: tileset.columns, + margin: 0, + spacing: 0, + tiles: tileset.colliding.map((id) => ({ id, properties: [{ name: "collides", type: "bool", value: true }] })), + }, + ], + }; +} From bd556d560b91caee50821638de3d777e3a3d6e47 Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 23 Sep 2026 00:48:51 +0200 Subject: [PATCH 008/153] feat(game): engine core (M1.4 to M1.11) Tiled loading with tile-property collision, tile-locked movement with turn-in-place and chained steps, sign and NPC interaction with a simple dialogue box, doors between maps, tap/click-to-move with A* (straight-run preference) and walk-to-talk, unified keyboard/gamepad/pointer input, a versioned localStorage save that survives hostile storage, and ?at= deep links that skip the title. Pure logic is unit tested. --- components/game/GameShell.tsx | 11 +- game/boot.ts | 30 +++- game/entities/Actor.ts | 50 ++++++ game/input/InputController.ts | 107 ++++++++++++ game/input/directionStack.ts | 27 +++ game/save/save.test.ts | 90 ++++++++++ game/save/save.ts | 102 +++++++++++ game/scenes/PreloadScene.ts | 24 ++- game/scenes/WorldScene.ts | 313 +++++++++++++++++++++++++++++++--- game/ui/DialogueBox.ts | 118 +++++++++++++ game/ui/text.test.ts | 24 +++ game/ui/text.ts | 37 ++++ game/world/grid.ts | 77 +++++++++ game/world/movement.test.ts | 84 +++++++++ game/world/movement.ts | 159 +++++++++++++++++ game/world/pathfind.test.ts | 91 ++++++++++ game/world/pathfind.ts | 144 ++++++++++++++++ game/world/places.ts | 20 +++ 18 files changed, 1481 insertions(+), 27 deletions(-) create mode 100644 game/entities/Actor.ts create mode 100644 game/input/InputController.ts create mode 100644 game/input/directionStack.ts create mode 100644 game/save/save.test.ts create mode 100644 game/save/save.ts create mode 100644 game/ui/DialogueBox.ts create mode 100644 game/ui/text.test.ts create mode 100644 game/ui/text.ts create mode 100644 game/world/grid.ts create mode 100644 game/world/movement.test.ts create mode 100644 game/world/movement.ts create mode 100644 game/world/pathfind.test.ts create mode 100644 game/world/pathfind.ts create mode 100644 game/world/places.ts diff --git a/components/game/GameShell.tsx b/components/game/GameShell.tsx index 54ad36f..934ff81 100644 --- a/components/game/GameShell.tsx +++ b/components/game/GameShell.tsx @@ -15,16 +15,21 @@ export function GameShell({ titleArt }: { titleArt: ReactNode }) { const handleRef = useRef(null); const [phase, setPhase] = useState("loading"); const [progress, setProgress] = useState(0); + const [hasSave, setHasSave] = useState(false); useEffect(() => { let cancelled = false; import("@/game/boot") .then(({ bootGame }) => { if (cancelled || !containerRef.current) return; - handleRef.current = bootGame(containerRef.current, { + const handle = bootGame(containerRef.current, { onProgress: (p) => setProgress(p), onReady: () => setPhase((current) => (current === "loading" ? "ready" : current)), }); + handleRef.current = handle; + setHasSave(handle.hasSave); + // A shared `?at=place` link goes straight into the world. + if (handle.deepLinked) setPhase("playing"); }) .catch((err) => { console.error("[game] failed to load", err); @@ -77,7 +82,9 @@ export function GameShell({ titleArt }: { titleArt: ReactNode }) { {phase === "failed" ? "Could not load" : phase === "ready" || playing - ? "▶ Start" + ? hasSave + ? "▶ Continue" + : "▶ Start" : `Loading ${Math.round(progress * 100)}%`} > & { @@ -28,6 +51,7 @@ export type GameServices = Required> & { onReady: () => void; /** Resolves when the player has asked to start. */ startRequested: Promise; + start: WorldTarget; }; export const SERVICES_KEY = "services"; @@ -35,13 +59,15 @@ export const SERVICES_KEY = "services"; export function bootGame(parent: HTMLElement, options: BootOptions = {}): GameHandle { let requestStart!: () => void; const startRequested = new Promise((resolve) => (requestStart = resolve)); - if (options.autoStart) requestStart(); + const { target, deepLinked, hasSave } = resolveStart(window.location.search); + if (options.autoStart || deepLinked) requestStart(); const services: GameServices = { assetBase: options.assetBase ?? "/game/", onProgress: options.onProgress ?? (() => {}), onReady: options.onReady ?? (() => {}), startRequested, + start: target, }; const dpr = () => window.devicePixelRatio || 1; @@ -94,6 +120,8 @@ export function bootGame(parent: HTMLElement, options: BootOptions = {}): GameHa return { start: requestStart, + deepLinked, + hasSave, destroy() { observer.disconnect(); dprQuery?.removeEventListener("change", onDprChange); diff --git a/game/entities/Actor.ts b/game/entities/Actor.ts new file mode 100644 index 0000000..0d9dacf --- /dev/null +++ b/game/entities/Actor.ts @@ -0,0 +1,50 @@ +// A character on the map: a 16×32 sprite driven by a GridMover. The sprite's feet sit +// on the bottom of its tile and it depth-sorts by its feet so it walks behind things. +import Phaser from "phaser"; +import { TILE } from "../constants"; +import { ANIMS, animKey, type AnimName } from "../characters/sheet"; +import { GridMover, type MoverConfig } from "../world/movement"; +import type { Point } from "../world/grid"; +import type { Facing } from "../world/objects"; + +export class Actor { + readonly sprite: Phaser.GameObjects.Sprite; + readonly mover: GridMover; + private currentAnim = ""; + + constructor( + scene: Phaser.Scene, + readonly id: string, + readonly character: string, + tile: Point, + facing: Facing, + config: MoverConfig, + ) { + this.mover = new GridMover(tile, facing, config); + this.sprite = scene.add.sprite(0, 0, `char:${character}`).setOrigin(0, 1); + this.sync(); + } + + /** Move the sprite to the mover's position and pick the right animation. */ + sync(): void { + const { x, y } = this.mover.position; + this.sprite.setPosition(Math.round(x * TILE), Math.round((y + 1) * TILE)); + this.sprite.setDepth(this.sprite.y); + this.play(this.mover.moving ? "walk" : "idle"); + } + + private play(anim: AnimName): void { + const key = animKey(this.character, anim, this.mover.facing); + if (key === this.currentAnim) return; + // Turning a corner mid-walk: keep the stride instead of restarting the cycle. + // currentFrame.index is 1-based; startFrame is 0-based. + const wasWalking = this.currentAnim.includes(":walk:"); + const stride = wasWalking && anim === "walk" ? (this.sprite.anims.currentFrame?.index ?? 1) - 1 : 0; + this.currentAnim = key; + this.sprite.play({ key, startFrame: stride % ANIMS.walk.framesPerDirection }, true); + } + + destroy(): void { + this.sprite.destroy(); + } +} diff --git a/game/input/InputController.ts b/game/input/InputController.ts new file mode 100644 index 0000000..5848f18 --- /dev/null +++ b/game/input/InputController.ts @@ -0,0 +1,107 @@ +// Merges keyboard, gamepad and pointer into one set of actions per frame +// (docs/game/PLAN.md M1.9). Scenes read `poll()` and never touch devices directly. +import Phaser from "phaser"; +import type { Facing } from "../world/objects"; +import { DirectionStack } from "./directionStack"; + +export type FrameInput = { + /** Held direction from keys, d-pad or stick. */ + dir: Facing | null; + run: boolean; + /** Pressed this frame. */ + interact: boolean; + back: boolean; + menu: boolean; + /** Taps/clicks this frame, in world coordinates. */ + taps: { x: number; y: number }[]; +}; + +const KEY_DIRS: [string, Facing][] = [ + ["UP", "up"], + ["W", "up"], + ["DOWN", "down"], + ["S", "down"], + ["LEFT", "left"], + ["A", "left"], + ["RIGHT", "right"], + ["D", "right"], +]; + +const STICK_DEADZONE = 0.45; +/** A press shorter than this, that moved less than TAP_SLOP pixels, counts as a tap. */ +const TAP_MAX_MS = 500; +const TAP_SLOP = 12; + +export class InputController { + private readonly stack = new DirectionStack(); + private readonly keys: Record = {}; + private interactQueued = false; + private backQueued = false; + private menuQueued = false; + private taps: { x: number; y: number }[] = []; + private padPrev = { a: false, b: false, start: false }; + + constructor(private readonly scene: Phaser.Scene) { + const kb = scene.input.keyboard; + if (kb) { + for (const [name, dir] of KEY_DIRS) { + const key = kb.addKey(name); + key.on("down", () => this.stack.press(dir)); + key.on("up", () => this.stack.release(dir)); + this.keys[name] = key; + } + this.keys.SHIFT = kb.addKey("SHIFT"); + for (const name of ["E", "SPACE", "Z"]) kb.addKey(name).on("down", () => (this.interactQueued = true)); + for (const name of ["X", "ESC", "BACKSPACE"]) kb.addKey(name).on("down", () => (this.backQueued = true)); + kb.addKey("ENTER").on("down", () => (this.menuQueued = true)); + // Losing focus (alt-tab) must not leave a direction stuck down. + scene.game.events.on(Phaser.Core.Events.BLUR, () => this.stack.clear()); + } + + scene.input.on(Phaser.Input.Events.POINTER_UP, (p: Phaser.Input.Pointer) => { + const dist = Phaser.Math.Distance.Between(p.downX, p.downY, p.upX, p.upY); + if (p.getDuration() <= TAP_MAX_MS && dist <= TAP_SLOP) { + const world = p.positionToCamera(scene.cameras.main) as Phaser.Math.Vector2; + this.taps.push({ x: world.x, y: world.y }); + } + }); + } + + private padDirection(pad: Phaser.Input.Gamepad.Gamepad): Facing | null { + if (pad.up) return "up"; + if (pad.down) return "down"; + if (pad.left) return "left"; + if (pad.right) return "right"; + const x = pad.leftStick.x; + const y = pad.leftStick.y; + if (Math.max(Math.abs(x), Math.abs(y)) < STICK_DEADZONE) return null; + if (Math.abs(x) > Math.abs(y)) return x > 0 ? "right" : "left"; + return y > 0 ? "down" : "up"; + } + + poll(): FrameInput { + let dir = this.stack.current; + let run = this.keys.SHIFT?.isDown ?? false; + let interact = this.interactQueued; + let back = this.backQueued; + let menu = this.menuQueued; + + const pad = this.scene.input.gamepad?.pad1; + if (pad) { + dir = this.padDirection(pad) ?? dir; + const a = pad.A; + const b = pad.B; + const start = pad.buttons[9]?.pressed ?? false; + interact ||= a && !this.padPrev.a; + back ||= b && !this.padPrev.b; + menu ||= start && !this.padPrev.start; + run ||= b; + this.padPrev = { a, b, start }; + } + + const taps = this.taps; + this.taps = []; + this.interactQueued = this.backQueued = this.menuQueued = false; + return { dir, run, interact, back, menu, taps }; + } +} diff --git a/game/input/directionStack.ts b/game/input/directionStack.ts new file mode 100644 index 0000000..1c3db36 --- /dev/null +++ b/game/input/directionStack.ts @@ -0,0 +1,27 @@ +import type { Facing } from "../world/objects"; + +/** + * Tracks held direction keys so the most recently pressed one wins, and releasing it + * falls back to whichever is still held. Holding right then tapping up walks up and + * returns to right: what players expect from a keyboard. + */ +export class DirectionStack { + private held: Facing[] = []; + + press(dir: Facing): void { + this.release(dir); + this.held.push(dir); + } + + release(dir: Facing): void { + this.held = this.held.filter((d) => d !== dir); + } + + clear(): void { + this.held = []; + } + + get current(): Facing | null { + return this.held.at(-1) ?? null; + } +} diff --git a/game/save/save.test.ts b/game/save/save.test.ts new file mode 100644 index 0000000..ee9b346 --- /dev/null +++ b/game/save/save.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { DirectionStack } from "../input/directionStack"; +import { placeFromSearch } from "../world/places"; +import { SAVE_KEY, clearSave, loadSave, writeSave, type StorageLike } from "./save"; + +function memoryStorage(initial: Record = {}): StorageLike & { data: Record } { + const data = { ...initial }; + return { + data, + getItem: (k) => data[k] ?? null, + setItem: (k, v) => void (data[k] = v), + removeItem: (k) => void delete data[k], + }; +} + +const sample = { + map: "town", + x: 3, + y: 4, + facing: "up" as const, + stamps: ["library"], + flags: { metFerryman: true }, + dialogue: {}, + settings: { muted: false, showVisitors: true, reducedMotion: null }, +}; + +describe("save", () => { + it("round-trips", () => { + const storage = memoryStorage(); + expect(writeSave(storage, sample)).toBe(true); + expect(loadSave(storage)).toMatchObject(sample); + }); + + it("treats missing, corrupt or foreign data as no save", () => { + expect(loadSave(memoryStorage())).toBeNull(); + expect(loadSave(memoryStorage({ [SAVE_KEY]: "{not json" }))).toBeNull(); + expect(loadSave(memoryStorage({ [SAVE_KEY]: JSON.stringify({ version: 99, map: "town" }) }))).toBeNull(); + expect(loadSave(memoryStorage({ [SAVE_KEY]: JSON.stringify({ ...sample, version: 1, facing: "sideways" }) }))).toBeNull(); + expect(loadSave(undefined)).toBeNull(); + }); + + it("survives storage that throws", () => { + const hostile: StorageLike = { + getItem: () => { + throw new Error("SecurityError"); + }, + setItem: () => { + throw new Error("QuotaExceededError"); + }, + removeItem: () => { + throw new Error("SecurityError"); + }, + }; + expect(loadSave(hostile)).toBeNull(); + expect(writeSave(hostile, sample)).toBe(false); + expect(() => clearSave(hostile)).not.toThrow(); + }); + + it("repairs sloppy fields instead of discarding progress", () => { + const storage = memoryStorage({ + [SAVE_KEY]: JSON.stringify({ ...sample, version: 1, stamps: ["a", "a", 3], settings: "nope" }), + }); + const save = loadSave(storage)!; + expect(save.stamps).toEqual(["a"]); + expect(save.settings).toEqual({ muted: false, showVisitors: true, reducedMotion: null }); + }); +}); + +describe("DirectionStack", () => { + it("uses the latest held key and falls back when it is released", () => { + const s = new DirectionStack(); + s.press("right"); + s.press("up"); + expect(s.current).toBe("up"); + s.release("up"); + expect(s.current).toBe("right"); + s.release("right"); + expect(s.current).toBeNull(); + }); +}); + +describe("placeFromSearch", () => { + it("accepts known places only", () => { + expect(placeFromSearch("?at=office")).toBe("office"); + expect(placeFromSearch("?at=Office")).toBe("office"); + expect(placeFromSearch("?at=nowhere")).toBeNull(); + expect(placeFromSearch("?at=constructor")).toBeNull(); + expect(placeFromSearch("")).toBeNull(); + }); +}); diff --git a/game/save/save.ts b/game/save/save.ts new file mode 100644 index 0000000..3177df5 --- /dev/null +++ b/game/save/save.ts @@ -0,0 +1,102 @@ +// Versioned save in localStorage (docs/game/PLAN.md M1.10). Every read and write is +// guarded: private windows, blocked storage and corrupt data all fall back to a new game. +import type { Facing } from "../world/objects"; + +export const SAVE_KEY = "fjordtown.save"; +export const SAVE_VERSION = 1; + +export type SaveData = { + version: typeof SAVE_VERSION; + map: string; + x: number; + y: number; + facing: Facing; + stamps: string[]; + flags: Record; + /** Ink visit state per dialogue, stored as the runtime's JSON (M2). */ + dialogue: Record; + settings: { muted: boolean; showVisitors: boolean; reducedMotion: boolean | null }; + updatedAt: string; +}; + +export type StorageLike = Pick; + +const FACINGS = new Set(["right", "up", "left", "down"]); + +/** A structurally valid save, or null. Unknown extra fields are dropped. */ +function validate(raw: unknown): SaveData | null { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as Record; + if (r.version !== SAVE_VERSION) return null; + if (typeof r.map !== "string" || !r.map) return null; + if (!Number.isInteger(r.x) || !Number.isInteger(r.y)) return null; + if (typeof r.facing !== "string" || !FACINGS.has(r.facing)) return null; + const stamps = Array.isArray(r.stamps) ? r.stamps.filter((s): s is string => typeof s === "string") : []; + const flags = typeof r.flags === "object" && r.flags !== null ? (r.flags as Record) : {}; + const dialogue = typeof r.dialogue === "object" && r.dialogue !== null ? (r.dialogue as Record) : {}; + const s = (typeof r.settings === "object" && r.settings !== null ? r.settings : {}) as Record; + return { + version: SAVE_VERSION, + map: r.map, + x: r.x as number, + y: r.y as number, + facing: r.facing as Facing, + stamps: [...new Set(stamps)], + flags, + dialogue, + settings: { + muted: s.muted === true, + showVisitors: s.showVisitors !== false, + reducedMotion: typeof s.reducedMotion === "boolean" ? s.reducedMotion : null, + }, + updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : new Date(0).toISOString(), + }; +} + +/** + * Upgrades older save shapes. There is only version 1 so far; when the shape changes, + * add a step here rather than discarding people's progress. + */ +function migrate(raw: unknown): unknown { + return raw; +} + +export function loadSave(storage: StorageLike | undefined): SaveData | null { + if (!storage) return null; + try { + const text = storage.getItem(SAVE_KEY); + if (!text) return null; + return validate(migrate(JSON.parse(text))); + } catch { + return null; + } +} + +/** Returns false when the browser refused to store it (quota, privacy mode). */ +export function writeSave(storage: StorageLike | undefined, data: Omit): boolean { + if (!storage) return false; + try { + const full: SaveData = { ...data, version: SAVE_VERSION, updatedAt: new Date().toISOString() }; + storage.setItem(SAVE_KEY, JSON.stringify(full)); + return true; + } catch { + return false; + } +} + +export function clearSave(storage: StorageLike | undefined): void { + try { + storage?.removeItem(SAVE_KEY); + } catch { + // Nothing to do: storage is unavailable. + } +} + +/** localStorage when the browser allows touching it, else undefined. */ +export function browserStorage(): StorageLike | undefined { + try { + return typeof window !== "undefined" ? window.localStorage : undefined; + } catch { + return undefined; + } +} diff --git a/game/scenes/PreloadScene.ts b/game/scenes/PreloadScene.ts index db0a7ef..074e467 100644 --- a/game/scenes/PreloadScene.ts +++ b/game/scenes/PreloadScene.ts @@ -1,5 +1,7 @@ import Phaser from "phaser"; import { SERVICES_KEY, type GameServices } from "../boot"; +import { CHARACTERS } from "../assets/manifest"; +import { ANIMS, DIRECTIONS, FRAME_HEIGHT, FRAME_WIDTH, animFrames, animKey, type AnimName } from "../characters/sheet"; /** Loads everything the first map needs, reports progress, then waits for Start. */ export class PreloadScene extends Phaser.Scene { @@ -11,13 +13,31 @@ export class PreloadScene extends Phaser.Scene { const services = this.registry.get(SERVICES_KEY) as GameServices; this.load.setBaseURL(services.assetBase); this.load.on(Phaser.Loader.Events.PROGRESS, services.onProgress); - // Asset list arrives with the pipeline (PLAN M1.3). + + this.load.image("tiles:greybox", "tilesets/greybox.png"); + this.load.bitmapFont("pixel", "fonts/pixel.png", "fonts/pixel.xml"); + for (const id of Object.keys(CHARACTERS)) { + this.load.spritesheet(`char:${id}`, `characters/${id}.png`, { frameWidth: FRAME_WIDTH, frameHeight: FRAME_HEIGHT }); + } + this.load.tilemapTiledJSON(`map:${services.start.map}`, `maps/${services.start.map}.tmj`); } create() { const services = this.registry.get(SERVICES_KEY) as GameServices; + for (const id of Object.keys(CHARACTERS)) { + for (const anim of Object.keys(ANIMS) as AnimName[]) { + for (const dir of DIRECTIONS) { + this.anims.create({ + key: animKey(id, anim, dir), + frames: this.anims.generateFrameNumbers(`char:${id}`, { frames: animFrames(anim, dir) }), + frameRate: ANIMS[anim].frameRate, + repeat: ANIMS[anim].repeat, + }); + } + } + } services.onProgress(1); services.onReady(); - services.startRequested.then(() => this.scene.start("World")); + services.startRequested.then(() => this.scene.start("World", services.start)); } } diff --git a/game/scenes/WorldScene.ts b/game/scenes/WorldScene.ts index a85a2fb..c515b47 100644 --- a/game/scenes/WorldScene.ts +++ b/game/scenes/WorldScene.ts @@ -1,38 +1,307 @@ import Phaser from "phaser"; +import { SERVICES_KEY, type GameServices, type WorldTarget } from "../boot"; import { TILE } from "../constants"; +import { Actor } from "../entities/Actor"; +import { InputController, type FrameInput } from "../input/InputController"; +import { browserStorage, loadSave, writeSave } from "../save/save"; +import { DialogueBox } from "../ui/DialogueBox"; +import { CollisionGrid, directionBetween, neighbour, type Point } from "../world/grid"; +import { NPC_MOVEMENT, PLAYER_MOVEMENT, type MoverEvent } from "../world/movement"; +import { parseMapObject, type Facing, type MapObject, type TiledObject } from "../world/objects"; +import { findPath, findPathAdjacent } from "../world/pathfind"; + +type Door = Extract; +type Sign = Extract; +type NpcDef = Extract; + +const PLAYER_ID = "player"; +const tileKey = (p: Point) => `${p.x},${p.y}`; +/** Tap-to-move runs when the path is at least this long. */ +const RUN_PATH_LENGTH = 7; -/** The explorable world. For now: a test pattern to verify crisp scaling (PLAN M1.2). */ export class WorldScene extends Phaser.Scene { - private marker?: Phaser.GameObjects.Rectangle; + private target!: WorldTarget; + private services!: GameServices; + private grid!: CollisionGrid; + private player!: Actor; + private npcs = new Map(); + private doors = new Map(); + private signs = new Map(); + private input2!: InputController; + private dialogue!: DialogueBox; + private path: Point[] = []; + private pathMarkers: Phaser.GameObjects.Rectangle[] = []; + /** What to do when the current path ends (talk to the NPC that was tapped). */ + private onArrive: (() => void) | null = null; + private transitioning = false; + private mapWidth = 0; + private mapHeight = 0; + private debugText?: Phaser.GameObjects.BitmapText; constructor() { super("World"); } + init(target: WorldTarget) { + this.target = target; + this.services = this.registry.get(SERVICES_KEY) as GameServices; + this.npcs = new Map(); + this.doors = new Map(); + this.signs = new Map(); + this.path = []; + this.pathMarkers = []; + this.onArrive = null; + this.transitioning = false; + } + + preload() { + const key = `map:${this.target.map}`; + if (!this.cache.tilemap.exists(key)) { + this.load.setBaseURL(this.services.assetBase); + this.load.tilemapTiledJSON(key, `maps/${this.target.map}.tmj`); + } + } + create() { - const cols = 60; - const rows = 40; - const g = this.add.graphics(); - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - g.fillStyle((x + y) % 2 ? 0x2f6b3f : 0x3b7d4a); - g.fillRect(x * TILE, y * TILE, TILE, TILE); - // One-pixel detail in every tile so blur or uneven pixels are obvious. - g.fillStyle(0xe8f5e9); - g.fillRect(x * TILE + 3, y * TILE + 3, 1, 1); - g.fillRect(x * TILE + 5, y * TILE + 3, 2, 1); + const map = this.make.tilemap({ key: `map:${this.target.map}` }); + const tileset = map.addTilesetImage("greybox", "tiles:greybox"); + if (!tileset) throw new Error(`Map ${this.target.map} does not use the greybox tileset`); + this.mapWidth = map.width; + this.mapHeight = map.height; + this.grid = new CollisionGrid(map.width, map.height); + + for (const layerData of map.layers) { + const layer = map.createLayer(layerData.name, tileset); + if (!layer) continue; + layer.setDepth(layerData.name.startsWith("above") ? 50_000 : -1); + layer.forEachTile((tile) => { + if (tile.index >= 0 && tile.properties?.collides) this.grid.setBlocked(tile.x, tile.y); + }); + } + + const spawns = new Map>(); + const rawObjects = (map.getObjectLayer("objects")?.objects ?? []) as unknown as TiledObject[]; + for (const raw of rawObjects) { + const obj = parseMapObject(raw, TILE); + if (obj.type === "spawn") spawns.set(obj.id, obj); + if (obj.type === "door") this.doors.set(tileKey(obj), obj); + if (obj.type === "sign") this.signs.set(tileKey(obj), obj); + if (obj.type === "npc") { + const actor = new Actor(this, obj.id, obj.character, obj, obj.facing, NPC_MOVEMENT); + this.npcs.set(obj.id, { actor, def: obj }); + this.grid.occupy(obj.x, obj.y, obj.id); + } + } + + const spawn = this.target.spawn ? spawns.get(this.target.spawn) : undefined; + const startAt = this.target.tile ?? spawn ?? [...spawns.values()][0]; + if (!startAt) throw new Error(`Map ${this.target.map} has no spawn point`); + const start = { x: startAt.x, y: startAt.y }; + const facing = this.target.facing ?? spawn?.facing ?? "down"; + this.player = new Actor(this, PLAYER_ID, "player", start, facing, PLAYER_MOVEMENT); + this.grid.occupy(start.x, start.y, PLAYER_ID); + + this.input2 = new InputController(this); + this.dialogue = new DialogueBox(this); + this.fitCamera(); + this.cameras.main.startFollow(this.player.sprite, true); + this.cameras.main.fadeIn(180, 11, 19, 32); + this.scale.on(Phaser.Scale.Events.RESIZE, this.onResize, this); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.scale.off(Phaser.Scale.Events.RESIZE, this.onResize, this)); + + if (new URLSearchParams(window.location.search).has("debug")) { + this.debugText = this.add.bitmapText(2, 2, "pixel", "").setScrollFactor(0).setDepth(200_000).setTint(0xffd27a); + } + this.save(); + } + + private onResize(size: Phaser.Structs.Size) { + this.cameras.resize(size.width, size.height); + this.fitCamera(); + this.dialogue.relayout(); + } + + /** Clamp the camera to the map, centring maps smaller than the screen. */ + private fitCamera() { + const cam = this.cameras.main; + const w = this.mapWidth * TILE; + const h = this.mapHeight * TILE; + const x = w < cam.width ? -Math.floor((cam.width - w) / 2) : 0; + const y = h < cam.height ? -Math.floor((cam.height - h) / 2) : 0; + cam.setBounds(x, y, Math.max(w, cam.width), Math.max(h, cam.height)); + } + + update(time: number, delta: number) { + const dt = Math.min(delta, 50); // no huge jumps after a stalled tab + const input = this.input2.poll(); + this.dialogue.update(dt, time); + + if (this.transitioning) return; + if (this.dialogue.open) { + if (input.interact || input.back || input.taps.length) this.dialogue.advance(); + this.player.sync(); + return; + } + + this.handleTaps(input); + if (input.interact && !this.player.mover.moving) this.interactAhead(); + + if (input.dir) this.clearPath(); // keys and pads always override a tap path + + const events = this.player.mover.update(dt, { dir: input.dir, run: input.run }, (p) => + this.grid.isWalkable(p.x, p.y, PLAYER_ID), + ); + this.handleEvents(events); + if (this.path.length && !this.player.mover.moving && !this.transitioning) { + this.followPath(input.run || this.path.length >= RUN_PATH_LENGTH); + } + + this.player.sync(); + for (const { actor } of this.npcs.values()) actor.sync(); + + if (this.debugText) { + const p = this.player.mover.tile; + this.debugText.setText(`${Math.round(this.game.loop.actualFps)} fps ${this.target.map} ${p.x},${p.y}`); + // Read by e2e tests and dev tooling; only exists with ?debug. + (window as unknown as { __fjord?: object }).__fjord = { + map: this.target.map, + tile: { ...p }, + facing: this.player.mover.facing, + moving: this.player.mover.moving, + dialogueOpen: this.dialogue.open, + camera: { x: this.cameras.main.worldView.x, y: this.cameras.main.worldView.y, zoom: this.scale.zoom }, + }; + } + } + + private handleEvents(events: MoverEvent[]) { + for (const e of events) { + if (e.type === "stepStarted") { + // The player holds only the tile they are heading into. + this.grid.vacate(e.from.x, e.from.y, PLAYER_ID); + this.grid.occupy(e.to.x, e.to.y, PLAYER_ID); + } else if (e.type === "stepEnded") { + this.pathMarkers.shift()?.destroy(); + const door = this.doors.get(tileKey(e.at)); + if (door) { + this.enterDoor(door); + return; + } + this.save(); + if (!this.path.length && this.onArrive) { + const arrive = this.onArrive; + this.onArrive = null; + arrive(); + } + } else if (e.type === "bumped") { + this.clearPath(); } } - this.marker = this.add.rectangle(0, 0, TILE, TILE, 0xffd54f).setOrigin(0); - this.cameras.main.setBounds(0, 0, cols * TILE, rows * TILE); - this.cameras.main.startFollow(this.marker, true); } - update(time: number) { - if (!this.marker) return; - // Slow drift so camera movement can be checked for shimmer. - const t = time / 1000; - this.marker.x = Math.round(300 + Math.cos(t * 0.5) * 200); - this.marker.y = Math.round(300 + Math.sin(t * 0.5) * 150); + private followPath(run: boolean) { + const next = this.path[0]; + const dir = directionBetween(this.player.mover.tile, next); + if (!dir || !this.grid.isWalkable(next.x, next.y, PLAYER_ID)) { + this.clearPath(); + return; + } + this.path.shift(); + this.handleEvents(this.player.mover.walk(dir, run, (p) => this.grid.isWalkable(p.x, p.y, PLAYER_ID))); + } + + private handleTaps(input: FrameInput) { + const tap = input.taps.at(-1); + if (!tap) return; + const target = { x: Math.floor(tap.x / TILE), y: Math.floor(tap.y / TILE) }; + if (!this.grid.inBounds(target.x, target.y)) return; + const from = this.player.mover.destination; + const walkable = { width: this.grid.width, height: this.grid.height, isWalkable: (x: number, y: number) => this.grid.isWalkable(x, y, PLAYER_ID) }; + + const npc = this.npcAt(target); + const sign = this.signs.get(tileKey(target)); + if (npc || sign) { + const path = findPathAdjacent(walkable, from, target); + if (!path) return; + this.setPath(path, () => { + const dir = directionBetween(this.player.mover.tile, target); + if (dir) this.player.mover.face(dir); + this.interactWith(target); + }); + return; + } + const path = findPath(walkable, from, target); + if (path) this.setPath(path, null); + } + + private setPath(path: Point[], onArrive: (() => void) | null) { + this.clearPath(); + this.path = path; + this.onArrive = onArrive; + this.pathMarkers = path.map((p) => + this.add.rectangle(p.x * TILE + 7, p.y * TILE + 7, 2, 2, 0xe8f5e9, 0.8).setOrigin(0).setDepth(-0.5), + ); + if (!path.length && onArrive) { + this.onArrive = null; + onArrive(); + } + } + + private clearPath() { + this.path = []; + this.onArrive = null; + for (const m of this.pathMarkers) m.destroy(); + this.pathMarkers = []; + } + + private npcAt(p: Point) { + const id = this.grid.occupantAt(p.x, p.y); + return id && id !== PLAYER_ID ? this.npcs.get(id) : undefined; + } + + private interactAhead() { + this.interactWith(neighbour(this.player.mover.tile, this.player.mover.facing)); + } + + private interactWith(p: Point) { + const npc = this.npcAt(p); + if (npc) { + const toward = directionBetween(p, this.player.mover.tile); + if (toward) npc.actor.mover.face(toward); + this.dialogue.show(npc.def.text, npc.def.id === "datagutt" ? "Thomas" : capitalise(npc.def.id)); + return; + } + const sign = this.signs.get(tileKey(p)); + if (sign) this.dialogue.show(sign.text, null); + } + + private enterDoor(door: Door) { + this.transitioning = true; + this.clearPath(); + const cam = this.cameras.main; + cam.fadeOut(160, 11, 19, 32); + cam.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => { + this.scene.restart({ map: door.toMap, spawn: door.toSpawn } satisfies WorldTarget); + }); } + + private save() { + const storage = browserStorage(); + const previous = loadSave(storage); + const { tile, facing } = this.player.mover; + writeSave(storage, { + map: this.target.map, + x: tile.x, + y: tile.y, + facing: facing as Facing, + stamps: previous?.stamps ?? [], + flags: previous?.flags ?? {}, + dialogue: previous?.dialogue ?? {}, + settings: previous?.settings ?? { muted: false, showVisitors: true, reducedMotion: null }, + }); + } +} + +function capitalise(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/game/ui/DialogueBox.ts b/game/ui/DialogueBox.ts new file mode 100644 index 0000000..914f762 --- /dev/null +++ b/game/ui/DialogueBox.ts @@ -0,0 +1,118 @@ +// Minimal in-engine text box for signs and NPCs (docs/game/PLAN.md M1.6). The full +// dialogue UI with portraits, choices and Ink comes in M2.4; this keeps the same shape: +// typewriter reveal, interact to finish the page, interact again for the next one. +import Phaser from "phaser"; +import { paginate, wrapText } from "./text"; + +const FONT = "pixel"; +const PADDING = 6; +const LINES = 3; +const CHARS_PER_SECOND = 50; + +export class DialogueBox { + private readonly container: Phaser.GameObjects.Container; + private readonly frame: Phaser.GameObjects.Graphics; + private readonly label: Phaser.GameObjects.BitmapText; + private readonly nameTag: Phaser.GameObjects.BitmapText; + private readonly more: Phaser.GameObjects.BitmapText; + private pages: string[][] = []; + private page = 0; + private revealed = 0; + private onClose: (() => void) | null = null; + + constructor(private readonly scene: Phaser.Scene) { + this.frame = scene.add.graphics(); + this.label = scene.add.bitmapText(0, 0, FONT, "").setTint(0xe8f5e9); + this.nameTag = scene.add.bitmapText(0, 0, FONT, "").setTint(0x46e294); + this.more = scene.add.bitmapText(0, 0, FONT, "▼").setTint(0x46e294); + // "▼" is not in the font: fall back to a caret drawn from "v". + if (!this.more.text || this.more.width === 0) this.more.setText("v"); + this.container = scene.add + .container(0, 0, [this.frame, this.nameTag, this.label, this.more]) + .setScrollFactor(0) + .setDepth(100_000) + .setVisible(false); + } + + get open(): boolean { + return this.container.visible; + } + + private get lineHeight(): number { + return this.scene.cache.bitmapFont.get(FONT).data.lineHeight; + } + + private measure = (text: string): number => this.label.setText(text).getTextBounds().local.width; + + show(text: string, speaker: string | null, onClose?: () => void): void { + const { width } = this.layout(); + this.pages = paginate(wrapText(text, width - PADDING * 2, this.measure), LINES); + this.page = 0; + this.revealed = 0; + this.onClose = onClose ?? null; + this.nameTag.setText(speaker ?? ""); + this.label.setText(""); + this.container.setVisible(true); + this.draw(); + } + + /** Interact pressed: finish the page, go to the next one, or close. */ + advance(): void { + if (!this.open) return; + const full = this.pages[this.page].join("\n"); + if (this.revealed < full.length) { + this.revealed = full.length; + } else if (this.page < this.pages.length - 1) { + this.page++; + this.revealed = 0; + } else { + this.container.setVisible(false); + const done = this.onClose; + this.onClose = null; + done?.(); + return; + } + this.draw(); + } + + update(dtMs: number, timeMs: number): void { + if (!this.open) return; + const full = this.pages[this.page].join("\n"); + if (this.revealed < full.length) { + this.revealed = Math.min(full.length, this.revealed + (dtMs / 1000) * CHARS_PER_SECOND); + this.label.setText(full.slice(0, Math.floor(this.revealed))); + } + this.more.setVisible(this.revealed >= full.length && Math.floor(timeMs / 400) % 2 === 0); + } + + /** Re-layout after the viewport changes size. */ + relayout(): void { + if (this.open) this.draw(); + } + + private layout() { + const cam = this.scene.cameras.main; + const width = Math.min(cam.width - 8, 300); + const height = LINES * this.lineHeight + PADDING * 2; + const x = Math.floor((cam.width - width) / 2); + const y = cam.height - height - 4; + return { x, y, width, height }; + } + + private draw(): void { + const { x, y, width, height } = this.layout(); + const g = this.frame.clear(); + g.fillStyle(0x0b1320, 0.94).fillRect(x, y, width, height); + g.lineStyle(1, 0xe8f5e9, 1).strokeRect(x + 0.5, y + 0.5, width - 1, height - 1); + g.lineStyle(1, 0x1dc672, 1).strokeRect(x + 2.5, y + 2.5, width - 5, height - 5); + const full = this.pages[this.page].join("\n"); + this.label.setText(full.slice(0, Math.floor(this.revealed))).setPosition(x + PADDING, y + PADDING); + if (this.nameTag.text) { + const w = this.nameTag.getTextBounds().local.width + 8; + g.fillStyle(0x0b1320, 1).fillRect(x + 4, y - this.lineHeight + 2, w, this.lineHeight); + g.lineStyle(1, 0x1dc672, 1).strokeRect(x + 4.5, y - this.lineHeight + 2.5, w - 1, this.lineHeight - 1); + this.nameTag.setPosition(x + 8, y - this.lineHeight + 3); + } + this.more.setPosition(x + width - PADDING - 5, y + height - PADDING - 6); + } +} diff --git a/game/ui/text.test.ts b/game/ui/text.test.ts new file mode 100644 index 0000000..1b714e9 --- /dev/null +++ b/game/ui/text.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { paginate, wrapText } from "./text"; + +const mono: (t: string) => number = (t) => t.length * 5; + +describe("wrapText", () => { + it("wraps on word boundaries within the width", () => { + const lines = wrapText("Welcome to Fjord Town, traveller.", 60, mono); + expect(lines).toEqual(["Welcome to", "Fjord Town,", "traveller."]); + expect(lines.every((l) => mono(l) <= 60)).toBe(true); + }); + + it("keeps explicit line breaks and hard-breaks very long words", () => { + expect(wrapText("a\nb", 100, mono)).toEqual(["a", "b"]); + expect(wrapText("supercalifragilistic", 30, mono)).toEqual(["superc", "alifra", "gilist", "ic"]); + }); +}); + +describe("paginate", () => { + it("splits into pages and never returns zero pages", () => { + expect(paginate(["1", "2", "3"], 2)).toEqual([["1", "2"], ["3"]]); + expect(paginate([], 3)).toEqual([[""]]); + }); +}); diff --git a/game/ui/text.ts b/game/ui/text.ts new file mode 100644 index 0000000..44bb701 --- /dev/null +++ b/game/ui/text.ts @@ -0,0 +1,37 @@ +// Word wrapping and paging for dialogue, measured with the bitmap font's real advances. + +export type Measure = (text: string) => number; + +/** Wrap on spaces; words longer than a line are broken by character. Keeps "\n". */ +export function wrapText(text: string, maxWidth: number, measure: Measure): string[] { + const lines: string[] = []; + for (const paragraph of text.split("\n")) { + let line = ""; + for (const word of paragraph.split(/ +/).filter(Boolean)) { + const candidate = line ? `${line} ${word}` : word; + if (measure(candidate) <= maxWidth) { + line = candidate; + continue; + } + if (line) lines.push(line); + // A single word wider than the box: hard-break it. + let rest = word; + while (measure(rest) > maxWidth) { + let cut = rest.length - 1; + while (cut > 1 && measure(rest.slice(0, cut)) > maxWidth) cut--; + lines.push(rest.slice(0, cut)); + rest = rest.slice(cut); + } + line = rest; + } + lines.push(line); + } + return lines; +} + +/** Group wrapped lines into pages of at most `linesPerPage`. */ +export function paginate(lines: string[], linesPerPage: number): string[][] { + const pages: string[][] = []; + for (let i = 0; i < lines.length; i += linesPerPage) pages.push(lines.slice(i, i + linesPerPage)); + return pages.length ? pages : [[""]]; +} diff --git a/game/world/grid.ts b/game/world/grid.ts new file mode 100644 index 0000000..8f04d53 --- /dev/null +++ b/game/world/grid.ts @@ -0,0 +1,77 @@ +import type { Facing } from "./objects"; + +export type Point = { x: number; y: number }; + +export const STEP: Record = { + right: { x: 1, y: 0 }, + up: { x: 0, y: -1 }, + left: { x: -1, y: 0 }, + down: { x: 0, y: 1 }, +}; + +export function neighbour(p: Point, dir: Facing): Point { + return { x: p.x + STEP[dir].x, y: p.y + STEP[dir].y }; +} + +/** Direction of a single orthogonal step from `a` to `b`, or null if they are not adjacent. */ +export function directionBetween(a: Point, b: Point): Facing | null { + const dx = b.x - a.x; + const dy = b.y - a.y; + if (Math.abs(dx) + Math.abs(dy) !== 1) return null; + if (dx === 1) return "right"; + if (dx === -1) return "left"; + return dy === 1 ? "down" : "up"; +} + +/** + * Which tiles can be walked on. Static blocking comes from the map; occupants (NPCs, + * the player) are tracked separately so they can move without rebuilding anything. + */ +export class CollisionGrid { + readonly width: number; + readonly height: number; + private readonly blocked: Uint8Array; + private readonly occupants = new Map(); + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + this.blocked = new Uint8Array(width * height); + } + + private index(x: number, y: number): number { + return y * this.width + x; + } + + inBounds(x: number, y: number): boolean { + return x >= 0 && y >= 0 && x < this.width && y < this.height; + } + + setBlocked(x: number, y: number, blocked = true): void { + if (this.inBounds(x, y)) this.blocked[this.index(x, y)] = blocked ? 1 : 0; + } + + isStaticBlocked(x: number, y: number): boolean { + return !this.inBounds(x, y) || this.blocked[this.index(x, y)] === 1; + } + + occupy(x: number, y: number, who: string): void { + this.occupants.set(this.index(x, y), who); + } + + vacate(x: number, y: number, who: string): void { + const i = this.index(x, y); + if (this.occupants.get(i) === who) this.occupants.delete(i); + } + + occupantAt(x: number, y: number): string | undefined { + return this.inBounds(x, y) ? this.occupants.get(this.index(x, y)) : undefined; + } + + /** Walkable for `who`: in bounds, not a wall, and not taken by someone else. */ + isWalkable(x: number, y: number, who?: string): boolean { + if (this.isStaticBlocked(x, y)) return false; + const occupant = this.occupantAt(x, y); + return occupant === undefined || occupant === who; + } +} diff --git a/game/world/movement.test.ts b/game/world/movement.test.ts new file mode 100644 index 0000000..9736465 --- /dev/null +++ b/game/world/movement.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { GridMover, type MoverConfig } from "./movement"; +import type { Point } from "./grid"; + +const config: MoverConfig = { walkSpeed: 4, runSpeed: 8, turnDelayMs: 90 }; +const open = () => true; +const wallAt = + (wall: Point) => + (p: Point): boolean => + !(p.x === wall.x && p.y === wall.y); + +describe("GridMover", () => { + it("walks immediately in the direction it already faces", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + const events = m.update(16, { dir: "right", run: false }, open); + expect(events).toEqual([{ type: "stepStarted", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }]); + expect(m.moving).toBe(true); + }); + + it("turns in place on a tap in a new direction", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + expect(m.update(16, { dir: "down", run: false }, open)).toEqual([{ type: "turned", facing: "down" }]); + expect(m.update(16, { dir: null, run: false }, open)).toEqual([]); + expect(m.tile).toEqual({ x: 0, y: 0 }); + expect(m.facing).toBe("down"); + }); + + it("starts walking once a new direction is held past the turn delay", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + m.update(16, { dir: "down", run: false }, open); + expect(m.update(50, { dir: "down", run: false }, open)).toEqual([]); + const events = m.update(50, { dir: "down", run: false }, open); + expect(events.map((e) => e.type)).toEqual(["stepStarted"]); + }); + + it("takes exactly 1/speed seconds per tile", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + m.update(0, { dir: "right", run: false }, open); + m.update(125, { dir: null, run: false }, open); + expect(m.position.x).toBeCloseTo(0.5); + const events = m.update(125, { dir: null, run: false }, open); + expect(events).toEqual([{ type: "stepEnded", at: { x: 1, y: 0 } }]); + expect(m.moving).toBe(false); + }); + + it("chains steps without losing time while the direction is held", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + m.update(0, { dir: "right", run: false }, open); + // 1.5 tiles' worth of time in one frame. + const events = m.update(375, { dir: "right", run: false }, open); + expect(events.map((e) => e.type)).toEqual(["stepEnded", "stepStarted"]); + expect(m.position.x).toBeCloseTo(1.5); + }); + + it("turns a corner mid-walk without the turn delay", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + m.update(0, { dir: "right", run: false }, open); + const events = m.update(250, { dir: "down", run: false }, open); + expect(events.map((e) => e.type)).toEqual(["stepEnded", "turned", "stepStarted"]); + expect(m.destination).toEqual({ x: 1, y: 1 }); + }); + + it("bumps once per hold instead of walking into a wall", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + const blocked = wallAt({ x: 1, y: 0 }); + expect(m.update(16, { dir: "right", run: false }, blocked).map((e) => e.type)).toEqual(["bumped"]); + expect(m.update(16, { dir: "right", run: false }, blocked)).toEqual([]); + m.update(16, { dir: null, run: false }, blocked); + expect(m.update(16, { dir: "right", run: false }, blocked).map((e) => e.type)).toEqual(["bumped"]); + expect(m.tile).toEqual({ x: 0, y: 0 }); + }); + + it("runs at run speed", () => { + const m = new GridMover({ x: 0, y: 0 }, "right", config); + m.update(0, { dir: "right", run: true }, open); + expect(m.update(125, { dir: null, run: false }, open)).toEqual([{ type: "stepEnded", at: { x: 1, y: 0 } }]); + }); + + it("walk() starts a scripted step with no turn delay", () => { + const m = new GridMover({ x: 2, y: 2 }, "up", config); + expect(m.walk("left", false, open).map((e) => e.type)).toEqual(["turned", "stepStarted"]); + expect(m.destination).toEqual({ x: 1, y: 2 }); + }); +}); diff --git a/game/world/movement.ts b/game/world/movement.ts new file mode 100644 index 0000000..87a6f80 --- /dev/null +++ b/game/world/movement.ts @@ -0,0 +1,159 @@ +// Tile-locked movement for the player and NPCs (docs/game/PLAN.md M1.5). Pure state, no +// Phaser, so the feel can be unit tested: +// - Pressing the direction you already face walks immediately. +// - Pressing a new direction while standing turns in place; keep holding it past the +// turn delay and you start walking. Quick taps just change facing. +// - A held direction continues into the next step on the same frame the last one ends, +// so walking never stutters and no input is dropped. +// - Walking into something blocked reports a single "bumped" per hold. +import type { Facing } from "./objects"; +import { neighbour, type Point } from "./grid"; + +export type MoverConfig = { + /** Tiles per second. */ + walkSpeed: number; + runSpeed: number; + /** How long a newly turned-to direction must be held before walking starts. */ + turnDelayMs: number; +}; + +export const PLAYER_MOVEMENT: MoverConfig = { walkSpeed: 4.5, runSpeed: 8, turnDelayMs: 90 }; +export const NPC_MOVEMENT: MoverConfig = { walkSpeed: 3, runSpeed: 5, turnDelayMs: 0 }; + +export type MoverEvent = + | { type: "turned"; facing: Facing } + | { type: "stepStarted"; from: Point; to: Point } + | { type: "stepEnded"; at: Point } + | { type: "bumped"; facing: Facing; into: Point }; + +export type MoverInput = { + /** Direction currently held, if any. */ + dir: Facing | null; + run: boolean; +}; + +type Step = { from: Point; to: Point; progress: number; speed: number }; + +export class GridMover { + tile: Point; + facing: Facing; + private step: Step | null = null; + /** After turning in place: how long the new direction has been held. Null when not waiting. */ + private turnHeldMs: number | null = null; + /** Direction of the last bump in the current hold, to report it only once. */ + private bumpedDir: Facing | null = null; + + constructor( + start: Point, + facing: Facing, + private readonly config: MoverConfig, + ) { + this.tile = { ...start }; + this.facing = facing; + } + + get moving(): boolean { + return this.step !== null; + } + + /** Position in tiles, fractional while moving. */ + get position(): Point { + if (!this.step) return { ...this.tile }; + const { from, to, progress } = this.step; + return { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress }; + } + + /** Tile the mover occupies or is heading into. */ + get destination(): Point { + return this.step ? { ...this.step.to } : { ...this.tile }; + } + + /** Face a direction without moving (e.g. to talk to someone). */ + face(dir: Facing): MoverEvent[] { + if (this.facing === dir) return []; + this.facing = dir; + return [{ type: "turned", facing: dir }]; + } + + /** Teleport, e.g. after a door. */ + place(tile: Point, facing: Facing): void { + this.tile = { ...tile }; + this.facing = facing; + this.step = null; + this.turnHeldMs = null; + this.bumpedDir = null; + } + + /** + * Start a step right away in `dir` (path following, NPC scripts). No turn delay. + * Returns what happened; nothing if already moving. + */ + walk(dir: Facing, run: boolean, canEnter: (p: Point) => boolean): MoverEvent[] { + const events: MoverEvent[] = []; + if (this.step) return events; + events.push(...this.face(dir)); + this.tryStart(run, canEnter, events); + return events; + } + + /** Advance by `dtMs` of held input. `canEnter` decides whether a tile may be entered now. */ + update(dtMs: number, input: MoverInput, canEnter: (p: Point) => boolean): MoverEvent[] { + const events: MoverEvent[] = []; + let remaining = dtMs; + + while (this.step) { + const msPerTile = 1000 / this.step.speed; + const needed = (1 - this.step.progress) * msPerTile; + if (remaining < needed) { + this.step.progress += remaining / msPerTile; + return events; + } + remaining -= needed; + this.tile = this.step.to; + this.step = null; + events.push({ type: "stepEnded", at: { ...this.tile } }); + // Chain into the next step on the same frame while a direction is held. + if (!input.dir) break; + events.push(...this.face(input.dir)); + if (!this.tryStart(input.run, canEnter, events)) break; + if (remaining <= 0) return events; + } + if (this.step) return events; + + if (!input.dir) { + this.turnHeldMs = null; + this.bumpedDir = null; + return events; + } + + if (input.dir !== this.facing) { + this.facing = input.dir; + this.turnHeldMs = 0; + this.bumpedDir = null; + events.push({ type: "turned", facing: input.dir }); + return events; + } + + if (this.turnHeldMs !== null) { + this.turnHeldMs += remaining; + if (this.turnHeldMs < this.config.turnDelayMs) return events; + this.turnHeldMs = null; + } + this.tryStart(input.run, canEnter, events); + return events; + } + + private tryStart(run: boolean, canEnter: (p: Point) => boolean, events: MoverEvent[]): boolean { + const to = neighbour(this.tile, this.facing); + if (!canEnter(to)) { + if (this.bumpedDir !== this.facing) events.push({ type: "bumped", facing: this.facing, into: to }); + this.bumpedDir = this.facing; + return false; + } + this.bumpedDir = null; + const from = { ...this.tile }; + this.step = { from, to, progress: 0, speed: run ? this.config.runSpeed : this.config.walkSpeed }; + events.push({ type: "stepStarted", from, to }); + return true; + } +} diff --git a/game/world/pathfind.test.ts b/game/world/pathfind.test.ts new file mode 100644 index 0000000..8e7541e --- /dev/null +++ b/game/world/pathfind.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { CollisionGrid, directionBetween } from "./grid"; +import { findPath, findPathAdjacent } from "./pathfind"; + +/** Build a grid from ASCII: '#' is a wall, anything else is floor. */ +function gridFrom(rows: string[]): CollisionGrid { + const g = new CollisionGrid(rows[0].length, rows.length); + rows.forEach((row, y) => [...row].forEach((ch, x) => ch === "#" && g.setBlocked(x, y))); + return g; +} + +function isContinuous(start: { x: number; y: number }, path: { x: number; y: number }[]) { + let prev = start; + for (const p of path) { + if (!directionBetween(prev, p)) return false; + prev = p; + } + return true; +} + +describe("findPath", () => { + it("returns [] when already at the target", () => { + expect(findPath(gridFrom(["..."]), { x: 1, y: 0 }, { x: 1, y: 0 })).toEqual([]); + }); + + it("finds a shortest path around walls", () => { + const g = gridFrom([ + ".....", // + ".###.", + "...#.", + ".#...", + ]); + const path = findPath(g, { x: 0, y: 0 }, { x: 2, y: 2 })!; + expect(path).not.toBeNull(); + expect(path.at(-1)).toEqual({ x: 2, y: 2 }); + expect(path).toHaveLength(4); + expect(isContinuous({ x: 0, y: 0 }, path)).toBe(true); + expect(path.every((p) => g.isWalkable(p.x, p.y))).toBe(true); + }); + + it("returns null for unreachable or blocked targets", () => { + const g = gridFrom([".#.", ".#.", ".#."]); + expect(findPath(g, { x: 0, y: 0 }, { x: 2, y: 0 })).toBeNull(); + expect(findPath(g, { x: 0, y: 0 }, { x: 1, y: 1 })).toBeNull(); + }); + + it("prefers straight runs over zig-zags of equal length", () => { + const g = gridFrom(["....", "....", "...."]); + const path = findPath(g, { x: 0, y: 0 }, { x: 3, y: 2 })!; + let turns = 0; + let prevDir = directionBetween({ x: 0, y: 0 }, path[0]); + for (let i = 1; i < path.length; i++) { + const dir = directionBetween(path[i - 1], path[i]); + if (dir !== prevDir) turns++; + prevDir = dir; + } + expect(path).toHaveLength(5); + expect(turns).toBe(1); + }); + + it("treats occupied tiles as blocked for everyone but the occupant", () => { + const g = gridFrom(["...", "...", "..."]); + g.occupy(1, 0, "npc"); + expect(g.isWalkable(1, 0)).toBe(false); + expect(g.isWalkable(1, 0, "npc")).toBe(true); + const path = findPath(g, { x: 0, y: 0 }, { x: 2, y: 0 })!; + expect(path.some((p) => p.x === 1 && p.y === 0)).toBe(false); + }); + + it("gives up on huge searches instead of stalling", () => { + const g = gridFrom(Array.from({ length: 200 }, () => ".".repeat(200))); + expect(findPath(g, { x: 0, y: 0 }, { x: 199, y: 199 }, { maxExpanded: 50 })).toBeNull(); + }); +}); + +describe("findPathAdjacent", () => { + it("walks to the nearest free side of a blocked target", () => { + const g = gridFrom([ + ".....", // + "..#..", + ".....", + ]); + const path = findPathAdjacent(g, { x: 0, y: 1 }, { x: 2, y: 1 })!; + expect(path.at(-1)).toEqual({ x: 1, y: 1 }); + }); + + it("returns [] when already next to the target", () => { + const g = gridFrom(["..#.."]); + expect(findPathAdjacent(g, { x: 1, y: 0 }, { x: 2, y: 0 })).toEqual([]); + }); +}); diff --git a/game/world/pathfind.ts b/game/world/pathfind.ts new file mode 100644 index 0000000..e5a2eb6 --- /dev/null +++ b/game/world/pathfind.ts @@ -0,0 +1,144 @@ +// A* on the tile grid for tap/click-to-move (docs/game/PLAN.md M1.8). 4-directional, +// with a small penalty for turning so paths prefer long straight runs, which reads as +// deliberate walking rather than zig-zagging. +import type { Point } from "./grid"; + +export type Walkable = { width: number; height: number; isWalkable(x: number, y: number): boolean }; + +export type PathOptions = { + /** Give up after expanding this many tiles (keeps a far-off tap from stalling a frame). */ + maxExpanded?: number; + /** Extra cost for changing direction. */ + turnCost?: number; +}; + +const DIRS = [ + [1, 0], + [0, -1], + [-1, 0], + [0, 1], +] as const; + +/** Binary min-heap of node indices keyed by f-score. */ +class Heap { + private items: number[] = []; + constructor(private readonly score: Float64Array) {} + get size() { + return this.items.length; + } + push(n: number) { + const items = this.items; + items.push(n); + let i = items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (this.score[items[parent]] <= this.score[items[i]]) break; + [items[parent], items[i]] = [items[i], items[parent]]; + i = parent; + } + } + pop(): number { + const items = this.items; + const top = items[0]; + const last = items.pop()!; + if (items.length) { + items[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1; + const r = l + 1; + let m = i; + if (l < items.length && this.score[items[l]] < this.score[items[m]]) m = l; + if (r < items.length && this.score[items[r]] < this.score[items[m]]) m = r; + if (m === i) break; + [items[m], items[i]] = [items[i], items[m]]; + i = m; + } + } + return top; + } +} + +/** + * Shortest path from `from` to `to`, excluding `from` and including `to`. + * Returns [] when already there and null when unreachable. + */ +export function findPath(grid: Walkable, from: Point, to: Point, options: PathOptions = {}): Point[] | null { + const { maxExpanded = 4000, turnCost = 0.01 } = options; + if (from.x === to.x && from.y === to.y) return []; + if (!grid.isWalkable(to.x, to.y)) return null; + + const w = grid.width; + const size = w * grid.height; + // State is (tile, arrival direction) so turn costs are exact. + const states = size * 4; + const g = new Float64Array(states).fill(Infinity); + const f = new Float64Array(states).fill(Infinity); + const came = new Int32Array(states).fill(-1); + const closed = new Uint8Array(states); + const heap = new Heap(f); + const h = (x: number, y: number) => Math.abs(x - to.x) + Math.abs(y - to.y); + + for (let d = 0; d < 4; d++) { + const s = (from.y * w + from.x) * 4 + d; + g[s] = 0; + f[s] = h(from.x, from.y); + } + // Start with no preferred direction: seed a single state; the others stay unreached. + const start = (from.y * w + from.x) * 4; + heap.push(start); + + let expanded = 0; + while (heap.size) { + const s = heap.pop(); + if (closed[s]) continue; + closed[s] = 1; + const tile = s >> 2; + const dir = s & 3; + const x = tile % w; + const y = (tile - x) / w; + + if (x === to.x && y === to.y) { + const path: Point[] = []; + let cur = s; + while (cur !== start && cur !== -1) { + const t = cur >> 2; + path.push({ x: t % w, y: Math.floor(t / w) }); + cur = came[cur]; + } + return path.reverse(); + } + if (++expanded > maxExpanded) return null; + + for (let nd = 0; nd < 4; nd++) { + const nx = x + DIRS[nd][0]; + const ny = y + DIRS[nd][1]; + if (nx < 0 || ny < 0 || nx >= w || ny >= grid.height || !grid.isWalkable(nx, ny)) continue; + const ns = (ny * w + nx) * 4 + nd; + if (closed[ns]) continue; + const turned = s !== start && nd !== dir; + const cost = g[s] + 1 + (turned ? turnCost : 0); + if (cost < g[ns]) { + g[ns] = cost; + f[ns] = cost + h(nx, ny); + came[ns] = s; + heap.push(ns); + } + } + } + return null; +} + +/** + * Path to stand next to `target` (to talk to an NPC or read a sign), picking the + * shortest of the reachable neighbouring tiles. Returns the path and the tile to face. + */ +export function findPathAdjacent(grid: Walkable, from: Point, target: Point, options?: PathOptions): Point[] | null { + let best: Point[] | null = null; + for (const [dx, dy] of DIRS) { + const spot = { x: target.x + dx, y: target.y + dy }; + const path = spot.x === from.x && spot.y === from.y ? [] : findPath(grid, from, spot, options); + if (path && (best === null || path.length < best.length)) best = path; + } + return best; +} diff --git a/game/world/places.ts b/game/world/places.ts new file mode 100644 index 0000000..3f8f20b --- /dev/null +++ b/game/world/places.ts @@ -0,0 +1,20 @@ +// Named places for `?at=` deep links (docs/game/PLAN.md M1.11): each spawns the player +// outside that place. Moves to content/places.ts with the content module in M2.1. + +export type Place = { map: string; spawn: string }; + +export const PLACES = { + dock: { map: "town", spawn: "ferry" }, + home: { map: "town", spawn: "house_door" }, + office: { map: "town", spawn: "office_door" }, +} as const satisfies Record; + +export type PlaceId = keyof typeof PLACES; + +export const START_PLACE: PlaceId = "dock"; + +/** The place named by `?at=` in a URL search string, if it is a known place. */ +export function placeFromSearch(search: string): PlaceId | null { + const at = new URLSearchParams(search).get("at")?.trim().toLowerCase(); + return at && Object.hasOwn(PLACES, at) ? (at as PlaceId) : null; +} From a8718f5aa49ad99db010c33b9695407fe54579c0 Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 23 Sep 2026 00:48:51 +0200 Subject: [PATCH 009/153] test(game): e2e walkthrough and CI (M1.13) Plays the real page: deep link, doors both ways, talking to datagutt, continuing from a save. CI runs lint, typecheck, unit tests, a placeholder-art build and the e2e suite. --- .github/workflows/ci.yml | 33 ++++++++++++++++++++ e2e/game.spec.ts | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 e2e/game.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2750c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [master, game] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm exec tsc --noEmit + - run: pnpm test + # No access to the private art repo here: this builds and plays the game with + # placeholder art, which is exactly what a fresh clone gets. + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:e2e + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: test-results/ + retention-days: 7 diff --git a/e2e/game.spec.ts b/e2e/game.spec.ts new file mode 100644 index 0000000..d6bec2d --- /dev/null +++ b/e2e/game.spec.ts @@ -0,0 +1,65 @@ +import { expect, test, type Page } from "@playwright/test"; + +type FjordState = { map: string; tile: { x: number; y: number }; facing: string; moving: boolean; dialogueOpen: boolean }; + +const state = (page: Page) => page.evaluate(() => (window as unknown as { __fjord?: FjordState }).__fjord ?? null); + +async function holdKey(page: Page, key: string, ms: number) { + await page.keyboard.down(key); + await page.waitForTimeout(ms); + await page.keyboard.up(key); +} + +test.describe("world", () => { + // Keyboard play only; the phone project covers taps through the title-screen test. + test.skip(({ isMobile }) => isMobile, "keyboard walkthrough"); + + test("deep link skips the title, doors connect both ways, NPCs talk", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto("/?at=home&debug"); + await expect(page.getByRole("heading", { name: "datagutt" })).toBeHidden(); + await expect.poll(() => state(page), { timeout: 30_000 }).toMatchObject({ map: "town", tile: { x: 9, y: 7 } }); + + // Step up onto the door tile. + await holdKey(page, "ArrowUp", 300); + await expect.poll(() => state(page)).toMatchObject({ map: "house", tile: { x: 6, y: 7 }, facing: "up" }); + + // Walk to datagutt: up two, left one, then face him. + await holdKey(page, "ArrowUp", 480); + await expect.poll(async () => (await state(page))?.moving).toBe(false); + await holdKey(page, "ArrowLeft", 250); + await expect.poll(async () => (await state(page))?.moving).toBe(false); + const beforeTalk = await state(page); + if (beforeTalk?.facing !== "left") await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("e"); + await expect.poll(async () => (await state(page))?.dialogueOpen).toBe(true); + + // Finish the text and close it. + for (let i = 0; i < 4 && (await state(page))?.dialogueOpen; i++) { + await page.keyboard.press("e"); + await page.waitForTimeout(200); + } + await expect.poll(async () => (await state(page))?.dialogueOpen).toBe(false); + + // And back out through the door. + await holdKey(page, "ArrowRight", 250); + await holdKey(page, "ArrowDown", 1200); + await expect.poll(() => state(page), { timeout: 5_000 }).toMatchObject({ map: "town", tile: { x: 9, y: 7 } }); + expect(errors).toEqual([]); + }); + + test("reloading continues where the player left off", async ({ page }) => { + await page.goto("/?at=office&debug"); + await expect.poll(() => state(page), { timeout: 30_000 }).toMatchObject({ map: "town", tile: { x: 30, y: 7 } }); + await holdKey(page, "ArrowLeft", 300); + await expect.poll(async () => (await state(page))?.moving).toBe(false); + const moved = await state(page); + + await page.goto("/?debug"); + await expect(page.getByRole("button", { name: /continue/i })).toBeEnabled({ timeout: 30_000 }); + await page.getByRole("button", { name: /continue/i }).click(); + await expect.poll(() => state(page)).toMatchObject({ map: "town", tile: moved!.tile }); + }); +}); From e6d1732fe0254e3129b80eacdb2074581c83a16a Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 23 Sep 2026 00:49:14 +0200 Subject: [PATCH 010/153] docs(game): M1 complete; handoff points at M2.1 --- CLAUDE.md | 6 +++--- docs/game/HANDOFF.md | 27 +++++++++++++++++++++------ docs/game/PLAN.md | 36 +++++++++++++++++++++--------------- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4518a7e..624cd15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,13 +14,13 @@ Personal portfolio site for datagutt, built with **Next.js 16.3**, **React 19.3* ```bash pnpm dev # Start dev server (Turbopack) -pnpm game:dev # Standalone game harness at http://localhost:3200/game/dev.html (esbuild, live reload) +pnpm game:dev # Standalone game harness at http://localhost:3200/game/dev.html?debug (esbuild, live reload) +pnpm assets # Fetch licensed art (or fall back to placeholders) and build public/game/ pnpm build # Production build pnpm start # Start production server pnpm lint # ESLint CLI (Next.js config; `next lint` no longer exists in Next 16) pnpm test # Vitest unit tests (*.test.ts / *.test.mjs) -pnpm test:e2e # Playwright smoke tests against `next start` (run `pnpm build` first) -pnpm assets:fetch # Locate licensed game art (../datagutt-assets, ASSETS_DIR or ASSETS_REPO_TOKEN) +pnpm test:e2e # Playwright against `next start` (run `pnpm build` first) or E2E_BASE_URL pnpm format # Format with Prettier (includes Tailwind class sorting) pnpm format:check # Check formatting ``` diff --git a/docs/game/HANDOFF.md b/docs/game/HANDOFF.md index cf41a37..1162a16 100644 --- a/docs/game/HANDOFF.md +++ b/docs/game/HANDOFF.md @@ -1,6 +1,6 @@ # Handoff -Last updated: 2026-09-22 (session 1: design, planning, assets repo, M0, M1.1–M1.2) +Last updated: 2026-09-23 (session 1: design, planning, assets repo, M0, M1 complete) ## Current state @@ -14,6 +14,15 @@ Last updated: 2026-09-22 (session 1: design, planning, assets repo, M0, M1.1–M and UI art, character and portrait generator layers (with `.ase` sources), and the original licence files: about 30k files, 85 MB. Layout is in its README.md. The zips and generator tool builds stay local and gitignored. +- **M1 is complete** (greybox): walk the town and house with keyboard, gamepad or taps, + talk to the ferryman and datagutt, read signs, use doors, reload to continue, and + deep-link with `?at=dock|home|office`. `?debug` shows FPS and exposes + `window.__fjord` (map, tile, facing, dialogueOpen, camera) for tests. +- Asset pipeline: `pnpm assets` = fetch + `scripts/assets/build.mjs` into `public/game/`. + Character recipes live in `game/assets/manifest.ts` (datagutt: Body_02, Outfit_14_04, + Hairstyle_20_01 recoloured yellow, Glasses_01 recoloured black). Greybox maps are code + in `world/greybox/`. In-game text uses Geist Pixel converted to a bitmap font. +- CI: `.github/workflows/ci.yml` runs lint, tsc, unit tests, a placeholder build and e2e. - M1.1 and M1.2 done: `/` is a server-rendered title screen (`components/game/TitleArt.tsx` SVG fjord scene, `GameShell.tsx` client host) that boots Phaser behind it; old page at `/legacy`; `/journal` is a stub. Game code in `game/` (boot, viewport, Boot/Preload/World @@ -26,11 +35,10 @@ Last updated: 2026-09-22 (session 1: design, planning, assets repo, M0, M1.1–M ## Next step -**M1.3**: asset pipeline (`scripts/assets/build.mjs`): pack the sprites and tilesets the -game uses into `public/game/`, with placeholder output of the same shape. Inspect the -LimeZu character sheet layout first (`limezu/characters/Spritesheet_animations_GUIDE.png`). -The user said to focus on local testing: **M0.11 (Vercel token) is deferred** until they -ask for it. +**M2.1**: the `content/` module. Move `data/*.ts` plus the copy inlined in `Hero`, `About`, +`Contact` and `Socials` (now under `app/legacy` usage) into typed modules, and move +`game/world/places.ts` into `content/places.ts`. Then M2.2 (WorldState payload) and +M2.3 (Ink pipeline). **M0.11 (Vercel token) stays deferred** until the user asks. ## Blockers and things waiting on the user @@ -64,6 +72,13 @@ ask for it. `ss -ltnp | awk '/:3100 /'`. - `eslint-config-next` is still 15.2.2 because 16.x needs ESLint 9 flat config; migrate when convenient. +- LimeZu character sheets: 16×32 frames, 56 per row, directions right/up/left/down; + rows 0 stand, 1 idle, 2 walk, 3 sleep, 4–5 sit, 6 phone. Some sheets are wider than + 896 px (Body_01 is 927), so the build crops. Some PNGs have trailing bytes that pngjs + rejects; use sharp. +- Next allows only one `next dev` per project folder; if one is already running (for + example the user's), point `E2E_BASE_URL` at it instead of starting another. +- Commit after each finished task, not in large batches (user preference). - Phaser ships `docs/` and `skills/` inside `node_modules/phaser`. Read those for Phaser 4 APIs. - `jq` is not installed on this machine; hook scripts use plain shell and node. diff --git a/docs/game/PLAN.md b/docs/game/PLAN.md index 73fbec0..cc65679 100644 --- a/docs/game/PLAN.md +++ b/docs/game/PLAN.md @@ -7,7 +7,7 @@ goal and discussion; they link here and close when the milestone's exit criteria Decisions live in [DESIGN.md](./DESIGN.md). Session state lives in [HANDOFF.md](./HANDOFF.md). Workflow rules are in [README.md](./README.md). -Task format: `- [ ] **M1.3** Title: what to do. *Done when* the observable check.` +Task format: `- [x] **M1.3** Title: what to do. *Done when* the observable check.` Tasks are ordered by dependency within a milestone. Milestones can overlap once their dependencies are done (see the graph). @@ -78,37 +78,43 @@ phone. 1×, 2× and 3× DPR with no subpixel shimmer while the camera moves. (Verified by screenshot analysis at DPR 1, 3 and 2.625: 3 colours only, 1-px dots exactly `zoom` device pixels. `UI` and `Cutscene` scenes get added with their features.) -- [ ] **M1.3** Asset pipeline v1 `scripts/assets/build.mjs`: read the manifest +- [x] **M1.3** Asset pipeline v1 `scripts/assets/build.mjs`: read the manifest `game/assets.manifest.ts` (which sprites and tiles are used), pack atlases into `public/game/atlas/*.png|json`, emit a typed key list `game/generated/keys.ts`. Placeholder mode emits coloured rectangles with the same keys. *Done when* both - modes produce loadable atlases. -- [ ] **M1.4** Tiled loader: load `.tmj` maps, tile layers, object layers (`door`, `npc`, + modes produce loadable atlases. (Done as per-kind sheets rather than packed atlases, + with ids typed from `game/assets/manifest.ts` instead of generated keys; packing + moves to the M5.9 perf pass.) +- [x] **M1.4** Tiled loader: load `.tmj` maps, tile layers, object layers (`door`, `npc`, `sign`, `stamp`, `spawn`, `trigger`), collision from a `collision` layer and tile properties. *Done when* a hand-made test map renders with collision. -- [ ] **M1.5** Grid movement: 4-direction, tile-locked, walk and run speeds, facing, input +- [x] **M1.5** Grid movement: 4-direction, tile-locked, walk and run speeds, facing, input buffering so turning feels tight (Celeste standard: no dropped inputs). *Done when* - unit tests cover the movement state machine and it feels good by hand. -- [ ] **M1.6** Interaction: face a tile and press interact; objects dispatch by type. + unit tests cover the movement state machine and it feels good by hand. (Unit + tested; the "by hand" feel is for the user to judge.) +- [x] **M1.6** Interaction: face a tile and press interact; objects dispatch by type. *Done when* a sign shows text. -- [ ] **M1.7** Doors and warps: fade transition, target map and spawn id, lazy-load +- [x] **M1.7** Doors and warps: fade transition, target map and spawn id, lazy-load interior maps and atlases on first entry. *Done when* entering and leaving a building keeps facing and position correct. -- [ ] **M1.8** Tap and click to move: A* on the collision grid (pure function, unit +- [x] **M1.8** Tap and click to move: A* on the collision grid (pure function, unit tested), path preview dots, tap on NPC or door walks adjacent then interacts. - *Done when* it works on a real phone. -- [ ] **M1.9** Input layer: keyboard, gamepad, pointer unified into actions + *Done when* it works on a real phone. (Works in phone emulation at DPR 3; a real + device check is still for the user.) +- [x] **M1.9** Input layer: keyboard, gamepad, pointer unified into actions (`move`, `interact`, `back`, `menu`). *Done when* all three drive the same actions. -- [ ] **M1.10** Save system: versioned `localStorage` blob (position, map, stamps, flags, + (Keyboard and pointer tested; gamepad wired but untested without hardware.) +- [x] **M1.10** Save system: versioned `localStorage` blob (position, map, stamps, flags, visited dialogue, settings) with migration and try/catch everywhere. *Done when* reload restores state and corrupt data falls back to a new game. -- [ ] **M1.11** Deep links: `?at=` spawns outside that place and skips the +- [x] **M1.11** Deep links: `?at=` spawns outside that place and skips the title. *Done when* every place id in the content map works. -- [ ] **M1.12** Greybox maps: overworld plus one interior, generated by a minimal +- [x] **M1.12** Greybox maps: overworld plus one interior, generated by a minimal version of the generator (M3.1) or hand-written, enough to exercise M1.4–M1.11. *Done when* the whole loop runs in placeholder mode. -- [ ] **M1.13** Perf harness: an FPS and frame-time overlay behind `?debug`, plus a +- [x] **M1.13** Perf harness: an FPS and frame-time overlay behind `?debug`, plus a Playwright check that the game boots without console errors. *Done when* in CI. + (`.github/workflows/ci.yml`; `?debug` also exposes `window.__fjord` state for tests.) ## M2: Content, dialogue and UI From 3125fd325d815610b3c7bf63288a7d521ea00aaa Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 23 Sep 2026 00:57:15 +0200 Subject: [PATCH 011/153] feat(content): single content module for game and site (M2.1) data/ moves to content/ (with slug project ids) and gains profile, socials and places. places.ts is the whole town content map; a test checks every project and job has exactly one place. The legacy components and lib/github read from it, and the game uses it for ?at= deep links. Discord stays out of socials, as on the old site. --- components/About.tsx | 18 +-- components/Contact.tsx | 8 +- components/Experience.tsx | 2 +- components/Footer.tsx | 5 +- components/Hero.tsx | 17 +-- components/Portfolio.tsx | 2 +- components/Socials.tsx | 216 ++++++++++++++------------------ components/TechStack.tsx | 2 +- content/content.test.ts | 46 +++++++ {data => content}/experience.ts | 2 +- content/places.ts | 63 ++++++++++ content/profile.ts | 29 +++++ {data => content}/projects.ts | 13 +- {data => content}/skills.ts | 2 +- content/socials.ts | 18 +++ docs/game/HANDOFF.md | 11 +- docs/game/PLAN.md | 5 +- game/boot.ts | 8 +- game/save/save.test.ts | 11 -- game/world/places.ts | 20 --- lib/github.ts | 3 +- 21 files changed, 302 insertions(+), 199 deletions(-) create mode 100644 content/content.test.ts rename {data => content}/experience.ts (97%) create mode 100644 content/places.ts create mode 100644 content/profile.ts rename {data => content}/projects.ts (90%) rename {data => content}/skills.ts (96%) create mode 100644 content/socials.ts delete mode 100644 game/world/places.ts diff --git a/components/About.tsx b/components/About.tsx index 24ede30..5e83e1c 100644 --- a/components/About.tsx +++ b/components/About.tsx @@ -5,15 +5,11 @@ import { ScrollTrigger } from "gsap/dist/ScrollTrigger"; import { useEffect, useRef } from "react"; import { useDeferredInit } from "../hooks/useDeferredInit"; import { useNearViewport } from "../hooks/useNearViewport"; +import { profile } from "@/content/profile"; gsap.registerPlugin(ScrollTrigger); -const FACTS = [ - { label: "Location", value: "Oslo, Norway" }, - { label: "Role", value: "Full-stack Developer" }, - { label: "Focus", value: "Streaming & Payments" }, - { label: "Side projects", value: "Always shipping" }, -]; +const FACTS = profile.quickFacts; export default function About() { const sectionRef = useRef(null); @@ -80,16 +76,10 @@ export default function About() { >

- I'm Thomas, a full-stack developer based in Norway. - I enjoy building things for the web — from live - streaming infrastructure and payment integrations to - weird side projects that keep me up at night. + {profile.about[0]}

- Most of my work revolves around TypeScript, React, and - Node.js, but I also dabble in Rust when performance - matters. I care about shipping fast, writing clean code, - and making products that people actually want to use. + {profile.about[1]}

diff --git a/components/Contact.tsx b/components/Contact.tsx index 8908b35..47bd385 100644 --- a/components/Contact.tsx +++ b/components/Contact.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef } from "react"; import { useDeferredInit } from "../hooks/useDeferredInit"; import { useNearViewport } from "../hooks/useNearViewport"; import Socials from "./Socials"; +import { profile } from "@/content/profile"; gsap.registerPlugin(ScrollTrigger); @@ -76,12 +77,11 @@ export default function Contact() { className="flex flex-col items-center text-center" >

- Got a project in mind, want to collaborate, or just want to - say hi? My inbox is always open. + {profile.contactPitch}

- mail@datagutt.no + {profile.email} diff --git a/components/Experience.tsx b/components/Experience.tsx index ade83f5..510a1fc 100644 --- a/components/Experience.tsx +++ b/components/Experience.tsx @@ -5,7 +5,7 @@ import { ScrollTrigger } from "gsap/dist/ScrollTrigger"; import { useEffect, useRef } from "react"; import { useDeferredInit } from "../hooks/useDeferredInit"; import { useNearViewport } from "../hooks/useNearViewport"; -import { experience } from "../data/experience"; +import { experience } from "@/content/experience"; gsap.registerPlugin(ScrollTrigger); diff --git a/components/Footer.tsx b/components/Footer.tsx index f8f33c3..334b8c9 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,4 +1,5 @@ import { connection } from "next/server"; +import { profile } from "@/content/profile"; export default async function Footer() { await connection(); @@ -9,12 +10,12 @@ export default async function Footer() {

@@ -165,7 +169,7 @@ export default function Hero({ initialBg }: HeroProps) { } as React.CSSProperties } > - Full-stack + {roleHead} - Full-stack + {roleHead} - Full-stack + {roleHead}
- developer + {roleTail}

- Building live streaming tools, payment solutions, and weird - side projects from Norway. + {profile.tagline}

diff --git a/components/Portfolio.tsx b/components/Portfolio.tsx index a20eef4..858b4fd 100644 --- a/components/Portfolio.tsx +++ b/components/Portfolio.tsx @@ -5,7 +5,7 @@ import { ScrollTrigger } from "gsap/dist/ScrollTrigger"; import { useEffect, useRef } from "react"; import { useDeferredInit } from "../hooks/useDeferredInit"; import { useNearViewport } from "../hooks/useNearViewport"; -import { projects } from "../data/projects"; +import { projects } from "@/content/projects"; import ProjectCard from "./ProjectCard"; gsap.registerPlugin(ScrollTrigger); diff --git a/components/Socials.tsx b/components/Socials.tsx index 8fba7dc..50b0699 100644 --- a/components/Socials.tsx +++ b/components/Socials.tsx @@ -1,125 +1,103 @@ +import type { ReactNode } from "react"; +import { socials } from "@/content/socials"; + +const ICONS: Record = { + x: ( + + + + ), + bluesky: ( + + + + ), + linkedin: ( + + + + ), + instagram: ( + + + + ), + github: ( + + + + ), + twitch: ( + + + + ), +}; + export default function Socials() { return (
- - - - - - - - - - - {/* - - - - */} - - - - - - - - - - - - - - - - - - - - - - + {socials.map((s) => + ICONS[s.id] ? ( + + {ICONS[s.id]} + + ) : null, + )}
); } diff --git a/components/TechStack.tsx b/components/TechStack.tsx index be872ef..a3bd756 100644 --- a/components/TechStack.tsx +++ b/components/TechStack.tsx @@ -5,7 +5,7 @@ import { ScrollTrigger } from "gsap/dist/ScrollTrigger"; import { useEffect, useRef } from "react"; import { useDeferredInit } from "../hooks/useDeferredInit"; import { useNearViewport } from "../hooks/useNearViewport"; -import { skillCategories } from "../data/skills"; +import { skillCategories } from "@/content/skills"; gsap.registerPlugin(ScrollTrigger); diff --git a/content/content.test.ts b/content/content.test.ts new file mode 100644 index 0000000..7bbaef4 --- /dev/null +++ b/content/content.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { experience } from "./experience"; +import { places, placeFromSearch } from "./places"; +import { projects } from "./projects"; + +describe("content", () => { + it("gives every project and job exactly one place in town", () => { + const refs = places.flatMap((p) => p.presents as readonly { kind: string; id?: string }[]); + for (const project of projects) { + expect(refs.filter((r) => r.kind === "project" && r.id === project.id), project.id).toHaveLength(1); + } + for (const job of experience) { + expect(refs.filter((r) => r.kind === "experience" && r.id === job.id), job.id).toHaveLength(1); + } + for (const kind of ["profile", "skills", "repos", "stats", "contact"]) { + expect(refs.some((r) => r.kind === kind), kind).toBe(true); + } + }); + + it("only references content that exists", () => { + for (const p of places) { + for (const ref of p.presents as readonly { kind: string; id?: string }[]) { + if (ref.kind === "project") expect(projects.map((x) => x.id)).toContain(ref.id); + if (ref.kind === "experience") expect(experience.map((x) => x.id)).toContain(ref.id); + } + } + }); + + it("uses unique ids", () => { + for (const list of [places, projects, experience]) { + const ids = list.map((x) => x.id); + expect(new Set(ids).size).toBe(ids.length); + } + }); +}); + +describe("placeFromSearch", () => { + it("accepts built places only", () => { + expect(placeFromSearch("?at=office")?.id).toBe("office"); + expect(placeFromSearch("?at=Office")?.id).toBe("office"); + expect(placeFromSearch("?at=library")).toBeNull(); // not on a map yet + expect(placeFromSearch("?at=nowhere")).toBeNull(); + expect(placeFromSearch("?at=constructor")).toBeNull(); + expect(placeFromSearch("")).toBeNull(); + }); +}); diff --git a/data/experience.ts b/content/experience.ts similarity index 97% rename from data/experience.ts rename to content/experience.ts index bdf0ab5..d5cbb18 100644 --- a/data/experience.ts +++ b/content/experience.ts @@ -1,4 +1,4 @@ -type Experience = { +export type Experience = { id: string; company: string; role: string; diff --git a/content/places.ts b/content/places.ts new file mode 100644 index 0000000..df83fee --- /dev/null +++ b/content/places.ts @@ -0,0 +1,63 @@ +// The town's places and what each one presents (docs/game/DESIGN.md §5). Used for +// passport stamps, `?at=` deep links, the Journal's "visit in game" links and the +// validator that checks every piece of content has a home. + +export type ContentRef = + | { kind: "profile" } + | { kind: "project"; id: string } + | { kind: "experience"; id: string } + | { kind: "skills" } + | { kind: "repos" } + | { kind: "stats" } + | { kind: "contact" }; + +export type Place = { + id: string; + name: string; + presents: ContentRef[]; + /** Gives a Fjord Passport stamp when its main NPC has been talked to. */ + stamp: boolean; + /** Where `?at=` puts the player. Missing until the place exists on a map. */ + entrance?: { map: string; spawn: string }; +}; + +export const places = [ + { id: "dock", name: "Ferry dock", presents: [], stamp: false, entrance: { map: "town", spawn: "ferry" } }, + { + id: "home", + name: "datagutt's house", + presents: [{ kind: "profile" }, { kind: "project", id: "portfolio" }], + stamp: true, + entrance: { map: "town", spawn: "house_door" }, + }, + { id: "boathouse", name: "Boathouse studio", presents: [{ kind: "project", id: "guac" }], stamp: true }, + { id: "radio-tower", name: "Radio tower", presents: [{ kind: "project", id: "irlserver" }], stamp: true }, + { id: "kiosk", name: "Kiosk", presents: [{ kind: "project", id: "donate-chat" }], stamp: true }, + { + id: "office", + name: "Nettbureau office", + presents: [{ kind: "experience", id: "nettbureau" }], + stamp: true, + entrance: { map: "town", spawn: "office_door" }, + }, + { id: "town-hall", name: "Town hall", presents: [{ kind: "experience", id: "iod" }], stamp: true }, + { id: "smithy", name: "Smithy", presents: [{ kind: "skills" }], stamp: true }, + { id: "library", name: "Library", presents: [{ kind: "repos" }], stamp: true }, + { id: "farm", name: "Farm", presents: [{ kind: "stats" }], stamp: true }, + { id: "post-office", name: "Post office", presents: [{ kind: "contact" }], stamp: true }, +] as const satisfies readonly Place[]; + +export type PlaceId = (typeof places)[number]["id"]; + +export const START_PLACE: PlaceId = "dock"; + +export function place(id: PlaceId): Place { + return places.find((p) => p.id === id)!; +} + +/** The place named by `?at=` in a URL search string, if it is known and has an entrance. */ +export function placeFromSearch(search: string): Place | null { + const at = new URLSearchParams(search).get("at")?.trim().toLowerCase(); + const found = at ? (places as readonly Place[]).find((p) => p.id === at) : undefined; + return found?.entrance ? found : null; +} diff --git a/content/profile.ts b/content/profile.ts new file mode 100644 index 0000000..93445cd --- /dev/null +++ b/content/profile.ts @@ -0,0 +1,29 @@ +// Who datagutt is. Single source for the game's dialogue and the Journal +// (docs/game/DESIGN.md §12). Keep facts here, not in components or Ink files. + +export type QuickFact = { label: string; value: string }; + +export const profile = { + name: "Thomas Lekanger", + firstName: "Thomas", + handle: "datagutt", + role: "Full-stack developer", + tagline: "Building live streaming tools, payment solutions, and weird side projects from Norway.", + location: "Oslo, Norway", + email: "mail@datagutt.no", + avatar: "/images/avatar.png", + /** Used for "years coding" in stats. */ + codingSince: 2010, + about: [ + "I'm Thomas, a full-stack developer based in Norway. I enjoy building things for the web — from live streaming infrastructure and payment integrations to weird side projects that keep me up at night.", + "Most of my work revolves around TypeScript, React, and Node.js, but I also dabble in Rust when performance matters. I care about shipping fast, writing clean code, and making products that people actually want to use.", + ], + quickFacts: [ + { label: "Location", value: "Oslo, Norway" }, + { label: "Role", value: "Full-stack Developer" }, + { label: "Focus", value: "Streaming & Payments" }, + { label: "Side projects", value: "Always shipping" }, + ] satisfies QuickFact[], + contactPitch: "Got a project in mind, want to collaborate, or just want to say hi? My inbox is always open.", + sourceCode: "https://github.com/datagutt/datagutt.no", +} as const; diff --git a/data/projects.ts b/content/projects.ts similarity index 90% rename from data/projects.ts rename to content/projects.ts index 1ed969f..dea7f98 100644 --- a/data/projects.ts +++ b/content/projects.ts @@ -1,8 +1,8 @@ -type TechTag = { +export type TechTag = { name: string; }; -type Project = { +export type Project = { id: string; name: string; description?: string; @@ -13,9 +13,10 @@ type Project = { poweredBy?: TechTag[]; }; +/** Stable ids are referenced from dialogue, e.g. project_desc("irlserver"). */ export const projects: Project[] = [ { - id: "1", + id: "portfolio", name: "Portfolio", description: "My personal portfolio", image: "/images/avatar.png", @@ -31,7 +32,7 @@ export const projects: Project[] = [ ], }, { - id: "2", + id: "donate-chat", name: "Donate.chat", description: "Development of payment solutions for live streamers and influencers, including support for Vipps and card payments, as well as integrations with platforms such as Twitch, YouTube, Streamelements and Streamlabs.", @@ -48,7 +49,7 @@ export const projects: Project[] = [ ], }, { - id: "3", + id: "irlserver", name: "IRLServer", description: "Simplifying IRL streaming by providing software and services that allow for stable live streams from anywhere in the world by bonding multiple network connections.", @@ -66,7 +67,7 @@ export const projects: Project[] = [ ], }, { - id: "4", + id: "guac", name: "Guac.tv", description: "Guac is a fully-featured live streaming platform. Including everything from live streaming, VODs and clips to a chat with custom emotes and moderation tools.", diff --git a/data/skills.ts b/content/skills.ts similarity index 96% rename from data/skills.ts rename to content/skills.ts index b0e3187..1c39e9a 100644 --- a/data/skills.ts +++ b/content/skills.ts @@ -1,4 +1,4 @@ -type SkillCategory = { +export type SkillCategory = { name: string; skills: string[]; }; diff --git a/content/socials.ts b/content/socials.ts new file mode 100644 index 0000000..c07fbc1 --- /dev/null +++ b/content/socials.ts @@ -0,0 +1,18 @@ +export type Social = { id: string; label: string; url: string }; + +/** In display order. */ +export const socials: Social[] = [ + { id: "x", label: "X", url: "https://x.com/datagutt" }, + { id: "bluesky", label: "Bluesky", url: "https://bsky.app/profile/datagutt.no" }, + // Discord is left out until there is an invite link (it was commented out on the old site). + { id: "linkedin", label: "LinkedIn", url: "https://www.linkedin.com/in/thomas-lekanger-a77990b0/" }, + { id: "instagram", label: "Instagram", url: "https://instagram.com/lekanger" }, + { id: "github", label: "GitHub", url: "https://github.com/datagutt" }, + { id: "twitch", label: "Twitch", url: "https://twitch.tv/datagutt" }, +]; + +export function social(id: string): Social { + const found = socials.find((s) => s.id === id); + if (!found) throw new Error(`Unknown social "${id}"`); + return found; +} diff --git a/docs/game/HANDOFF.md b/docs/game/HANDOFF.md index 1162a16..9e2d6e0 100644 --- a/docs/game/HANDOFF.md +++ b/docs/game/HANDOFF.md @@ -14,6 +14,9 @@ Last updated: 2026-09-23 (session 1: design, planning, assets repo, M0, M1 compl and UI art, character and portrait generator layers (with `.ase` sources), and the original licence files: about 30k files, 85 MB. Layout is in its README.md. The zips and generator tool builds stay local and gitignored. +- M2.1 done: `content/` holds profile, socials, projects (slug ids), experience, skills + and places (the full DESIGN §5 content map, with a test that every project and job has + exactly one place). The legacy components read from it too. - **M1 is complete** (greybox): walk the town and house with keyboard, gamepad or taps, talk to the ferryman and datagutt, read signs, use doors, reload to continue, and deep-link with `?at=dock|home|office`. `?debug` shows FPS and exposes @@ -35,10 +38,10 @@ Last updated: 2026-09-23 (session 1: design, planning, assets repo, M0, M1 compl ## Next step -**M2.1**: the `content/` module. Move `data/*.ts` plus the copy inlined in `Hero`, `About`, -`Contact` and `Socials` (now under `app/legacy` usage) into typed modules, and move -`game/world/places.ts` into `content/places.ts`. Then M2.2 (WorldState payload) and -M2.3 (Ink pipeline). **M0.11 (Vercel token) stays deferred** until the user asks. +**M2.2**: the WorldState payload (live GitHub repos, stats and contributions) embedded in +`/`. Static content no longer needs to travel in it: `game/` imports `content/` directly, +which is plain TypeScript and allowed by the lint boundary. Then **M2.3** (Ink pipeline). +**M0.11 (Vercel token) stays deferred** until the user asks. ## Blockers and things waiting on the user diff --git a/docs/game/PLAN.md b/docs/game/PLAN.md index cc65679..7d8cc5a 100644 --- a/docs/game/PLAN.md +++ b/docs/game/PLAN.md @@ -121,11 +121,12 @@ phone. Goal: every piece of site content is reachable through NPC dialogue with portraits, and the passport works. -- [ ] **M2.1** `content/` module: `profile.ts` (name, tagline, about paragraphs, quick +- [x] **M2.1** `content/` module: `profile.ts` (name, tagline, about paragraphs, quick facts, email, socials), `projects.ts`, `experience.ts`, `skills.ts`, `places.ts` (place ids, names, map positions, stamp ids). Move `data/*.ts` and the copy inlined in `Hero`, `About`, `Contact`, `Socials`. *Done when* no copy is left in components - and types compile. + and types compile. (Projects now have slug ids such as `irlserver`; `places.ts` holds + the whole content map, and only places already on a map have an `?at=` entrance.) - [ ] **M2.2** WorldState payload: a server function combining `content/` with live GitHub data (repos, stats, contributions) into one typed JSON object, embedded in `/` as ` - - - -`, -); - -const ctx = await esbuild.context({ - entryPoints: { harness: "game/dev/harness.ts" }, - bundle: true, - format: "esm", - outdir: outDir, - sourcemap: true, - target: "es2022", - define: { "process.env.NODE_ENV": '"development"' }, - logLevel: "info", -}); - -await ctx.watch(); -// esbuild serves on a private port; everything but the socket is passed through to it. -const inner = await ctx.serve({ servedir: path.join(root, "public"), host: "127.0.0.1", port: port + 1000 }); -const server = http.createServer((req, res) => { - const upstream = http.request({ host: "127.0.0.1", port: inner.port, path: req.url, method: req.method, headers: req.headers }, (up) => { - res.writeHead(up.statusCode ?? 502, up.headers); - up.pipe(res); - }); - upstream.on("error", () => res.writeHead(502).end()); - req.pipe(upstream); -}); -attachWorldSocket(server); -server.listen(port, () => console.log(`[game-dev] http://localhost:${port}/game/dev.html`)); diff --git a/apps/datagutt/scripts/assets/og.mjs b/apps/datagutt/scripts/og.mjs similarity index 97% rename from apps/datagutt/scripts/assets/og.mjs rename to apps/datagutt/scripts/og.mjs index b39e077..73fd86a 100644 --- a/apps/datagutt/scripts/assets/og.mjs +++ b/apps/datagutt/scripts/og.mjs @@ -18,8 +18,8 @@ import { STRIP, SUN, W, -} from "../../components/game/titleScene.ts"; -import { Raster, hex } from "./raster.mjs"; +} from "../components/game/titleScene.ts"; +import { Raster, hex } from "@datagutt/kai-assets/raster"; const OG = { w: 600, h: 315 }; /** Where the title scene's origin lands: centred across, its bottom on the image's bottom. */ diff --git a/apps/datagutt/scripts/world/snow-drafts.mjs b/apps/datagutt/scripts/snow-drafts.mjs similarity index 83% rename from apps/datagutt/scripts/world/snow-drafts.mjs rename to apps/datagutt/scripts/snow-drafts.mjs index cb5ee3e..6e7a02c 100644 --- a/apps/datagutt/scripts/world/snow-drafts.mjs +++ b/apps/datagutt/scripts/snow-drafts.mjs @@ -1,18 +1,18 @@ -#!/usr/bin/env node -// Drafts fully snowed roofs for the town's buildings into datagutt-assets -// `seasons/winter/`, as the starting point for hand-drawn winter art (clean them up in +// Drafts fully snowed roofs for the town's buildings into the art repository's +// seasonal overrides (winter), as the starting point for hand-drawn winter art (clean them up in // Aseprite and commit them there). The world build uses whatever is in that folder over // the automatic snow caps (@datagutt/kai-limezu source.ts). Existing files are kept: they may be // hand-edited. Also writes world/out/snow-drafts.png, every draft side by side. // -// node scripts/world/snow-drafts.mjs [--force] [--only=] +// bun scripts/snow-drafts.mjs [--force] [--only=] import fs from "node:fs"; import path from "node:path"; import sharp from "sharp"; import { LimeZuSheets, seasonRecolor } from "@datagutt/kai-limezu/source"; import { fullRoofMask, paintSnowRoof } from "@datagutt/kai-limezu/snowDraft"; -import { PREFABS } from "../../world/gen/prefabs.ts"; -import { localArtDir, SEASON_OVERRIDES } from "../assets/source.mjs"; +import { loadApp } from "@datagutt/kai-assets/app"; +import { localArtDir, seasonOverridesDir } from "@datagutt/kai-assets/art/source"; +import { PREFABS } from "../world/gen/prefabs.ts"; /** The town's buildings with roofs. */ const BUILDINGS = [ @@ -35,7 +35,9 @@ const root = process.cwd(); const args = process.argv.slice(2); const force = args.includes("--force"); const only = args.find((a) => a.startsWith("--only="))?.split("=")[1]; -const source = { dir: localArtDir(root) }; +const app = loadApp(root); +const SEASON_OVERRIDES = seasonOverridesDir(app.config); +const source = { dir: localArtDir(root, app.config.assets, (await app.adapter()).isArtDir) }; if (!source.dir) { console.error("[snow] Needs a local checkout of the art repository (kai.json assets.localPath)."); process.exit(1); diff --git a/apps/datagutt/scripts/title.ts b/apps/datagutt/scripts/title.ts new file mode 100644 index 0000000..48de53f --- /dev/null +++ b/apps/datagutt/scripts/title.ts @@ -0,0 +1,28 @@ +// Fjord Town's title strip and link-preview image, after `kai assets`: the title screen's +// waterfront (world/gen/title.ts) drawn from the same sheets as the maps, and the +// preview (og.mjs) drawn from it. Placeholder builds have no sheets to draw them from: +// the title shows its sky alone and link previews go without a picture. +import fs from "node:fs"; +import path from "node:path"; +import { loadApp } from "@datagutt/kai-assets/app"; +import { seasonOverridesDir } from "@datagutt/kai-assets/art/source"; +import { buildFont, readSource } from "@datagutt/kai-assets/build/assets"; +import { shrinkPng } from "@datagutt/kai-assets/png"; +import { TileRegistry } from "@datagutt/kai-worldgen/registry"; +import { renderTmj } from "@datagutt/kai-worldgen/render"; +import { canvasToTmj } from "@datagutt/kai-worldgen/tmj"; +import { titleScene } from "../world/gen/title.ts"; +import { buildOgImage } from "./og.mjs"; + +const app = loadApp(process.cwd()); +const source = readSource(app); +fs.rmSync(path.join(app.outDir, "og.png"), { force: true }); +if (source.dir) { + const tiles = new TileRegistry(JSON.parse(fs.readFileSync(path.join(app.worldDir, "tile-ids.json"), "utf8"))); + const tmj = canvasToTmj("title", titleScene(), tiles); + const sheets = (await app.adapter()).sheets(source.dir, { overridesDir: seasonOverridesDir(app.config) }); + const waterfront = await renderTmj(tmj, tiles.tiles, sheets); + fs.writeFileSync(path.join(app.outDir, "ui/title.png"), await shrinkPng(waterfront)); + fs.writeFileSync(path.join(app.outDir, "og.png"), await shrinkPng(await buildOgImage(waterfront, await buildFont(app)))); + console.log("[title] Drew the title strip and the link preview"); +} diff --git a/apps/datagutt/scripts/world/gen.mjs b/apps/datagutt/scripts/world/gen.mjs deleted file mode 100644 index e57bae5..0000000 --- a/apps/datagutt/scripts/world/gen.mjs +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env node -// Generates world/maps/*.tmj from world/gen/maps (docs/game/PLAN.md M3.1). Output is -// committed; `manual_*` layers already in a map file are kept. With the private art it -// also refreshes world/tile-colors.json (placeholder colours) and world/tilesets/world.png -// (for opening maps in Tiled). -// -// node scripts/world/gen.mjs regenerate -// node scripts/world/gen.mjs --check fail if the committed maps are out of date -// node scripts/world/gen.mjs --prune rebuild the tile registry from scratch (only -// while no map has manual layers) -// node scripts/world/gen.mjs --render also write world/out/.png -// [--collision] [--objects] [--grid] [--scale=N] [--only=] -// [--season=spring|summer|autumn|winter] outdoor maps in that season -import fs from "node:fs"; -import path from "node:path"; -import { GENERATED_MAPS } from "../../world/gen/maps/index.ts"; -import { TileRegistry } from "@datagutt/kai-worldgen/registry"; -import { canvasToTmj, formatTmj, isManual } from "@datagutt/kai-worldgen/tmj"; -import { buildAtlas, tileColors } from "@datagutt/kai-worldgen/atlas"; -import { renderTmj } from "@datagutt/kai-worldgen/render"; -import { validateMap } from "@datagutt/kai-worldgen/validate"; -import { checkCuts } from "@datagutt/kai-limezu/cuts"; -import { seasonalTile } from "@datagutt/kai-limezu/seasons"; -import { LimeZuSheets } from "@datagutt/kai-limezu/source"; -import { applySeason, isSeason } from "@datagutt/kai/world/season"; -import { localArtDir, SEASON_OVERRIDES } from "../assets/source.mjs"; - -const root = process.cwd(); -const args = process.argv.slice(2); -const flag = (name) => args.includes(`--${name}`); -const opt = (name) => args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1]; - -const files = { - registry: path.join(root, "world/tile-ids.json"), - colors: path.join(root, "world/tile-colors.json"), - maps: path.join(root, "world/maps"), - tilesetDir: path.join(root, "world/tilesets"), - out: path.join(root, "world/out"), -}; -const readJson = (file) => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null); - -if (flag("prune") && opt("only")) { - console.error("[world] --prune rebuilds tile ids for every map; it can't be combined with --only."); - process.exit(1); -} -if (flag("prune")) { - const edited = GENERATED_MAPS.filter((m) => readJson(path.join(files.maps, `${m.id}.tmj`))?.layers.some(isManual)); - if (edited.length) { - console.error(`[world] Can't prune: ${edited.map((m) => m.id).join(", ")} have manual layers that use today's tile ids.`); - process.exit(1); - } -} -const registry = new TileRegistry(flag("prune") ? undefined : (readJson(files.registry) ?? undefined)); -const only = opt("only"); -const outputs = new Map(); -const problems = []; -for (const map of GENERATED_MAPS) { - if (only && map.id !== only) continue; - const file = path.join(files.maps, `${map.id}.tmj`); - const canvas = map.build(); - const tmj = canvasToTmj(map.id, canvas, registry, { properties: { ...map.properties, ...(map.outdoor ? { outdoor: "true" } : {}) }, previous: readJson(file), ...(map.outdoor ? { seasonal: seasonalTile } : {}) }); - problems.push(...validateMap(map.id, tmj), ...checkCuts(map.id, canvas.stamped)); - outputs.set(file, formatTmj(tmj)); -} -if (problems.length) { - console.error(`[world] ${problems.length} problem(s):\n ${problems.join("\n ")}`); - process.exit(1); -} -outputs.set(files.registry, JSON.stringify(registry.toJSON(), null, "\t") + "\n"); - -if (flag("check")) { - const stale = [...outputs].filter(([file, text]) => !fs.existsSync(file) || fs.readFileSync(file, "utf8") !== text); - if (stale.length) { - console.error(`[world] Out of date: ${stale.map(([f]) => path.relative(root, f)).join(", ")}. Run \`bun run world:gen\`.`); - process.exit(1); - } - console.log(`[world] ${outputs.size - 1} maps up to date`); - process.exit(0); -} - -fs.mkdirSync(files.maps, { recursive: true }); -for (const [file, text] of outputs) fs.writeFileSync(file, text); - -const artDir = localArtDir(root); -if (!artDir) { - console.log(`[world] Wrote ${outputs.size - 1} maps. No art checkout, so no colours, tileset or renders.`); - process.exit(0); -} -const sheets = new LimeZuSheets(artDir, { overridesDir: SEASON_OVERRIDES }); -const colors = await tileColors(registry.tiles, sheets); -fs.writeFileSync(files.colors, JSON.stringify(colors, null, "\t") + "\n"); -fs.mkdirSync(files.tilesetDir, { recursive: true }); -fs.writeFileSync(path.join(files.tilesetDir, "world.png"), await buildAtlas(registry.tiles, sheets)); - -const season = opt("season") ?? "summer"; -if (!isSeason(season)) { - console.error(`[world] Unknown season "${season}"`); - process.exit(1); -} -if (flag("render")) { - fs.mkdirSync(files.out, { recursive: true }); - for (const [file, text] of outputs) { - if (!file.endsWith(".tmj")) continue; - const png = await renderTmj(applySeason(JSON.parse(text), season), registry.tiles, sheets, { - collision: flag("collision"), - objects: flag("objects"), - grid: flag("grid"), - scale: Number(opt("scale") ?? 1), - }); - const target = path.join(files.out, path.basename(file, ".tmj") + (season === "summer" ? "" : `@${season}`) + ".png"); - fs.writeFileSync(target, png); - console.log(`[world] Rendered ${path.relative(root, target)}`); - } -} -console.log(`[world] Wrote ${outputs.size - 1} maps, ${registry.tiles.length} tiles registered`); diff --git a/apps/datagutt/world/gen/maps/index.ts b/apps/datagutt/world/gen/maps/index.ts index 96e78a8..1fb3847 100644 --- a/apps/datagutt/world/gen/maps/index.ts +++ b/apps/datagutt/world/gen/maps/index.ts @@ -1,5 +1,5 @@ // Every map the generator owns. `bun run world:gen` writes each to world/maps/.tmj. -import type { MapCanvas } from "@datagutt/kai-worldgen/canvas"; +import type { GeneratedMap } from "@datagutt/kai-worldgen/maps"; import { boathouse } from "./boathouse.ts"; import { farmhouse } from "./farmhouse.ts"; import { house, houseUpstairs } from "./house.ts"; @@ -14,8 +14,7 @@ import { youthClub } from "./youthClub.ts"; import { mountain } from "./mountain.ts"; import { overworld } from "./overworld.ts"; -/** `outdoor` maps change with the seasons (game/world/season.ts) and the time of day (game/fx/DayNight.ts). */ -export type GeneratedMap = { id: string; properties?: Record; outdoor?: boolean; build: () => MapCanvas }; +export type { GeneratedMap }; export const GENERATED_MAPS: GeneratedMap[] = [ { id: "town", properties: { name: "Fjord Town" }, outdoor: true, build: overworld }, diff --git a/apps/datagutt/world/gen/maps/maps.test.ts b/apps/datagutt/world/gen/maps/maps.test.ts index 1efd1ab..a5057ed 100644 --- a/apps/datagutt/world/gen/maps/maps.test.ts +++ b/apps/datagutt/world/gen/maps/maps.test.ts @@ -2,6 +2,7 @@ import { TileRegistry } from "@datagutt/kai-worldgen/registry"; import { canvasToTmj, formatTmj } from "@datagutt/kai-worldgen/tmj"; import { validateMap } from "@datagutt/kai-worldgen/validate"; import { describe, expect, it } from "vitest"; +import { isArcadeId } from "../../../game/arcade/ids"; import { GENERATED_MAPS } from "./index"; describe("generated maps", () => { @@ -16,4 +17,11 @@ describe("generated maps", () => { it("pass validation", () => { for (const map of GENERATED_MAPS) expect(validateMap(map.id, canvasToTmj(map.id, map.build(), new TileRegistry()))).toEqual([]); }); + + // Map objects name arcade games as plain strings; the registry is Fjord Town's code. + it("only place cabinets for games the arcade registry has", () => { + for (const map of GENERATED_MAPS) { + for (const obj of map.build().objects) if (obj.type === "arcade") expect(isArcadeId(obj.game), `${map.id}: ${obj.game}`).toBe(true); + } + }); }); diff --git a/bun.lock b/bun.lock index 2b38526..ee5df7b 100644 --- a/bun.lock +++ b/bun.lock @@ -39,24 +39,18 @@ "@datagutt/kai-tsconfig": "workspace:*", "@datagutt/kai-vitest-config": "workspace:*", "@datagutt/kai-worldgen": "workspace:*", - "@ffmpeg-installer/ffmpeg": "^1.1.0", "@playwright/test": "^1.63", "@types/node": "^24.13.6", - "@types/pngjs": "^6.0.5", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vercel/config": "^0.7.2", "autoprefixer": "^10.4.22", - "esbuild": "^0.28.2", "eslint": "^9", - "opentype.js": "^2.0.0", - "pngjs": "^7", "postcss": "^8.5.6", "sharp": "^0.35.4", "tailwindcss": "^3.4.18", "typescript": "^5.9.3", "vitest": "^5", - "wawoff2": "^2.0.1", }, }, "packages/kai": { @@ -96,6 +90,14 @@ }, "dependencies": { "@datagutt/kai": "workspace:*", + "@datagutt/kai-net": "workspace:*", + "@datagutt/kai-worldgen": "workspace:*", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "esbuild": "^0.28.2", + "inkjs": "^2.4.0", + "opentype.js": "^2.0.0", + "sharp": "^0.35.4", + "wawoff2": "^2.0.1", "yaml": "^2.9.1", }, "devDependencies": { @@ -117,6 +119,7 @@ "sharp": "^0.35.4", }, "devDependencies": { + "@datagutt/kai-assets": "workspace:*", "@datagutt/kai-eslint-config": "workspace:*", "@datagutt/kai-tsconfig": "workspace:*", "@datagutt/kai-vitest-config": "workspace:*", @@ -522,8 +525,6 @@ "@types/node": ["@types/node@24.13.6", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg=="], - "@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="], - "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], @@ -1142,8 +1143,6 @@ "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], @@ -1374,8 +1373,6 @@ "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], - "@types/pngjs/@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], - "@types/ws/@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.10", "", {}, "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q=="], @@ -1420,8 +1417,6 @@ "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "@types/pngjs/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "@types/ws/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], diff --git a/docs/kai/DESIGN.md b/docs/kai/DESIGN.md index e165029..170b494 100644 --- a/docs/kai/DESIGN.md +++ b/docs/kai/DESIGN.md @@ -145,3 +145,7 @@ Visitors must not notice the refactor. - 2026-09-24: places are each game's own collection, not an engine one: what a place means differs per game (in Fjord Town, the content it presents). The engine only needs a place's id, name, stamp and entrance. The ambience mix stays engine code. +- 2026-09-24: the build reaches game code only through modules `kai.json` names: the art + adapter (`assets.adapter`, exporting an `ArtAdapter`), the map builders + (`paths.maps`), the dialogue host (`paths.dialogueHost`) and the harness entry + (`paths.harness`). This keeps `kai-assets` free of any art family and any one game. diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 61f5d55..e289548 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -89,9 +89,15 @@ the character recipes and music tracks as content, not as app TypeScript. objects, seasons and light shapes moved to `@datagutt/kai` first, with arcade and unlock ids as strings checked by the asset build. The asset output, including the title render, and `world:gen` are byte-identical.) -- [ ] **K3.4** `@datagutt/kai-assets` and the `kai` CLI on Bun: `kai assets`, +- [x] **K3.4** `@datagutt/kai-assets` and the `kai` CLI on Bun: `kai assets`, `kai world gen|check|render|catalog`, the dev harness. The link preview image stays an app script. *Done when* `public/game/` from the CLI matches the old build. + (Commands: `kai content|assets|world|characters|art|dev`. The CLI loads the art + adapter, the maps and the dialogue host from the modules `kai.json` names, so it + assumes no LimeZu. The adapter's own tools run as `kai art ` (the catalogue). + The title strip and link preview are `scripts/title.ts`; the snow drafts and + find-single stay app scripts since they read the town's prefabs. Arcade ids are + checked by an app test. Output is byte-identical.) - [ ] **K3.5** Assets repo: move `seasons/` to `games/datagutt/seasons/` in `datagutt-assets`, in step with the `kai.json` change. **Needs the user** to approve the push to the assets repo. *Done when* a clean build with the token finds the diff --git a/packages/kai-assets/package.json b/packages/kai-assets/package.json index 7aa914a..3dcd142 100644 --- a/packages/kai-assets/package.json +++ b/packages/kai-assets/package.json @@ -17,6 +17,14 @@ }, "dependencies": { "@datagutt/kai": "workspace:*", + "@datagutt/kai-net": "workspace:*", + "@datagutt/kai-worldgen": "workspace:*", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "esbuild": "^0.28.2", + "inkjs": "^2.4.0", + "opentype.js": "^2.0.0", + "sharp": "^0.35.4", + "wawoff2": "^2.0.1", "yaml": "^2.9.1" }, "devDependencies": { diff --git a/packages/kai-assets/src/adapter.ts b/packages/kai-assets/src/adapter.ts new file mode 100644 index 0000000..c28f42b --- /dev/null +++ b/packages/kai-assets/src/adapter.ts @@ -0,0 +1,24 @@ +// What an art adapter (such as @datagutt/kai-limezu) gives the build: tile pixels, the +// seasonal tile rule, checks on how prefabs are cut, character composition, and tools of +// its own. kai.json `assets.adapter` names the module; it exports `adapter`. +import type { CharacterRecipe } from "@datagutt/kai/schema/engine"; +import type { SheetSource } from "@datagutt/kai-worldgen/atlas"; +import type { Prefab } from "@datagutt/kai-worldgen/canvas"; +import type { SeasonalTiles } from "@datagutt/kai-worldgen/tmj"; + +export type ArtTool = (ctx: { appDir: string; artDir: string | null; args: string[] }) => Promise; + +export type ArtAdapter = { + /** Whether `dir` holds the art this adapter reads (a checkout of the art repository). */ + isArtDir(dir: string): boolean; + sheets(artDir: string, options: { overridesDir: string }): SheetSource; + seasonalTile: SeasonalTiles; + /** Problems with how a map's stamped prefabs are cut from their sheets. */ + checkCuts(mapId: string, stamped: Prefab[]): string[]; + /** A character's walk sheet in the runtime's sheet layout. */ + character(artDir: string, id: string, recipe: CharacterRecipe): Promise; + /** A character's dialogue portrait, or null when the recipe has none. */ + portrait(artDir: string, id: string, recipe: CharacterRecipe): Promise; + /** Tools run with `kai art `, such as rebuilding a sheet catalogue. */ + tools?: Record; +}; diff --git a/packages/kai-assets/src/app.ts b/packages/kai-assets/src/app.ts new file mode 100644 index 0000000..0ef13d6 --- /dev/null +++ b/packages/kai-assets/src/app.ts @@ -0,0 +1,41 @@ +// A game as the build sees it: its folder, its validated kai.json, and the modules kai.json +// points at (the art adapter, the maps, the dialogue host), loaded on demand. +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { loadKaiConfig, type KaiConfig } from "@datagutt/kai/schema"; +import type { GeneratedMap } from "@datagutt/kai-worldgen/maps"; +import type { ArtAdapter } from "./adapter.ts"; +import type { DialogueHost } from "./build/ink.ts"; + +export type KaiApp = { + dir: string; + config: KaiConfig; + /** Where the built assets go: public/ plus the base path. */ + outDir: string; + /** The folder the tile registry, the generated maps and the renders live in. */ + worldDir: string; + adapter(): Promise; + maps(): Promise; + dialogueHost(): Promise; +}; + +async function importFrom(file: string, name: string): Promise { + const mod = await import(pathToFileURL(file).href); + if (!(name in mod)) throw new Error(`${file} does not export \`${name}\``); + return mod[name] as T; +} + +export function loadApp(dir: string): KaiApp { + const config = loadKaiConfig(dir); + const local = (rel: string) => path.join(dir, rel); + return { + dir, + config, + outDir: path.join(dir, "public", config.basePath), + worldDir: local("world"), + adapter: () => importFrom(createRequire(local("package.json")).resolve(config.assets.adapter), "adapter"), + maps: () => importFrom(local(config.paths.maps), "GENERATED_MAPS"), + dialogueHost: () => importFrom(local(config.paths.dialogueHost), "dialogueHost"), + }; +} diff --git a/packages/kai-assets/src/art/fetch.ts b/packages/kai-assets/src/art/fetch.ts new file mode 100644 index 0000000..b62e417 --- /dev/null +++ b/packages/kai-assets/src/art/fetch.ts @@ -0,0 +1,66 @@ +// Makes the licensed source art available to the asset build and records where it came +// from in .assets-cache/source.json. kai.json's `assets` names the repository, and in +// order this uses: +// +// ASSETS_DIR= this checkout of the art repository (must exist) +// assets.localPath a local checkout, relative to the monorepo root, when present +// $ a token to shallow-clone the repository (Vercel builds) +// otherwise placeholder mode (coloured rectangles), never in production +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { KaiApp } from "../app.ts"; +import { findRepoRoot, resolveAssetSource, type AssetSource } from "./source.ts"; + +export const sourceFile = (app: KaiApp) => path.join(app.dir, ".assets-cache/source.json"); + +export async function fetchArt(app: KaiApp): Promise { + const { assets } = app.config; + const adapter = await app.adapter(); + // A clone must also carry the licences, which ship with the art. + const isAssetsDir = (dir: string) => adapter.isArtDir(dir) && fs.existsSync(path.join(dir, "LICENSES.md")); + + function git(args: string[], token: string) { + // Pass the token as a one-off header so it never lands in .git/config or in logs. + const auth = Buffer.from(`x-access-token:${token}`).toString("base64"); + try { + execFileSync("git", ["-c", `http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`, ...args], { + stdio: ["ignore", "ignore", "pipe"], + }); + } catch (err) { + const stderr = String((err as { stderr?: unknown }).stderr ?? "") + .replaceAll(token, "***") + .replaceAll(auth, "***"); + throw new Error(`git ${args[0]} failed for ${assets.repo}:\n${stderr.trim()}`); + } + } + + function cloneOrUpdate(dir: string, token: string) { + const url = `https://github.com/${assets.repo}.git`; + if (fs.existsSync(path.join(dir, ".git"))) { + git(["-C", dir, "fetch", "--depth", "1", "origin", assets.branch], token); + git(["-C", dir, "reset", "--hard", "FETCH_HEAD"], token); + } else { + fs.mkdirSync(path.dirname(dir), { recursive: true }); + git(["clone", "--depth", "1", "--branch", assets.branch, url, dir], token); + } + if (!isAssetsDir(dir)) throw new Error(`Cloned ${assets.repo}, but ${dir} has no art or LICENSES.md.`); + } + + const source = resolveAssetSource({ env: process.env, cwd: app.dir, repoRoot: findRepoRoot(app.dir), assets, isAssetsDir }); + if (source.mode === "clone") cloneOrUpdate(source.dir, process.env[assets.tokenEnv]!); + + fs.mkdirSync(path.dirname(sourceFile(app)), { recursive: true }); + fs.writeFileSync(sourceFile(app), JSON.stringify(source, null, "\t") + "\n"); + + if (source.mode === "placeholder") { + console.warn( + "[assets] No licensed art found: building in PLACEHOLDER mode (coloured rectangles).\n" + + `[assets] Check out ${assets.repo} at ${assets.localPath} (from the repository root), set ASSETS_DIR, ` + + `or set ${assets.tokenEnv}.`, + ); + } else { + console.log(`[assets] Using licensed art (${source.mode}) from ${source.dir}`); + } + return source; +} diff --git a/apps/datagutt/scripts/assets/source.test.mjs b/packages/kai-assets/src/art/source.test.ts similarity index 88% rename from apps/datagutt/scripts/assets/source.test.mjs rename to packages/kai-assets/src/art/source.test.ts index 86fc50e..4d6d367 100644 --- a/apps/datagutt/scripts/assets/source.test.mjs +++ b/packages/kai-assets/src/art/source.test.ts @@ -1,13 +1,16 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { cloneDir, findRepoRoot, resolveAssetSource as resolve } from "./source.mjs"; +import { cloneDir, findRepoRoot, resolveAssetSource as resolve } from "./source.ts"; const repoRoot = "/work/datagutt"; const cwd = `${repoRoot}/apps/datagutt`; const assets = { repo: "datagutt/datagutt-assets", localPath: "../datagutt-assets", tokenEnv: "ASSETS_REPO_TOKEN" }; const sibling = path.resolve(repoRoot, assets.localPath); -const resolveAssetSource = (opts) => resolve({ assets, ...opts }); -const onlyExists = (...dirs) => (dir) => dirs.includes(dir); +const resolveAssetSource = (opts: Omit[0], "assets">) => resolve({ assets, ...opts }); +const onlyExists = + (...dirs: string[]) => + (dir: string) => + dirs.includes(dir); describe("resolveAssetSource", () => { it("uses ASSETS_DIR when it points at an assets checkout", () => { @@ -55,7 +58,7 @@ describe("resolveAssetSource", () => { describe("findRepoRoot", () => { it("walks up to the directory with turbo.json", () => { - const exists = (file) => file === "/work/datagutt/turbo.json"; + const exists = (file: string) => file === "/work/datagutt/turbo.json"; expect(findRepoRoot("/work/datagutt/apps/datagutt", exists)).toBe("/work/datagutt"); }); }); diff --git a/apps/datagutt/scripts/assets/source.mjs b/packages/kai-assets/src/art/source.ts similarity index 56% rename from apps/datagutt/scripts/assets/source.mjs rename to packages/kai-assets/src/art/source.ts index 89bade0..00b0373 100644 --- a/apps/datagutt/scripts/assets/source.mjs +++ b/packages/kai-assets/src/art/source.ts @@ -1,39 +1,37 @@ // Decides where the licensed source art comes from: kai.json's `assets` names the // repository, its usual local checkout and the token variable. resolveAssetSource() is -// free of side effects so it can be unit tested; scripts/assets/fetch.mjs does the work. +// free of side effects so it can be unit tested; fetch.ts does the work. import fs from "node:fs"; import path from "node:path"; -import { loadKaiConfig } from "@datagutt/kai/schema"; +import type { KaiConfig } from "@datagutt/kai/schema"; -/** The game's hand-drawn seasonal art, relative to the art checkout. */ -export const SEASON_OVERRIDES = "seasons"; +export type AssetSource = { mode: "local"; dir: string } | { mode: "clone"; dir: string } | { mode: "placeholder"; dir: null }; + +type AssetsConfig = Pick; /** Where a clone of `repo` goes, relative to the app. */ -export const cloneDir = (repo) => `.assets-cache/${repo.split("/")[1]}`; +export const cloneDir = (repo: string) => `.assets-cache/${repo.split("/")[1]}`; -/** - * @typedef {{ mode: "local", dir: string } - * | { mode: "clone", dir: string } - * | { mode: "placeholder", dir: null }} AssetSource - */ +/** The game's hand-drawn seasonal art, relative to the art checkout. */ +export const seasonOverridesDir = (_config: Pick) => "seasons"; -/** - * @param {object} opts - * @param {Record} opts.env - * @param {string} opts.cwd The app's directory. - * @param {string} opts.repoRoot The repository root. - * @param {{ repo: string, localPath: string, tokenEnv: string }} opts.assets kai.json's `assets`. - * @param {(dir: string) => boolean} opts.isAssetsDir True when `dir` looks like a - * checkout of the assets repo. - * @returns {AssetSource} - */ -export function resolveAssetSource({ env, cwd, repoRoot, assets, isAssetsDir }) { +export function resolveAssetSource(opts: { + env: Record; + /** The app's directory. */ + cwd: string; + /** The repository root. */ + repoRoot: string; + assets: AssetsConfig; + /** True when `dir` looks like a checkout of the assets repo. */ + isAssetsDir: (dir: string) => boolean; +}): AssetSource { + const { env, cwd, repoRoot, assets, isAssetsDir } = opts; if (env.ASSETS_DIR) { const dir = path.resolve(cwd, env.ASSETS_DIR); if (!isAssetsDir(dir)) { throw new Error( `ASSETS_DIR is set to ${dir}, but that is not a checkout of ${assets.repo} ` + - "(no limezu/ folder or LICENSES.md). Fix the path or unset ASSETS_DIR.", + "(no art folder or LICENSES.md). Fix the path or unset ASSETS_DIR.", ); } return { mode: "local", dir }; @@ -57,7 +55,7 @@ export function resolveAssetSource({ env, cwd, repoRoot, assets, isAssetsDir }) } /** The monorepo root: the nearest directory above `from` that holds turbo.json. */ -export function findRepoRoot(from, exists = fs.existsSync) { +export function findRepoRoot(from: string, exists: (file: string) => boolean = fs.existsSync): string { for (let dir = path.resolve(from); ; dir = path.dirname(dir)) { if (exists(path.join(dir, "turbo.json"))) return dir; if (path.dirname(dir) === dir) throw new Error(`No turbo.json above ${from}.`); @@ -68,14 +66,12 @@ export function findRepoRoot(from, exists = fs.existsSync) { * The local art checkout for the tools that need real art (catalog, snow drafts, the * character review), or null. They never clone. */ -export function localArtDir(cwd) { - const { assets } = loadKaiConfig(cwd); - const source = resolveAssetSource({ +export function localArtDir(appDir: string, assets: AssetsConfig, isAssetsDir: (dir: string) => boolean): string | null { + return resolveAssetSource({ env: { ...process.env, [assets.tokenEnv]: undefined }, - cwd, - repoRoot: findRepoRoot(cwd), + cwd: appDir, + repoRoot: findRepoRoot(appDir), assets, - isAssetsDir: (dir) => fs.existsSync(path.join(dir, "limezu")), - }); - return source.dir; + isAssetsDir, + }).dir; } diff --git a/apps/datagutt/scripts/assets/font.mjs b/packages/kai-assets/src/build/font.mjs similarity index 99% rename from apps/datagutt/scripts/assets/font.mjs rename to packages/kai-assets/src/build/font.mjs index eb33850..72a2286 100644 --- a/apps/datagutt/scripts/assets/font.mjs +++ b/packages/kai-assets/src/build/font.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import opentype from "opentype.js"; import wawoff from "wawoff2"; -import { Raster, hex } from "./raster.mjs"; +import { Raster, hex } from "../raster.ts"; export const FONT_CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + diff --git a/packages/kai-assets/src/build/ink.ts b/packages/kai-assets/src/build/ink.ts new file mode 100644 index 0000000..b2cd3f2 --- /dev/null +++ b/packages/kai-assets/src/build/ink.ts @@ -0,0 +1,82 @@ +// Compiles a game's Ink dialogue (main.ink and its INCLUDEs) to JSON and checks it against +// the game's registry of external functions: an unknown id in a call such as +// project_desc("irlsever") fails the build instead of failing in front of a visitor. +import fs from "node:fs"; +import path from "node:path"; +import { Compiler, CompilerOptions } from "inkjs/full"; + +/** One external function dialogue can call, and the kind of its first argument. */ +export type ExternalSpec = { params: readonly string[]; arg: string; doc: string }; + +/** What the build needs to know about a game's dialogue functions and link tags. */ +export type DialogueHost = { + externals: Readonly>; + /** Ink source declaring every external, prepended to the story. */ + declarations: () => string; + /** Valid literal values for an argument kind, or null when not checkable statically. */ + validIds: (arg: string) => readonly string[] | null; + /** A tag checked as a link: an error, a link, or null when the tag is no link. */ + resolveLink: (tag: string) => { error: string } | object | null; +}; + +const EXTERNALS_FILE = "__externals.ink"; + +/** Literal first-argument checks on the raw source, with file and line for the error. */ +export function validateSource(host: DialogueHost, file: string, source: string): string[] { + const problems: string[] = []; + const names = Object.keys(host.externals).join("|"); + const call = new RegExp(`\\b(${names})\\s*\\(\\s*(?:"([^"]*)"|(-?\\d+))?`, "g"); + source.split("\n").forEach((line, i) => { + if (/^\s*\/\//.test(line)) return; + for (const tag of line.split("#").slice(1)) { + const link = host.resolveLink(tag); + if (link && "error" in link) problems.push(`${file}:${i + 1}: ${link.error}`); + } + for (const m of line.matchAll(call)) { + const [, name, str] = m; + const spec = host.externals[name]; + if (str === undefined) continue; // variable or number argument: checked at runtime + const ids = host.validIds(spec.arg); + if (ids && !ids.includes(str)) { + problems.push(`${file}:${i + 1}: ${name}("${str}"): unknown ${spec.arg}. Valid: ${ids.join(", ")}`); + } + } + }); + return problems; +} + +export type CompiledDialogue = { json: string; files: string[]; knots: string[] }; + +/** Compiles `/main.ink`. Throws with every problem listed. */ +export function compileDialogue(dir: string, host: DialogueHost): CompiledDialogue { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".ink")); + const problems = files.flatMap((f) => validateSource(host, f, fs.readFileSync(path.join(dir, f), "utf8"))); + + const errors: string[] = []; + const fileHandler = { + ResolveInkFilename: (name: string) => name, + LoadInkFileContents: (name: string) => (name === EXTERNALS_FILE ? host.declarations() : fs.readFileSync(path.join(dir, name), "utf8")), + }; + const errorHandler = (message: string, type: number) => { + // type 2 is an error, 1 a warning, 0 author TODOs. + if (type === 2) errors.push(message); + else if (type === 1) console.warn(`[ink] ${message}`); + }; + const main = `INCLUDE ${EXTERNALS_FILE}\n${fs.readFileSync(path.join(dir, "main.ink"), "utf8")}`; + const compiler = new Compiler(main, new CompilerOptions("main.ink", [], false, errorHandler, fileHandler)); + let story = null; + try { + story = compiler.Compile(); + } catch (err) { + errors.push(String((err as Error)?.message ?? err)); + } + + const all = [...problems, ...errors]; + if (all.length || !story) { + throw new Error(`Dialogue has ${all.length} problem(s):\n ${all.join("\n ")}`); + } + const json = story.ToJson() ?? ""; + const root = JSON.parse(json).root; + const knots = Object.keys(root[root.length - 1] ?? {}).filter((k) => !k.startsWith("#")); + return { json, files, knots }; +} diff --git a/apps/datagutt/scripts/assets/music.mjs b/packages/kai-assets/src/build/music.mjs similarity index 93% rename from apps/datagutt/scripts/assets/music.mjs rename to packages/kai-assets/src/build/music.mjs index 374e8f7..b3f9876 100644 --- a/apps/datagutt/scripts/assets/music.mjs +++ b/packages/kai-assets/src/build/music.mjs @@ -1,13 +1,13 @@ -// Background music for the game (docs/game/PLAN.md B4): each loop in the MUSIC manifest, -// cut to its exact loop, levelled, and encoded twice, small: Opus in Ogg, and MP3 for -// browsers without Opus. Encoding takes a while, so results are cached in .assets-cache. +// Background music for the game: each track in content/music.json, cut to its exact loop, +// levelled, and encoded twice, small: Opus in Ogg, and MP3 for browsers without Opus. +// Encoding takes a while, so results are cached in .assets-cache. // // The loops are whole bars (their tempos measure as round numbers), so the loop length is // sacred: cutting a rest the composer wrote would move the downbeat at the join. Only the // codec's own silence is cut. The source MP3s carry no gapless header (their decoded // length is a whole number of 1152-sample frames), so decoding adds the encoder's and the -// decoder's delay in front and up to two frames of padding behind. Tax Office ends in a -// 0.2 s rest and Goodnight starts with a quiet moment; both are part of the loop. +// decoder's delay in front and up to two frames of padding behind. A loop may end in a +// rest or start with a quiet moment; both are part of the loop. import { spawn } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; @@ -27,7 +27,7 @@ const SILENCE = 0.001; * does not trim a codec's delay or padding then still loops seamlessly (see Music.ts). */ export const ROLL = 0.1; -/** Every track is turned down to the quietest one's loudness (Goodnight, about -19.4 LUFS). */ +/** Every track is levelled to this loudness: just below Fjord Town's quietest loop (about -19.4 LUFS), so tracks are only ever turned down. */ const TARGET_LUFS = -19.5; const ENCODINGS = { ogg: ["-c:a", "libopus", "-b:a", "48k", "-vbr", "on", "-compression_level", "10", "-f", "ogg"], diff --git a/apps/datagutt/scripts/assets/music.test.mjs b/packages/kai-assets/src/build/music.test.mjs similarity index 100% rename from apps/datagutt/scripts/assets/music.test.mjs rename to packages/kai-assets/src/build/music.test.mjs diff --git a/packages/kai-assets/src/characters/review.ts b/packages/kai-assets/src/characters/review.ts new file mode 100644 index 0000000..f748a9e --- /dev/null +++ b/packages/kai-assets/src/characters/review.ts @@ -0,0 +1,65 @@ +// `kai characters`: a contact sheet of every character recipe for review, the four +// standing directions and the portrait, at 1x on grass (how they read in game) and at 4x +// (to check the pixels). Composes straight from the recipes, so no asset build is needed +// in between. Writes world/out/characters.png (gitignored: licensed pixels). +import fs from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; +import { DIRECTIONS, FRAME_HEIGHT, FRAME_WIDTH, PORTRAIT_CROP } from "@datagutt/kai/characters/sheet"; +import type { CharacterRecipe } from "@datagutt/kai/schema/engine"; +import type { KaiApp } from "../app.ts"; +import { localArtDir } from "../art/source.ts"; + +const ZOOM = 4; +const P = PORTRAIT_CROP.size; +const PAD = 8; +const COLUMNS = 2; +// Each character gets a 1x strip, then the same at 4x, with its id underneath. +const STEP = FRAME_WIDTH + 2; +const smallW = DIRECTIONS.length * STEP + P; +const bigLeft = smallW + PAD * 2; +const cellW = PAD + bigLeft + (DIRECTIONS.length * STEP + P) * ZOOM + PAD; +const cellH = FRAME_HEIGHT * ZOOM + 24; + +const up = (png: Buffer, w: number, h: number) => sharp(png).resize(w * ZOOM, h * ZOOM, { kernel: "nearest" }).png().toBuffer(); +const label = (text: string) => + Buffer.from(`${text}`); + +export async function reviewCharacters(app: KaiApp): Promise { + const adapter = await app.adapter(); + const artDir = localArtDir(app.dir, app.config.assets, adapter.isArtDir); + if (!artDir) throw new Error("[characters] Needs a local checkout of the art repository (kai.json assets.localPath)."); + const characters: Record = JSON.parse(fs.readFileSync(path.join(app.dir, ".kai/content.json"), "utf8")).characters; + + const layers = []; + const ids = Object.keys(characters); + for (const [i, id] of ids.entries()) { + const recipe = characters[id]; + const left = (i % COLUMNS) * cellW + PAD; + const top = Math.floor(i / COLUMNS) * cellH + PAD; + const sheet = await adapter.character(artDir, id, recipe); + for (let d = 0; d < DIRECTIONS.length; d++) { + // Row 0 holds one standing frame per direction. + const frame = await sharp(sheet).extract({ left: d * FRAME_WIDTH, top: 0, width: FRAME_WIDTH, height: FRAME_HEIGHT }).png().toBuffer(); + layers.push({ input: frame, left: left + d * STEP, top: top + FRAME_HEIGHT * (ZOOM - 1) }); + layers.push({ input: await up(frame, FRAME_WIDTH, FRAME_HEIGHT), left: left + bigLeft + d * STEP * ZOOM, top }); + } + const portraits = await adapter.portrait(artDir, id, recipe); + if (portraits) { + const face = await sharp(portraits).extract({ left: 0, top: 0, width: P, height: P }).png().toBuffer(); + layers.push({ input: face, left: left + DIRECTIONS.length * STEP, top: top + FRAME_HEIGHT * ZOOM - P }); + layers.push({ input: await up(face, P, P), left: left + bigLeft + DIRECTIONS.length * STEP * ZOOM, top }); + } + layers.push({ input: label(id), left, top: top + FRAME_HEIGHT * ZOOM + 2 }); + } + + const out = path.join(app.worldDir, "out/characters.png"); + fs.mkdirSync(path.dirname(out), { recursive: true }); + await sharp({ + create: { width: COLUMNS * cellW + PAD, height: Math.ceil(ids.length / COLUMNS) * cellH + PAD, channels: 4, background: "#5b8a4f" }, + }) + .composite(layers) + .png() + .toFile(out); + console.log(`[characters] ${ids.length} characters -> ${path.relative(app.dir, out)}`); +} diff --git a/packages/kai-assets/src/cli.ts b/packages/kai-assets/src/cli.ts index 91e397e..395f242 100644 --- a/packages/kai-assets/src/cli.ts +++ b/packages/kai-assets/src/cli.ts @@ -1,21 +1,56 @@ #!/usr/bin/env bun -// The kai command line, run from a game's folder: +// The kai command line, run from a game's folder (the one with kai.json): // -// kai content check content/ against its schemas and write the .kai/ bundle +// kai content check content/ against its schemas, write .kai/ +// kai assets fetch the art, then build public/ +// kai world gen|check|render [...] generate, check or render the maps (see world/gen.ts) +// kai characters contact sheet of every character (world/out/) +// kai art [...] a tool of the art adapter, such as `catalog` +// kai dev the standalone game harness with live reload +import { loadApp } from "./app.ts"; +import { fetchArt } from "./art/fetch.ts"; +import { localArtDir } from "./art/source.ts"; +import { buildAssets } from "./build/assets.ts"; +import { reviewCharacters } from "./characters/review.ts"; import { compileContent, ContentError, writeContent } from "./content/compile.ts"; import { loadCollections } from "./content/load.ts"; +import { devHarness } from "./dev/harness.ts"; +import { worldGen } from "./world/gen.ts"; -const [command] = process.argv.slice(2); +const [command, ...args] = process.argv.slice(2); const appDir = process.cwd(); -async function content() { - const collections = await loadCollections(appDir); - const compiled = compileContent(appDir, collections); - writeContent(appDir, collections, compiled); - console.log(`[content] ${Object.keys(collections).length} collections checked`); -} - -const commands: Record Promise> = { content }; +const commands: Record Promise> = { + async content() { + const collections = await loadCollections(appDir); + writeContent(appDir, collections, compileContent(appDir, collections)); + console.log(`[content] ${Object.keys(collections).length} collections checked`); + }, + async assets() { + const app = loadApp(appDir); + await fetchArt(app); + await buildAssets(app); + }, + async world() { + const [mode, ...rest] = args; + if (mode !== "gen" && mode !== "check" && mode !== "render") throw new Error("Usage: kai world [options]"); + await worldGen(loadApp(appDir), mode, rest); + }, + async characters() { + await reviewCharacters(loadApp(appDir)); + }, + async art() { + const [name, ...rest] = args; + const app = loadApp(appDir); + const adapter = await app.adapter(); + const tool = name ? adapter.tools?.[name] : undefined; + if (!tool) throw new Error(`Usage: kai art <${Object.keys(adapter.tools ?? {}).join("|")}> [options]`); + await tool({ appDir, artDir: localArtDir(appDir, app.config.assets, adapter.isArtDir), args: rest }); + }, + async dev() { + await devHarness(loadApp(appDir)); + }, +}; const run = commands[command ?? ""]; if (!run) { diff --git a/packages/kai-assets/src/dev/harness.ts b/packages/kai-assets/src/dev/harness.ts new file mode 100644 index 0000000..b1c54d7 --- /dev/null +++ b/packages/kai-assets/src/dev/harness.ts @@ -0,0 +1,64 @@ +// `kai dev`: a standalone harness for the game, with no host site involved: esbuild watch +// and serve, live reload, and the world socket so ghosts work between two tabs. Open +// http://localhost:3200dev.html (add ?debug for the debug overlay). Files are +// written under public/ (gitignored), so the built assets are served too. +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import * as esbuild from "esbuild"; +import { attachWorldSocket } from "@datagutt/kai-net/node"; +import type { KaiApp } from "../app.ts"; + +export async function devHarness(app: KaiApp): Promise { + const { config, outDir } = app; + if (!config.paths.harness) throw new Error("[dev] kai.json paths.harness names no entry module for the harness."); + const port = Number(process.env.PORT ?? 3200); + fs.mkdirSync(path.join(outDir, "dev"), { recursive: true }); + fs.writeFileSync( + path.join(outDir, "dev.html"), + ` + + + + +${config.title} (dev harness) + + + +
+ + + + +`, + ); + + const ctx = await esbuild.context({ + absWorkingDir: app.dir, + entryPoints: { harness: config.paths.harness }, + bundle: true, + format: "esm", + outdir: path.join(outDir, "dev"), + sourcemap: true, + target: "es2022", + define: { "process.env.NODE_ENV": '"development"' }, + logLevel: "info", + }); + + await ctx.watch(); + // esbuild serves on a private port; everything but the socket is passed through to it. + const inner = await ctx.serve({ servedir: path.join(app.dir, "public"), host: "127.0.0.1", port: port + 1000 }); + const server = http.createServer((req, res) => { + const upstream = http.request({ host: "127.0.0.1", port: inner.port, path: req.url, method: req.method, headers: req.headers }, (res2) => { + res.writeHead(res2.statusCode ?? 502, res2.headers); + res2.pipe(res); + }); + upstream.on("error", () => res.writeHead(502).end()); + req.pipe(upstream); + }); + attachWorldSocket(server); + server.listen(port, () => console.log(`[dev] http://localhost:${port}${config.basePath}dev.html`)); +} diff --git a/apps/datagutt/scripts/assets/png.mjs b/packages/kai-assets/src/png.ts similarity index 89% rename from apps/datagutt/scripts/assets/png.mjs rename to packages/kai-assets/src/png.ts index 9d1aa7c..c82ee61 100644 --- a/apps/datagutt/scripts/assets/png.mjs +++ b/packages/kai-assets/src/png.ts @@ -3,11 +3,11 @@ // pixel for pixel before it is used, so nothing ever changes colour. import sharp from "sharp"; -export async function shrinkPng(input) { +export async function shrinkPng(input: Buffer): Promise { const { data, info } = await sharp(input).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); - const colours = new Set(); + const colours = new Set(); for (let i = 0; i < data.length && colours.size <= 256; i += 4) colours.add(data.readUInt32LE(i)); - const raw = { raw: { width: info.width, height: info.height, channels: 4 } }; + const raw = { raw: { width: info.width, height: info.height, channels: 4 as const } }; const truecolour = await sharp(data, raw).png({ compressionLevel: 9, effort: 10, adaptiveFiltering: true }).toBuffer(); if (colours.size > 256) return truecolour; const indexed = await sharp(data, raw).png({ palette: true, colours: colours.size, dither: 0, effort: 10, compressionLevel: 9 }).toBuffer(); diff --git a/apps/datagutt/scripts/assets/raster.mjs b/packages/kai-assets/src/raster.ts similarity index 54% rename from apps/datagutt/scripts/assets/raster.mjs rename to packages/kai-assets/src/raster.ts index f7c3b5e..fb0c423 100644 --- a/apps/datagutt/scripts/assets/raster.mjs +++ b/packages/kai-assets/src/raster.ts @@ -1,25 +1,30 @@ // Tiny RGBA raster for drawing generated art (greybox tiles, placeholder sprites). import sharp from "sharp"; -export const hex = (h) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 255]; +export type Rgba = [number, number, number, number]; + +export const hex = (h: string): Rgba => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 255]; export class Raster { - constructor(width, height) { - this.width = width; - this.height = height; + readonly data: Uint8Array; + + constructor( + readonly width: number, + readonly height: number, + ) { this.data = new Uint8Array(width * height * 4); } - px(x, y, rgba) { + px(x: number, y: number, rgba: readonly number[]) { if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; this.data.set(rgba, (y * this.width + x) * 4); } - rect(x, y, w, h, rgba) { + rect(x: number, y: number, w: number, h: number, rgba: readonly number[]) { for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) this.px(xx, yy, rgba); } - toPng() { + toPng(): Promise { return sharp(Buffer.from(this.data), { raw: { width: this.width, height: this.height, channels: 4 } }).png().toBuffer(); } } diff --git a/packages/kai-assets/src/world/gen.ts b/packages/kai-assets/src/world/gen.ts new file mode 100644 index 0000000..178972c --- /dev/null +++ b/packages/kai-assets/src/world/gen.ts @@ -0,0 +1,99 @@ +// `kai world`: generates world/maps/*.tmj from the game's map builders (kai.json +// `paths.maps`). Output is committed; `manual_*` layers already in a map file are kept. +// With the art it also refreshes world/tile-colors.json (placeholder colours) and +// world/tilesets/world.png (for opening maps in Tiled). +// +// kai world gen regenerate +// kai world check fail if the committed maps are out of date +// kai world gen --prune rebuild the tile registry from scratch (only while no map +// has manual layers) +// kai world render also write world/out/.png +// [--collision] [--objects] [--grid] [--scale=N] [--only=] +// [--season=spring|summer|autumn|winter] outdoor maps in that season +import fs from "node:fs"; +import path from "node:path"; +import { applySeason, isSeason } from "@datagutt/kai/world/season"; +import { buildAtlas, tileColors } from "@datagutt/kai-worldgen/atlas"; +import { TileRegistry } from "@datagutt/kai-worldgen/registry"; +import { renderTmj } from "@datagutt/kai-worldgen/render"; +import { canvasToTmj, formatTmj, isManual, type Tmj } from "@datagutt/kai-worldgen/tmj"; +import { validateMap } from "@datagutt/kai-worldgen/validate"; +import type { KaiApp } from "../app.ts"; +import { localArtDir, seasonOverridesDir } from "../art/source.ts"; + +export async function worldGen(app: KaiApp, mode: "gen" | "check" | "render", args: string[]): Promise { + const flag = (name: string) => args.includes(`--${name}`); + const opt = (name: string) => args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1]; + const files = { + registry: path.join(app.worldDir, "tile-ids.json"), + colors: path.join(app.worldDir, "tile-colors.json"), + maps: path.join(app.worldDir, "maps"), + tilesetDir: path.join(app.worldDir, "tilesets"), + out: path.join(app.worldDir, "out"), + }; + const readJson = (file: string) => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null); + const [adapter, maps] = await Promise.all([app.adapter(), app.maps()]); + + if (flag("prune") && opt("only")) throw new Error("[world] --prune rebuilds tile ids for every map; it can't be combined with --only."); + if (flag("prune")) { + const edited = maps.filter((m) => (readJson(path.join(files.maps, `${m.id}.tmj`)) as Tmj | null)?.layers.some(isManual)); + if (edited.length) throw new Error(`[world] Can't prune: ${edited.map((m) => m.id).join(", ")} have manual layers that use today's tile ids.`); + } + const registry = new TileRegistry(flag("prune") ? undefined : (readJson(files.registry) ?? undefined)); + const only = opt("only"); + const outputs = new Map(); + const problems: string[] = []; + for (const map of maps) { + if (only && map.id !== only) continue; + const file = path.join(files.maps, `${map.id}.tmj`); + const canvas = map.build(); + const tmj = canvasToTmj(map.id, canvas, registry, { + properties: { ...map.properties, ...(map.outdoor ? { outdoor: "true" } : {}) }, + previous: readJson(file), + ...(map.outdoor ? { seasonal: adapter.seasonalTile } : {}), + }); + problems.push(...validateMap(map.id, tmj), ...adapter.checkCuts(map.id, canvas.stamped)); + outputs.set(file, formatTmj(tmj)); + } + if (problems.length) throw new Error(`[world] ${problems.length} problem(s):\n ${problems.join("\n ")}`); + outputs.set(files.registry, JSON.stringify(registry.toJSON(), null, "\t") + "\n"); + + if (mode === "check") { + const stale = [...outputs].filter(([file, text]) => !fs.existsSync(file) || fs.readFileSync(file, "utf8") !== text); + if (stale.length) throw new Error(`[world] Out of date: ${stale.map(([f]) => path.relative(app.dir, f)).join(", ")}. Run \`kai world gen\`.`); + console.log(`[world] ${outputs.size - 1} maps up to date`); + return; + } + + fs.mkdirSync(files.maps, { recursive: true }); + for (const [file, text] of outputs) fs.writeFileSync(file, text); + + const artDir = localArtDir(app.dir, app.config.assets, adapter.isArtDir); + if (!artDir) { + console.log(`[world] Wrote ${outputs.size - 1} maps. No art checkout, so no colours, tileset or renders.`); + return; + } + const sheets = adapter.sheets(artDir, { overridesDir: seasonOverridesDir(app.config) }); + fs.writeFileSync(files.colors, JSON.stringify(await tileColors(registry.tiles, sheets), null, "\t") + "\n"); + fs.mkdirSync(files.tilesetDir, { recursive: true }); + fs.writeFileSync(path.join(files.tilesetDir, "world.png"), await buildAtlas(registry.tiles, sheets)); + + const season = opt("season") ?? "summer"; + if (!isSeason(season)) throw new Error(`[world] Unknown season "${season}"`); + if (mode === "render") { + fs.mkdirSync(files.out, { recursive: true }); + for (const [file, text] of outputs) { + if (!file.endsWith(".tmj")) continue; + const png = await renderTmj(applySeason(JSON.parse(text), season), registry.tiles, sheets, { + collision: flag("collision"), + objects: flag("objects"), + grid: flag("grid"), + scale: Number(opt("scale") ?? 1), + }); + const target = path.join(files.out, path.basename(file, ".tmj") + (season === "summer" ? "" : `@${season}`) + ".png"); + fs.writeFileSync(target, png); + console.log(`[world] Rendered ${path.relative(app.dir, target)}`); + } + } + console.log(`[world] Wrote ${outputs.size - 1} maps, ${registry.tiles.length} tiles registered`); +} diff --git a/packages/kai-assets/tsconfig.json b/packages/kai-assets/tsconfig.json index 5e51a8a..3c17672 100644 --- a/packages/kai-assets/tsconfig.json +++ b/packages/kai-assets/tsconfig.json @@ -1,4 +1,10 @@ { "extends": "@datagutt/kai-tsconfig/base.json", - "include": ["src"] + "include": [ + "src" + ], + "compilerOptions": { + "allowJs": true, + "checkJs": false + } } diff --git a/packages/kai-limezu/package.json b/packages/kai-limezu/package.json index 0aa2110..64969ab 100644 --- a/packages/kai-limezu/package.json +++ b/packages/kai-limezu/package.json @@ -18,6 +18,7 @@ "sharp": "^0.35.4" }, "devDependencies": { + "@datagutt/kai-assets": "workspace:*", "@datagutt/kai-eslint-config": "workspace:*", "@datagutt/kai-tsconfig": "workspace:*", "@datagutt/kai-vitest-config": "workspace:*", diff --git a/packages/kai-limezu/src/adapter.ts b/packages/kai-limezu/src/adapter.ts new file mode 100644 index 0000000..ca668cb --- /dev/null +++ b/packages/kai-limezu/src/adapter.ts @@ -0,0 +1,24 @@ +// The LimeZu art adapter for the kai build (kai.json `assets.adapter`): the art lives +// under limezu/ in the art repository, characters and portraits from LimeZu's generators. +import fs from "node:fs"; +import path from "node:path"; +import type { ArtAdapter } from "@datagutt/kai-assets/adapter"; +import { composeCharacter, composePortrait } from "./characters.ts"; +import { checkCuts } from "./cuts.ts"; +import { seasonalTile } from "./seasons.ts"; +import { LimeZuSheets } from "./source.ts"; +import { catalogTool } from "./tools/catalog.mjs"; + +const characters = (artDir: string) => path.join(artDir, "limezu/characters"); +const portraits = (artDir: string) => path.join(artDir, "limezu/portraits"); + +export const adapter: ArtAdapter = { + isArtDir: (dir) => fs.existsSync(path.join(dir, "limezu")), + sheets: (artDir, { overridesDir }) => new LimeZuSheets(artDir, { overridesDir }), + seasonalTile, + checkCuts, + character: (artDir, id, recipe) => composeCharacter(characters(artDir), id, recipe), + portrait: (artDir, id, recipe) => + recipe.portrait === false ? Promise.resolve(null) : composePortrait(portraits(artDir), id, recipe, (file) => fs.existsSync(path.join(portraits(artDir), file))), + tools: { catalog: catalogTool }, +}; diff --git a/apps/datagutt/scripts/assets/characters.test.mjs b/packages/kai-limezu/src/characters.test.ts similarity index 91% rename from apps/datagutt/scripts/assets/characters.test.mjs rename to packages/kai-limezu/src/characters.test.ts index 025f425..83fa217 100644 --- a/apps/datagutt/scripts/assets/characters.test.mjs +++ b/packages/kai-limezu/src/characters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { portraitLayers, recolorPixels } from "./characters.mjs"; +import { portraitLayers, recolorPixels } from "./characters.ts"; describe("recolorPixels", () => { it("swaps exact colours on opaque pixels only", () => { @@ -10,7 +10,7 @@ describe("recolorPixels", () => { }); describe("portraitLayers", () => { - const exists = (file) => file !== "Accessories/PG_Accessory_03_Backpack_1.png"; + const exists = (file: string) => file !== "Accessories/PG_Accessory_03_Backpack_1.png"; it("maps sprite layers to their portrait counterparts and drops the outfit", () => { const recipe = { diff --git a/apps/datagutt/scripts/assets/characters.mjs b/packages/kai-limezu/src/characters.ts similarity index 54% rename from apps/datagutt/scripts/assets/characters.mjs rename to packages/kai-limezu/src/characters.ts index ac80679..9ac120d 100644 --- a/apps/datagutt/scripts/assets/characters.mjs +++ b/packages/kai-limezu/src/characters.ts @@ -1,11 +1,9 @@ // Walk sheets and dialogue portraits from the LimeZu character and portrait generators, -// stacked per recipe (game/assets/manifest.ts), plus stand-ins of the same shape for -// placeholder builds. +// stacked per recipe (content/characters.json in a game). import path from "node:path"; import sharp from "sharp"; +import type { CharacterRecipe } from "@datagutt/kai/schema/engine"; import { - ANIMS, - DIRECTIONS, FRAME_HEIGHT, FRAME_WIDTH, PORTRAIT_ANIMS, @@ -14,36 +12,40 @@ import { PORTRAIT_SOURCE_FRAME, SHEET_COLUMNS, SHEET_ROWS, -} from "../../game/characters/sheet.ts"; -import { Raster, hex } from "./raster.mjs"; +} from "@datagutt/kai/characters/sheet"; + +type Layer = CharacterRecipe["layers"][number]; const SHEET_W = SHEET_COLUMNS * FRAME_WIDTH; const SHEET_H = SHEET_ROWS * FRAME_HEIGHT; const PORTRAIT_ROWS = Object.keys(PORTRAIT_ANIMS).length; const P = PORTRAIT_CROP.size; -const INK = hex("1b1b24"); -const layerFile = (layer) => (typeof layer === "string" ? layer : layer.file); +const layerFile = (layer: Layer) => (typeof layer === "string" ? layer : layer.file); + +const rgb = (h: string) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; /** Exact colour swaps (hex without #) on raw RGBA pixels, in place. */ -export function recolorPixels(data, map) { - const swaps = new Map(Object.entries(map).map(([from, to]) => [from.toLowerCase(), hex(to)])); +export function recolorPixels(data: Uint8Array, map: Record): Uint8Array { + const swaps = new Map(Object.entries(map).map(([from, to]) => [from.toLowerCase(), rgb(to)])); for (let i = 0; i < data.length; i += 4) { if (!data[i + 3]) continue; const key = ((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]).toString(16).padStart(6, "0"); const to = swaps.get(key); - if (to) data.set(to.slice(0, 3), i); + if (to) data.set(to, i); } return data; } +type PortraitSource = Pick; + /** * Portrait layers for a recipe: explicit ones, or derived from the sprite layers (the two * generators share numbering; outfits don't appear in head portraits). Accessories the * portrait generator lacks, such as backpacks, are skipped. A layer can name its own * portrait counterpart instead. */ -export function portraitLayers(recipe, exists) { +export function portraitLayers(recipe: PortraitSource, exists: (file: string) => boolean): string[] | null { if (recipe.portrait === false) return null; if (Array.isArray(recipe.portrait)) return recipe.portrait; return recipe.layers.flatMap((layer) => { @@ -52,7 +54,7 @@ export function portraitLayers(recipe, exists) { }); } -function portraitFile(file, exists) { +function portraitFile(file: string, exists: (file: string) => boolean): string | null { let m; if ((m = /^Bodies\/Body_0?(\d+)\.png$/.exec(file))) return `Skins/PG_Skin_${Number(m[1])}.png`; if ((m = /^Eyes\/Eyes_(\d+)\.png$/.exec(file))) return `Eyes/PG_Eyes_${m[1]}.png`; @@ -65,38 +67,38 @@ function portraitFile(file, exists) { } /** One layer cropped to the sheet, with the recipe's colour swaps. */ -async function loadLayer(dir, layer, recipe, width, height, what) { +async function loadLayer(dir: string, layer: Layer, recipe: CharacterRecipe, width: number, height: number, what: string): Promise { const file = path.join(dir, layerFile(layer)); const { data, info } = await sharp(file) .extract({ left: 0, top: 0, width, height }) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }) - .catch((err) => { + .catch((err: Error) => { throw new Error(`${what}: layer ${layerFile(layer)} could not be read from the assets repo (${err.message})`); }); if (recipe.recolor) recolorPixels(data, recipe.recolor); return sharp(data, { raw: info }).png().toBuffer(); } -const transparent = (width, height) => ({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }); +const transparent = (width: number, height: number) => ({ create: { width, height, channels: 4 as const, background: { r: 0, g: 0, b: 0, alpha: 0 } } }); -async function stack(dir, layers, recipe, width, height, what) { +async function stack(dir: string, layers: Layer[], recipe: CharacterRecipe, width: number, height: number, what: string): Promise { const inputs = []; for (const layer of layers) inputs.push({ input: await loadLayer(dir, layer, recipe, width, height, what) }); return sharp(transparent(width, height)).composite(inputs).png().toBuffer(); } /** The walk sheet: every layer cropped to the rows we use (Body_01 is wider than the grid). */ -export function composeCharacter(charactersDir, id, recipe) { +export function composeCharacter(charactersDir: string, id: string, recipe: CharacterRecipe): Promise { return stack(charactersDir, recipe.layers, recipe, SHEET_W, SHEET_H, `Character "${id}"`); } /** Stack the portrait layers, then crop every frame to the area heads actually use. */ -export async function composePortrait(portraitsDir, id, recipe, exists) { +export async function composePortrait(portraitsDir: string, id: string, recipe: CharacterRecipe, exists: (file: string) => boolean): Promise { const width = PORTRAIT_COLUMNS * PORTRAIT_SOURCE_FRAME; const height = PORTRAIT_ROWS * PORTRAIT_SOURCE_FRAME; - const full = await stack(portraitsDir, portraitLayers(recipe, exists), recipe, width, height, `Portrait "${id}"`); + const full = await stack(portraitsDir, portraitLayers(recipe, exists) ?? [], recipe, width, height, `Portrait "${id}"`); const frames = []; for (let row = 0; row < PORTRAIT_ROWS; row++) { for (let col = 0; col < PORTRAIT_COLUMNS; col++) { @@ -114,51 +116,3 @@ export async function composePortrait(portraitsDir, id, recipe, exists) { } return sharp(transparent(PORTRAIT_COLUMNS * P, PORTRAIT_ROWS * P)).composite(frames).png().toBuffer(); } - -/** A stand-in figure with the same frame layout: body colour, lighter head, eyes that face. */ -export function placeholderCharacter(recipe) { - const img = new Raster(SHEET_W, SHEET_H); - const body = hex(recipe.placeholder); - const head = [...body.slice(0, 3).map((c) => Math.min(255, c + 70)), 255]; - for (const spec of Object.values(ANIMS)) { - DIRECTIONS.forEach((dir, d) => { - for (let f = 0; f < spec.framesPerDirection; f++) { - const ox = (d * spec.framesPerDirection + f) * FRAME_WIDTH; - const oy = spec.row * FRAME_HEIGHT; - const bob = spec === ANIMS.walk ? f % 2 : 0; - img.rect(ox + 3, oy + 12 + bob, 10, 9, INK); - img.rect(ox + 4, oy + 13 + bob, 8, 7, head); - img.rect(ox + 4, oy + 21 + bob, 8, 7, body); - const step = spec === ANIMS.walk ? (f % 3) - 1 : 0; - img.rect(ox + 5 + step, oy + 28, 2, 3, INK); - img.rect(ox + 9 - step, oy + 28, 2, 3, INK); - const eyes = { right: [9, 11], left: [4, 6], down: [5, 9], up: null }[dir]; - if (eyes) for (const ex of eyes) img.rect(ox + ex, oy + 16 + bob, 1, 2, INK); - } - }); - } - return img.toPng(); -} - -/** A stand-in face: talking opens and closes the mouth, nodding bobs, shaking sways. */ -export function placeholderPortrait(recipe) { - const img = new Raster(PORTRAIT_COLUMNS * P, PORTRAIT_ROWS * P); - const skin = hex(recipe.placeholder); - const light = [...skin.slice(0, 3).map((c) => Math.min(255, c + 70)), 255]; - for (let row = 0; row < PORTRAIT_ROWS; row++) { - for (let col = 0; col < PORTRAIT_COLUMNS; col++) { - const dx = row === 2 ? [0, -1, -2, -1, 0, 1, 2, 1, 0, 0][col] : 0; - const dy = row === 1 ? [0, 1, 2, 1, 0, 1, 2, 1, 0, 0][col] : 0; - const ox = col * P + 5 + dx; - const oy = row * P + 4 + dy; - img.rect(ox, oy, 15, 16, INK); - img.rect(ox + 1, oy + 1, 13, 14, light); - img.rect(ox + 1, oy + 1, 13, 4, skin); - img.rect(ox + 4, oy + 7, 1, 2, INK); - img.rect(ox + 10, oy + 7, 1, 2, INK); - const open = row === 0 && col % 2 === 1; - img.rect(ox + 6, oy + 11, 3, open ? 2 : 1, INK); - } - } - return img.toPng(); -} diff --git a/apps/datagutt/scripts/world/catalog.mjs b/packages/kai-limezu/src/tools/catalog.mjs similarity index 67% rename from apps/datagutt/scripts/world/catalog.mjs rename to packages/kai-limezu/src/tools/catalog.mjs index 97135a3..c671486 100644 --- a/apps/datagutt/scripts/world/catalog.mjs +++ b/packages/kai-limezu/src/tools/catalog.mjs @@ -1,41 +1,29 @@ -#!/usr/bin/env node -// Builds the sprite catalogue (@datagutt/kai-limezu catalog/.json), metadata only (no -// pixels, so it is committed): the whole objects of each LimeZu sheet. Needs the art. +// `kai art catalog`: builds the sprite catalogue (catalog/.json in this package), +// metadata only (no pixels, so it is committed): the whole objects of each LimeZu sheet. +// Needs the art. // // Sheets with a "Singles" folder: LimeZu's singles are the finished objects, and some are // assembled from parts laid out separately in the sheet, so maps use the singles -// themselves (@datagutt/kai-limezu singles.ts). The catalogue records each single's size in tiles +// themselves (singles.ts). The catalogue records each single's size in tiles // and, when its pixels appear as-is in the sheet, its tile position there (the validator // uses that to catch prefabs cut from the sheet that slice an object in half). // Sheets without singles (AUTO_CATALOG) get objects detected from their pixels, with // touching pixels grouped (8-connected). // -// Also writes world/out/catalog/: .png (the sheet with objects boxed and +// Also writes the game's world/out/catalog/: .png (the sheet with objects boxed and // numbered) and -singles.png (every single, numbered), for picking by eye. // -// node scripts/world/catalog.mjs [--only=] +// kai art catalog [--only=] import fs from "node:fs"; import path from "node:path"; import sharp from "sharp"; import { fileURLToPath } from "node:url"; -import { AUTO_CATALOG, SHEETS, SINGLES } from "@datagutt/kai-limezu/sheets"; -import { singleKeys } from "@datagutt/kai-limezu/singleKey"; -import { localArtDir } from "../assets/source.mjs"; +import { AUTO_CATALOG, SHEETS, SINGLES } from "../sheets.ts"; +import { singleKeys } from "../singleKey.ts"; const T = 16; -const root = process.cwd(); -const only = process.argv.find((a) => a.startsWith("--only="))?.split("=")[1]; -const source = { dir: localArtDir(root) }; -if (!source.dir) { - console.error("[catalog] Needs a local checkout of the art repository (kai.json assets.localPath)."); - process.exit(1); -} -const art = (p) => path.join(source.dir, "limezu", p); // The catalogue belongs to the LimeZu adapter, which every LimeZu game shares. -const outJson = fileURLToPath(new URL("catalog", import.meta.resolve("@datagutt/kai-limezu/sheets"))); -const outPng = path.join(root, "world/out/catalog"); -fs.mkdirSync(outJson, { recursive: true }); -fs.mkdirSync(outPng, { recursive: true }); +const outJson = fileURLToPath(new URL("../catalog", import.meta.url)); const raw = async (file) => { const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); @@ -151,7 +139,7 @@ const toTiles = (b) => { return [col, row, Math.floor((b.x + b.w - 1) / T) - col + 1, Math.floor((b.y + b.h - 1) / T) - row + 1]; }; -async function contactSheet(id, sheet, items) { +async function contactSheet(outPng, id, sheet, items) { const scale = 2; let svg = ``; for (const [n, col, row, w, h] of items) { @@ -168,7 +156,7 @@ async function contactSheet(id, sheet, items) { } /** Every single side by side with its number, for picking the ones not in the sheet. */ -async function singlesMontage(id, montage) { +async function singlesMontage(outPng, id, montage) { const scale = 2; const width = 900; let x = 0, y = 0, rowH = 0; @@ -198,50 +186,59 @@ async function singlesMontage(id, montage) { .toFile(path.join(outPng, `${id}-singles.png`)); } -const ids = [...Object.keys(SINGLES), ...AUTO_CATALOG].filter((id) => !only || id === only); -for (const id of ids) { - const sheet = await raw(art(SHEETS[id])); - let items; - let method; - let singles = null; - const montage = []; - if (SINGLES[id]) { - method = "singles"; - const dir = art(SINGLES[id]); - const keys = [...singleKeys(fs.readdirSync(dir).filter((f) => f.endsWith(".png")))]; - const order = (k) => (/^\d+$/.test(k) ? Number(k) : Infinity); - keys.sort(([a], [b]) => order(a) - order(b) || a.localeCompare(b)); - items = []; - singles = []; - for (const [key, file] of keys) { - const single = await raw(path.join(dir, file)); - const at = locate(sheet, single); - const size = [single.width / T, single.height / T]; - const tileAligned = at && at.x % T === 0 && at.y % T === 0; - singles.push([key, ...size, tileAligned ? at.x / T : null, tileAligned ? at.y / T : null, coverage(single)]); - if (at) items.push([key, ...toTiles(opaqueBox(single, at))]); - montage.push({ n: key, single }); +/** `kai art catalog [--only=]` (the adapter's `tools`). */ +export async function catalogTool({ appDir, artDir, args }) { + if (!artDir) throw new Error("[catalog] Needs a local checkout of the art repository (kai.json assets.localPath)."); + const only = args.find((a) => a.startsWith("--only="))?.split("=")[1]; + const art = (p) => path.join(artDir, "limezu", p); + const outPng = path.join(appDir, "world/out/catalog"); + fs.mkdirSync(outJson, { recursive: true }); + fs.mkdirSync(outPng, { recursive: true }); + const ids = [...Object.keys(SINGLES), ...AUTO_CATALOG].filter((id) => !only || id === only); + for (const id of ids) { + const sheet = await raw(art(SHEETS[id])); + let items; + let method; + let singles = null; + const montage = []; + if (SINGLES[id]) { + method = "singles"; + const dir = art(SINGLES[id]); + const keys = [...singleKeys(fs.readdirSync(dir).filter((f) => f.endsWith(".png")))]; + const order = (k) => (/^\d+$/.test(k) ? Number(k) : Infinity); + keys.sort(([a], [b]) => order(a) - order(b) || a.localeCompare(b)); + items = []; + singles = []; + for (const [key, file] of keys) { + const single = await raw(path.join(dir, file)); + const at = locate(sheet, single); + const size = [single.width / T, single.height / T]; + const tileAligned = at && at.x % T === 0 && at.y % T === 0; + singles.push([key, ...size, tileAligned ? at.x / T : null, tileAligned ? at.y / T : null, coverage(single)]); + if (at) items.push([key, ...toTiles(opaqueBox(single, at))]); + montage.push({ n: key, single }); + } + const loose = singles.filter((s) => s[3] === null).length; + if (loose) console.log(`[catalog] ${id}: ${loose} of ${keys.length} singles are assembled or moved (not found as-is in the sheet)`); + } else { + method = "detected"; + items = detect(sheet).map((b, i) => [i + 1, ...toTiles(b)]); } - const loose = singles.filter((s) => s[3] === null).length; - if (loose) console.log(`[catalog] ${id}: ${loose} of ${keys.length} singles are assembled or moved (not found as-is in the sheet)`); - } else { - method = "detected"; - items = detect(sheet).map((b, i) => [i + 1, ...toTiles(b)]); + items.sort((a, b) => a[2] - b[2] || a[1] - b[1]); + const json = { + sheet: id, + method, + // Objects as they appear in the sheet: [key, col, row, w, h] in tiles. + objects: items, + // Singles: [key, w, h, col, row, coverage] in tiles; col/row null when not in the + // sheet as-is; coverage as in coverage() below. + ...(singles ? { singles } : {}), + // The whole sheet's coverage, one string per tile row, for prefabs cut from it. + coverage: coverage(sheet).split("/"), + }; + fs.writeFileSync(path.join(outJson, `${id}.json`), JSON.stringify(json).replace(/\],\[/g, "],\n[") + "\n"); + await contactSheet(outPng, id, sheet, items); + if (montage.length) await singlesMontage(outPng, id, montage); + console.log(`[catalog] ${id}: ${items.length} objects (${method})`); } - items.sort((a, b) => a[2] - b[2] || a[1] - b[1]); - const json = { - sheet: id, - method, - // Objects as they appear in the sheet: [key, col, row, w, h] in tiles. - objects: items, - // Singles: [key, w, h, col, row, coverage] in tiles; col/row null when not in the - // sheet as-is; coverage as in coverage() below. - ...(singles ? { singles } : {}), - // The whole sheet's coverage, one string per tile row, for prefabs cut from it. - coverage: coverage(sheet).split("/"), - }; - fs.writeFileSync(path.join(outJson, `${id}.json`), JSON.stringify(json).replace(/\],\[/g, "],\n[") + "\n"); - await contactSheet(id, sheet, items); - if (montage.length) await singlesMontage(id, montage); - console.log(`[catalog] ${id}: ${items.length} objects (${method})`); } diff --git a/packages/kai-limezu/tsconfig.json b/packages/kai-limezu/tsconfig.json index 5e51a8a..3c17672 100644 --- a/packages/kai-limezu/tsconfig.json +++ b/packages/kai-limezu/tsconfig.json @@ -1,4 +1,10 @@ { "extends": "@datagutt/kai-tsconfig/base.json", - "include": ["src"] + "include": [ + "src" + ], + "compilerOptions": { + "allowJs": true, + "checkJs": false + } } diff --git a/packages/kai-worldgen/src/maps.ts b/packages/kai-worldgen/src/maps.ts new file mode 100644 index 0000000..0c2773e --- /dev/null +++ b/packages/kai-worldgen/src/maps.ts @@ -0,0 +1,7 @@ +import type { MapCanvas } from "./canvas.ts"; + +/** + * A map a game generates. `outdoor` maps change with the seasons and the time of day. + * A game's map module (kai.json `paths.maps`) exports `GENERATED_MAPS: GeneratedMap[]`. + */ +export type GeneratedMap = { id: string; properties?: Record; outdoor?: boolean; build: () => MapCanvas }; diff --git a/packages/kai/package.json b/packages/kai/package.json index 3f70fde..0096b25 100644 --- a/packages/kai/package.json +++ b/packages/kai/package.json @@ -7,7 +7,10 @@ "exports": { "./schema": "./src/schema/index.ts", "./world/*": "./src/world/*.ts", - "./fx/*": "./src/fx/*.ts" + "./fx/*": "./src/fx/*.ts", + "./characters/*": "./src/characters/*.ts", + "./ui/*": "./src/ui/*.ts", + "./schema/*": "./src/schema/*.ts" }, "scripts": { "lint": "eslint .", diff --git a/apps/datagutt/game/characters/sheet.ts b/packages/kai/src/characters/sheet.ts similarity index 90% rename from apps/datagutt/game/characters/sheet.ts rename to packages/kai/src/characters/sheet.ts index 61bdd18..29abddd 100644 --- a/apps/datagutt/game/characters/sheet.ts +++ b/packages/kai/src/characters/sheet.ts @@ -1,5 +1,6 @@ -// Frame layout of a LimeZu character sheet (16x16 pack): 16×32 frames, 56 per row, -// directions always in the order right, up, left, down. The build keeps rows 0..6. +// Frame layout of a character sheet in the LimeZu generator's format (16x16 pack): 16×32 +// frames, 56 per row, directions always in the order right, up, left, down. The build +// keeps rows 0..6. The runtime animates characters by it; the art adapter composes them. export const FRAME_WIDTH = 16; export const FRAME_HEIGHT = 32; diff --git a/packages/kai/src/schema/config.test.ts b/packages/kai/src/schema/config.test.ts index dab7d5c..1e7584c 100644 --- a/packages/kai/src/schema/config.test.ts +++ b/packages/kai/src/schema/config.test.ts @@ -15,7 +15,9 @@ const valid = { title: "Demo", timezone: "Europe/Oslo", startPlace: "dock", - assets: { repo: "someone/art", localPath: "../art", tokenEnv: "ART_TOKEN" }, + basePath: "/game/", + paths: { maps: "world/maps.ts", ink: "dialogue", dialogueHost: "dialogue/host.ts" }, + assets: { repo: "someone/art", localPath: "../art", tokenEnv: "ART_TOKEN", adapter: "@datagutt/kai-limezu/adapter" }, ui: { frame: { file: "ui.png", x: 0, y: 0, width: 8, height: 8 }, emotes: "emotes.png" }, font: { module: "geist/font/pixel", file: "f.woff2", unitsPerPixel: 76 }, }; diff --git a/packages/kai/src/schema/config.ts b/packages/kai/src/schema/config.ts index e576245..5032473 100644 --- a/packages/kai/src/schema/config.ts +++ b/packages/kai/src/schema/config.ts @@ -23,6 +23,19 @@ export const kaiConfigSchema = z.object({ }, "not an IANA time zone"), /** Where a new game starts: a place id from the game's places. */ startPlace: id, + /** The URL path the built assets are served from; the build writes them to public/ plus this. */ + basePath: z.string().regex(/^\/([a-z0-9-]+\/)*$/, 'a path such as "/game/"'), + /** Modules and folders of the game that the build loads, relative to the game's folder. */ + paths: z.object({ + /** Exports `GENERATED_MAPS` (@datagutt/kai-worldgen/maps). */ + maps: z.string(), + /** The folder holding main.ink. */ + ink: z.string(), + /** Exports `dialogueHost` (@datagutt/kai-assets/build/ink). */ + dialogueHost: z.string(), + /** The entry module `kai dev` bundles: boots the game into a #game element. */ + harness: z.string().optional(), + }), assets: z.object({ /** The private art repository on GitHub, as "owner/name". */ repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, 'use "owner/name"'), @@ -31,6 +44,8 @@ export const kaiConfigSchema = z.object({ localPath: z.string(), /** The environment variable that holds a read-only token for cloning `repo`. */ tokenEnv: z.string().regex(/^[A-Z_][A-Z0-9_]*$/), + /** The art adapter module, resolved from the game ("@datagutt/kai-limezu/adapter"). */ + adapter: z.string(), }), ui: z.object({ /** The dialogue box's nine-slice frame: a cut from a sheet. */ diff --git a/packages/kai/src/schema/engine.ts b/packages/kai/src/schema/engine.ts index 320cae5..94af8c7 100644 --- a/packages/kai/src/schema/engine.ts +++ b/packages/kai/src/schema/engine.ts @@ -92,6 +92,8 @@ export const achievement = z.object({ /** What opens an unlock: for now, every stamp there is. */ export const unlockCondition = z.object({ stamps: z.literal("all") }); +export type CharacterRecipe = z.output; + export const credits = z.object({ title: text, byline: text, diff --git a/apps/datagutt/game/ui/emotes.ts b/packages/kai/src/ui/emotes.ts similarity index 100% rename from apps/datagutt/game/ui/emotes.ts rename to packages/kai/src/ui/emotes.ts From e6a4cfe1f71bdd3763a42d886559a2a29b1a85ff Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 20:24:20 +0200 Subject: [PATCH 125/153] fix(kai-assets): commit the asset build modules the ignore rules hid The root .gitignore ignored every build/ folder instead of the Next.js output alone, so packages/kai-assets/src/build/ was partly left out. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- .gitignore | 2 - apps/datagutt/.gitignore | 3 + packages/kai-assets/src/build/assets.ts | 181 ++++++++++++++++++ packages/kai-assets/src/build/placeholders.ts | 83 ++++++++ 4 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 packages/kai-assets/src/build/assets.ts create mode 100644 packages/kai-assets/src/build/placeholders.ts diff --git a/.gitignore b/.gitignore index b8d36be..97d4fbb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,6 @@ node_modules/ # build and tool output .next/ -out/ -build/ .turbo/ coverage/ *.tsbuildinfo diff --git a/apps/datagutt/.gitignore b/apps/datagutt/.gitignore index c289fbb..07c5b9a 100644 --- a/apps/datagutt/.gitignore +++ b/apps/datagutt/.gitignore @@ -1,3 +1,6 @@ +# Next.js static export and build output +/out/ +/build/ # Licensed or generated asset output. Never commit LimeZu pixels (docs/game/DESIGN.md §9). /public/game/ /.assets-cache/ diff --git a/packages/kai-assets/src/build/assets.ts b/packages/kai-assets/src/build/assets.ts new file mode 100644 index 0000000..9a08fa3 --- /dev/null +++ b/packages/kai-assets/src/build/assets.ts @@ -0,0 +1,181 @@ +// `kai assets`: builds everything the game loads into public/ (gitignored: it +// holds licensed pixels when real art is available), from the art the fetch step found. +// Placeholder mode produces files of the same shape without the licensed art. +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import sharp from "sharp"; +import { parseMapObject, type MapObject, type TiledObject } from "@datagutt/kai/world/objects"; +import { EMOTE_COLUMNS, EMOTE_FRAME, EMOTE_TAIL, EMOTES } from "@datagutt/kai/ui/emotes"; +import type { CharacterRecipe } from "@datagutt/kai/schema/engine"; +import { buildAtlas, buildPlaceholderAtlas } from "@datagutt/kai-worldgen/atlas"; +import type { KaiApp } from "../app.ts"; +import { sourceFile } from "../art/fetch.ts"; +import { seasonOverridesDir, type AssetSource } from "../art/source.ts"; +import { shrinkPng } from "../png.ts"; +import { hex, Raster } from "../raster.ts"; +import { buildBitmapFont } from "./font.mjs"; +import { compileDialogue } from "./ink.ts"; +import { buildMusic } from "./music.mjs"; +import { placeholderCharacter, placeholderPortrait, placeholderSprite } from "./placeholders.ts"; + +const TILE = 16; + +/** The parts of the content bundle the build reads. */ +type BuildContent = { + characters: Record; + music: { tracks: Record }; + unlocks: Record; +}; + +/** Where the fetch step found the art. Run `kai assets` (fetch and build), not the build alone. */ +export function readSource(app: KaiApp): AssetSource { + if (!fs.existsSync(sourceFile(app))) throw new Error("[assets] .assets-cache/source.json is missing: run `kai assets` (fetch and build)."); + return JSON.parse(fs.readFileSync(sourceFile(app), "utf8")); +} + +/** The in-game bitmap font, from the web font kai.json names. */ +export async function buildFont(app: KaiApp) { + const { font } = app.config; + const fontDir = path.dirname(createRequire(path.join(app.dir, "package.json")).resolve(font.module)); + return buildBitmapFont(path.join(fontDir, font.file), font.unitsPerPixel, "pixel"); +} + +export async function buildAssets(app: KaiApp): Promise { + const { config, outDir } = app; + const source = readSource(app); + const adapter = await app.adapter(); + const content: BuildContent = JSON.parse(fs.readFileSync(path.join(app.dir, ".kai/content.json"), "utf8")); + const art = source.dir; + + for (const sub of ["characters", "portraits", "tilesets", "maps", "fonts", "dialogue", "ui", "music"]) { + fs.rmSync(path.join(outDir, sub), { recursive: true, force: true }); + fs.mkdirSync(path.join(outDir, sub), { recursive: true }); + } + const started = Date.now(); + const write = (file: string, data: Buffer | string) => fs.writeFileSync(path.join(outDir, file), data); + + const portraits: string[] = []; + for (const [id, recipe] of Object.entries(content.characters)) { + write(`characters/${id}.png`, await shrinkPng(art ? await adapter.character(art, id, recipe) : await placeholderCharacter(recipe))); + if (recipe.portrait === false) continue; + const portrait = art ? await adapter.portrait(art, id, recipe) : await placeholderPortrait(recipe); + if (!portrait) continue; + write(`portraits/${id}.png`, await shrinkPng(portrait)); + portraits.push(id); + } + + // Maps come from `kai world gen` (world/maps). They share one packed tileset, from the + // art or from the committed colour sketches. + const { worldDir } = app; + const registry = JSON.parse(fs.readFileSync(path.join(worldDir, "tile-ids.json"), "utf8")); + const atlas = art + ? await buildAtlas(registry.tiles, adapter.sheets(art, { overridesDir: seasonOverridesDir(config) })) + : await buildPlaceholderAtlas(registry.tiles, JSON.parse(fs.readFileSync(path.join(worldDir, "tile-colors.json"), "utf8"))); + write("tilesets/world.png", await shrinkPng(atlas)); + const maps = fs + .readdirSync(path.join(worldDir, "maps")) + .filter((f) => f.endsWith(".tmj")) + .map((file) => { + const tmj = JSON.parse(fs.readFileSync(path.join(worldDir, "maps", file), "utf8")); + write(`maps/${file}`, JSON.stringify(tmj)); + const objects = (tmj.layers as { type: string; objects?: TiledObject[] }[]) + .filter((l) => l.type === "objectgroup") + .flatMap((l) => (l.objects ?? []).map((o) => parseMapObject(o, TILE))); + return { id: path.basename(file, ".tmj"), objects }; + }); + + // The dialogue frame: a nine-slice cut from a sheet, or a drawn stand-in of the same size. + const { frame } = config.ui; + write("ui/frame.png", art ? await sharp(path.join(art, frame.file)).extract({ left: frame.x, top: frame.y, width: frame.width, height: frame.height }).png().toBuffer() : await placeholderFrame(frame)); + write("ui/emotes.png", await shrinkPng(art ? await sharp(path.join(art, config.ui.emotes)).png().toBuffer() : await placeholderEmotes())); + for (const [name, sprite] of Object.entries(config.sprites)) { + write(`ui/${name}.png`, await shrinkPng(art ? await sharp(path.join(art, sprite.file)).png().toBuffer() : await placeholderSprite(sprite))); + } + + const font = await buildFont(app); + write("fonts/pixel.png", font.png); + write("fonts/pixel.xml", font.xml); + + // Ink dialogue, checked against the game's dialogue functions (fails the build on bad ids). + const dialogue = compileDialogue(path.join(app.dir, config.paths.ink), await app.dialogueHost()); + write("dialogue/main.json", dialogue.json); + checkMaps(maps, dialogue.knots, Object.keys(content.unlocks)); + + // Placeholder builds have no music, and the game stays quiet. + const music = art + ? await buildMusic({ tracks: content.music.tracks, musicDir: path.join(art, "music"), outDir: path.join(outDir, "music"), cacheDir: path.join(app.dir, ".assets-cache/music") }) + : {}; + + const manifest = { + mode: source.mode, + builtAt: new Date().toISOString(), + characters: Object.keys(content.characters), + portraits, + maps: maps.map((m) => m.id), + tilesets: ["world"], + fonts: ["pixel"], + dialogue: dialogue.files, + music, + }; + write("assets.json", JSON.stringify(manifest, null, "\t") + "\n"); + console.log( + `[assets] Built ${manifest.characters.length} characters, ${manifest.maps.length} maps, ` + + `${manifest.tilesets.length} tilesets, ${Object.keys(music).length} music tracks (${source.mode} art) in ${Date.now() - started} ms`, + ); +} + +/** Doors lead to a spawn that exists, signs and NPCs name dialogue knots, locks name unlocks. */ +function checkMaps(maps: { id: string; objects: MapObject[] }[], knots: string[], unlocks: string[]) { + const spawnsByMap = new Map(maps.map(({ id, objects }) => [id, new Set(objects.filter((o) => o.type === "spawn").map((o) => o.id))])); + for (const { id, objects } of maps) { + for (const obj of objects) { + const at = `${id}: ${obj.type} at (${obj.x}, ${obj.y})`; + if (obj.type === "door") { + const spawns = spawnsByMap.get(obj.toMap); + if (!spawns) throw new Error(`${at} leads to unknown map "${obj.toMap}"`); + if (!spawns.has(obj.toSpawn)) throw new Error(`${at} leads to missing spawn "${obj.toSpawn}" on ${obj.toMap}`); + } + const unlock = obj.type === "door" || obj.type === "gate" ? obj.unlock : undefined; + if (unlock !== undefined && !unlocks.includes(unlock)) throw new Error(`${at} has unknown unlock "${unlock}" (content/unlocks.json)`); + if (obj.type === "sign" && obj.dialogue && !knots.includes(obj.dialogue)) throw new Error(`${at} uses dialogue "${obj.dialogue}", which is not a knot.`); + if (obj.type === "npc" && !knots.includes(obj.dialogue)) { + throw new Error(`${id}: NPC "${obj.id}" uses dialogue "${obj.dialogue}", which is not a knot. Knots: ${knots.join(", ")}`); + } + } + } +} + +/** A wood-rimmed parchment box in the frame's size, for placeholder builds. */ +function placeholderFrame({ width, height }: { width: number; height: number }) { + const img = new Raster(width, height); + const edge = hex("3b2a3a"); + img.rect(1, 0, width - 2, height - 1, edge); + img.rect(0, 1, width, height - 3, edge); + img.rect(1, 1, width - 2, height - 4, hex("b8733d")); + img.rect(3, 3, width - 6, height - 8, edge); + img.rect(4, 4, width - 8, height - 10, hex("c4a888")); + return img.toPng(); +} + +/** White bubbles with a coloured mark in the frames the game uses, for placeholder builds. */ +function placeholderEmotes() { + const size = EMOTE_FRAME * EMOTE_COLUMNS; + const img = new Raster(size, size); + const ink = hex("3b2a3a"); + const white = hex("f2eef7"); + const marks = ["4a7fd6", "8e5cc4", "3aa0c8", "7a7a90", "e0a020", "d05a3a", "d8404f"]; + Object.values(EMOTES).forEach(([col, row], i) => { + for (const dx of [0, 1]) { + const x = (col + dx) * EMOTE_FRAME; + const y = row * EMOTE_FRAME; + img.rect(x + 2, y + 1, 12, 13, ink); + img.rect(x + 3, y + 2, 10, 11, white); + img.rect(x + 6, y + 5, 4, 5, hex(marks[i % marks.length])); + } + }); + const [tx, ty] = EMOTE_TAIL; + img.rect(tx * EMOTE_FRAME + 7, ty * EMOTE_FRAME + 2, 2, 2, white); + img.rect(tx * EMOTE_FRAME + 6, ty * EMOTE_FRAME + 6, 2, 2, white); + return img.toPng(); +} diff --git a/packages/kai-assets/src/build/placeholders.ts b/packages/kai-assets/src/build/placeholders.ts new file mode 100644 index 0000000..44296cf --- /dev/null +++ b/packages/kai-assets/src/build/placeholders.ts @@ -0,0 +1,83 @@ +// Stand-ins for licensed art, drawn in code, so a fresh clone builds and plays: characters +// and portraits in the right frame layout, and animated sprite strips. +import type { CharacterRecipe } from "@datagutt/kai/schema/engine"; +import { + ANIMS, + DIRECTIONS, + FRAME_HEIGHT, + FRAME_WIDTH, + PORTRAIT_ANIMS, + PORTRAIT_COLUMNS, + PORTRAIT_CROP, + SHEET_COLUMNS, + SHEET_ROWS, +} from "@datagutt/kai/characters/sheet"; +import { hex, Raster } from "../raster.ts"; + +const SHEET_W = SHEET_COLUMNS * FRAME_WIDTH; +const SHEET_H = SHEET_ROWS * FRAME_HEIGHT; +const PORTRAIT_ROWS = Object.keys(PORTRAIT_ANIMS).length; +const P = PORTRAIT_CROP.size; +const INK = hex("1b1b24"); + +const lighter = (rgba: number[]) => [...rgba.slice(0, 3).map((c) => Math.min(255, c + 70)), 255]; + +/** A stand-in figure with the same frame layout: body colour, lighter head, eyes that face. */ +export function placeholderCharacter(recipe: Pick): Promise { + const img = new Raster(SHEET_W, SHEET_H); + const body = hex(recipe.placeholder); + const head = lighter(body); + for (const spec of Object.values(ANIMS)) { + DIRECTIONS.forEach((dir, d) => { + for (let f = 0; f < spec.framesPerDirection; f++) { + const ox = (d * spec.framesPerDirection + f) * FRAME_WIDTH; + const oy = spec.row * FRAME_HEIGHT; + const bob = spec === ANIMS.walk ? f % 2 : 0; + img.rect(ox + 3, oy + 12 + bob, 10, 9, INK); + img.rect(ox + 4, oy + 13 + bob, 8, 7, head); + img.rect(ox + 4, oy + 21 + bob, 8, 7, body); + const step = spec === ANIMS.walk ? (f % 3) - 1 : 0; + img.rect(ox + 5 + step, oy + 28, 2, 3, INK); + img.rect(ox + 9 - step, oy + 28, 2, 3, INK); + const eyes = { right: [9, 11], left: [4, 6], down: [5, 9], up: null }[dir]; + if (eyes) for (const ex of eyes) img.rect(ox + ex, oy + 16 + bob, 1, 2, INK); + } + }); + } + return img.toPng(); +} + +/** A stand-in face: talking opens and closes the mouth, nodding bobs, shaking sways. */ +export function placeholderPortrait(recipe: Pick): Promise { + const img = new Raster(PORTRAIT_COLUMNS * P, PORTRAIT_ROWS * P); + const skin = hex(recipe.placeholder); + const light = lighter(skin); + for (let row = 0; row < PORTRAIT_ROWS; row++) { + for (let col = 0; col < PORTRAIT_COLUMNS; col++) { + const dx = row === 2 ? [0, -1, -2, -1, 0, 1, 2, 1, 0, 0][col] : 0; + const dy = row === 1 ? [0, 1, 2, 1, 0, 1, 2, 1, 0, 0][col] : 0; + const ox = col * P + 5 + dx; + const oy = row * P + 4 + dy; + img.rect(ox, oy, 15, 16, INK); + img.rect(ox + 1, oy + 1, 13, 14, light); + img.rect(ox + 1, oy + 1, 13, 4, skin); + img.rect(ox + 4, oy + 7, 1, 2, INK); + img.rect(ox + 10, oy + 7, 1, 2, INK); + const open = row === 0 && col % 2 === 1; + img.rect(ox + 6, oy + 11, 3, open ? 2 : 1, INK); + } + } + return img.toPng(); +} + +/** A grey shape in the lower middle of each frame of a sprite strip, breathing. */ +export function placeholderSprite({ frameWidth, frameHeight, frames }: { frameWidth: number; frameHeight: number; frames: number }): Promise { + const img = new Raster(frameWidth * frames, frameHeight); + const width = Math.max(2, Math.round(frameWidth / 2)); + const height = Math.max(2, Math.round(frameHeight * 0.4)); + for (let f = 0; f < frames; f++) { + const inhale = f % 4 < 2 ? 1 : 0; + img.rect(f * frameWidth + Math.round((frameWidth - width) / 2), frameHeight - height - 1 + (1 - inhale), width, height - 1 + inhale, hex("8a8fa8")); + } + return img.toPng(); +} From a892ca761b31d14ad0f8d82d703125980edac1b9 Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 20:28:51 +0200 Subject: [PATCH 126/153] feat(datagutt): move the maps' words into content/mapText.json Map names and every sign and shut-door text leave the map builders; the builders place them by key. Layout stays in code. The generated maps are unchanged. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/content/mapText.json | 117 +++++++++++++++++++++ apps/datagutt/content/schema.ts | 5 + apps/datagutt/world/gen/maps/boathouse.ts | 8 +- apps/datagutt/world/gen/maps/farmhouse.ts | 8 +- apps/datagutt/world/gen/maps/gym.ts | 14 +-- apps/datagutt/world/gen/maps/house.ts | 13 ++- apps/datagutt/world/gen/maps/index.ts | 33 +++--- apps/datagutt/world/gen/maps/kiosk.ts | 14 +-- apps/datagutt/world/gen/maps/library.ts | 6 +- apps/datagutt/world/gen/maps/maps.test.ts | 9 ++ apps/datagutt/world/gen/maps/mountain.ts | 6 +- apps/datagutt/world/gen/maps/office.ts | 14 +-- apps/datagutt/world/gen/maps/overworld.ts | 36 ++++--- apps/datagutt/world/gen/maps/postOffice.ts | 8 +- apps/datagutt/world/gen/maps/radioHut.ts | 10 +- apps/datagutt/world/gen/maps/townHall.ts | 15 +-- apps/datagutt/world/gen/maps/youthClub.ts | 6 +- apps/datagutt/world/gen/text.ts | 18 ++++ docs/kai/PLAN.md | 8 +- 19 files changed, 266 insertions(+), 82 deletions(-) create mode 100644 apps/datagutt/content/mapText.json create mode 100644 apps/datagutt/world/gen/text.ts diff --git a/apps/datagutt/content/mapText.json b/apps/datagutt/content/mapText.json new file mode 100644 index 0000000..91d7767 --- /dev/null +++ b/apps/datagutt/content/mapText.json @@ -0,0 +1,117 @@ +{ + "$schema": "../.kai/schema/mapText.json", + "names": { + "town": "Fjord Town", + "house": "datagutt's house", + "house-up": "datagutt's house, upstairs", + "boathouse": "Boathouse studio", + "library": "Library", + "kiosk": "Kiosk", + "post-office": "Post office", + "gym": "Gym", + "farmhouse": "Farmhouse", + "radio-hut": "Radio hut", + "town-hall": "Town hall", + "town-hall-basement": "Town hall basement", + "office": "Nettbureau office", + "youth-club": "Youth club", + "mountain": "Mountain trail" + }, + "signs": { + "town": { + "trail-closed": "* TRAIL CLOSED. Rockfall on the mountain path. By order of the council, until further notice. Someone has added in pencil: \"ask Arne\".", + "villa-orange-closed": "Nobody answers. Through the window: a very tidy living room, and a cat judging you.", + "villa-blue-closed": "Nobody's home. A note says the neighbours are out on the fjord.", + "villa-purple-closed": "You knock. Somewhere inside, a radio is playing. Nobody comes.", + "villa-brown-closed": "Locked. There's a pair of skis leaning by the door, waiting for winter.", + "welcome": "Welcome to Fjord Town. Population: small, but opinionated.", + "datagutt-house": "datagutt's house. Thomas lives here. The door is open, and so is the fridge (energy drinks only).", + "library": "Fjord Town Library. Every book on the shelves is one of datagutt's repositories. Shh.", + "youth-club": "Fjord Town Youth Club. Open every evening. Arcade inside, pool table, the good sofa.", + "town-hall": "Town Hall. The basement hums. That's the servers, not the ghosts. Probably.", + "farm": "Ola's farm. The crops grow when datagutt pushes code. Nobody knows how.", + "kiosk": "Kiosk. Snacks, newspapers and Randi's opinions, all free.", + "post-office": "Post Office. Letters for datagutt are delivered by Liv, rain or shine.", + "boathouse-studio": "Boathouse Studio. When the red light is on, Sunniva is live. Keep it down.", + "office": "Nettbureau. datagutt's day job, since 2021.", + "gym": "Treningsstudio. Tor's gym in the old log cabin. Lift the whole stack.", + "radio-tower": "Radio Tower. Kjell keeps it running, so the streams stay live." + }, + "house": { + "scale-model": "* A scale model of this very house. There's a tiny you inside it, reading a tiny sign. Best not to think about it.", + "stove": "* The stove. Spotless. It has never been used, and it knows it.", + "tv": "* The TV is paused on a speedrun. Someone is about to clip through a wall." + }, + "house-up": { + "computers": "* Three computers. Several terminals are open. Something is compiling.", + "mini-fridge": "* A mini-fridge. Energy drinks, top to bottom. Also one lemon, looking lonely." + }, + "boathouse": { + "stream-preview": "* The stream preview. Chat is typing faster than anyone can read.", + "on-air-lamp": "* The ON AIR lamp. It is always on. Sunniva says that's the point.", + "camera": "* The camera's little red light blinks at you. You are, briefly, content." + }, + "library": { + "featured-shelf": "* The featured shelf. Every book on it is one of Thomas's projects, free for anyone to borrow.", + "globe": "* A globe. Someone has drawn a tiny circle around Norway." + }, + "kiosk": { + "drinks-cooler": "* The drinks cooler. Brus, juice, and water nobody buys.", + "second-fridge": "* The second fridge. It is entirely energy drinks. There's a label on it: THOMAS.", + "skolebrod": "* Skolebrød and cinnamon buns. Randi says the waffles are \"coming\".", + "pick-and-mix": "* A case of pick-and-mix. The sour ones are already gone.", + "ice-cream-freezer": "* The ice cream freezer. It says Kroneis. It has always said Kroneis.", + "tip-jar": "* A tip jar. The label says: Donate.chat accepted. Also Vipps." + }, + "post-office": { + "noticeboard": "* The noticeboard: GitHub, Bluesky, LinkedIn and more. Liv can point you at any of them.", + "postbox": "* The red box. For letters to Thomas. Liv has his address.", + "writing-desk": "* A writing desk, a pen on a string, and a stack of postcards with the ferry on them." + }, + "gym": { + "languages-rack": "* The languages rack. Mostly TypeScript-weight dumbbells.", + "frameworks-rack": "* The frameworks rack. Every bar is loaded differently.", + "treadmill": "* The treadmill: cloud and DevOps. It never stops running.", + "warm-up-weights": "* Warm-up weights: everyday tools. Nobody skips them here.", + "heavy-rack": "* The heavy rack: video and streaming. The bar is bending slightly.", + "front-desk": "* The front desk. Memberships, payments, a bowl of protein bars." + }, + "farmhouse": { + "harvest-ledger": "* Ola's harvest ledger. Tally marks, one page per year. The newest page is the busiest.", + "oven": "* The oven is still warm. It smells of lefse.", + "crates": "* Crates of potatoes, carrots and one suspicious cabbage." + }, + "radio-hut": { + "signal-meter": "* Six signal meters, one per connection. All of them at five bars. Kjell looks very pleased.", + "transceiver": "* A transceiver, tuned to the tower. It hums.", + "spare-transceiver": "* A spare transceiver. Labelled: DO NOT UNPLUG (KJELL).", + "old-radio": "* A very old PC. It runs the stream dashboard, somehow." + }, + "town-hall": { + "mayors": "* A past mayor. She is smiling. Nobody on the council knows why.", + "lectern": "* The lectern. A note taped to it says: KEEP IT SHORT." + }, + "town-hall-basement": { + "escalator": "* An escalator. The council spent the IT budget for 2019 on it. It only goes up.", + "printers": "* The printers. One of them says PC LOAD LETTER. Nobody knows what that means.", + "tickets": "* The ticket board. OPEN: 4012. Under it, in marker: \"most of them are the printer\".", + "server": "* A server with a name tag: FIDO. A dog biscuit sits on top of it. Best not to touch." + }, + "office": { + "whiteboard-loans": "* A whiteboard: \"Loan offers compared this week\". The line goes up and to the right. Someone drew a rocket at the end.", + "whiteboard-diagram": "* A whiteboard full of boxes and arrows. In the corner: \"do NOT erase (Thomas)\".", + "poster": "* A neon-lit poster of smiling pixel faces. Across the bottom: \"Ship it. Then ship it again.\"", + "pie-chart": "* A pie chart. Most of the pie is labelled \"refinancing\". A small slice says \"cake\".", + "vending-machine": "* The vending machine sells one thing: energy drinks. Thomas asked for that.", + "money": "* A pile of money on the floor. Nobody seems worried." + }, + "youth-club": { + "consoles": "* Three consoles and a tangle of controllers. Two of them work. Nobody knows which two.", + "pool-table": "* The pool table. The eight ball went missing; someone plays with a potato." + }, + "mountain": { + "hytte-door": "* Locked. A note on the door: \"Gone fishing. Back when the next quest is ready. - T.\"", + "lookout": "The whole of Fjord Town fits in one look from up here: the harbour, the square, the radio tower blinking on its hill. The fjord goes on further than you can see." + } + } +} diff --git a/apps/datagutt/content/schema.ts b/apps/datagutt/content/schema.ts index ae3a3f2..8d813e1 100644 --- a/apps/datagutt/content/schema.ts +++ b/apps/datagutt/content/schema.ts @@ -77,6 +77,11 @@ export const collections = { ), }), ), + /** + * The words in the maps: each map's name, and the texts its builder places on signs + * and shut doors, by map id and key (world/gen/text.ts). + */ + mapText: json(z.object({ names: z.record(text, text), signs: z.record(text, z.record(text, text)) })), /** Jobs, one Markdown file each. */ experience: markdown(z.object({ id: text, company: text, role: text, period: text, description: text, tech: z.array(text) }), { body: "description", diff --git a/apps/datagutt/world/gen/maps/boathouse.ts b/apps/datagutt/world/gen/maps/boathouse.ts index 49ded0e..bc944a6 100644 --- a/apps/datagutt/world/gen/maps/boathouse.ts +++ b/apps/datagutt/world/gen/maps/boathouse.ts @@ -10,8 +10,10 @@ import { glow, GLOWS } from "@datagutt/kai-limezu/lighting"; import { FLIP, MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; import { Region } from "@datagutt/kai-worldgen/layout"; +import { mapText } from "../text.ts"; export function boathouse(): MapCanvas { + const say = mapText("boathouse"); const W = 20; const H = 13; const c = new MapCanvas(W, H); @@ -53,8 +55,8 @@ export function boathouse(): MapCanvas { exitDoor(c, r, 10, { toMap: "town", toSpawn: "boathouse_door" }); const sunniva = NPCS.find((n) => n.id === "streamer")!; c.add({ type: "npc", id: "streamer", character: "streamer", x: 15, y: 6, facing: "down", name: sunniva.name, dialogue: "streamer" }); - c.add({ type: "sign", x: 9, y: 6, text: "* The stream preview. Chat is typing faster than anyone can read." }); - c.add({ type: "sign", x: 7, y: 2, text: "* The ON AIR lamp. It is always on. Sunniva says that's the point." }); - c.add({ type: "sign", x: 14, y: 9, text: "* The camera's little red light blinks at you. You are, briefly, content." }); + c.add({ type: "sign", x: 9, y: 6, text: say("stream-preview") }); + c.add({ type: "sign", x: 7, y: 2, text: say("on-air-lamp") }); + c.add({ type: "sign", x: 14, y: 9, text: say("camera") }); return c; } diff --git a/apps/datagutt/world/gen/maps/farmhouse.ts b/apps/datagutt/world/gen/maps/farmhouse.ts index a7026a9..32ec341 100644 --- a/apps/datagutt/world/gen/maps/farmhouse.ts +++ b/apps/datagutt/world/gen/maps/farmhouse.ts @@ -5,8 +5,10 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder, windowLight } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function farmhouse(): MapCanvas { + const say = mapText("farmhouse"); const c = new MapCanvas(18, 11); const r = { x: 2, y: 1, w: 14, h: 8 }; room(c, r, { wall: WALLS.gingham, floor: FLOORS.palePlanks }); @@ -28,8 +30,8 @@ export function farmhouse(): MapCanvas { c.stamp(F.crates, 13, 6); exitDoor(c, r, 9, { toMap: "town", toSpawn: "farmhouse_door" }); - c.add({ type: "sign", x: 3, y: 7, text: "* Ola's harvest ledger. Tally marks, one page per year. The newest page is the busiest." }); - c.add({ type: "sign", x: 11, y: 3, text: "* The oven is still warm. It smells of lefse." }); - c.add({ type: "sign", x: 13, y: 7, text: "* Crates of potatoes, carrots and one suspicious cabbage." }); + c.add({ type: "sign", x: 3, y: 7, text: say("harvest-ledger") }); + c.add({ type: "sign", x: 11, y: 3, text: say("oven") }); + c.add({ type: "sign", x: 13, y: 7, text: say("crates") }); return c; } diff --git a/apps/datagutt/world/gen/maps/gym.ts b/apps/datagutt/world/gen/maps/gym.ts index ae68e1a..d04bb96 100644 --- a/apps/datagutt/world/gen/maps/gym.ts +++ b/apps/datagutt/world/gen/maps/gym.ts @@ -9,8 +9,10 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function gym(): MapCanvas { + const say = mapText("gym"); const c = new MapCanvas(20, 13); const r = { x: 2, y: 1, w: 16, h: 10 }; room(c, r, { wall: WALLS.mint, floor: FLOORS.rubberGrid }); @@ -35,11 +37,11 @@ export function gym(): MapCanvas { exitDoor(c, r, 10, { toMap: "town", toSpawn: "gym_door" }); const tor = NPCS.find((n) => n.id === "trainer")!; c.add({ type: "npc", id: "trainer", character: "trainer", x: 9, y: 7, facing: "down", name: tor.name, dialogue: "trainer" }); - c.add({ type: "sign", x: 3, y: 3, text: "* The languages rack. Mostly TypeScript-weight dumbbells." }); - c.add({ type: "sign", x: 7, y: 5, text: "* The frameworks rack. Every bar is loaded differently." }); - c.add({ type: "sign", x: 16, y: 7, text: "* The treadmill: cloud and DevOps. It never stops running." }); - c.add({ type: "sign", x: 4, y: 7, text: "* Warm-up weights: everyday tools. Nobody skips them here." }); - c.add({ type: "sign", x: 12, y: 7, text: "* The heavy rack: video and streaming. The bar is bending slightly." }); - c.add({ type: "sign", x: 6, y: 10, text: "* The front desk. Memberships, payments, a bowl of protein bars." }); + c.add({ type: "sign", x: 3, y: 3, text: say("languages-rack") }); + c.add({ type: "sign", x: 7, y: 5, text: say("frameworks-rack") }); + c.add({ type: "sign", x: 16, y: 7, text: say("treadmill") }); + c.add({ type: "sign", x: 4, y: 7, text: say("warm-up-weights") }); + c.add({ type: "sign", x: 12, y: 7, text: say("heavy-rack") }); + c.add({ type: "sign", x: 6, y: 10, text: say("front-desk") }); return c; } diff --git a/apps/datagutt/world/gen/maps/house.ts b/apps/datagutt/world/gen/maps/house.ts index 85ec0bd..1ede1c7 100644 --- a/apps/datagutt/world/gen/maps/house.ts +++ b/apps/datagutt/world/gen/maps/house.ts @@ -9,8 +9,10 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder, windowLight } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function house(): MapCanvas { + const say = mapText("house"); const c = new MapCanvas(20, 14); const r = { x: 2, y: 1, w: 16, h: 11 }; const floor = room(c, r, { wall: WALLS.woodPanel, floor: FLOORS.oak }); @@ -44,13 +46,14 @@ export function house(): MapCanvas { c.add({ type: "spawn", id: "stairs", x: 16, y: 5, facing: "down" }); exitDoor(c, r, 10, { toMap: "town", toSpawn: "house_door" }); - c.add({ type: "sign", x: 15, y: 9, text: "* A scale model of this very house. There's a tiny you inside it, reading a tiny sign. Best not to think about it." }); - c.add({ type: "sign", x: 2, y: 3, text: "* The stove. Spotless. It has never been used, and it knows it." }); - c.add({ type: "sign", x: 9, y: 3, text: "* The TV is paused on a speedrun. Someone is about to clip through a wall." }); + c.add({ type: "sign", x: 15, y: 9, text: say("scale-model") }); + c.add({ type: "sign", x: 2, y: 3, text: say("stove") }); + c.add({ type: "sign", x: 9, y: 3, text: say("tv") }); return c; } export function houseUpstairs(): MapCanvas { + const say = mapText("house-up"); const c = new MapCanvas(20, 13); const r = { x: 2, y: 1, w: 16, h: 10 }; room(c, r, { wall: WALLS.blueGrey, floor: FLOORS.darkWood }); @@ -84,7 +87,7 @@ export function houseUpstairs(): MapCanvas { c.add({ type: "spot", id: "datagutt-bed", x: 3, y: 7, facing: "down" }); // The left computer idles on a Game of Life screensaver (docs/game/PLAN.md B2). c.add({ type: "arcade", x: 5, y: 4, game: "screensaver" }); - c.add({ type: "sign", x: 7, y: 4, text: "* Three computers. Several terminals are open. Something is compiling." }); - c.add({ type: "sign", x: 9, y: 3, text: "* A mini-fridge. Energy drinks, top to bottom. Also one lemon, looking lonely." }); + c.add({ type: "sign", x: 7, y: 4, text: say("computers") }); + c.add({ type: "sign", x: 9, y: 3, text: say("mini-fridge") }); return c; } diff --git a/apps/datagutt/world/gen/maps/index.ts b/apps/datagutt/world/gen/maps/index.ts index 1fb3847..4cfcf1e 100644 --- a/apps/datagutt/world/gen/maps/index.ts +++ b/apps/datagutt/world/gen/maps/index.ts @@ -1,5 +1,6 @@ // Every map the generator owns. `bun run world:gen` writes each to world/maps/.tmj. import type { GeneratedMap } from "@datagutt/kai-worldgen/maps"; +import { content } from "../../../content/index.ts"; import { boathouse } from "./boathouse.ts"; import { farmhouse } from "./farmhouse.ts"; import { house, houseUpstairs } from "./house.ts"; @@ -16,20 +17,22 @@ import { overworld } from "./overworld.ts"; export type { GeneratedMap }; +const NAMES = content.mapText.names; + export const GENERATED_MAPS: GeneratedMap[] = [ - { id: "town", properties: { name: "Fjord Town" }, outdoor: true, build: overworld }, - { id: "house", properties: { name: "datagutt's house" }, build: house }, - { id: "house-up", properties: { name: "datagutt's house, upstairs" }, build: houseUpstairs }, - { id: "boathouse", properties: { name: "Boathouse studio" }, build: boathouse }, - { id: "library", properties: { name: "Library" }, build: library }, - { id: "kiosk", properties: { name: "Kiosk" }, build: kiosk }, - { id: "post-office", properties: { name: "Post office" }, build: postOffice }, - { id: "gym", properties: { name: "Gym" }, build: gym }, - { id: "farmhouse", properties: { name: "Farmhouse" }, build: farmhouse }, - { id: "radio-hut", properties: { name: "Radio hut" }, build: radioHut }, - { id: "town-hall", properties: { name: "Town hall" }, build: townHall }, - { id: "town-hall-basement", properties: { name: "Town hall basement" }, build: townHallBasement }, - { id: "office", properties: { name: "Nettbureau office" }, build: office }, - { id: "youth-club", properties: { name: "Youth club" }, build: youthClub }, - { id: "mountain", properties: { name: "Mountain trail" }, outdoor: true, build: mountain }, + { id: "town", properties: { name: NAMES["town"] }, outdoor: true, build: overworld }, + { id: "house", properties: { name: NAMES["house"] }, build: house }, + { id: "house-up", properties: { name: NAMES["house-up"] }, build: houseUpstairs }, + { id: "boathouse", properties: { name: NAMES["boathouse"] }, build: boathouse }, + { id: "library", properties: { name: NAMES["library"] }, build: library }, + { id: "kiosk", properties: { name: NAMES["kiosk"] }, build: kiosk }, + { id: "post-office", properties: { name: NAMES["post-office"] }, build: postOffice }, + { id: "gym", properties: { name: NAMES["gym"] }, build: gym }, + { id: "farmhouse", properties: { name: NAMES["farmhouse"] }, build: farmhouse }, + { id: "radio-hut", properties: { name: NAMES["radio-hut"] }, build: radioHut }, + { id: "town-hall", properties: { name: NAMES["town-hall"] }, build: townHall }, + { id: "town-hall-basement", properties: { name: NAMES["town-hall-basement"] }, build: townHallBasement }, + { id: "office", properties: { name: NAMES["office"] }, build: office }, + { id: "youth-club", properties: { name: NAMES["youth-club"] }, build: youthClub }, + { id: "mountain", properties: { name: NAMES["mountain"] }, outdoor: true, build: mountain }, ]; diff --git a/apps/datagutt/world/gen/maps/kiosk.ts b/apps/datagutt/world/gen/maps/kiosk.ts index 8a631ed..12c21a7 100644 --- a/apps/datagutt/world/gen/maps/kiosk.ts +++ b/apps/datagutt/world/gen/maps/kiosk.ts @@ -8,8 +8,10 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function kiosk(): MapCanvas { + const say = mapText("kiosk"); const c = new MapCanvas(18, 12); const r = { x: 2, y: 1, w: 14, h: 9 }; room(c, r, { wall: WALLS.butter, floor: FLOORS.checker }); @@ -32,11 +34,11 @@ export function kiosk(): MapCanvas { exitDoor(c, r, 8, { toMap: "town", toSpawn: "kiosk_door" }); const randi = NPCS.find((n) => n.id === "shopkeeper")!; c.add({ type: "npc", id: "shopkeeper", character: "shopkeeper", x: 12, y: 4, facing: "down", name: randi.name, dialogue: "shopkeeper" }); - c.add({ type: "sign", x: 3, y: 3, text: "* The drinks cooler. Brus, juice, and water nobody buys." }); - c.add({ type: "sign", x: 5, y: 3, text: "* The second fridge. It is entirely energy drinks. There's a label on it: THOMAS." }); - c.add({ type: "sign", x: 8, y: 2, text: "* Skolebrød and cinnamon buns. Randi says the waffles are \"coming\"." }); - c.add({ type: "sign", x: 5, y: 7, text: "* A case of pick-and-mix. The sour ones are already gone." }); - c.add({ type: "sign", x: 10, y: 4, text: "* The ice cream freezer. It says Kroneis. It has always said Kroneis." }); - c.add({ type: "sign", x: 13, y: 6, text: "* A tip jar. The label says: Donate.chat accepted. Also Vipps." }); + c.add({ type: "sign", x: 3, y: 3, text: say("drinks-cooler") }); + c.add({ type: "sign", x: 5, y: 3, text: say("second-fridge") }); + c.add({ type: "sign", x: 8, y: 2, text: say("skolebrod") }); + c.add({ type: "sign", x: 5, y: 7, text: say("pick-and-mix") }); + c.add({ type: "sign", x: 10, y: 4, text: say("ice-cream-freezer") }); + c.add({ type: "sign", x: 13, y: 6, text: say("tip-jar") }); return c; } diff --git a/apps/datagutt/world/gen/maps/library.ts b/apps/datagutt/world/gen/maps/library.ts index e1ae0ff..d74a17b 100644 --- a/apps/datagutt/world/gen/maps/library.ts +++ b/apps/datagutt/world/gen/maps/library.ts @@ -7,8 +7,10 @@ import { bookcase, FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function library(): MapCanvas { + const say = mapText("library"); const c = new MapCanvas(26, 14); const r = { x: 2, y: 1, w: 22, h: 11 }; room(c, r, { wall: WALLS.teal, floor: FLOORS.herringbone }); @@ -46,9 +48,9 @@ export function library(): MapCanvas { type: "sign", x: 12, y: 3, - text: "* The featured shelf. Every book on it is one of Thomas's projects, free for anyone to borrow.", + text: say("featured-shelf"), dialogue: "featured_shelf", }); - c.add({ type: "sign", x: 22, y: 4, text: "* A globe. Someone has drawn a tiny circle around Norway." }); + c.add({ type: "sign", x: 22, y: 4, text: say("globe") }); return c; } diff --git a/apps/datagutt/world/gen/maps/maps.test.ts b/apps/datagutt/world/gen/maps/maps.test.ts index a5057ed..a90dde5 100644 --- a/apps/datagutt/world/gen/maps/maps.test.ts +++ b/apps/datagutt/world/gen/maps/maps.test.ts @@ -3,6 +3,8 @@ import { canvasToTmj, formatTmj } from "@datagutt/kai-worldgen/tmj"; import { validateMap } from "@datagutt/kai-worldgen/validate"; import { describe, expect, it } from "vitest"; import { isArcadeId } from "../../../game/arcade/ids"; +import { content } from "../../../content/index"; +import { usedMapText } from "../text"; import { GENERATED_MAPS } from "./index"; describe("generated maps", () => { @@ -24,4 +26,11 @@ describe("generated maps", () => { for (const obj of map.build().objects) if (obj.type === "arcade") expect(isArcadeId(obj.game), `${map.id}: ${obj.game}`).toBe(true); } }); + + it("use every text in content/mapText.json, and name every map", () => { + for (const map of GENERATED_MAPS) map.build(); + const all = Object.entries(content.mapText.signs).flatMap(([map, texts]) => Object.keys(texts).map((key) => `${map}.${key}`)); + expect(all.filter((key) => !usedMapText().has(key))).toEqual([]); + for (const map of GENERATED_MAPS) expect(map.properties?.name, map.id).toBeTruthy(); + }); }); diff --git a/apps/datagutt/world/gen/maps/mountain.ts b/apps/datagutt/world/gen/maps/mountain.ts index a8aa323..f338094 100644 --- a/apps/datagutt/world/gen/maps/mountain.ts +++ b/apps/datagutt/world/gen/maps/mountain.ts @@ -7,11 +7,13 @@ import { PREFABS } from "../prefabs.ts"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { building, forest, meadow, sign } from "@datagutt/kai-limezu/features"; import { Region } from "@datagutt/kai-worldgen/layout"; +import { mapText } from "../text.ts"; const W = 40; const H = 30; export function mountain(): MapCanvas { + const say = mapText("mountain"); const c = new MapCanvas(W, H); c.fill("ground", (x, y) => variant(GRASS, x, y)); @@ -27,12 +29,12 @@ export function mountain(): MapCanvas { type: "sign", x: door.x, y: door.y, - text: "* Locked. A note on the door: \"Gone fishing. Back when the next quest is ready. - T.\"", + text: say("hytte-door"), }); // The lookout: a bench facing out over the fjord, far below. c.stamp(PREFABS.bench, 32, 18); - sign(c, 34, 19, "The whole of Fjord Town fits in one look from up here: the harbour, the square, the radio tower blinking on its hill. The fjord goes on further than you can see.", "signpost"); + sign(c, 34, 19, say("lookout"), "signpost"); // Rocks from the old rockfall, pushed off the path. for (const [kind, x, y] of [ diff --git a/apps/datagutt/world/gen/maps/office.ts b/apps/datagutt/world/gen/maps/office.ts index 41cef53..ce1ae51 100644 --- a/apps/datagutt/world/gen/maps/office.ts +++ b/apps/datagutt/world/gen/maps/office.ts @@ -14,6 +14,7 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; import { MapCanvas, type Prefab } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, floorPatch, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; /** * One desk, LimeZu style: the desk top, a computer set laid on it, and a chair pulled up @@ -37,6 +38,7 @@ const extra = (id: string, name: string, x: number, y: number, facing: "up" | "d }); export function office(): MapCanvas { + const say = mapText("office"); const c = new MapCanvas(42, 29); const r = { x: 2, y: 1, w: 38, h: 26 }; room(c, r, { wall: WALLS.officeWhite, floor: FLOORS.carpetGrey }); @@ -111,11 +113,11 @@ export function office(): MapCanvas { c.add(extra("officeGrowth", "Nora", 25, 25, "up")); c.add(extra("officeFinance", "Aisha", 30, 25, "left")); - c.add({ type: "sign", x: 4, y: 2, text: "* A whiteboard: \"Loan offers compared this week\". The line goes up and to the right. Someone drew a rocket at the end." }); - c.add({ type: "sign", x: 7, y: 2, text: "* A whiteboard full of boxes and arrows. In the corner: \"do NOT erase (Thomas)\"." }); - c.add({ type: "sign", x: 19, y: 2, text: "* A neon-lit poster of smiling pixel faces. Across the bottom: \"Ship it. Then ship it again.\"" }); - c.add({ type: "sign", x: 23, y: 2, text: "* A pie chart. Most of the pie is labelled \"refinancing\". A small slice says \"cake\"." }); - c.add({ type: "sign", x: 34, y: 3, text: "* The vending machine sells one thing: energy drinks. Thomas asked for that." }); - c.add({ type: "sign", x: 29, y: 25, text: "* A pile of money on the floor. Nobody seems worried." }); + c.add({ type: "sign", x: 4, y: 2, text: say("whiteboard-loans") }); + c.add({ type: "sign", x: 7, y: 2, text: say("whiteboard-diagram") }); + c.add({ type: "sign", x: 19, y: 2, text: say("poster") }); + c.add({ type: "sign", x: 23, y: 2, text: say("pie-chart") }); + c.add({ type: "sign", x: 34, y: 3, text: say("vending-machine") }); + c.add({ type: "sign", x: 29, y: 25, text: say("money") }); return c; } diff --git a/apps/datagutt/world/gen/maps/overworld.ts b/apps/datagutt/world/gen/maps/overworld.ts index 6c5670c..2e1ff3b 100644 --- a/apps/datagutt/world/gen/maps/overworld.ts +++ b/apps/datagutt/world/gen/maps/overworld.ts @@ -13,6 +13,7 @@ import { variant } from "@datagutt/kai-worldgen/autotile"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { building, fence, forest, meadow, pier, plateau, sign } from "@datagutt/kai-limezu/features"; import { Region, wobble } from "@datagutt/kai-worldgen/layout"; +import { mapText } from "../text.ts"; export const W = 96; export const H = 76; @@ -24,6 +25,7 @@ function npc(id: string, x: number, y: number, facing: Facing): MapObject { } export function overworld(): MapCanvas { + const say = mapText("town"); const c = new MapCanvas(W, H); c.fill("ground", (x, y) => variant(GRASS, x, y)); @@ -93,7 +95,7 @@ export function overworld(): MapCanvas { w: 3, h: 6, unlock: "passport", - text: "* TRAIL CLOSED. Rockfall on the mountain path. By order of the council, until further notice. Someone has added in pencil: \"ask Arne\".", + text: say("trail-closed"), }); c.add({ type: "door", x: 61, y: 0, toMap: "mountain", toSpawn: "trailhead", unlock: "passport" }); // A bench at the hill's edge, binoculars left on it: the stars at night (B2). @@ -102,16 +104,16 @@ export function overworld(): MapCanvas { // Closed until its interior is built (it waits for LimeZu's Modern Office pack). building(c, PREFABS.office, 69, 22, { link: { toMap: "office", toSpawn: "entrance" } }); const gymDoor = building(c, PREFABS.logCabin, 79, 28, { link: { toMap: "gym", toSpawn: "entrance" } }); - building(c, PREFABS.villaOrange, 6, 29, { closed: "Nobody answers. Through the window: a very tidy living room, and a cat judging you." }); + building(c, PREFABS.villaOrange, 6, 29, { closed: say("villa-orange-closed") }); const home = building(c, PREFABS.homeVilla, 16, 29, { link: { toMap: "house", toSpawn: "entrance" } }); - building(c, PREFABS.villaBlue, 26, 29, { closed: "Nobody's home. A note says the neighbours are out on the fjord." }); + building(c, PREFABS.villaBlue, 26, 29, { closed: say("villa-blue-closed") }); const kioskDoor = building(c, PREFABS.kiosk, 57, 30, { link: { toMap: "kiosk", toSpawn: "entrance" } }); const postDoor = building(c, PREFABS.postOffice, 37, 44, { link: { toMap: "post-office", toSpawn: "entrance" } }); const boathouseDoor = building(c, PREFABS.boathouse, 7, 47, { link: { toMap: "boathouse", toSpawn: "entrance" } }); pier(c, 22, shore[22] - 1, shore[22] + 3); const clubDoor = building(c, PREFABS.youthClub, 53, 46, { link: { toMap: "youth-club", toSpawn: "entrance" } }); - building(c, PREFABS.villaPurple, 64, 45, { closed: "You knock. Somewhere inside, a radio is playing. Nobody comes." }); - building(c, PREFABS.villaBrown, 76, 45, { closed: "Locked. There's a pair of skis leaning by the door, waiting for winter." }); + building(c, PREFABS.villaPurple, 64, 45, { closed: say("villa-purple-closed") }); + building(c, PREFABS.villaBrown, 76, 45, { closed: say("villa-brown-closed") }); // --- Harbour ------------------------------------------------------------------------- const pierTop = shore[HARBOUR_X] - 1; @@ -191,19 +193,19 @@ export function overworld(): MapCanvas { c.add({ type: "spawn", id: "trail", x: 61, y: 2, facing: "down" }); // The hidden cat (B3), sunning itself on the far end of the beach behind the last pines. c.add({ type: "cat", x: 94, y: 61 }); - sign(c, HARBOUR_X + 3, pierTop - 2, "Welcome to Fjord Town. Population: small, but opinionated."); + sign(c, HARBOUR_X + 3, pierTop - 2, say("welcome")); // A name sign in front of every building, so the town reads without talking to anyone. - sign(c, 24, 41, "datagutt's house. Thomas lives here. The door is open, and so is the fridge (energy drinks only)."); - sign(c, 33, 27, "Fjord Town Library. Every book on the shelves is one of datagutt's repositories. Shh."); - sign(c, 59, 53, "Fjord Town Youth Club. Open every evening. Arcade inside, pool table, the good sofa.", "signpost"); - sign(c, 44, 26, "Town Hall. The basement hums. That's the servers, not the ghosts. Probably."); - sign(c, 12, 28, "Ola's farm. The crops grow when datagutt pushes code. Nobody knows how."); - sign(c, 60, 35, "Kiosk. Snacks, newspapers and Randi's opinions, all free."); - sign(c, 40, 57, "Post Office. Letters for datagutt are delivered by Liv, rain or shine."); - sign(c, 10, 61, "Boathouse Studio. When the red light is on, Sunniva is live. Keep it down."); - sign(c, 69, 37, "Nettbureau. datagutt's day job, since 2021."); - sign(c, 85, 37, "Treningsstudio. Tor's gym in the old log cabin. Lift the whole stack."); - sign(c, 78, 13, "Radio Tower. Kjell keeps it running, so the streams stay live."); + sign(c, 24, 41, say("datagutt-house")); + sign(c, 33, 27, say("library")); + sign(c, 59, 53, say("youth-club"), "signpost"); + sign(c, 44, 26, say("town-hall")); + sign(c, 12, 28, say("farm")); + sign(c, 60, 35, say("kiosk")); + sign(c, 40, 57, say("post-office")); + sign(c, 10, 61, say("boathouse-studio")); + sign(c, 69, 37, say("office")); + sign(c, 85, 37, say("gym")); + sign(c, 78, 13, say("radio-tower")); c.add(npc("ferryman", HARBOUR_X + 2, pierEnd - 1, "left")); c.add(npc("farmer", 16, 12, "down")); return c; diff --git a/apps/datagutt/world/gen/maps/postOffice.ts b/apps/datagutt/world/gen/maps/postOffice.ts index c668133..269aa0d 100644 --- a/apps/datagutt/world/gen/maps/postOffice.ts +++ b/apps/datagutt/world/gen/maps/postOffice.ts @@ -6,8 +6,10 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder, windowLight } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function postOffice(): MapCanvas { + const say = mapText("post-office"); const c = new MapCanvas(20, 13); const r = { x: 2, y: 1, w: 16, h: 10 }; room(c, r, { wall: WALLS.postenRed, floor: FLOORS.paleTiles }); @@ -32,8 +34,8 @@ export function postOffice(): MapCanvas { exitDoor(c, r, 7, { toMap: "town", toSpawn: "post_office_door" }); const liv = NPCS.find((n) => n.id === "postmaster")!; c.add({ type: "npc", id: "postmaster", character: "postmaster", x: 12, y: 5, facing: "down", name: liv.name, dialogue: "postmaster" }); - c.add({ type: "sign", x: 3, y: 2, text: "* The noticeboard: GitHub, Bluesky, LinkedIn and more. Liv can point you at any of them." }); - c.add({ type: "sign", x: 5, y: 3, text: "* The red box. For letters to Thomas. Liv has his address." }); - c.add({ type: "sign", x: 3, y: 7, text: "* A writing desk, a pen on a string, and a stack of postcards with the ferry on them." }); + c.add({ type: "sign", x: 3, y: 2, text: say("noticeboard") }); + c.add({ type: "sign", x: 5, y: 3, text: say("postbox") }); + c.add({ type: "sign", x: 3, y: 7, text: say("writing-desk") }); return c; } diff --git a/apps/datagutt/world/gen/maps/radioHut.ts b/apps/datagutt/world/gen/maps/radioHut.ts index 5cc315c..63cae63 100644 --- a/apps/datagutt/world/gen/maps/radioHut.ts +++ b/apps/datagutt/world/gen/maps/radioHut.ts @@ -7,8 +7,10 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function radioHut(): MapCanvas { + const say = mapText("radio-hut"); const c = new MapCanvas(14, 10); const r = { x: 2, y: 1, w: 10, h: 7 }; room(c, r, { wall: WALLS.industrial, floor: FLOORS.concrete }); @@ -28,9 +30,9 @@ export function radioHut(): MapCanvas { exitDoor(c, r, 7, { toMap: "town", toSpawn: "radio_hut_door" }); const kjell = NPCS.find((n) => n.id === "technician")!; c.add({ type: "npc", id: "technician", character: "technician", x: 7, y: 4, facing: "down", name: kjell.name, dialogue: "technician" }); - c.add({ type: "sign", x: 7, y: 2, text: "* Six signal meters, one per connection. All of them at five bars. Kjell looks very pleased." }); - c.add({ type: "sign", x: 3, y: 3, text: "* A transceiver, tuned to the tower. It hums." }); - c.add({ type: "sign", x: 10, y: 6, text: "* A spare transceiver. Labelled: DO NOT UNPLUG (KJELL)." }); - c.add({ type: "sign", x: 3, y: 6, text: "* A very old PC. It runs the stream dashboard, somehow." }); + c.add({ type: "sign", x: 7, y: 2, text: say("signal-meter") }); + c.add({ type: "sign", x: 3, y: 3, text: say("transceiver") }); + c.add({ type: "sign", x: 10, y: 6, text: say("spare-transceiver") }); + c.add({ type: "sign", x: 3, y: 6, text: say("old-radio") }); return c; } diff --git a/apps/datagutt/world/gen/maps/townHall.ts b/apps/datagutt/world/gen/maps/townHall.ts index bb6fe54..4069e1e 100644 --- a/apps/datagutt/world/gen/maps/townHall.ts +++ b/apps/datagutt/world/gen/maps/townHall.ts @@ -9,8 +9,10 @@ import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { officeDesk } from "./office.ts"; import { exitDoor, floorPatch, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; export function townHall(): MapCanvas { + const say = mapText("town-hall"); const c = new MapCanvas(24, 14); const r = { x: 2, y: 1, w: 20, h: 11 }; room(c, r, { wall: WALLS.burgundy, floor: FLOORS.marble }); @@ -35,8 +37,8 @@ export function townHall(): MapCanvas { exitDoor(c, r, 12, { toMap: "town", toSpawn: "town_hall_door" }); c.block(19, 2, false).add({ type: "door", x: 19, y: 2, toMap: "town-hall-basement", toSpawn: "stairs" }); c.add({ type: "spawn", id: "basement", x: 19, y: 3, facing: "down" }); - c.add({ type: "sign", x: 4, y: 2, text: "* A past mayor. She is smiling. Nobody on the council knows why." }); - c.add({ type: "sign", x: 11, y: 3, text: "* The lectern. A note taped to it says: KEEP IT SHORT." }); + c.add({ type: "sign", x: 4, y: 2, text: say("mayors") }); + c.add({ type: "sign", x: 11, y: 3, text: say("lectern") }); return c; } @@ -46,6 +48,7 @@ export function townHall(): MapCanvas { * (the real job) between them, the helpdesk, and Fido, the server in the corner. */ export function townHallBasement(): MapCanvas { + const say = mapText("town-hall-basement"); const c = new MapCanvas(28, 14); const r = { x: 2, y: 1, w: 24, h: 11 }; room(c, r, { wall: WALLS.officeGrey, floor: FLOORS.carpetDark }); @@ -75,9 +78,9 @@ export function townHallBasement(): MapCanvas { const bjorn = NPCS.find((n) => n.id === "sysadmin")!; c.add({ type: "npc", id: "sysadmin", character: "sysadmin", x: 16, y: 6, facing: "down", name: bjorn.name, dialogue: "sysadmin" }); - c.add({ type: "sign", x: 4, y: 4, text: "* An escalator. The council spent the IT budget for 2019 on it. It only goes up." }); - c.add({ type: "sign", x: 17, y: 3, text: "* The printers. One of them says PC LOAD LETTER. Nobody knows what that means." }); - c.add({ type: "sign", x: 21, y: 2, text: "* The ticket board. OPEN: 4012. Under it, in marker: \"most of them are the printer\"." }); - c.add({ type: "sign", x: 25, y: 3, text: "* A server with a name tag: FIDO. A dog biscuit sits on top of it. Best not to touch." }); + c.add({ type: "sign", x: 4, y: 4, text: say("escalator") }); + c.add({ type: "sign", x: 17, y: 3, text: say("printers") }); + c.add({ type: "sign", x: 21, y: 2, text: say("tickets") }); + c.add({ type: "sign", x: 25, y: 3, text: say("server") }); return c; } diff --git a/apps/datagutt/world/gen/maps/youthClub.ts b/apps/datagutt/world/gen/maps/youthClub.ts index 3bb31ad..2ca9fac 100644 --- a/apps/datagutt/world/gen/maps/youthClub.ts +++ b/apps/datagutt/world/gen/maps/youthClub.ts @@ -7,6 +7,7 @@ import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { exitDoor, FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { mapText } from "../text.ts"; const person = (id: string, name: string, x: number, y: number, facing: "up" | "down" | "left" | "right"): MapObject => ({ type: "npc", @@ -20,6 +21,7 @@ const person = (id: string, name: string, x: number, y: number, facing: "up" | " }); export function youthClub(): MapCanvas { + const say = mapText("youth-club"); const c = new MapCanvas(20, 13); const r = { x: 2, y: 1, w: 16, h: 10 }; room(c, r, { wall: WALLS.teal, floor: FLOORS.darkWood }); @@ -54,7 +56,7 @@ export function youthClub(): MapCanvas { c.add(person("clubGamer", "Mats", 8, 4, "up")); c.add(person("clubLounger", "Ingrid", 14, 7, "up")); - c.add({ type: "sign", x: 15, y: 3, text: "* Three consoles and a tangle of controllers. Two of them work. Nobody knows which two." }); - c.add({ type: "sign", x: 6, y: 8, text: "* The pool table. The eight ball went missing; someone plays with a potato." }); + c.add({ type: "sign", x: 15, y: 3, text: say("consoles") }); + c.add({ type: "sign", x: 6, y: 8, text: say("pool-table") }); return c; } diff --git a/apps/datagutt/world/gen/text.ts b/apps/datagutt/world/gen/text.ts new file mode 100644 index 0000000..daac209 --- /dev/null +++ b/apps/datagutt/world/gen/text.ts @@ -0,0 +1,18 @@ +// The copy a map builder places (content/mapText.json): sign texts and what a shut door +// says. Positions stay in the builders; the words live with the rest of the content. +import { content } from "../../content/index.ts"; + +const used = new Set(); + +/** A map's copy by key, for that map's builder. A missing key fails the map build. */ +export function mapText(map: string): (key: string) => string { + return (key) => { + const text = content.mapText.signs[map]?.[key]; + if (text === undefined) throw new Error(`No text "${key}" for map "${map}" in content/mapText.json`); + used.add(`${map}.${key}`); + return text; + }; +} + +/** Every `map.key` a builder has asked for since the process started. */ +export const usedMapText = (): ReadonlySet => used; diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index e289548..90ea1fb 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -125,8 +125,12 @@ Goal: every hardcoded piece of content is a JSON or Markdown resource, validated (Markdown). The Journal reads the bundle. *Done when* `/journal` renders the same HTML as before. (The visible text of `/journal` is identical before and after; the `content/*.ts` modules are thin typed accessors now.) -- [ ] **K4.4** Map data: sign texts, door targets, spawn points and NPC placements move - from the map builders to JSON. *Done when* `world:check` shows no diff. +- [x] **K4.4** Map data: sign texts, door targets, spawn points and NPC placements move + from the map builders to JSON. *Done when* `world:check` shows no diff. (The copy + moved: map names and 65 sign and shut-door texts, in `content/mapText.json` by map + and key. Positions, doors, spawns and NPC placements stay in the builders: they are + layout, computed from the builders' own geometry, and as JSON they would become + loose numbers. NPC names already come from `npcs.json`. A test fails on unused copy.) - [ ] **K4.5** `content/strings.json` with the engine's English defaults. *Done when* no engine UI file has a visible string literal. From 8c002c8309cd388d3a77c8b42e22c1c39a059801 Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 20:46:39 +0200 Subject: [PATCH 127/153] test(datagutt): give the finale e2e a time budget instead of a press count Under a full parallel run the text types slower, and sixty presses ran out before the contact conversation ended. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/e2e/game.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/datagutt/e2e/game.spec.ts b/apps/datagutt/e2e/game.spec.ts index 5cb2c50..13a4a4e 100644 --- a/apps/datagutt/e2e/game.spec.ts +++ b/apps/datagutt/e2e/game.spec.ts @@ -198,11 +198,16 @@ test.describe("world", () => { }); test("the last stamp leads to the finale: the pier at night, credits, then contact", async ({ page }) => { + // Three conversations and the credits: under a full parallel run, slow machines need + // longer than the default. + test.setTimeout(150_000); // Every stamp but Thomas's, then talk to him at his desk. const others = ["boathouse", "radio-tower", "kiosk", "office", "town-hall", "gym", "library", "farm", "post-office"]; await continueAt(page, { map: "house-up", x: 7, y: 5, facing: "left" }, "coding", others); const readUntil = async (done: () => Promise) => { - for (let i = 0; i < 60 && !(await done()); i++) { + // A time budget rather than a count of presses: typing speed follows the frame rate. + const deadline = Date.now() + 40_000; + while (Date.now() < deadline && !(await done())) { const s = await state(page); // Say goodbye as soon as it's offered, then read on. const bye = s?.choices?.findIndex((c) => /see you|thanks|let him sleep/i.test(c)) ?? -1; From b67dba7b4ce957455a816f0849cdee2b171c1f4b Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 20:46:41 +0200 Subject: [PATCH 128/153] feat(kai): take ui copy from strings.json over engine defaults The engine ships its English UI copy by key; a game overrides any key in content/strings.json, and an unknown key fails the content build. The town's own one-off lines move into Ink. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/content/strings.json | 8 +++ .../game/dialogue/DialogueRunner.test.ts | 24 ++++++++ apps/datagutt/game/dialogue/ink/main.ink | 1 + apps/datagutt/game/dialogue/ink/world.ink | 15 +++++ apps/datagutt/game/progress/achievements.ts | 8 --- apps/datagutt/game/scenes/WorldScene.ts | 19 ++---- apps/datagutt/game/strings.ts | 8 +++ apps/datagutt/game/ui/ArcadeScreen.ts | 3 +- apps/datagutt/game/ui/Passport.ts | 18 +++--- apps/datagutt/game/ui/Prompt.ts | 18 +++++- apps/datagutt/game/ui/StartMenu.ts | 36 +++++------ docs/kai/HANDOFF.md | 2 - docs/kai/PLAN.md | 8 ++- packages/kai/src/schema/engine.ts | 8 +++ packages/kai/src/ui/strings.test.ts | 15 +++++ packages/kai/src/ui/strings.ts | 61 +++++++++++++++++++ 16 files changed, 196 insertions(+), 56 deletions(-) create mode 100644 apps/datagutt/content/strings.json create mode 100644 apps/datagutt/game/dialogue/ink/world.ink create mode 100644 apps/datagutt/game/strings.ts create mode 100644 packages/kai/src/ui/strings.test.ts create mode 100644 packages/kai/src/ui/strings.ts diff --git a/apps/datagutt/content/strings.json b/apps/datagutt/content/strings.json new file mode 100644 index 0000000..739d8c9 --- /dev/null +++ b/apps/datagutt/content/strings.json @@ -0,0 +1,8 @@ +{ + "$schema": "../.kai/schema/strings.json", + "menu.status": "datagutt's status", + "menu.journal": "Journal (plain text)", + "status.title": "datagutt's status", + "passport.title": "Fjord Passport", + "passport.fullHint": "Full passport. Every stamp in town! {pageHint}" +} diff --git a/apps/datagutt/game/dialogue/DialogueRunner.test.ts b/apps/datagutt/game/dialogue/DialogueRunner.test.ts index 3ccccb7..decb914 100644 --- a/apps/datagutt/game/dialogue/DialogueRunner.test.ts +++ b/apps/datagutt/game/dialogue/DialogueRunner.test.ts @@ -165,3 +165,27 @@ describe("DialogueRunner", () => { }); }); + +describe("the world's own lines", () => { + it("pets the cat and looks through the binoculars", () => { + for (const [knot, start] of [ + ["cat_petted", "* Mjau."], + ["binoculars_by_day", "* Just the town"], + ]) { + const runner = new DialogueRunner(json, ctx); + runner.start(knot); + expect(read(runner).lines[0].startsWith(start), knot).toBe(true); + } + }); + + it("says a different edge line each time, in turn", () => { + const runner = new DialogueRunner(json, ctx); + const lines = Array.from({ length: 5 }, () => { + runner.start("edge_of_world"); + return read(runner).lines[0]; + }); + expect(new Set(lines.slice(0, 4)).size).toBe(4); + expect(lines[4]).toBe(lines[0]); + expect(lines[0]).toBe("* The map ends here. Past this point it's all placeholder grass, and nobody wants that."); + }); +}); diff --git a/apps/datagutt/game/dialogue/ink/main.ink b/apps/datagutt/game/dialogue/ink/main.ink index d88614c..c3b1a67 100644 --- a/apps/datagutt/game/dialogue/ink/main.ink +++ b/apps/datagutt/game/dialogue/ink/main.ink @@ -32,5 +32,6 @@ INCLUDE farmer.ink INCLUDE postmaster.ink INCLUDE office.ink INCLUDE club.ink +INCLUDE world.ink -> END diff --git a/apps/datagutt/game/dialogue/ink/world.ink b/apps/datagutt/game/dialogue/ink/world.ink new file mode 100644 index 0000000..ed473ff --- /dev/null +++ b/apps/datagutt/game/dialogue/ink/world.ink @@ -0,0 +1,15 @@ +// Lines the world says outside a conversation (game/scenes/WorldScene.ts): petting the +// cat, the binoculars by day, and walking into the edge of the map, which says a +// different line each time, in turn. + +=== cat_petted === +\* Mjau. The cat allows it, this once. +-> END + +=== binoculars_by_day === +\* Just the town and the fjord in daylight. The stars come out after dark. +-> END + +=== edge_of_world === +\* {&The map ends here. Past this point it's all placeholder grass, and nobody wants that.|You lean on the edge of the world. It holds.|A small sign would say HERE BE NOTHING, if anyone had bothered to put one up.|Somewhere a level designer winces.} +-> END diff --git a/apps/datagutt/game/progress/achievements.ts b/apps/datagutt/game/progress/achievements.ts index 3de74ff..d506da5 100644 --- a/apps/datagutt/game/progress/achievements.ts +++ b/apps/datagutt/game/progress/achievements.ts @@ -20,11 +20,3 @@ export function achievement(id: AchievementId): Achievement { if (!found) throw new Error(`Unknown achievement "${id}"`); return found; } - -/** The lines the map's edge says when someone walks into it, in turn. */ -export const EDGE_LINES = [ - "* The map ends here. Past this point it's all placeholder grass, and nobody wants that.", - "* You lean on the edge of the world. It holds.", - "* A small sign would say HERE BE NOTHING, if anyone had bothered to put one up.", - "* Somewhere a level designer winces.", -]; diff --git a/apps/datagutt/game/scenes/WorldScene.ts b/apps/datagutt/game/scenes/WorldScene.ts index 76c0afb..78ec80c 100644 --- a/apps/datagutt/game/scenes/WorldScene.ts +++ b/apps/datagutt/game/scenes/WorldScene.ts @@ -42,7 +42,7 @@ import { ArcadeScreen } from "../ui/ArcadeScreen"; import { makeArcade } from "../arcade"; import { isArcadeId } from "../arcade/ids"; import { isUnlocked } from "../progress/unlocks"; -import { ACHIEVEMENTS, achievement, BLOCKS_TARGET, EDGE_LINES, type AchievementId } from "../progress/achievements"; +import { ACHIEVEMENTS, achievement, BLOCKS_TARGET, type AchievementId } from "../progress/achievements"; import { fieldLevels } from "../live/field"; import { spines } from "../live/shelf"; import { findPath, findPathAdjacent } from "../world/pathfind"; @@ -51,6 +51,7 @@ import { resolveWeather, weatherSound } from "../world/weather"; import type { WeatherNow } from "@datagutt/kai-live"; import { CALM_WEATHER } from "../../content/live"; import { statusLines } from "../live/datagutt"; +import { t } from "../strings"; type Door = Extract; type Sign = Extract; @@ -87,7 +88,6 @@ export class WorldScene extends Phaser.Scene { /** Where the hidden cat lies (B3). */ private cats = new Set(); /** Fourth-wall lines at the map's edge: which is next, and when it may speak again. */ - private edgeLines = 0; private edgeQuietUntil = 0; private ghosts!: GhostLayer; private wheel!: EmoteWheel; @@ -824,10 +824,7 @@ export class WorldScene extends Phaser.Scene { return; } if (this.cats.has(tileKey(p))) { - this.dialogue.say("* Mjau. The cat allows it, this once.", null, () => { - this.dialogue.close(); - this.achieve("cat"); - }); + this.playKnot("cat_petted", null, () => this.achieve("cat")); return; } const cabinet = this.cabinets.get(tileKey(p)); @@ -848,18 +845,14 @@ export class WorldScene extends Phaser.Scene { private bumpedEdge() { if (this.time.now < this.edgeQuietUntil || this.dialogue.open) return; this.edgeQuietUntil = this.time.now + 20_000; - const line = EDGE_LINES[this.edgeLines++ % EDGE_LINES.length]; - this.dialogue.say(line, null, () => { - this.dialogue.close(); - this.achieve("edge"); - }); + this.playKnot("edge_of_world", null, () => this.achieve("edge")); } /** Step up to a cabinet: its game takes the input until the player leaves (back). */ private playCabinet(cabinet: Extract) { // The binoculars only show stars once it's dark (or on the finale's night). if (cabinet.game === "stargazing" && this.dayNight.current.dark < 0.5 && !this.services.finale) { - this.dialogue.say("* Just the town and the fjord in daylight. The stars come out after dark.", null, () => this.dialogue.close()); + this.playKnot("binoculars_by_day", null, () => {}); return; } if (!isArcadeId(cabinet.game)) return; @@ -975,7 +968,7 @@ export class WorldScene extends Phaser.Scene { /** "Open github.com?" after a line with a `# link:` tag. */ private offerLink(url: string, label: string, then: () => void) { this.links.arm(url); - this.dialogue.choose([`Open ${label}`, "Not now"], (i) => { + this.dialogue.choose([t("link.open", { label }), t("link.notNow")], (i) => { if (i === 0) this.links.confirm(); else this.links.disarm(); then(); diff --git a/apps/datagutt/game/strings.ts b/apps/datagutt/game/strings.ts new file mode 100644 index 0000000..8874de8 --- /dev/null +++ b/apps/datagutt/game/strings.ts @@ -0,0 +1,8 @@ +// The UI copy: the engine's defaults with content/strings.json over them. +import { format, withDefaults, type StringKey } from "@datagutt/kai/ui/strings"; +import { content } from "../content/index.ts"; + +const STRINGS = withDefaults(content.strings); + +/** A UI text by key, with its `{name}` values filled in. */ +export const t = (key: StringKey, vars?: Record): string => (vars ? format(STRINGS[key], vars) : STRINGS[key]); diff --git a/apps/datagutt/game/ui/ArcadeScreen.ts b/apps/datagutt/game/ui/ArcadeScreen.ts index 83435bc..d41ace9 100644 --- a/apps/datagutt/game/ui/ArcadeScreen.ts +++ b/apps/datagutt/game/ui/ArcadeScreen.ts @@ -6,6 +6,7 @@ import Phaser from "phaser"; import type { ArcadeGame, ArcadeInput, Direction } from "@datagutt/kai-arcade"; import { SCREEN_H, SCREEN_W } from "@datagutt/kai-arcade"; import type { FrameInput, InputDevice } from "../input/InputController"; +import { t } from "../strings"; const DEPTH = 114_000; const TEXTURE = "arcade:screen"; @@ -63,7 +64,7 @@ export class ArcadeScreen { } if (input.device !== this.device) { this.device = input.device; - const back = input.device === "touch" ? "Tap outside: leave" : `${input.device === "gamepad" ? "B" : "Esc"}: leave`; + const back = input.device === "touch" ? t("arcade.leaveTouch") : t("arcade.leave", { button: input.device === "gamepad" ? "B" : "Esc" }); const lines = this.game.hint.replace(/\bA:/g, `${BUTTON[input.device]}:`).split(/\s{2,}/); this.hint.setText([...lines, back].map((l) => l.toUpperCase())); // The case reaches down past however many lines the hint has. diff --git a/apps/datagutt/game/ui/Passport.ts b/apps/datagutt/game/ui/Passport.ts index 8fbaf78..e5d32b8 100644 --- a/apps/datagutt/game/ui/Passport.ts +++ b/apps/datagutt/game/ui/Passport.ts @@ -3,6 +3,7 @@ import Phaser from "phaser"; import { ACHIEVEMENTS } from "../progress/achievements"; import { STAMP_PLACES } from "../progress/passport"; +import { t } from "../strings"; const FONT = "pixel"; const FRAME = "ui:frame"; @@ -46,11 +47,11 @@ export class StampToast { constructor(private readonly scene: Phaser.Scene) {} show(place: string, count: number, reducedMotion: boolean): void { - this.banner(`Stamped: ${placeName(place)} ${count}/${STAMP_PLACES.length}`, drawStamp, reducedMotion); + this.banner(t("toast.stamped", { place: placeName(place), count, total: STAMP_PLACES.length }), drawStamp, reducedMotion); } showAchievement(name: string, reducedMotion: boolean): void { - this.banner(`Achievement: ${name}`, drawStar, reducedMotion); + this.banner(t("toast.achievement", { name }), drawStar, reducedMotion); } private banner(label: string, mark: typeof drawStamp, reducedMotion: boolean): void { @@ -104,16 +105,11 @@ export class PassportPanel { const entries = page === 0 ? STAMP_PLACES.map((p) => ({ label: p.name, has: stamps.includes(p.id) })) - : ACHIEVEMENTS.map((a) => ({ label: achievements.includes(a.id) ? a.name : "???", has: achievements.includes(a.id) })); + : ACHIEVEMENTS.map((a) => ({ label: achievements.includes(a.id) ? a.name : t("passport.unknown"), has: achievements.includes(a.id) })); const rows = Math.ceil(entries.length / columns); const earned = entries.filter((e) => e.has).length; - const pageHint = "Left, right or tap: turn the page."; - const hintText = - page === 1 - ? `Things to find beyond the stamps. ${pageHint}` - : earned === STAMP_PLACES.length - ? `Full passport. Every stamp in town! ${pageHint}` - : `Talk to people to collect stamps. ${pageHint}`; + const pageHint = t("passport.pageHint"); + const hintText = t(page === 1 ? "passport.achievementsHint" : earned === STAMP_PLACES.length ? "passport.fullHint" : "passport.stampsHint", { pageHint }); const hint = scene.add.bitmapText(12, 0, FONT, hintText).setTint(FADED).setMaxWidth(w - 24); const hintHeight = Math.ceil(hint.getTextBounds().local.height); const listTop = 9 + Math.round(1.5 * lineHeight); @@ -126,7 +122,7 @@ export class PassportPanel { const parts: Phaser.GameObjects.GameObject[] = [frame(scene, w, h)]; const g = scene.add.graphics(); parts.push(g); - parts.push(scene.add.bitmapText(12, 9, FONT, page === 0 ? "Fjord Passport" : "Achievements").setTint(INK)); + parts.push(scene.add.bitmapText(12, 9, FONT, t(page === 0 ? "passport.title" : "passport.achievements")).setTint(INK)); const counter = scene.add.bitmapText(0, 9, FONT, `${earned}/${entries.length}`).setTint(STAMP_RED); counter.x = w - 12 - counter.getTextBounds().local.width; parts.push(counter); diff --git a/apps/datagutt/game/ui/Prompt.ts b/apps/datagutt/game/ui/Prompt.ts index 890c822..4b92f91 100644 --- a/apps/datagutt/game/ui/Prompt.ts +++ b/apps/datagutt/game/ui/Prompt.ts @@ -3,6 +3,8 @@ // the input the player used last. import Phaser from "phaser"; import type { InputDevice } from "../input/InputController"; +import type { StringKey } from "@datagutt/kai/ui/strings"; +import { t } from "../strings"; export type PromptAction = "Talk" | "Read" | "Enter" | "Wake" | "Play" | "Look" | "Use" | "Pet"; @@ -11,6 +13,17 @@ const PAPER = 0xf2eef7; const KEY = 0xffd27a; const DEPTH = 60_002; +const LABEL: Record = { + Talk: "prompt.talk", + Read: "prompt.read", + Enter: "prompt.enter", + Wake: "prompt.wake", + Play: "prompt.play", + Look: "prompt.look", + Use: "prompt.use", + Pet: "prompt.pet", +}; + /** The button to show for each device. */ const BUTTON: Record = { keyboard: "E", gamepad: "A", touch: "Tap" }; @@ -36,11 +49,12 @@ export class Prompt { /** Centred over (x, bottom) in world pixels. */ show(action: PromptAction, device: InputDevice, x: number, bottom: number): void { const button = BUTTON[device]; - const text = `${button} ${action}`; + const label = t(LABEL[action]); + const text = `${button} ${label}`; if (text !== this.shown) { this.shown = text; this.key.setText(button); - this.label.setText(action); + this.label.setText(label); } const kw = Math.ceil(this.key.getTextBounds().local.width) + 4; const lw = Math.ceil(this.label.getTextBounds().local.width); diff --git a/apps/datagutt/game/ui/StartMenu.ts b/apps/datagutt/game/ui/StartMenu.ts index 0f936b0..b0ab661 100644 --- a/apps/datagutt/game/ui/StartMenu.ts +++ b/apps/datagutt/game/ui/StartMenu.ts @@ -6,6 +6,7 @@ import type { Facing } from "@datagutt/kai/world/objects"; import { PassportPanel } from "./Passport"; import { credits } from "../../content/credits"; import type { EffectsSetting } from "../save/save"; +import { t } from "../strings"; const FONT = "pixel"; const FRAME = "ui:frame"; @@ -120,29 +121,30 @@ export class StartMenu { private items(): { label: string; run: () => void }[] { if (this.view === "settings") { const s = this.hooks.settings(); - const motion = s.reducedMotion === null ? "Auto" : s.reducedMotion ? "On" : "Off"; + const onOff = (on: boolean) => ({ value: t(on ? "value.on" : "value.off") }); + const motion = s.reducedMotion === null ? t("value.auto") : t(s.reducedMotion ? "value.on" : "value.off"); return [ - { label: `Sound: ${s.muted ? "Off" : "On"}`, run: () => this.hooks.changeSettings({ ...s, muted: !s.muted }) }, - { label: `Music: ${s.music ? "On" : "Off"}`, run: () => this.hooks.changeSettings({ ...s, music: !s.music }) }, - { label: `Other visitors: ${s.showVisitors ? "On" : "Off"}`, run: () => this.hooks.changeSettings({ ...s, showVisitors: !s.showVisitors }) }, + { label: t("settings.sound", onOff(!s.muted)), run: () => this.hooks.changeSettings({ ...s, muted: !s.muted }) }, + { label: t("settings.music", onOff(s.music)), run: () => this.hooks.changeSettings({ ...s, music: !s.music }) }, + { label: t("settings.visitors", onOff(s.showVisitors)), run: () => this.hooks.changeSettings({ ...s, showVisitors: !s.showVisitors }) }, { - label: `Effects: ${s.effects === "auto" ? "Auto" : s.effects === "high" ? "High" : "Low"}`, + label: t("settings.effects", { value: t(s.effects === "auto" ? "value.auto" : s.effects === "high" ? "value.high" : "value.low") }), run: () => this.hooks.changeSettings({ ...s, effects: s.effects === "auto" ? "high" : s.effects === "high" ? "low" : "auto" }), }, { - label: `Reduced motion: ${motion}`, + label: t("settings.reducedMotion", { value: motion }), run: () => this.hooks.changeSettings({ ...s, reducedMotion: s.reducedMotion === null ? true : s.reducedMotion ? false : null }), }, - { label: "Back", run: () => this.backToMain() }, + { label: t("menu.back"), run: () => this.backToMain() }, ]; } return [ - { label: "Passport", run: () => this.showPassport() }, - { label: "datagutt's status", run: () => this.openView("status") }, - { label: "Journal (plain text)", run: () => this.hooks.openJournal() }, - { label: "Settings", run: () => this.openView("settings") }, - { label: "Credits", run: () => this.openView("credits") }, - { label: "Close", run: () => this.close() }, + { label: t("menu.passport"), run: () => this.showPassport() }, + { label: t("menu.status"), run: () => this.openView("status") }, + { label: t("menu.journal"), run: () => this.hooks.openJournal() }, + { label: t("menu.settings"), run: () => this.openView("settings") }, + { label: t("menu.credits"), run: () => this.openView("credits") }, + { label: t("menu.close"), run: () => this.close() }, ]; } @@ -182,10 +184,10 @@ export class StartMenu { const w = Math.min(cam.width - 16, 300); const body = scene.add.bitmapText(12, 9 + lh + 6, FONT, text).setTint(INK).setMaxWidth(w - 24); const bodyBottom = 9 + lh + 6 + Math.ceil(body.getTextBounds().local.height); - const hint = scene.add.bitmapText(12, bodyBottom + 8, FONT, "Tap or press E to go back.").setTint(FADED).setMaxWidth(w - 24); + const hint = scene.add.bitmapText(12, bodyBottom + 8, FONT, t("menu.backHint")).setTint(FADED).setMaxWidth(w - 24); const h = bodyBottom + 8 + Math.ceil(hint.getTextBounds().local.height) + 12; parts.push(scene.add.nineslice(0, 0, FRAME, undefined, w, h, ...SLICE).setOrigin(0), body, hint); - parts.push(scene.add.bitmapText(12, 9, FONT, this.view === "credits" ? "Credits" : "datagutt's status").setTint(ACCENT)); + parts.push(scene.add.bitmapText(12, 9, FONT, t(this.view === "credits" ? "credits.title" : "status.title")).setTint(ACCENT)); this.container = scene.add .container(Math.floor((cam.width - w) / 2), Math.max(4, Math.floor((cam.height - h) / 2)), parts) .setScrollFactor(0) @@ -194,7 +196,7 @@ export class StartMenu { } const items = this.items(); - const title = this.view === "settings" ? "Settings" : "Menu"; + const title = t(this.view === "settings" ? "settings.title" : "menu.title"); const w = Math.min(cam.width - 16, 200); const h = 9 + lh + 4 + items.length * lh + 10; const x = Math.floor((cam.width - w) / 2); @@ -218,7 +220,7 @@ export class MenuButton { private bounds = { x: 0, y: 0, w: 0, h: 0 }; constructor(scene: Phaser.Scene) { - const label = scene.add.bitmapText(8, 5, FONT, "Menu").setTint(INK); + const label = scene.add.bitmapText(8, 5, FONT, t("menu.button")).setTint(INK); const w = Math.ceil(label.getTextBounds().local.width) + 16; const h = 22; const box = scene.add.nineslice(0, 0, FRAME, undefined, w, h, ...SLICE).setOrigin(0); diff --git a/docs/kai/HANDOFF.md b/docs/kai/HANDOFF.md index e2d7b4c..e7b06c3 100644 --- a/docs/kai/HANDOFF.md +++ b/docs/kai/HANDOFF.md @@ -34,7 +34,5 @@ an app script. Then K3.5 (assets repo move, needs the user), K4.4, K4.5, K5. ids that were literal unions (place, music, achievement) are plain strings now. - `pkill -f next-server` in a Bash call kills the call's own shell. Use `pkill -f "[n]ext-server"`. -- The e2e finale test ("the last stamp leads to the finale") fails now and then under a - full parallel run on WSL. It passes alone. - `game:dev` from `apps/datagutt` does not build assets or content first. Run it from the root (`turbo run game:dev`) or run `bun run content && bun run assets` before it. diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 90ea1fb..a4b9efc 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -131,8 +131,12 @@ Goal: every hardcoded piece of content is a JSON or Markdown resource, validated and key. Positions, doors, spawns and NPC placements stay in the builders: they are layout, computed from the builders' own geometry, and as JSON they would become loose numbers. NPC names already come from `npcs.json`. A test fails on unused copy.) -- [ ] **K4.5** `content/strings.json` with the engine's English defaults. *Done when* no - engine UI file has a visible string literal. +- [x] **K4.5** `content/strings.json` with the engine's English defaults. *Done when* no + engine UI file has a visible string literal. (Defaults in + `@datagutt/kai/ui/strings`; the `strings` collection rejects unknown keys. The + cat, the binoculars by day and the edge-of-the-world lines are Fjord Town's words, + so they became Ink knots in `world.ink` (the edge lines cycle), not strings. The + presence status lines follow in K5.5.) ## K5: the runtime and the plugin API diff --git a/packages/kai/src/schema/engine.ts b/packages/kai/src/schema/engine.ts index 94af8c7..9731082 100644 --- a/packages/kai/src/schema/engine.ts +++ b/packages/kai/src/schema/engine.ts @@ -1,6 +1,7 @@ // The collections the engine itself reads. Every game has them; a game's own collections // sit beside them (content/schema.ts in the game). import { z } from "zod"; +import { STRING_KEYS } from "../ui/strings.ts"; import { json, type Collections } from "./content.ts"; const id = z.string().regex(/^[a-zA-Z0-9_-]+$/, "use letters, digits, dashes and underscores"); @@ -113,4 +114,11 @@ export const ENGINE_COLLECTIONS = { unlocks: json(z.record(id, unlockCondition).default({})), /** The credits page and the credits roll. */ credits: json(credits), + /** Overrides of the engine's UI copy by key (@datagutt/kai/ui/strings). An unknown key is an error. */ + strings: json( + z + .object(Object.fromEntries(STRING_KEYS.map((key) => [key, text.optional()])) as Record<(typeof STRING_KEYS)[number], z.ZodOptional>) + .strict() + .default({}), + ), } satisfies Collections; diff --git a/packages/kai/src/ui/strings.test.ts b/packages/kai/src/ui/strings.test.ts new file mode 100644 index 0000000..b0e1265 --- /dev/null +++ b/packages/kai/src/ui/strings.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_STRINGS, format, withDefaults } from "./strings.ts"; + +describe("strings", () => { + it("override the defaults by key", () => { + const strings = withDefaults({ "passport.title": "Fjord Passport" }); + expect(strings["passport.title"]).toBe("Fjord Passport"); + expect(strings["menu.title"]).toBe(DEFAULT_STRINGS["menu.title"]); + }); + + it("fill in named values and leave unknown ones", () => { + expect(format("Stamped: {place} {count}/{total}", { place: "Library", count: 4, total: 10 })).toBe("Stamped: Library 4/10"); + expect(format("{known} {unknown}", { known: "a" })).toBe("a {unknown}"); + }); +}); diff --git a/packages/kai/src/ui/strings.ts b/packages/kai/src/ui/strings.ts new file mode 100644 index 0000000..b5d5e24 --- /dev/null +++ b/packages/kai/src/ui/strings.ts @@ -0,0 +1,61 @@ +// The engine's UI copy in English. A game overrides any of it by key in +// content/strings.json; `{name}` in a text is filled in where it is shown. + +export const DEFAULT_STRINGS = { + "menu.title": "Menu", + "menu.button": "Menu", + "menu.passport": "Passport", + "menu.status": "Status", + "menu.journal": "Journal", + "menu.settings": "Settings", + "menu.credits": "Credits", + "menu.close": "Close", + "menu.back": "Back", + "menu.backHint": "Tap or press E to go back.", + "status.title": "Status", + "credits.title": "Credits", + "settings.title": "Settings", + "settings.sound": "Sound: {value}", + "settings.music": "Music: {value}", + "settings.visitors": "Other visitors: {value}", + "settings.effects": "Effects: {value}", + "settings.reducedMotion": "Reduced motion: {value}", + "value.on": "On", + "value.off": "Off", + "value.auto": "Auto", + "value.high": "High", + "value.low": "Low", + "passport.title": "Passport", + "passport.achievements": "Achievements", + "passport.pageHint": "Left, right or tap: turn the page.", + "passport.stampsHint": "Talk to people to collect stamps. {pageHint}", + "passport.fullHint": "Full passport. Every stamp! {pageHint}", + "passport.achievementsHint": "Things to find beyond the stamps. {pageHint}", + "passport.unknown": "???", + "toast.stamped": "Stamped: {place} {count}/{total}", + "toast.achievement": "Achievement: {name}", + "prompt.talk": "Talk", + "prompt.read": "Read", + "prompt.enter": "Enter", + "prompt.wake": "Wake", + "prompt.play": "Play", + "prompt.look": "Look", + "prompt.use": "Use", + "prompt.pet": "Pet", + "link.open": "Open {label}", + "link.notNow": "Not now", + "arcade.leave": "{button}: leave", + "arcade.leaveTouch": "Tap outside: leave", +} as const; + +export type StringKey = keyof typeof DEFAULT_STRINGS; +export type Strings = Record; + +export const STRING_KEYS = Object.keys(DEFAULT_STRINGS) as StringKey[]; + +/** The engine's defaults with a game's overrides on top. */ +export const withDefaults = (overrides: Partial): Strings => ({ ...DEFAULT_STRINGS, ...overrides }); + +/** `template` with each `{name}` replaced by `vars[name]`; unknown names stay as they are. */ +export const format = (template: string, vars: Record): string => + template.replace(/\{(\w+)\}/g, (whole, name: string) => (name in vars ? String(vars[name]) : whole)); From cf88d5870f3f8caa7b93b557a6078e2b04d7ce03 Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 21:15:48 +0200 Subject: [PATCH 129/153] refactor(datagutt): run fjord town's own behaviour as plugins of a generic world The world scene gets a plugin API: object types, usables, prompts, talk and stamp hooks, menu items, takeovers and debug fields. The cat, the cabinets, the live GitHub objects, the ferry intro, the finale, the live Thomas NPC and the Journal link become plugins; summit, the edge lines and the passport achievement become declarative triggers. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/components/game/GameShell.tsx | 8 +- apps/datagutt/content/triggers.json | 8 + apps/datagutt/game/audio/playlist.test.ts | 6 +- apps/datagutt/game/audio/playlist.ts | 4 +- apps/datagutt/game/boot.ts | 74 ++-- apps/datagutt/game/dev/harness.ts | 4 +- .../game/dialogue/DialogueRunner.test.ts | 30 +- apps/datagutt/game/dialogue/DialogueRunner.ts | 9 +- apps/datagutt/game/dialogue/externals.ts | 15 +- apps/datagutt/game/index.ts | 38 ++ .../game/{scenes => plugins}/Intro.ts | 0 apps/datagutt/game/plugins/api.ts | 129 ++++++ apps/datagutt/game/plugins/arcade.ts | 55 +++ apps/datagutt/game/plugins/cat.ts | 24 + apps/datagutt/game/plugins/ferryIntro.ts | 47 ++ apps/datagutt/game/plugins/finale.ts | 56 +++ apps/datagutt/game/plugins/github.ts | 40 ++ apps/datagutt/game/plugins/journal.ts | 19 + apps/datagutt/game/plugins/presence.ts | 74 ++++ apps/datagutt/game/plugins/triggers.ts | 46 ++ apps/datagutt/game/progress/achievements.ts | 3 - apps/datagutt/game/progress/passport.ts | 3 + apps/datagutt/game/progress/unlocks.ts | 4 +- apps/datagutt/game/scenes/PreloadScene.ts | 21 +- apps/datagutt/game/scenes/WorldScene.ts | 414 +++++++----------- apps/datagutt/game/ui/StartMenu.ts | 42 +- docs/kai/PLAN.md | 21 +- packages/kai-assets/src/cli.ts | 5 +- packages/kai-assets/src/content/compile.ts | 6 + packages/kai/src/schema/config.ts | 9 +- packages/kai/src/schema/engine.ts | 25 ++ 31 files changed, 882 insertions(+), 357 deletions(-) create mode 100644 apps/datagutt/content/triggers.json create mode 100644 apps/datagutt/game/index.ts rename apps/datagutt/game/{scenes => plugins}/Intro.ts (100%) create mode 100644 apps/datagutt/game/plugins/api.ts create mode 100644 apps/datagutt/game/plugins/arcade.ts create mode 100644 apps/datagutt/game/plugins/cat.ts create mode 100644 apps/datagutt/game/plugins/ferryIntro.ts create mode 100644 apps/datagutt/game/plugins/finale.ts create mode 100644 apps/datagutt/game/plugins/github.ts create mode 100644 apps/datagutt/game/plugins/journal.ts create mode 100644 apps/datagutt/game/plugins/presence.ts create mode 100644 apps/datagutt/game/plugins/triggers.ts diff --git a/apps/datagutt/components/game/GameShell.tsx b/apps/datagutt/components/game/GameShell.tsx index 5e619a2..0dbbc99 100644 --- a/apps/datagutt/components/game/GameShell.tsx +++ b/apps/datagutt/components/game/GameShell.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { credits } from "@/content/credits"; -import type { GameHandle } from "@/game/boot"; +import type { GameHandle } from "@/game"; type Phase = "loading" | "ready" | "playing" | "failed"; /** Where the title screen is: the "Press start" splash, its menu, or a page off the menu. */ @@ -28,10 +28,10 @@ export function GameShell({ titleArt }: { titleArt: ReactNode }) { useEffect(() => { let cancelled = false; - import("@/game/boot") - .then(({ bootGame }) => { + import("@/game") + .then(({ startFjordTown }) => { if (cancelled || !containerRef.current) return; - const handle = bootGame(containerRef.current, { + const handle = startFjordTown(containerRef.current, { onProgress: (p) => setProgress(p), onReady: () => setPhase((current) => (current === "loading" ? "ready" : current)), }); diff --git a/apps/datagutt/content/triggers.json b/apps/datagutt/content/triggers.json new file mode 100644 index 0000000..b4d24b7 --- /dev/null +++ b/apps/datagutt/content/triggers.json @@ -0,0 +1,8 @@ +{ + "$schema": "../.kai/schema/triggers.json", + "list": [ + { "on": "enterMap", "map": "mountain", "delay": 600, "grant": "summit" }, + { "on": "bumpEdge", "cooldown": 20000, "knot": "edge_of_world", "grant": "edge" }, + { "on": "passportFull", "grant": "passport", "announce": false } + ] +} diff --git a/apps/datagutt/game/audio/playlist.test.ts b/apps/datagutt/game/audio/playlist.test.ts index 78becaf..a16e6a1 100644 --- a/apps/datagutt/game/audio/playlist.test.ts +++ b/apps/datagutt/game/audio/playlist.test.ts @@ -3,7 +3,7 @@ import { GENERATED_MAPS } from "../../world/gen/maps/index"; import { MUSIC } from "../assets/manifest"; import { trackFor, type Moment } from "./playlist"; -const town: Extract = { scene: "world", map: "town", outdoors: true, phase: "day", season: "summer", finale: false }; +const town: Extract = { scene: "world", map: "town", outdoors: true, phase: "day", season: "summer", night: false }; const inside = (map: string) => trackFor({ ...town, map, outdoors: false }); describe("music by place and time", () => { @@ -25,8 +25,8 @@ describe("music by place and time", () => { expect(trackFor({ ...town, season: "autumn" })).toBe("sunrise"); }); - it("plays the night track for the finale, whatever the season", () => { - expect(trackFor({ ...town, finale: true, season: "winter" })).toBe("goodnight"); + it("plays the night track on a story night, whatever the season", () => { + expect(trackFor({ ...town, night: true, season: "winter" })).toBe("goodnight"); }); it("gives each room its mood, day or night", () => { diff --git a/apps/datagutt/game/audio/playlist.ts b/apps/datagutt/game/audio/playlist.ts index b4dbb7a..d0e7b27 100644 --- a/apps/datagutt/game/audio/playlist.ts +++ b/apps/datagutt/game/audio/playlist.ts @@ -9,7 +9,7 @@ import type { Season } from "@datagutt/kai/world/season"; export type Moment = | { scene: "title" } | { scene: "credits" } - | { scene: "world"; map: string; outdoors: boolean; phase: Phase; season: Season; finale: boolean }; + | { scene: "world"; map: string; outdoors: boolean; phase: Phase; season: Season; night: boolean }; /** content/music.json `playlist`: the title and credits tracks, the town's, and each room's mood. */ const PLAYLIST = content.music.playlist; @@ -19,6 +19,6 @@ export function trackFor(moment: Moment): MusicId { if (moment.scene === "credits") return PLAYLIST.credits; if (!moment.outdoors) return PLAYLIST.indoors.maps[moment.map] ?? PLAYLIST.indoors.default; // Night wins over winter, so winter keeps a day and a night track like the rest of the year. - if (moment.finale || moment.phase === "night") return PLAYLIST.outdoors.night; + if (moment.night || moment.phase === "night") return PLAYLIST.outdoors.night; return moment.season === "winter" ? PLAYLIST.outdoors.winter : PLAYLIST.outdoors.day; } diff --git a/apps/datagutt/game/boot.ts b/apps/datagutt/game/boot.ts index 3941226..21a8e8f 100644 --- a/apps/datagutt/game/boot.ts +++ b/apps/datagutt/game/boot.ts @@ -6,7 +6,6 @@ import { PreloadScene } from "./scenes/PreloadScene"; import { WorldScene } from "./scenes/WorldScene"; import { readWorldState, type WorldState } from "@datagutt/kai-live"; import { EMPTY_WORLD_STATE } from "../content/live"; -import config from "../kai.json"; import { browserStorage, clearSave, loadSave } from "./save/save"; import { computeViewport } from "./viewport"; import type { Point } from "./world/grid"; @@ -14,9 +13,10 @@ import type { Facing } from "@datagutt/kai/world/objects"; import { START_PLACE, place, placeFromSearch } from "../content/places"; import { resolveSeason, type Season } from "@datagutt/kai/world/season"; import { clock, monthNow } from "./world/dayNight"; -import { LanyardClient, PresenceFeed } from "@datagutt/kai-live"; -import { MOCK_PRESENCES } from "./live/datagutt"; import { GhostClient, ghostsDisabled, worldSocketUrl } from "@datagutt/kai-net/client"; +import type { KaiPlugin } from "./plugins/api"; +import type { Progress } from "./progress/Progress"; +import type { KaiConfig } from "@datagutt/kai/schema"; import { Music } from "./audio/Music"; import { trackFor } from "./audio/playlist"; @@ -24,7 +24,7 @@ import { trackFor } from "./audio/playlist"; export type WorldTarget = { map: string; spawn?: string; tile?: Point; facing?: Facing }; /** - * Deep link first (`?at=office`), then the saved position, then the ferry dock. + * Deep link first (`?at=`), then the saved position, then the start place's entrance. * `deepLinked` lets the page skip the title screen. */ export function resolveStart(search: string): { target: WorldTarget; deepLinked: boolean; hasSave: boolean } { @@ -39,7 +39,18 @@ export function resolveStart(search: string): { target: WorldTarget; deepLinked: return { target: { ...place(START_PLACE).entrance! }, deepLinked: false, hasSave: false }; } +/** What the game's dialogue functions can read: the live data, the player's progress. */ +export type ExternalsContext = { world: WorldState; progress: Progress; services: GameServices }; +/** The implementations of the game's Ink external functions, by name. */ +export type Externals = Record unknown>; + export type BootOptions = { + /** kai.json as the content build validated it (.kai/config.json). */ + config: KaiConfig; + /** The game's own behaviour. */ + plugins?: KaiPlugin[]; + /** Binds the game's Ink external functions (its dialogue host names them). */ + externals?: (ctx: ExternalsContext) => Externals; /** URL prefix where the built game assets live. */ assetBase?: string; /** Loading progress from 0 to 1. */ @@ -53,7 +64,7 @@ export type BootOptions = { export type GameHandle = { /** * Enter the world. Safe to call before loading finishes; it starts when ready. - * `fresh` is the title's New game: the save is forgotten (settings kept) and the ferry + * `fresh` is the title's New game: the save is forgotten (settings kept) and the game * brings the player in as on a first visit. */ start(options?: { fresh?: boolean }): void; @@ -69,7 +80,7 @@ export type GameHandle = { hasSave: boolean; }; -export type GameServices = Required> & { +export type GameServices = Required> & { onProgress: (progress: number) => void; onReady: () => void; /** Resolves when the player has asked to start. */ @@ -77,16 +88,16 @@ export type GameServices = Required> & { start: WorldTarget; /** Live GitHub data embedded by the page; empty in the dev harness. */ world: WorldState; - /** The season outdoors: today's in Norway, or `?debug&season=`. */ + /** The season outdoors: today's in the game's time zone, or `?debug&season=`. */ season: Season; /** - * The finale is on: night, the aurora, Thomas at the end of the pier. Set once the - * passport is full, or with `?debug&finale`. + * A story night: the clock stays at 23:00, the sky is clear and the aurora is out, on + * every map until a plugin ends it. */ - finale: boolean; + night: boolean; /** The "auto" effects setting found frames running slow this visit (fx/quality.ts). */ autoLow: boolean; - /** No save and no deep link: the ferry intro plays (or `?debug&intro` forces it). */ + /** No save and no deep link: a first visit, which an intro can greet. */ firstVisit: boolean; /** New game over a save: the story loaded from the save is replaced before the world starts. */ fresh: boolean; @@ -94,8 +105,6 @@ export type GameServices = Required> & { hours: () => number; /** The month on the visitor's clock (1–12), or `?debug&month=`. */ month: () => number; - /** Thomas's live Discord presence (Lanyard); empty until the first update. */ - presence: PresenceFeed; /** * Other visitors (the world socket), or null when switched off with `rx_off`. The World * scene starts and stops it by the "Other visitors" setting. @@ -107,7 +116,7 @@ export type GameServices = Required> & { export const SERVICES_KEY = "services"; -export function bootGame(parent: HTMLElement, options: BootOptions = {}): GameHandle { +export function bootGame(parent: HTMLElement, options: BootOptions): GameHandle { let requestStart!: () => void; const startRequested = new Promise((resolve) => (requestStart = resolve)); const { target, deepLinked, hasSave } = resolveStart(window.location.search); @@ -116,14 +125,16 @@ export function bootGame(parent: HTMLElement, options: BootOptions = {}): GameHa const assetBase = options.assetBase ?? "/game/"; const services: GameServices = { assetBase, + config: options.config, + plugins: options.plugins ?? [], + externals: options.externals ?? (() => ({})), onProgress: options.onProgress ?? (() => {}), onReady: options.onReady ?? (() => {}), startRequested, start: target, world: readWorldState(EMPTY_WORLD_STATE), - season: resolveSeason(window.location.search, config.timezone), - presence: new PresenceFeed(), - finale: false, + season: resolveSeason(window.location.search, options.config.timezone), + night: false, autoLow: false, fresh: false, firstVisit: (!hasSave && !deepLinked) || (new URLSearchParams(window.location.search).has("debug") && new URLSearchParams(window.location.search).has("intro")), @@ -135,32 +146,19 @@ export function bootGame(parent: HTMLElement, options: BootOptions = {}): GameHa return sound instanceof Phaser.Sound.WebAudioSoundManager ? { context: sound.context, destination: sound.destination } : null; }, assetBase), }; - const debug = new URLSearchParams(window.location.search).has("debug"); - if (debug && new URLSearchParams(window.location.search).has("finale")) { - services.finale = true; - services.start = { map: "town", spawn: "finale" }; - requestStart(); - } + const params = new URLSearchParams(window.location.search); + const debug = params.has("debug"); if (debug) { console.info( `[game] world state from ${services.world.fetchedAt}: ${services.world.repos.length} repos`, services.world.repos.map((r) => r.name), ); - services.presence.subscribe((p) => console.info("[game] presence", p)); } - // `?debug&presence=` stands in a fixed presence (MOCK_PRESENCES) for Lanyard, and - // window.__fjordPresence(name) switches it live, to watch Thomas move. - const mock = debug ? new URLSearchParams(window.location.search).get("presence") : null; - const lanyard = new LanyardClient(services.world.discordId, (p) => services.presence.set(p)); - if (mock && MOCK_PRESENCES[mock]) services.presence.set(MOCK_PRESENCES[mock]); - else lanyard.start(); - if (debug) { - (window as unknown as { __fjordPresence?: (name: string) => void }).__fjordPresence = (name) => { - if (!MOCK_PRESENCES[name]) throw new Error(`No mock presence "${name}": ${Object.keys(MOCK_PRESENCES).join(", ")}`); - lanyard.stop(); - services.ghosts?.stop(); - services.presence.set(MOCK_PRESENCES[name]); - }; + const stops: (() => void)[] = []; + for (const plugin of services.plugins) { + const booted = plugin.boot?.(services, params); + if (booted?.start) requestStart(); + if (booted?.stop) stops.push(booted.stop); } const dpr = () => window.devicePixelRatio || 1; @@ -228,7 +226,7 @@ export function bootGame(parent: HTMLElement, options: BootOptions = {}): GameHa deepLinked, hasSave, destroy() { - lanyard.stop(); + for (const stop of stops) stop(); services.music.destroy(); observer.disconnect(); dprQuery?.removeEventListener("change", onDprChange); diff --git a/apps/datagutt/game/dev/harness.ts b/apps/datagutt/game/dev/harness.ts index 0d1a34e..5d9a035 100644 --- a/apps/datagutt/game/dev/harness.ts +++ b/apps/datagutt/game/dev/harness.ts @@ -1,8 +1,8 @@ // Boots the game straight into the world for the standalone dev harness. -import { bootGame } from "../boot"; +import { startFjordTown } from "../index"; const container = document.getElementById("game"); if (!container) throw new Error("#game container missing"); -const handle = bootGame(container, { autoStart: true }); +const handle = startFjordTown(container, { autoStart: true }); (window as unknown as { __game: typeof handle }).__game = handle; diff --git a/apps/datagutt/game/dialogue/DialogueRunner.test.ts b/apps/datagutt/game/dialogue/DialogueRunner.test.ts index decb914..d0e9da5 100644 --- a/apps/datagutt/game/dialogue/DialogueRunner.test.ts +++ b/apps/datagutt/game/dialogue/DialogueRunner.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { EMPTY_WORLD_STATE } from "../../content/live"; import { profile } from "../../content/profile"; import { projects } from "../../content/projects"; -import { externalDeclarations } from "./externals"; +import { externalDeclarations, fjordExternals } from "./externals"; import { NPCS } from "../npcs"; import { DialogueRunner, type Beat } from "./DialogueRunner"; @@ -22,7 +22,9 @@ beforeAll(() => { json = new Compiler(main, new CompilerOptions("main.ink", [], false, null, fileHandler)).Compile().ToJson()!; }); -const ctx = { world: EMPTY_WORLD_STATE, hasStamp: () => false, isUnlocked: () => false, lanyardActivity: () => "offline" }; +const ctx = { world: EMPTY_WORLD_STATE, hasStamp: () => false, isUnlocked: () => false }; +/** The game's functions, and the presence plugin's as it answers when Thomas is offline. */ +const externals = (c: typeof ctx) => ({ ...fjordExternals(c), lanyard_activity: () => "" }); function read(runner: DialogueRunner): { lines: string[]; last: Beat } { const out: string[] = []; @@ -37,7 +39,7 @@ const choicesOf = (beat: Beat) => (beat.type === "choices" ? beat.choices : []); describe("DialogueRunner", () => { it("fills facts in from content", () => { - const runner = new DialogueRunner(json, ctx); + const runner = new DialogueRunner(json, externals(ctx)); runner.start("datagutt"); const { lines: said, last } = read(runner); expect(said[0]).toContain(profile.firstName); @@ -47,7 +49,7 @@ describe("DialogueRunner", () => { }); it("keeps unasked questions available, within a visit and on the next one", () => { - const first = new DialogueRunner(json, ctx); + const first = new DialogueRunner(json, externals(ctx)); first.start("datagutt"); let options = choicesOf(read(first).last); expect(options).toContain("What are you working on?"); @@ -60,7 +62,7 @@ describe("DialogueRunner", () => { first.choose(options.indexOf("See you around.")); expect(read(first).last.type).toBe("end"); - const second = new DialogueRunner(json, ctx, first.saveState()); + const second = new DialogueRunner(json, externals(ctx), first.saveState()); second.start("datagutt"); const again = read(second); expect(again.lines[0]).not.toContain(profile.about[0]); @@ -71,7 +73,7 @@ describe("DialogueRunner", () => { }); it("tags lines that offer a link", () => { - const runner = new DialogueRunner(json, ctx); + const runner = new DialogueRunner(json, externals(ctx)); runner.start("datagutt"); const options = choicesOf(read(runner).last); runner.choose(options.indexOf("Where can I find you online?")); @@ -80,10 +82,10 @@ describe("DialogueRunner", () => { }); it("greets returning visitors briefly", () => { - const first = new DialogueRunner(json, ctx); + const first = new DialogueRunner(json, externals(ctx)); first.start("ferryman"); expect(read(first).lines[0]).toMatch(/Welcome ashore/); - const second = new DialogueRunner(json, ctx, first.saveState()); + const second = new DialogueRunner(json, externals(ctx), first.saveState()); second.start("ferryman"); expect(read(second).lines[0]).not.toMatch(/Welcome ashore/); }); @@ -101,7 +103,7 @@ describe("DialogueRunner", () => { it.each(NPCS.flatMap((n) => [{ id: n.id, world: "live" }, { id: n.id, world: "empty" }]))( "$id: every question can be asked and the conversation ends ($world data)", ({ id, world }) => { - const runner = new DialogueRunner(json, { ...ctx, world: world === "live" ? sampleWorld : EMPTY_WORLD_STATE }); + const runner = new DialogueRunner(json, externals({ ...ctx, world: world === "live" ? sampleWorld : EMPTY_WORLD_STATE })); runner.start(id); const said: string[] = []; const asked = new Set(); @@ -128,7 +130,7 @@ describe("DialogueRunner", () => { it("reads the featured shelf from live data, and copes without it", () => { const repo = { author: "datagutt", name: "fjord", description: "A town.", language: "TypeScript", languageColor: "#3178c6", stars: 3, forks: 0 }; - const live = new DialogueRunner(json, { ...ctx, world: { ...EMPTY_WORLD_STATE, repos: [repo, { ...repo, name: "boat", language: "" }] } }); + const live = new DialogueRunner(json, externals({ ...ctx, world: { ...EMPTY_WORLD_STATE, repos: [repo, { ...repo, name: "boat", language: "" }] } })); live.start("featured_shelf"); const { lines, last } = read(live); expect(lines[0]).toContain("2 books"); @@ -136,13 +138,13 @@ describe("DialogueRunner", () => { expect(lines[2]).toBe('* "boat". A town.'); expect(last.type).toBe("end"); - const empty = new DialogueRunner(json, ctx); + const empty = new DialogueRunner(json, externals(ctx)); empty.start("featured_shelf"); expect(read(empty).lines[0]).toContain("bare today"); }); it("offers every question again once all have been asked", () => { - const runner = new DialogueRunner(json, ctx); + const runner = new DialogueRunner(json, externals(ctx)); runner.start("ferryman"); let last = read(runner).last; // Ask both questions. @@ -172,14 +174,14 @@ describe("the world's own lines", () => { ["cat_petted", "* Mjau."], ["binoculars_by_day", "* Just the town"], ]) { - const runner = new DialogueRunner(json, ctx); + const runner = new DialogueRunner(json, externals(ctx)); runner.start(knot); expect(read(runner).lines[0].startsWith(start), knot).toBe(true); } }); it("says a different edge line each time, in turn", () => { - const runner = new DialogueRunner(json, ctx); + const runner = new DialogueRunner(json, externals(ctx)); const lines = Array.from({ length: 5 }, () => { runner.start("edge_of_world"); return read(runner).lines[0]; diff --git a/apps/datagutt/game/dialogue/DialogueRunner.ts b/apps/datagutt/game/dialogue/DialogueRunner.ts index 7a194cd..7cc7644 100644 --- a/apps/datagutt/game/dialogue/DialogueRunner.ts +++ b/apps/datagutt/game/dialogue/DialogueRunner.ts @@ -1,6 +1,8 @@ // Walks the compiled Ink story one beat at a time for the dialogue UI. import { Story } from "inkjs"; -import { bindExternals, type ExternalContext } from "./externals"; + +/** Implementations of the story's EXTERNAL functions, by name. */ +export type ExternalFunctions = Record unknown>; export type Beat = | { type: "line"; text: string; tags: string[]; speaker: string | null } @@ -10,9 +12,10 @@ export type Beat = export class DialogueRunner { private readonly story: InstanceType; - constructor(json: string | object, ctx: ExternalContext, savedState?: string) { + constructor(json: string | object, externals: ExternalFunctions, savedState?: string) { this.story = new Story(typeof json === "string" ? json : JSON.stringify(json)); - bindExternals(this.story as never, ctx); + // Lookahead-safe: they only read, so Ink may call them while looking ahead. + for (const [name, fn] of Object.entries(externals)) this.story.BindExternalFunction(name, fn, true); if (savedState) { try { this.story.state.LoadJson(savedState); diff --git a/apps/datagutt/game/dialogue/externals.ts b/apps/datagutt/game/dialogue/externals.ts index 3d146d0..d1cbefd 100644 --- a/apps/datagutt/game/dialogue/externals.ts +++ b/apps/datagutt/game/dialogue/externals.ts @@ -79,14 +79,10 @@ export function externalDeclarations(): string { .join("\n"); } -/** Minimal surface of an inkjs Story needed to bind functions (keeps this file Node-friendly). */ -type Bindable = { BindExternalFunction(name: string, fn: (...args: never[]) => unknown, lookaheadSafe?: boolean): void }; - export type ExternalContext = { world: WorldState; hasStamp(place: string): boolean; isUnlocked(name: UnlockId): boolean; - lanyardActivity(): string; }; const find = (list: readonly T[], id: string, what: string): T => { @@ -103,9 +99,13 @@ export function asSentence(text: string): string { return /[.!?…)]$/.test(capital) ? capital : `${capital}.`; } -export function bindExternals(story: Bindable, ctx: ExternalContext): void { +/** + * Fjord Town's dialogue functions, reading the content and `ctx`. `lanyard_activity` + * comes from the presence plugin. + */ +export function fjordExternals(ctx: ExternalContext): Record, (...args: never[]) => unknown> { const repo = (i: number) => ctx.world.repos[Math.trunc(i)]; - const impl: Record unknown> = { + const impl: Record, (...args: never[]) => unknown> = { profile: (field: string) => String(profile[field as (typeof PROFILE_FIELDS)[number]] ?? ""), about: (i: number) => profile.about[Math.trunc(i)] ?? "", project_name: (id: string) => find(projects, id, "project").name, @@ -129,7 +129,6 @@ export function bindExternals(story: Bindable, ctx: ExternalContext): void { contributions_total: () => ctx.world.contributions.reduce((sum, d) => sum + d.count, 0), has_stamp: (place: string) => ctx.hasStamp(place), unlocked: (name: string) => ctx.isUnlocked(name as UnlockId), - lanyard_activity: () => ctx.lanyardActivity(), }; - for (const [name, fn] of Object.entries(impl)) story.BindExternalFunction(name, fn, true); + return impl; } diff --git a/apps/datagutt/game/index.ts b/apps/datagutt/game/index.ts new file mode 100644 index 0000000..74f72a5 --- /dev/null +++ b/apps/datagutt/game/index.ts @@ -0,0 +1,38 @@ +// Fjord Town: the kai engine with this game's config, content, dialogue functions and +// plugins. The site's game shell and the dev harness both start it here. +import type { KaiConfig } from "@datagutt/kai/schema"; +import config from "../.kai/config.json" with { type: "json" }; +import { content } from "../content/index.ts"; +import { bootGame, type BootOptions, type GameHandle } from "./boot"; +import { fjordExternals } from "./dialogue/externals"; +import { arcadePlugin } from "./plugins/arcade"; +import { catPlugin } from "./plugins/cat"; +import { ferryIntroPlugin } from "./plugins/ferryIntro"; +import { finalePlugin } from "./plugins/finale"; +import { githubPlugin } from "./plugins/github"; +import { journalPlugin } from "./plugins/journal"; +import { presencePlugin } from "./plugins/presence"; +import { triggersPlugin } from "./plugins/triggers"; +import { isUnlocked } from "./progress/unlocks"; + +export type { GameHandle }; + +export function startFjordTown(parent: HTMLElement, options: Omit = {}): GameHandle { + return bootGame(parent, { + ...options, + config: config as KaiConfig, + // In the order their start menu items appear: the status page, then the Journal. + plugins: [ + triggersPlugin(content.triggers.list), + catPlugin(), + arcadePlugin(), + githubPlugin(), + ferryIntroPlugin(), + finalePlugin(), + presencePlugin(), + journalPlugin(), + ], + externals: ({ world, progress }) => + fjordExternals({ world, hasStamp: (place) => progress.hasStamp(place), isUnlocked: (name) => isUnlocked(name, progress) }), + }); +} diff --git a/apps/datagutt/game/scenes/Intro.ts b/apps/datagutt/game/plugins/Intro.ts similarity index 100% rename from apps/datagutt/game/scenes/Intro.ts rename to apps/datagutt/game/plugins/Intro.ts diff --git a/apps/datagutt/game/plugins/api.ts b/apps/datagutt/game/plugins/api.ts new file mode 100644 index 0000000..a99380c --- /dev/null +++ b/apps/datagutt/game/plugins/api.ts @@ -0,0 +1,129 @@ +// The plugin API: how a game adds its own behaviour to the engine's world without the +// engine knowing about it. A plugin reacts to the world (a map loading, the player using +// something, a conversation ending), places its own map object types, adds start menu +// items, and can take over the frame for a while (a cutscene, a cabinet, the credits). +import type Phaser from "phaser"; +import type { MapObject } from "@datagutt/kai/world/objects"; +import type { Externals, ExternalsContext, GameServices, WorldTarget } from "../boot"; +import type { Actor } from "../entities/Actor"; +import type { FrameInput } from "../input/InputController"; +import type { Progress } from "../progress/Progress"; +import type { PromptAction } from "../ui/Prompt"; +import type { CollisionGrid, Point } from "../world/grid"; +import type { Daylight } from "../world/dayNight"; + +export type ObjectOf = Extract; +export type NpcDef = ObjectOf<"npc">; +export type TileLayer = Phaser.Tilemaps.TilemapLayer | Phaser.Tilemaps.TilemapGPULayer; + +/** The running world, as a plugin sees it. One lives for as long as the player is on a map. */ +export interface World { + readonly scene: Phaser.Scene; + /** The current map's id. */ + readonly map: string; + readonly outdoors: boolean; + readonly grid: CollisionGrid; + readonly player: Actor; + readonly services: GameServices; + readonly progress: Progress; + readonly daylight: Daylight; + /** The map's tile layers by name ("ground", "decal", "below", "above", ...). */ + readonly layers: ReadonlyMap; + readonly spawns: ReadonlyMap>; + readonly spots: ReadonlyMap>; + readonly areas: ReadonlyMap>; + /** Doors by tile ("x,y"). */ + readonly doors: ReadonlyMap>; + /** Where the player arrived on this map. */ + readonly arrival: { target: WorldTarget; tile: Point }; + readonly dialogueOpen: boolean; + + /** + * Make an NPC talkable like one from the map. A `managed` NPC is moved and synced by + * its plugin; the world leaves its actor alone. + */ + addNpc(def: NpcDef, actor: Actor, options?: { managed?: boolean }): void; + removeNpc(id: string): void; + npc(id: string): { actor: Actor; def: NpcDef } | undefined; + /** The NPC the interaction prompt is over, if any. */ + readonly promptNpc: string | null; + + /** Plays an Ink knot to its end: spoken by `npc` (portrait, name, voice), or narrated. */ + playKnot(knot: string, npc: NpcDef | null, onEnd: () => void): void; + /** Earn an achievement once: a banner (unless `announce` is false) and a save. */ + achieve(id: string, announce?: boolean): void; + save(): void; + /** Fade out and restart the world on another map (or spot). */ + goTo(target: WorldTarget, fadeMs?: number): void; + /** Hand the frame to `takeover` until its update returns false. */ + takeOver(takeover: Takeover): void; + clearPath(): void; + /** Run `fn` when the world shuts down (leaving the map). */ + onShutdown(fn: () => void): void; +} + +/** Something that has the frame for a while: the world stops walking and talking meanwhile. */ +export type Takeover = { + /** For the ?debug readout ("intro", "credits", or a cabinet's title). */ + readonly name: string; + /** One frame. Return false when done. */ + update(dt: number, input: FrameInput): boolean; + /** Keep map NPCs animating while it runs. */ + readonly syncNpcs?: boolean; + /** Play the playlist's credits track meanwhile. */ + readonly music?: "credits"; +}; + +/** Something the player can use on a tile: the prompt to show over it, and what using it does. */ +export type Usable = { prompt: PromptAction; use(): void }; + +/** A start menu item: an action, or a page of text (its id names the menu view). */ +export type MenuItem = { id: string; label: string } & ({ run(): void } | { title: string; lines(): string[] }); + +export interface KaiPlugin { + readonly name: string; + /** + * Once, as the game boots, before anything loads. It may change where the game starts + * (`services.start`); `start: true` then skips the title. `stop` runs when the game ends. + */ + boot?(services: GameServices, params: URLSearchParams): { start?: boolean; stop?: () => void } | void; + /** Ink external functions this plugin implements, bound beside the game's own. */ + externals?(ctx: ExternalsContext): Externals; + /** Map object types this plugin places, by `type`: called for each as the map loads. */ + readonly objects?: Partial<{ [T in MapObject["type"]]: (world: World, obj: ObjectOf) => void }>; + /** After the map, its objects and the player are in place. */ + mapCreated?(world: World): void; + /** What the player can use on `tile`, asked before signs and doors. */ + usableAt?(world: World, tile: Point): Usable | null; + /** The prompt over an NPC, instead of "Talk". */ + promptFor?(world: World, npc: NpcDef): PromptAction | null; + /** Talking to `npc`: return true when this plugin plays the conversation itself. */ + talk?(world: World, npc: NpcDef): boolean; + /** A conversation ended; `reachedOwnKnot` says whether it reached the NPC's own knot. */ + talked?(world: World, npc: NpcDef, knot: string, reachedOwnKnot: boolean): void; + /** A passport stamp was earned; `complete` when it was the last one. */ + stamped?(world: World, place: string, complete: boolean): void; + /** The player walked into the edge of the map. */ + bumpedEdge?(world: World): void; + /** Every frame the world runs freely (no takeover, menu or dialogue). */ + update?(world: World, dt: number, time: number): void; + /** Start menu items, after the Passport. */ + menuItems?(world: World): MenuItem[]; + /** Extra fields for the ?debug state (window.__fjord). */ + debug?(world: World): Record; +} + +/** + * State a plugin keeps for one visit to a map: `state(world)` makes it on first use and + * forgets it when the player leaves (a new World comes with every map). + */ +export function perWorld(make: (world: World) => T): (world: World) => T { + const states = new WeakMap(); + return (world) => { + let state = states.get(world); + if (state === undefined) states.set(world, (state = make(world))); + return state; + }; +} + +export const tileKey = (p: Point) => `${p.x},${p.y}`; diff --git a/apps/datagutt/game/plugins/arcade.ts b/apps/datagutt/game/plugins/arcade.ts new file mode 100644 index 0000000..8821ba9 --- /dev/null +++ b/apps/datagutt/game/plugins/arcade.ts @@ -0,0 +1,55 @@ +// Fjord Town's cabinets (the registry in ../arcade): step up to one and its game has the +// frame until the player leaves. The binoculars on the radio hill show stars only after +// dark, and Thomas's PC runs a screensaver. +import { ArcadeScreen } from "../ui/ArcadeScreen"; +import type { PromptAction } from "../ui/Prompt"; +import { makeArcade } from "../arcade"; +import { isArcadeId, type ArcadeId } from "../arcade/ids"; +import { perWorld, tileKey, type KaiPlugin, type ObjectOf, type World } from "./api"; + +/** The falling blocks score that earns "High score". */ +export const BLOCKS_TARGET = 1000; + +const PROMPTS: Partial> = { stargazing: "Look", screensaver: "Use" }; + +export function arcadePlugin(): KaiPlugin { + const cabinets = perWorld(() => new Map>()); + let playing: string | null = null; + + function play(world: World, cabinet: ObjectOf<"arcade">) { + if (cabinet.game === "stargazing" && world.daylight.dark < 0.5) return world.playKnot("binoculars_by_day", null, () => {}); + if (!isArcadeId(cabinet.game)) return; + if (cabinet.game === "stargazing") world.achieve("stars"); + const progress = world.progress; + const game = makeArcade(cabinet.game, { + best: (name) => progress.records[name] ?? 0, + record: (name, score) => { + if (progress.record(name, score)) world.save(); + if (name === "blocks" && score >= BLOCKS_TARGET) world.achieve("blocks"); + }, + }); + const screen = new ArcadeScreen(world.scene, game, () => world.save()); + playing = game.title; + world.takeOver({ + name: game.title, + update(dt, input) { + const on = screen.update(dt, input); + if (!on) playing = null; + return on; + }, + }); + } + + return { + name: "arcade", + objects: { + arcade: (world, cabinet) => void cabinets(world).set(tileKey(cabinet), cabinet), + }, + usableAt(world, p) { + const cabinet = cabinets(world).get(tileKey(p)); + if (!cabinet) return null; + return { prompt: (isArcadeId(cabinet.game) && PROMPTS[cabinet.game]) || "Play", use: () => play(world, cabinet) }; + }, + debug: () => ({ arcade: playing }), + }; +} diff --git a/apps/datagutt/game/plugins/cat.ts b/apps/datagutt/game/plugins/cat.ts new file mode 100644 index 0000000..f4efc96 --- /dev/null +++ b/apps/datagutt/game/plugins/cat.ts @@ -0,0 +1,24 @@ +// The hidden cat (docs/game/PLAN.md B3): it lies across its tile and the next one east, and +// petting it earns "cat". +import { TILE } from "../constants"; +import { perWorld, tileKey, type KaiPlugin } from "./api"; + +export function catPlugin(): KaiPlugin { + const cats = perWorld(() => new Set()); + return { + name: "cat", + objects: { + cat(world, cat) { + for (const x of [cat.x, cat.x + 1]) { + cats(world).add(tileKey({ x, y: cat.y })); + world.grid.occupy(x, cat.y, "cat"); + } + // The strip's frames are 48 wide; the cat is about 26 of that, centred at x 22.5. + const bottom = (cat.y + 1) * TILE; + world.scene.add.sprite((cat.x + 1) * TILE, bottom, "sprite:cat").setOrigin(22.5 / 48, 1).setDepth(bottom).play("sprite:cat"); + }, + }, + usableAt: (world, p) => + cats(world).has(tileKey(p)) ? { prompt: "Pet", use: () => world.playKnot("cat_petted", null, () => world.achieve("cat")) } : null, + }; +} diff --git a/apps/datagutt/game/plugins/ferryIntro.ts b/apps/datagutt/game/plugins/ferryIntro.ts new file mode 100644 index 0000000..3bb86cf --- /dev/null +++ b/apps/datagutt/game/plugins/ferryIntro.ts @@ -0,0 +1,47 @@ +// On a first visit, arriving at the dock: sail in on the ferry and meet Arne (Intro.ts). +// `?intro` plays it again over a save. +import type { KaiPlugin } from "./api"; +import { Intro } from "./Intro"; + +export function ferryIntroPlugin(): KaiPlugin { + let active = false; + return { + name: "ferry-intro", + mapCreated(world) { + const ferry = world.areas.get("ferry"); + const arne = world.npc("ferryman"); + const forced = new URLSearchParams(window.location.search).has("intro"); + if (!ferry || !arne || !world.services.firstVisit || world.arrival.target.spawn !== "ferry" || (world.progress.flags.intro && !forced)) return; + const layers = ["below", "above"].flatMap((name) => world.layers.get(name) ?? []); + const intro = new Intro( + world.scene, + world.player, + ferry, + layers, + world.arrival.tile, + world.progress.reducedMotion, + (done) => { + arne.actor.mover.face("left"); + world.playKnot("ferryman_intro", arne.def, () => { + world.progress.flags.intro = true; + world.services.firstVisit = false; + world.save(); + done(); + }); + }, + () => {}, + ); + active = true; + world.takeOver({ + name: "intro", + syncNpcs: true, + update(_dt, input) { + intro.update(input); + active = intro.active; + return active; + }, + }); + }, + debug: () => ({ intro: active }), + }; +} diff --git a/apps/datagutt/game/plugins/finale.ts b/apps/datagutt/game/plugins/finale.ts new file mode 100644 index 0000000..65777f3 --- /dev/null +++ b/apps/datagutt/game/plugins/finale.ts @@ -0,0 +1,56 @@ +// Fjord Town's finale: the last stamp brings a note from Thomas, then night falls on every +// map and he waits at the end of the pier; his goodbye, the credits, and how to reach him. +// `?debug&finale` starts on the pier. +import { CreditsRoll } from "../ui/CreditsRoll"; +import type { KaiPlugin } from "./api"; + +export const FINALE_KNOT = "datagutt_finale"; + +export function finalePlugin(): KaiPlugin { + let rolling = false; + return { + name: "finale", + boot(services, params) { + if (!params.has("debug") || !params.has("finale")) return; + services.night = true; + services.start = { map: "town", spawn: "finale" }; + return { start: true }; + }, + stamped(world, _place, complete) { + if (!complete || world.progress.flags.finale) return; + // Once the last stamp's banner has had its moment. + world.scene.time.delayedCall(1600, () => + world.playKnot("finale_note", null, () => { + world.services.night = true; + world.goTo({ map: "town", spawn: "finale" }); + }), + ); + }, + talk(world, npc) { + if (npc.dialogue !== FINALE_KNOT) return false; + world.playKnot(npc.dialogue, npc, () => { + const roll = new CreditsRoll(world.scene, world.progress.reducedMotion, () => { + rolling = false; + world.achieve("credits"); + world.playKnot("datagutt_contact", npc, () => { + // This night stays until the player moves on; the next map is back to normal. + world.progress.flags.finale = true; + world.services.night = false; + world.save(); + }); + }); + rolling = true; + world.takeOver({ + name: "credits", + music: "credits", + update(dt, input) { + roll.update(dt, input); + return rolling; + }, + }); + }); + return true; + }, + debug: (world) => ({ finale: world.services.night, credits: rolling }), + }; +} diff --git a/apps/datagutt/game/plugins/github.ts b/apps/datagutt/game/plugins/github.ts new file mode 100644 index 0000000..252ac5b --- /dev/null +++ b/apps/datagutt/game/plugins/github.ts @@ -0,0 +1,40 @@ +// Live GitHub data drawn into the maps: Ola's field (one tile per day of the last weeks, +// crops as tall as the commits) and the library's featured shelf (a spine per pinned repo, +// in its language colour). +import { TILE } from "../constants"; +import { fieldLevels } from "../live/field"; +import { spines } from "../live/shelf"; +import type { KaiPlugin } from "./api"; + +export function githubPlugin(): KaiPlugin { + return { + name: "github", + objects: { + crops(world, area) { + const days = world.services.world.contributions; + const decal = world.layers.get("decal"); + // Without live data the generator's sample crops stay. + if (!decal || !days.length) return; + const stages = area.stages.split(",").map(Number); + fieldLevels(days, area.w).forEach((row, dy) => { + if (dy >= area.h) return; + row.forEach((level, dx) => { + const x = area.x + dx; + const y = area.y + dy; + if (!level) decal.removeTileAt(x, y); + else decal.putTileAt(stages[Math.min(level, stages.length) - 1], x, y); + }); + }); + }, + books(world, area) { + const g = world.scene.add.graphics().setDepth(-0.5); + for (const s of spines(world.services.world.repos, area.w * TILE, area.h * TILE - 2)) { + const x = area.x * TILE + s.x; + const y = area.y * TILE + s.y; + g.fillStyle(s.edge).fillRect(x, y, s.w, s.h); + g.fillStyle(s.color).fillRect(x, y + 1, s.w - 1, s.h - 1); + } + }, + }, + }; +} diff --git a/apps/datagutt/game/plugins/journal.ts b/apps/datagutt/game/plugins/journal.ts new file mode 100644 index 0000000..a48a278 --- /dev/null +++ b/apps/datagutt/game/plugins/journal.ts @@ -0,0 +1,19 @@ +// The start menu's way out to the Journal, the site's plain-text twin of the game. +import { t } from "../strings"; +import type { KaiPlugin } from "./api"; + +export function journalPlugin(): KaiPlugin { + return { + name: "journal", + menuItems: (world) => [ + { + id: "journal", + label: t("menu.journal"), + run() { + world.save(); + window.location.href = "/journal"; + }, + }, + ], + }; +} diff --git a/apps/datagutt/game/plugins/presence.ts b/apps/datagutt/game/plugins/presence.ts new file mode 100644 index 0000000..e3a140e --- /dev/null +++ b/apps/datagutt/game/plugins/presence.ts @@ -0,0 +1,74 @@ +// The live datagutt NPC (LiveThomas): Thomas's Discord presence, read from Lanyard, picks +// where he is and what he shows. `?debug&presence=` stands in a fixed presence +// (MOCK_PRESENCES) for Lanyard, and window.__fjordPresence(name) switches it live. +import { LanyardClient, PresenceFeed } from "@datagutt/kai-live"; +import { LiveThomas, THOMAS_ID } from "../entities/LiveThomas"; +import { MOCK_PRESENCES, nowDoing, statusLines } from "../live/datagutt"; +import { npc as rosterNpc } from "../npcs"; +import { t } from "../strings"; +import { FINALE_KNOT } from "./finale"; +import type { KaiPlugin } from "./api"; + +export function presencePlugin(): KaiPlugin { + const feed = new PresenceFeed(); + let thomas: LiveThomas | null = null; + return { + name: "presence", + boot(services, params) { + const debug = params.has("debug"); + if (debug) feed.subscribe((p) => console.info("[game] presence", p)); + const lanyard = new LanyardClient(services.world.discordId, (p) => feed.set(p)); + const mock = debug ? params.get("presence") : null; + if (mock && MOCK_PRESENCES[mock]) feed.set(MOCK_PRESENCES[mock]); + else lanyard.start(); + if (debug) { + (window as unknown as { __fjordPresence?: (name: string) => void }).__fjordPresence = (name) => { + if (!MOCK_PRESENCES[name]) throw new Error(`No mock presence "${name}": ${Object.keys(MOCK_PRESENCES).join(", ")}`); + lanyard.stop(); + services.ghosts?.stop(); + feed.set(MOCK_PRESENCES[name]); + }; + } + return { stop: () => lanyard.stop() }; + }, + externals: () => ({ lanyard_activity: () => nowDoing(feed.current) }), + mapCreated(world) { + const live = new LiveThomas( + { + scene: world.scene, + map: world.map, + grid: world.grid, + spots: world.spots, + doors: world.doors, + addNpc: (def, actor) => world.addNpc(def, actor, { managed: true }), + removeNpc: (id) => world.removeNpc(id), + playerTile: () => world.player.mover.tile, + }, + feed, + rosterNpc(THOMAS_ID)?.name ?? "Thomas", + // On the finale's night he waits at the end of the pier, whatever his presence. + world.services.night ? { place: "pier", asleep: false, wanders: false, emote: null, says: null, dialogue: FINALE_KNOT } : null, + ); + thomas = live; + world.onShutdown(() => { + live.destroy(); + if (thomas === live) thomas = null; + }); + }, + update(world, dt, time) { + if (!thomas) return; + // His bubbles step aside for the interaction prompt over him. + thomas.quiet = world.promptNpc === THOMAS_ID; + thomas.update(dt, time); + }, + promptFor: (_world, npc) => (npc.id === THOMAS_ID && npc.dialogue.endsWith("_asleep") ? "Wake" : null), + talked(world, npc, knot, reached) { + // Woken up and talked to: he gets out of bed rather than lying back down. + if (!reached || npc.id !== THOMAS_ID || knot !== "datagutt_asleep") return; + thomas?.wokenByPlayer(); + world.achieve("wake"); + }, + menuItems: () => [{ id: "status", label: t("menu.status"), title: t("status.title"), lines: () => statusLines(feed.current) }], + debug: () => ({ presence: feed.current, thomas: thomas?.state }), + }; +} diff --git a/apps/datagutt/game/plugins/triggers.ts b/apps/datagutt/game/plugins/triggers.ts new file mode 100644 index 0000000..803c5fa --- /dev/null +++ b/apps/datagutt/game/plugins/triggers.ts @@ -0,0 +1,46 @@ +// Runs the declarative triggers (content/triggers.json): arriving on a map, walking into +// its edge, a full passport. +import type { Trigger } from "@datagutt/kai/schema/engine"; +import { passportFull } from "../progress/passport"; +import type { KaiPlugin, World } from "./api"; + +export function triggersPlugin(triggers: readonly Trigger[]): KaiPlugin { + /** When each bumpEdge trigger may fire again, by its index. */ + const quietUntil = new Map(); + + function fire(world: World, trigger: Trigger) { + const grant = () => { + if (trigger.flag) world.progress.flags[trigger.flag] = true; + if (trigger.grant) world.achieve(trigger.grant, trigger.announce); + else if (trigger.flag) world.save(); + }; + if (trigger.knot) world.playKnot(trigger.knot, null, grant); + else grant(); + } + + function checkPassport(world: World) { + if (!passportFull(world.progress)) return; + for (const trigger of triggers) if (trigger.on === "passportFull") fire(world, trigger); + } + + return { + name: "triggers", + mapCreated(world) { + for (const trigger of triggers) { + if (trigger.on === "enterMap" && trigger.map === world.map) world.scene.time.delayedCall(trigger.delay, () => fire(world, trigger)); + } + checkPassport(world); + }, + stamped(world, _place, complete) { + if (complete) checkPassport(world); + }, + bumpedEdge(world) { + const now = world.scene.time.now; + triggers.forEach((trigger, i) => { + if (trigger.on !== "bumpEdge" || world.dialogueOpen || now < (quietUntil.get(i) ?? 0)) return; + quietUntil.set(i, now + trigger.cooldown); + fire(world, trigger); + }); + }, + }; +} diff --git a/apps/datagutt/game/progress/achievements.ts b/apps/datagutt/game/progress/achievements.ts index d506da5..0a66ee9 100644 --- a/apps/datagutt/game/progress/achievements.ts +++ b/apps/datagutt/game/progress/achievements.ts @@ -10,9 +10,6 @@ export type AchievementId = string; /** In the order the passport lists them (content/achievements.json). `how` shows once earned; before that, "???". */ export const ACHIEVEMENTS: Achievement[] = content.achievements.list; -/** The falling blocks score that earns "High score". */ -export const BLOCKS_TARGET = 1000; - export const achievementFlag = (id: AchievementId) => `achievement:${id}`; export function achievement(id: AchievementId): Achievement { diff --git a/apps/datagutt/game/progress/passport.ts b/apps/datagutt/game/progress/passport.ts index 47677df..dfad26d 100644 --- a/apps/datagutt/game/progress/passport.ts +++ b/apps/datagutt/game/progress/passport.ts @@ -25,3 +25,6 @@ export function awardStamp(stamps: readonly string[], place: string | null): Sta const next = [...current, place]; return { stamps: next, newStamp: place, complete: next.length === known.size }; } + +/** Every stamp is in. */ +export const passportFull = (progress: { hasStamp(place: string): boolean }): boolean => STAMP_PLACES.every((p) => progress.hasStamp(p.id)); diff --git a/apps/datagutt/game/progress/unlocks.ts b/apps/datagutt/game/progress/unlocks.ts index 964965e..fbe0f3b 100644 --- a/apps/datagutt/game/progress/unlocks.ts +++ b/apps/datagutt/game/progress/unlocks.ts @@ -1,7 +1,7 @@ // Whether an unlock holds, from its condition in content/unlocks.json. The passport's // unlock (every stamp) clears the mountain trail's rockfall. import { content } from "../../content/index.ts"; -import { STAMP_PLACES } from "./passport"; +import { passportFull } from "./passport"; import type { UnlockId } from "./unlockIds"; type Story = { stamps: readonly string[]; flags: Readonly> }; @@ -10,5 +10,5 @@ type Story = { stamps: readonly string[]; flags: Readonly story.stamps.includes(p.id)); + return condition.stamps === "all" && passportFull({ hasStamp: (place) => story.stamps.includes(place) }); } diff --git a/apps/datagutt/game/scenes/PreloadScene.ts b/apps/datagutt/game/scenes/PreloadScene.ts index a224567..64d8129 100644 --- a/apps/datagutt/game/scenes/PreloadScene.ts +++ b/apps/datagutt/game/scenes/PreloadScene.ts @@ -1,7 +1,6 @@ import Phaser from "phaser"; import { SERVICES_KEY, type GameServices } from "../boot"; import { EMOTE_FRAME } from "@datagutt/kai/ui/emotes"; -import { nowDoing } from "../live/datagutt"; import { CHARACTERS } from "../assets/manifest"; import { ANIMS, @@ -19,7 +18,6 @@ import { } from "@datagutt/kai/characters/sheet"; import { DialogueRunner } from "../dialogue/DialogueRunner"; import { Progress, PROGRESS_KEY } from "../progress/Progress"; -import { isUnlocked } from "../progress/unlocks"; import { browserStorage, loadSave, type SaveData } from "../save/save"; export const DIALOGUE_KEY = "dialogue"; @@ -39,8 +37,10 @@ export class PreloadScene extends Phaser.Scene { this.load.bitmapFont("pixel", "fonts/pixel.png", "fonts/pixel.xml"); this.load.image("ui:frame", "ui/frame.png"); this.load.spritesheet("ui:emotes", "ui/emotes.png", { frameWidth: EMOTE_FRAME, frameHeight: EMOTE_FRAME }); - // 12 frames of 48×16; the cat lies across the middle two tiles of each. - this.load.spritesheet("ui:cat", "ui/cat.png", { frameWidth: 48, frameHeight: 16 }); + // Animated strips from kai.json `sprites`, for plugins to place. + for (const [name, sprite] of Object.entries(services.config.sprites)) { + this.load.spritesheet(`sprite:${name}`, `ui/${name}.png`, { frameWidth: sprite.frameWidth, frameHeight: sprite.frameHeight }); + } for (const [id, recipe] of Object.entries(CHARACTERS)) { this.load.spritesheet(`char:${id}`, `characters/${id}.png`, { frameWidth: FRAME_WIDTH, frameHeight: FRAME_HEIGHT }); if (!("portrait" in recipe && recipe.portrait === false)) { @@ -77,7 +77,9 @@ export class PreloadScene extends Phaser.Scene { }); } } - this.anims.create({ key: "cat", frames: this.anims.generateFrameNumbers("ui:cat", {}), frameRate: 8, repeat: -1 }); + for (const [name, sprite] of Object.entries(services.config.sprites)) { + this.anims.create({ key: `sprite:${name}`, frames: this.anims.generateFrameNumbers(`sprite:${name}`, {}), frameRate: sprite.frameRate, repeat: -1 }); + } // One story for the whole game, so visit counts survive map changes and reloads. const saved = loadSave(browserStorage()); this.beginStory(services, saved); @@ -98,12 +100,9 @@ export class PreloadScene extends Phaser.Scene { const progress = new Progress(saved); if (settings) progress.settings = settings; this.registry.set(PROGRESS_KEY, progress); - const runner = new DialogueRunner(this.cache.json.get("dialogue"), { - world: services.world, - hasStamp: (place) => progress.hasStamp(place), - isUnlocked: (name) => isUnlocked(name, progress), - lanyardActivity: () => nowDoing(services.presence.current), - }, saved?.dialogue.main); + const ctx = { world: services.world, progress, services }; + const externals = Object.assign({}, services.externals(ctx), ...services.plugins.map((p) => p.externals?.(ctx) ?? {})); + const runner = new DialogueRunner(this.cache.json.get("dialogue"), externals, saved?.dialogue.main); this.registry.set(DIALOGUE_KEY, runner); } } diff --git a/apps/datagutt/game/scenes/WorldScene.ts b/apps/datagutt/game/scenes/WorldScene.ts index 78ec80c..72034de 100644 --- a/apps/datagutt/game/scenes/WorldScene.ts +++ b/apps/datagutt/game/scenes/WorldScene.ts @@ -2,7 +2,6 @@ import Phaser from "phaser"; import { SERVICES_KEY, type GameServices, type WorldTarget } from "../boot"; import { TILE } from "../constants"; import { Actor } from "../entities/Actor"; -import { LiveThomas, THOMAS_ID } from "../entities/LiveThomas"; import { GhostLayer } from "../entities/Ghosts"; import { EmoteWheel } from "../ui/EmoteWheel"; import { Prompt } from "../ui/Prompt"; @@ -12,6 +11,7 @@ import { InputController, type FrameInput, type InputDevice } from "../input/Inp import { browserStorage, writeSave } from "../save/save"; import { playBump, playPaper, playStamp, playTick, type AudioOutput } from "../audio/sfx"; import { stampForNpc } from "../progress/passport"; +import type { KaiPlugin, NpcDef, ObjectOf, Takeover, TileLayer, Usable, World } from "../plugins/api"; import { PROGRESS_KEY, type Progress } from "../progress/Progress"; import { StampToast } from "../ui/Passport"; import { MenuButton, StartMenu } from "../ui/StartMenu"; @@ -36,26 +36,16 @@ import { trackFor } from "../audio/playlist"; import { distanceField, FAR } from "../world/distance"; import { Feel } from "../fx/Feel"; import { FrameWatch, qualityFor, type Quality } from "../fx/quality"; -import { Intro } from "./Intro"; -import { CreditsRoll } from "../ui/CreditsRoll"; -import { ArcadeScreen } from "../ui/ArcadeScreen"; -import { makeArcade } from "../arcade"; -import { isArcadeId } from "../arcade/ids"; import { isUnlocked } from "../progress/unlocks"; -import { ACHIEVEMENTS, achievement, BLOCKS_TARGET, type AchievementId } from "../progress/achievements"; -import { fieldLevels } from "../live/field"; -import { spines } from "../live/shelf"; +import { ACHIEVEMENTS, achievement } from "../progress/achievements"; import { findPath, findPathAdjacent } from "../world/pathfind"; import { applySeason } from "@datagutt/kai/world/season"; import { resolveWeather, weatherSound } from "../world/weather"; -import type { WeatherNow } from "@datagutt/kai-live"; -import { CALM_WEATHER } from "../../content/live"; -import { statusLines } from "../live/datagutt"; +import { calmWeather, type WeatherNow } from "@datagutt/kai-live"; import { t } from "../strings"; -type Door = Extract; -type Sign = Extract; -type NpcDef = Extract; +type Door = ObjectOf<"door">; +type Sign = ObjectOf<"sign">; const PLAYER_ID = "player"; const tileKey = (p: Point) => `${p.x},${p.y}`; @@ -70,25 +60,29 @@ export class WorldScene extends Phaser.Scene { private grid!: CollisionGrid; private player!: Actor; private npcs = new Map(); - private thomas!: LiveThomas; + /** NPCs a plugin moves and syncs itself. */ + private managed = new Set(); private dayNight!: DayNight; private weather: Weather | null = null; - /** Oslo's weather, or the debug override; it rains on indoor maps too, for the ambience. */ - private weatherNow: WeatherNow = CALM_WEATHER; + /** The visitor's weather, or the debug override; it rains on indoor maps too, for the ambience. */ + private weatherNow!: WeatherNow; private water: Water | null = null; private aurora: Aurora | null = null; private prompt!: Prompt; private feel!: Feel; - private intro: Intro | null = null; private readonly frameWatch = new FrameWatch(); - private credits: CreditsRoll | null = null; - /** A cabinet being played (game/ui/ArcadeScreen.ts). */ - private arcade: { screen: ArcadeScreen; title: string } | null = null; - private cabinets = new Map>(); - /** Where the hidden cat lies (B3). */ - private cats = new Set(); - /** Fourth-wall lines at the map's edge: which is next, and when it may speak again. */ - private edgeQuietUntil = 0; + /** A plugin has the frame (a cutscene, a cabinet, the credits). */ + private takeover: Takeover | null = null; + private kaiPlugins: KaiPlugin[] = []; + private world!: World; + private spawns = new Map>(); + private spots = new Map>(); + private areas = new Map>(); + private layers = new Map(); + private outdoors = false; + private start: Point = { x: 0, y: 0 }; + /** The NPC under the interaction prompt. */ + private promptNpc: string | null = null; private ghosts!: GhostLayer; private wheel!: EmoteWheel; /** The player's own emote, shown for a moment after picking it. */ @@ -125,12 +119,17 @@ export class WorldScene extends Phaser.Scene { init(target: WorldTarget) { this.target = target; this.services = this.registry.get(SERVICES_KEY) as GameServices; + this.kaiPlugins = this.services.plugins; this.npcs = new Map(); + this.managed = new Set(); this.doors = new Map(); this.signs = new Map(); - this.cabinets = new Map(); - this.cats = new Set(); - this.arcade = null; + this.spawns = new Map(); + this.spots = new Map(); + this.areas = new Map(); + this.layers = new Map(); + this.takeover = null; + this.promptNpc = null; this.path = []; this.pathMarkers = []; this.onArrive = null; @@ -156,7 +155,7 @@ export class WorldScene extends Phaser.Scene { this.grid = new CollisionGrid(map.width, map.height); // Layer order matters: `collision` then `manual_collision`, whose clear tiles unblock. - const layers = new Map(); + const layers = this.layers; for (const layerData of map.layers) { const layer = map.createLayer(layerData.name, tileset); if (!layer) continue; @@ -180,45 +179,38 @@ export class WorldScene extends Phaser.Scene { }); } - const spawns = new Map>(); - const spots = new Map>(); - const areas = new Map>(); + this.outdoors = (map.properties as { name: string; value: unknown }[] | undefined)?.some((p) => p.name === "outdoor" && p.value === "true") ?? false; + this.world = this.makeWorld(); // Generated `objects` plus any `manual_*` object layers added in Tiled. const rawObjects = map.objects.flatMap((layer) => layer.objects) as unknown as TiledObject[]; const lights: LightObject[] = []; - const signs: Extract[] = []; - const gates: Extract[] = []; + const signs: Sign[] = []; + const gates: ObjectOf<"gate">[] = []; + const placed: MapObject[] = []; for (const raw of rawObjects) { const obj = parseMapObject(raw, TILE); - if (obj.type === "spawn") spawns.set(obj.id, obj); - if (obj.type === "spot") spots.set(obj.id, obj); - if (obj.type === "area") areas.set(obj.id, obj); - if (obj.type === "door") { + if (obj.type === "spawn") this.spawns.set(obj.id, obj); + else if (obj.type === "spot") this.spots.set(obj.id, obj); + else if (obj.type === "area") this.areas.set(obj.id, obj); + else if (obj.type === "door") { // A locked warp is solid ground until it opens, whatever the map around it // allows: nobody slips round a gate onto it. if (obj.unlock && !isUnlocked(obj.unlock, this.progress)) this.grid.setBlocked(obj.x, obj.y); else this.doors.set(tileKey(obj), obj); - } - if (obj.type === "sign") signs.push(obj); - if (obj.type === "arcade") this.cabinets.set(tileKey(obj), obj); - if (obj.type === "gate") gates.push(obj); - if (obj.type === "cat") { - // The cat lies across its tile and the next one east (the frame is 48 wide, - // the cat about 26 of it, centred at x 22.5). - for (const x of [obj.x, obj.x + 1]) { - this.cats.add(tileKey({ x, y: obj.y })); - this.grid.occupy(x, obj.y, "cat"); - } - this.add.sprite((obj.x + 1) * TILE, (obj.y + 1) * TILE, "ui:cat").setOrigin(22.5 / 48, 1).setDepth((obj.y + 1) * TILE).play("cat"); - } - if (obj.type === "light") lights.push(obj); - if (obj.type === "crops") this.plantField(obj, layers.get("decal")); - if (obj.type === "books") this.stockShelf(obj); - if (obj.type === "npc") { + } else if (obj.type === "sign") signs.push(obj); + else if (obj.type === "gate") gates.push(obj); + else if (obj.type === "light") lights.push(obj); + else if (obj.type === "npc") { const actor = new Actor(this, obj.id, obj.character, obj, obj.facing, NPC_MOVEMENT); this.npcs.set(obj.id, { actor, def: obj }); this.grid.occupy(obj.x, obj.y, obj.id); - } + } else placed.push(obj); + } + // Every other type belongs to the plugin that places it. + for (const obj of placed) { + const place = this.kaiPlugins.map((p) => p.objects?.[obj.type] as ((world: World, o: MapObject) => void) | undefined).find(Boolean); + if (place) place(this.world, obj); + else console.warn(`[world] ${this.target.map}: no plugin places "${obj.type}" objects`); } // A shut gate reads like a sign; an open one clears its barriers off the map. for (const gate of gates) { @@ -243,13 +235,13 @@ export class WorldScene extends Phaser.Scene { } const images = addLights(this, lights, this.progress.reducedMotion); - const outdoors = (map.properties as { name: string; value: unknown }[] | undefined)?.some((p) => p.name === "outdoor" && p.value === "true") ?? false; - // The finale is always at night. - const hours = this.services.finale ? () => 23 : this.services.hours; + const outdoors = this.outdoors; + // A story night stays at night, under a clear sky, whatever the clock and the weather. + const hours = this.services.night ? () => 23 : this.services.hours; this.dayNight = new DayNight(this, lights.map((light, i) => ({ light, image: images[i] })), outdoors, hours, this.services.month); const low = this.quality === "low"; - // The finale keeps its clear night sky whatever the weather. - this.weatherNow = this.services.finale ? CALM_WEATHER : resolveWeather(window.location.search, this.services.world.weather); + const weather = this.services.world.weather; + this.weatherNow = this.services.night ? calmWeather(weather.place) : resolveWeather(window.location.search, weather); this.weather = outdoors ? new Weather(this, this.services.season, this.weatherNow, this.progress.reducedMotion, low) : null; const clearSky = this.weatherNow.kind === "clear"; this.aurora = outdoors && !low && clearSky ? Aurora.create(this, this.services.season, this.progress.reducedMotion) : null; @@ -258,29 +250,14 @@ export class WorldScene extends Phaser.Scene { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.water?.destroy()); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.weather?.destroy()); - const spawn = this.target.spawn ? spawns.get(this.target.spawn) : undefined; - const startAt = this.target.tile ?? spawn ?? [...spawns.values()][0]; + const spawn = this.target.spawn ? this.spawns.get(this.target.spawn) : undefined; + const startAt = this.target.tile ?? spawn ?? [...this.spawns.values()][0]; if (!startAt) throw new Error(`Map ${this.target.map} has no spawn point`); const start = { x: startAt.x, y: startAt.y }; + this.start = start; const facing = this.target.facing ?? spawn?.facing ?? "down"; this.player = new Actor(this, PLAYER_ID, "player", start, facing, PLAYER_MOVEMENT); this.grid.occupy(start.x, start.y, PLAYER_ID); - this.thomas = new LiveThomas( - { - scene: this, - map: this.target.map, - grid: this.grid, - spots, - doors: this.doors, - addNpc: (def, actor) => this.npcs.set(def.id, { actor, def }), - removeNpc: (id) => this.npcs.delete(id), - playerTile: () => this.player.mover.tile, - }, - this.services.presence, - rosterNpc(THOMAS_ID)?.name ?? "Thomas", - this.services.finale ? { place: "pier", asleep: false, wanders: false, emote: null, says: null, dialogue: "datagutt_finale" } : null, - ); - this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.thomas.destroy()); // Other visitors: this map's room, joined at the player's tile. this.ghosts = new GhostLayer(this, this.target.map); @@ -340,21 +317,14 @@ export class WorldScene extends Phaser.Scene { this.save(); if (effectsChanged) this.applyQuality(); }, - status: () => statusLines(this.services.presence.current), - openJournal: () => { - this.save(); - window.location.href = "/journal"; - }, + items: () => this.kaiPlugins.flatMap((p) => p.menuItems?.(this.world) ?? []), sound: (kind) => (kind === "open" ? playPaper(this.audioOut) : playTick(this.audioOut, kind === "select" ? 660 : 880)), }); this.stampToast = new StampToast(this); - // Saves from before achievements with a full passport get it quietly. - if (isUnlocked("passport", this.progress)) this.progress.achieve("passport"); - if (this.target.map === "mountain") this.time.delayedCall(600, () => this.achieve("summit")); this.fitCamera(); this.feel = new Feel(this, this.player, () => this.progress.reducedMotion); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.feel.destroy()); - this.startIntro(areas.get("ferry"), start, [layers.get("below"), layers.get("above")]); + for (const plugin of this.kaiPlugins) plugin.mapCreated?.(this.world); this.cameras.main.fadeIn(180, 11, 19, 32); this.scale.on(Phaser.Scale.Events.RESIZE, this.onResize, this); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.scale.off(Phaser.Scale.Events.RESIZE, this.onResize, this)); @@ -395,26 +365,19 @@ export class WorldScene extends Phaser.Scene { this.applyQuality(); } this.water?.update(); - this.aurora?.update(this.dayNight.current.dark, this.services.finale); + this.aurora?.update(this.dayNight.current.dark, this.services.night); this.updateAmbience(time); this.weather?.update(this.dayNight.current.dark); - if (this.transitioning || this.menu.open || this.dialogue.open || this.wheel.open || this.arcade) this.prompt.hide(); + if (this.transitioning || this.menu.open || this.dialogue.open || this.wheel.open || this.takeover) this.prompt.hide(); this.updateDebug(); if (this.transitioning) return; - if (this.credits && !this.dialogue.open) { - this.credits.update(dt, input); - this.player.sync(); - return; - } - if (this.intro?.active && !this.dialogue.open) { - this.intro.update(input); - for (const [id, { actor }] of this.npcs) if (id !== THOMAS_ID) actor.sync(); - return; - } - if (this.arcade) { - if (!this.arcade.screen.update(dt, input)) this.arcade = null; - this.player.sync(); + // A takeover pauses while it has dialogue on screen (a cutscene's conversation). + if (this.takeover && !this.dialogue.open) { + const takeover = this.takeover; + if (!takeover.update(dt, input) && this.takeover === takeover) this.takeover = null; + if (takeover.syncNpcs) this.syncNpcs(); + else this.player.sync(); return; } if (this.menu.open) { @@ -464,8 +427,8 @@ export class WorldScene extends Phaser.Scene { } this.player.sync(); - for (const [id, { actor }] of this.npcs) if (id !== THOMAS_ID) actor.sync(); - this.thomas.update(dt, time); + this.syncNpcs(); + for (const plugin of this.kaiPlugins) plugin.update?.(this.world, dt, time); this.simulated?.update(dt); this.ghosts.update(dt, time, this.player.mover.tile); this.updateSelfEmote(time); @@ -474,29 +437,9 @@ export class WorldScene extends Phaser.Scene { } - /** On a first visit, arriving at the dock: sail in on the ferry and meet Arne. */ - private startIntro(ferry: Extract | undefined, spawn: Point, layers: (Phaser.Tilemaps.TilemapLayer | Phaser.Tilemaps.TilemapGPULayer | undefined)[]) { - const arne = this.npcs.get("ferryman"); - const forced = new URLSearchParams(window.location.search).has("intro"); - if (!ferry || !arne || !this.services.firstVisit || this.target.spawn !== "ferry" || (this.progress.flags.intro && !forced)) return; - this.intro = new Intro( - this, - this.player, - ferry, - layers.filter((l) => l !== undefined), - spawn, - this.progress.reducedMotion, - (done) => { - arne.actor.mover.face("left"); - this.playKnot("ferryman_intro", arne.def, () => { - this.progress.flags.intro = true; - this.services.firstVisit = false; - this.save(); - done(); - }); - }, - () => (this.intro = null), - ); + /** The map NPCs' sprites follow their movers; plugins sync the NPCs they manage. */ + private syncNpcs() { + for (const [id, { actor }] of this.npcs) if (!this.managed.has(id)) actor.sync(); } /** How far the sea, the forest and any fire are from every tile, for the ambience. */ @@ -537,9 +480,10 @@ export class WorldScene extends Phaser.Scene { }), ); const { muted, music } = this.progress.settings; - const moment = this.credits - ? ({ scene: "credits" } as const) - : { scene: "world" as const, map: this.target.map, outdoors: s.outdoors, phase: this.dayNight.current.phase, season: this.services.season, finale: this.services.finale }; + const moment = + this.takeover?.music === "credits" + ? ({ scene: "credits" } as const) + : { scene: "world" as const, map: this.target.map, outdoors: s.outdoors, phase: this.dayNight.current.phase, season: this.services.season, night: this.services.night }; // Muted or switched off: nothing plays, and nothing downloads. this.services.music.play(muted || !music ? null : trackFor(moment)); } @@ -625,14 +569,9 @@ export class WorldScene extends Phaser.Scene { quality: this.quality, ambience: this.ambience.levels, music: this.services.music.current, - presence: this.services.presence.current, - thomas: this.thomas.state, ghosts: this.ghosts.count, emoteWheel: this.wheel.open, - intro: this.intro?.active ?? false, - arcade: this.arcade?.title ?? null, - credits: this.credits !== null, - finale: this.services.finale, + takeover: this.takeover?.name ?? null, prompt: this.prompt.text, tile: { ...p }, facing: this.player.mover.facing, @@ -646,6 +585,7 @@ export class WorldScene extends Phaser.Scene { menu: this.menu.currentView, audio: this.sound instanceof Phaser.Sound.WebAudioSoundManager ? this.sound.context.state : "none", camera: { x: this.cameras.main.worldView.x, y: this.cameras.main.worldView.y, zoom: this.scale.zoom }, + ...Object.assign({}, ...this.kaiPlugins.map((p) => p.debug?.(this.world) ?? {})), }; } @@ -676,7 +616,9 @@ export class WorldScene extends Phaser.Scene { } } else if (e.type === "bumped") { const ahead = neighbour(this.player.mover.tile, e.facing); - if (ahead.x < 0 || ahead.y < 0 || ahead.x >= this.mapWidth || ahead.y >= this.mapHeight) this.bumpedEdge(); + if (ahead.x < 0 || ahead.y < 0 || ahead.x >= this.mapWidth || ahead.y >= this.mapHeight) { + for (const plugin of this.kaiPlugins) plugin.bumpedEdge?.(this.world); + } this.clearPath(); this.feel.bump(e.facing); playBump(this.audioOut); @@ -705,7 +647,7 @@ export class WorldScene extends Phaser.Scene { const npc = this.npcAt(target); const sign = this.signs.get(tileKey(target)); - if (npc || sign) { + if (npc || sign || this.usableAt(target)) { const path = findPathAdjacent(walkable, from, target); if (!path) return; this.setPath(path, () => { @@ -744,34 +686,6 @@ export class WorldScene extends Phaser.Scene { return id && id !== PLAYER_ID ? this.npcs.get(id) : undefined; } - /** Ola's field: one tile per day of the last weeks, crops as tall as the commits. */ - private plantField(area: Extract, decal?: Phaser.Tilemaps.TilemapLayer | Phaser.Tilemaps.TilemapGPULayer) { - const days = this.services.world.contributions; - // Without live data the generator's sample crops stay. - if (!decal || !days.length) return; - const stages = area.stages.split(",").map(Number); - fieldLevels(days, area.w).forEach((row, dy) => { - if (dy >= area.h) return; - row.forEach((level, dx) => { - const x = area.x + dx; - const y = area.y + dy; - if (!level) decal.removeTileAt(x, y); - else decal.putTileAt(stages[Math.min(level, stages.length) - 1], x, y); - }); - }); - } - - /** The library's featured shelf: a spine per pinned repo, in its language colour. */ - private stockShelf(area: Extract) { - const g = this.add.graphics().setDepth(-0.5); - for (const s of spines(this.services.world.repos, area.w * TILE, area.h * TILE - 2)) { - const x = area.x * TILE + s.x; - const y = area.y * TILE + s.y; - g.fillStyle(s.edge).fillRect(x, y, s.w, s.h); - g.fillStyle(s.color).fillRect(x, y + 1, s.w - 1, s.h - 1); - } - } - /** * What interact would use: the tile ahead, or across a counter, Pokémon style (up to * two blocked tiles with nothing on them, and someone standing right behind). @@ -779,7 +693,7 @@ export class WorldScene extends Phaser.Scene { private targetAhead(): Point { const { tile, facing } = this.player.mover; const ahead = neighbour(tile, facing); - const isCounter = (p: Point) => !this.npcAt(p) && !this.signs.has(tileKey(p)) && !this.grid.isWalkable(p.x, p.y, PLAYER_ID); + const isCounter = (p: Point) => !this.npcAt(p) && !this.signs.has(tileKey(p)) && !this.usableAt(p) && !this.grid.isWalkable(p.x, p.y, PLAYER_ID); for (let p = ahead, depth = 0; depth < 2 && isCounter(p); depth++) { p = neighbour(p, facing); if (this.npcAt(p)) return p; @@ -790,7 +704,7 @@ export class WorldScene extends Phaser.Scene { private interactAhead() { const target = this.targetAhead(); // A door ahead: interact walks through it, as the prompt promises. - if (this.doors.has(tileKey(target)) && !this.npcAt(target) && !this.signs.has(tileKey(target))) { + if (this.doors.has(tileKey(target)) && !this.npcAt(target) && !this.signs.has(tileKey(target)) && !this.usableAt(target)) { this.handleEvents(this.player.mover.walk(this.player.mover.facing, false, (p) => this.grid.isWalkable(p.x, p.y, PLAYER_ID))); return; } @@ -801,15 +715,15 @@ export class WorldScene extends Phaser.Scene { private updatePrompt(device: InputDevice) { const target = this.player.mover.moving || this.path.length ? null : this.targetAhead(); const npc = target && this.npcAt(target); - this.thomas.quiet = npc?.def.id === THOMAS_ID; + this.promptNpc = npc ? npc.def.id : null; if (!target) return this.prompt.hide(); if (npc) { - return this.prompt.show(npc.def.dialogue.endsWith("_asleep") ? "Wake" : "Talk", device, npc.actor.centerX, npc.actor.headTop - 3); + const action = this.kaiPlugins.map((p) => p.promptFor?.(this.world, npc.def)).find(Boolean) ?? "Talk"; + return this.prompt.show(action, device, npc.actor.centerX, npc.actor.headTop - 3); } const x = (target.x + 0.5) * TILE; - if (this.cats.has(tileKey(target))) return this.prompt.show("Pet", device, x, target.y * TILE - 1); - const cabinet = this.cabinets.get(tileKey(target)); - if (cabinet) return this.prompt.show(cabinet.game === "stargazing" ? "Look" : cabinet.game === "screensaver" ? "Use" : "Play", device, x, target.y * TILE - 1); + const usable = this.usableAt(target); + if (usable) return this.prompt.show(usable.prompt, device, x, target.y * TILE - 1); if (this.signs.has(tileKey(target))) return this.prompt.show("Read", device, x, target.y * TILE - 1); if (this.doors.has(tileKey(target))) return this.prompt.show("Enter", device, x, target.y * TILE - 1); this.prompt.hide(); @@ -823,69 +737,44 @@ export class WorldScene extends Phaser.Scene { this.talk(npc.def); return; } - if (this.cats.has(tileKey(p))) { - this.playKnot("cat_petted", null, () => this.achieve("cat")); - return; + const usable = this.usableAt(p); + if (usable) { + this.clearPath(); + return usable.use(); } - const cabinet = this.cabinets.get(tileKey(p)); - if (cabinet) return this.playCabinet(cabinet); const sign = this.signs.get(tileKey(p)); if (sign?.dialogue) this.playKnot(sign.dialogue, null, () => this.save()); else if (sign) this.dialogue.say(sign.text, null, () => this.dialogue.close()); } /** Earn an achievement once: a banner (unless `announce` is false) and a save. */ - private achieve(id: AchievementId, announce = true) { + private achieve(id: string, announce = true) { if (!this.progress.achieve(id)) return; if (announce) this.stampToast.showAchievement(achievement(id).name, this.progress.reducedMotion); this.save(); } - /** Walking into the edge of the map: a fourth-wall line now and then (B3). */ - private bumpedEdge() { - if (this.time.now < this.edgeQuietUntil || this.dialogue.open) return; - this.edgeQuietUntil = this.time.now + 20_000; - this.playKnot("edge_of_world", null, () => this.achieve("edge")); - } - - /** Step up to a cabinet: its game takes the input until the player leaves (back). */ - private playCabinet(cabinet: Extract) { - // The binoculars only show stars once it's dark (or on the finale's night). - if (cabinet.game === "stargazing" && this.dayNight.current.dark < 0.5 && !this.services.finale) { - this.playKnot("binoculars_by_day", null, () => {}); - return; + /** What a plugin lets the player use on `p`, if anything. */ + private usableAt(p: Point): Usable | null { + for (const plugin of this.kaiPlugins) { + const usable = plugin.usableAt?.(this.world, p); + if (usable) return usable; } - if (!isArcadeId(cabinet.game)) return; - if (cabinet.game === "stargazing") this.achieve("stars"); - const progress = this.progress; - const game = makeArcade(cabinet.game, { - best: (name) => progress.records[name] ?? 0, - record: (name, score) => { - if (progress.record(name, score)) this.save(); - if (name === "blocks" && score >= BLOCKS_TARGET) this.achieve("blocks"); - }, - }); - this.clearPath(); - this.arcade = { screen: new ArcadeScreen(this, game, () => this.save()), title: game.title }; + return null; } /** Play an NPC's Ink knot beat by beat until it ends. */ private talk(npc: NpcDef) { - if (npc.dialogue === "datagutt_finale") return this.playFinale(npc); - // Each NPC's own knot has their id. Another knot first (Thomas asleep) only counts + if (this.kaiPlugins.some((p) => p.talk?.(this.world, npc))) return; + // Each NPC's own knot has their id. Another knot first (someone asleep) only counts // as talking to them if it leads there. const runner = this.registry.get(DIALOGUE_KEY) as DialogueRunner; const visits = runner.visits(npc.id); const knot = npc.dialogue; this.playKnot(knot, npc, () => { - if (runner.visits(npc.id) > visits) { - // Woken up and talked to: he gets out of bed rather than lying back down. - if (npc.id === THOMAS_ID && knot === "datagutt_asleep") { - this.thomas.wokenByPlayer(); - this.achieve("wake"); - } - this.finishedTalking(npc.id); - } + const reached = runner.visits(npc.id) > visits; + for (const plugin of this.kaiPlugins) plugin.talked?.(this.world, npc, knot, reached); + if (reached) this.finishedTalking(npc.id); this.save(); }); } @@ -935,34 +824,7 @@ export class WorldScene extends Phaser.Scene { playStamp(this.audioOut); this.feel.shake(); this.stampToast.show(result.newStamp, result.stamps.length, this.progress.reducedMotion); - // The last stamp: once the toast has had its moment, the finale begins. - // The last stamp's banner and the finale celebrate it; the achievement comes quietly. - if (result.complete) this.achieve("passport", false); - if (result.complete && !this.progress.flags.finale) this.time.delayedCall(1600, () => this.beginFinale()); - } - - /** A full passport: a note from Thomas, then night falls and he waits on the pier. */ - private beginFinale() { - this.playKnot("finale_note", null, () => { - this.services.finale = true; - this.goTo({ map: "town", spawn: "finale" }); - }); - } - - /** Thomas on the pier: his goodbye, the credits, and how to reach him. */ - private playFinale(npc: NpcDef) { - this.playKnot(npc.dialogue, npc, () => { - this.credits = new CreditsRoll(this, this.progress.reducedMotion, () => { - this.credits = null; - this.achieve("credits"); - this.playKnot("datagutt_contact", npc, () => { - // This night stays until the player moves on; the next map is back to normal. - this.progress.flags.finale = true; - this.services.finale = false; - this.save(); - }); - }); - }); + for (const plugin of this.kaiPlugins) plugin.stamped?.(this.world, result.newStamp, result.complete); } /** "Open github.com?" after a line with a `# link:` tag. */ @@ -990,6 +852,68 @@ export class WorldScene extends Phaser.Scene { cam.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => this.scene.restart(target)); } + /** The world as plugins see it: live views of this scene's state. */ + private makeWorld(): World { + const scene = this; + return { + scene, + get map() { + return scene.target.map; + }, + get outdoors() { + return scene.outdoors; + }, + get grid() { + return scene.grid; + }, + get player() { + return scene.player; + }, + get services() { + return scene.services; + }, + get progress() { + return scene.progress; + }, + get daylight() { + return scene.dayNight.current; + }, + layers: scene.layers, + spawns: scene.spawns, + spots: scene.spots, + areas: scene.areas, + doors: scene.doors, + get arrival() { + return { target: scene.target, tile: scene.start }; + }, + get dialogueOpen() { + return scene.dialogue.open; + }, + get promptNpc() { + return scene.promptNpc; + }, + addNpc(def, actor, options) { + scene.npcs.set(def.id, { actor, def }); + if (options?.managed) scene.managed.add(def.id); + }, + removeNpc(id) { + scene.npcs.delete(id); + scene.managed.delete(id); + }, + npc: (id) => scene.npcs.get(id), + playKnot: (knot, npc, onEnd) => scene.playKnot(knot, npc, onEnd), + achieve: (id, announce) => scene.achieve(id, announce), + save: () => scene.save(), + goTo: (target, fadeMs) => scene.goTo(target, fadeMs), + takeOver(takeover) { + scene.clearPath(); + scene.takeover = takeover; + }, + clearPath: () => scene.clearPath(), + onShutdown: (fn) => scene.events.once(Phaser.Scenes.Events.SHUTDOWN, fn), + }; + } + private save() { const { tile, facing } = this.player.mover; const progress = this.progress; diff --git a/apps/datagutt/game/ui/StartMenu.ts b/apps/datagutt/game/ui/StartMenu.ts index b0ab661..00f3f27 100644 --- a/apps/datagutt/game/ui/StartMenu.ts +++ b/apps/datagutt/game/ui/StartMenu.ts @@ -1,10 +1,11 @@ -// START menu (docs/game/PLAN.md M2.11): Passport, datagutt's live status, Journal, -// Settings, Credits. Opened with Enter, Start on a gamepad, or the on-screen Menu button. -// Items that depend on later milestones (other visitors) join the list when they exist. +// START menu: Passport, the plugins' items, Settings, Credits. Opened with Enter, Start on +// a gamepad, or the on-screen Menu button. A plugin's item runs an action or opens a page +// of text. import Phaser from "phaser"; import type { Facing } from "@datagutt/kai/world/objects"; import { PassportPanel } from "./Passport"; import { credits } from "../../content/credits"; +import type { MenuItem } from "../plugins/api"; import type { EffectsSetting } from "../save/save"; import { t } from "../strings"; @@ -24,13 +25,15 @@ export type MenuHooks = { achievements(): readonly string[]; settings(): MenuSettings; changeSettings(next: MenuSettings): void; - openJournal(): void; - /** Thomas's live status, one line each (game/live/datagutt.ts statusLines). */ - status(): string[]; + /** The plugins' items, shown after the Passport. */ + items(): MenuItem[]; sound(kind: "open" | "move" | "select"): void; }; -type View = "closed" | "main" | "passport" | "settings" | "credits" | "status"; +/** A built-in view, or the id of a plugin's page of text. */ +type View = "closed" | "main" | "passport" | "settings" | "credits" | (string & {}); + +type Page = { title: string; lines: string[] }; const CREDITS = [`${credits.title}, ${credits.byline.toLowerCase()}.`, ...credits.sections.map((s) => `${s.heading}: ${s.lines.join(" ")}`)]; @@ -41,6 +44,8 @@ export class StartMenu { private container?: Phaser.GameObjects.Container; private readonly passport: PassportPanel; private rows: { x: number; y: number; w: number; h: number }[] = []; + /** The page of text on show (credits, or a plugin's page). */ + private page: Page | null = null; constructor( private readonly scene: Phaser.Scene, @@ -83,7 +88,7 @@ export class StartMenu { } else if (input.interact || input.back || input.menu || input.taps.length) this.backToMain(); return; } - if (this.view === "credits" || this.view === "status") { + if (this.page) { if (input.interact || input.back || input.menu || input.taps.length) this.backToMain(); return; } @@ -113,6 +118,7 @@ export class StartMenu { private backToMain(): void { this.passport.close(); + this.page = null; this.view = "main"; this.selected = 0; this.render(); @@ -138,12 +144,15 @@ export class StartMenu { { label: t("menu.back"), run: () => this.backToMain() }, ]; } + const own = this.hooks.items().map((item) => ({ + label: item.label, + run: "run" in item ? () => item.run() : () => this.openPage(item.id, { title: item.title, lines: item.lines() }), + })); return [ { label: t("menu.passport"), run: () => this.showPassport() }, - { label: t("menu.status"), run: () => this.openView("status") }, - { label: t("menu.journal"), run: () => this.hooks.openJournal() }, + ...own, { label: t("menu.settings"), run: () => this.openView("settings") }, - { label: t("menu.credits"), run: () => this.openView("credits") }, + { label: t("menu.credits"), run: () => this.openPage("credits", { title: t("credits.title"), lines: CREDITS }) }, { label: t("menu.close"), run: () => this.close() }, ]; } @@ -160,6 +169,11 @@ export class StartMenu { this.render(); } + private openPage(view: string, page: Page): void { + this.page = page; + this.openView(view); + } + private showPassport(): void { this.view = "passport"; this.container?.destroy(); @@ -179,15 +193,15 @@ export class StartMenu { const parts: Phaser.GameObjects.GameObject[] = []; const g = scene.add.graphics(); - if (this.view === "credits" || this.view === "status") { - const text = this.view === "credits" ? CREDITS.join("\n\n") : this.hooks.status().join("\n"); + if (this.page) { + const text = this.page.lines.join(this.view === "credits" ? "\n\n" : "\n"); const w = Math.min(cam.width - 16, 300); const body = scene.add.bitmapText(12, 9 + lh + 6, FONT, text).setTint(INK).setMaxWidth(w - 24); const bodyBottom = 9 + lh + 6 + Math.ceil(body.getTextBounds().local.height); const hint = scene.add.bitmapText(12, bodyBottom + 8, FONT, t("menu.backHint")).setTint(FADED).setMaxWidth(w - 24); const h = bodyBottom + 8 + Math.ceil(hint.getTextBounds().local.height) + 12; parts.push(scene.add.nineslice(0, 0, FRAME, undefined, w, h, ...SLICE).setOrigin(0), body, hint); - parts.push(scene.add.bitmapText(12, 9, FONT, t(this.view === "credits" ? "credits.title" : "status.title")).setTint(ACCENT)); + parts.push(scene.add.bitmapText(12, 9, FONT, this.page.title).setTint(ACCENT)); this.container = scene.add .container(Math.floor((cam.width - w) / 2), Math.max(4, Math.floor((cam.height - h) / 2)), parts) .setScrollFactor(0) diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index a4b9efc..201976f 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -142,18 +142,29 @@ Goal: every hardcoded piece of content is a JSON or Markdown resource, validated Goal: `@datagutt/kai` holds no Fjord Town behaviour. +Order: the plugin API and the triggers are built inside the app first (K5.2, K5.3), and +each piece of Fjord Town behaviour moves into a plugin there (K5.4, K5.5) with e2e green +after each. Only then does the now generic runtime move into the package (K5.1): moving +it first would make the package import app code. + - [ ] **K5.1** `@datagutt/kai`: move the engine parts of `game/`. `createGame({ config, content, plugins })` replaces `bootGame`. `ArcadeScreen` moves to `kai-arcade`, which then depends on `kai`. *Done when* the app boots through `createGame`. - [ ] **K5.2** `KaiPlugin` API: setup, map enter, interaction, update, map object types, Ink externals, start menu items. *Done when* the API has unit tests with a fake - scene. -- [ ] **K5.3** Declarative triggers in content (enter map, talk to NPC, finish knot, + scene. (The API is in `game/plugins/api.ts` and runs Fjord Town; it moves with the + runtime in K5.1. Still open: its unit tests.) +- [x] **K5.3** Declarative triggers in content (enter map, talk to NPC, finish knot, flag set) that grant achievements and set flags. *Done when* `summit` comes from a - trigger. -- [ ] **K5.4** Move Fjord Town behaviour out of `WorldScene` into app plugins: the + trigger. (`content/triggers.json`: `enterMap`, `bumpEdge`, `passportFull`, each able + to play a knot, grant and set a flag. Summit, the edge lines and the passport + achievement are triggers.) +- [x] **K5.4** Move Fjord Town behaviour out of `WorldScene` into app plugins: the ferry intro, the cat, edge lines, the finale, the passport, the credits link. - *Done when* `WorldScene` names no NPC, map or knot of Fjord Town. + *Done when* `WorldScene` names no NPC, map or knot of Fjord Town. (Plugins in + `game/plugins/`: triggers, cat, arcade, github, ferry intro, finale, presence, + journal. The finale's night is the engine's generic `services.night`. The passport + itself stays engine: stamps are a place's `stamp` flag.) - [ ] **K5.5** `presenceNpc`, `githubField` and `repoShelf` plugins in `kai-live`, configured by `content/presence.json` and `kai.json`. The mock presences move to JSON. *Done when* the `?presence=` e2e pass. diff --git a/packages/kai-assets/src/cli.ts b/packages/kai-assets/src/cli.ts index 395f242..7d9e4ab 100644 --- a/packages/kai-assets/src/cli.ts +++ b/packages/kai-assets/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun // The kai command line, run from a game's folder (the one with kai.json): // -// kai content check content/ against its schemas, write .kai/ +// kai content check content/ and kai.json, write .kai/ // kai assets fetch the art, then build public/ // kai world gen|check|render [...] generate, check or render the maps (see world/gen.ts) // kai characters contact sheet of every character (world/out/) @@ -12,7 +12,7 @@ import { fetchArt } from "./art/fetch.ts"; import { localArtDir } from "./art/source.ts"; import { buildAssets } from "./build/assets.ts"; import { reviewCharacters } from "./characters/review.ts"; -import { compileContent, ContentError, writeContent } from "./content/compile.ts"; +import { compileContent, ContentError, writeConfig, writeContent } from "./content/compile.ts"; import { loadCollections } from "./content/load.ts"; import { devHarness } from "./dev/harness.ts"; import { worldGen } from "./world/gen.ts"; @@ -24,6 +24,7 @@ const commands: Record Promise> = { async content() { const collections = await loadCollections(appDir); writeContent(appDir, collections, compileContent(appDir, collections)); + writeConfig(appDir, loadApp(appDir).config); console.log(`[content] ${Object.keys(collections).length} collections checked`); }, async assets() { diff --git a/packages/kai-assets/src/content/compile.ts b/packages/kai-assets/src/content/compile.ts index b7df503..3ffcc2d 100644 --- a/packages/kai-assets/src/content/compile.ts +++ b/packages/kai-assets/src/content/compile.ts @@ -92,6 +92,12 @@ export function jsonSchemas(collections: Collections): Record { return out; } +/** kai.json as validated, defaults filled in, for the game to read at runtime (.kai/config.json). */ +export function writeConfig(appDir: string, config: unknown): void { + fs.mkdirSync(path.join(appDir, BUNDLE_DIR), { recursive: true }); + fs.writeFileSync(path.join(appDir, BUNDLE_DIR, "config.json"), JSON.stringify(config, null, "\t") + "\n"); +} + /** Writes the bundle and the editor schemas under `/.kai/`. */ export function writeContent(appDir: string, collections: Collections, content: Record): void { const out = path.join(appDir, BUNDLE_DIR); diff --git a/packages/kai/src/schema/config.ts b/packages/kai/src/schema/config.ts index 5032473..b6a8fe5 100644 --- a/packages/kai/src/schema/config.ts +++ b/packages/kai/src/schema/config.ts @@ -57,7 +57,14 @@ export const kaiConfigSchema = z.object({ sprites: z .record( id, - z.object({ file: artPath, frameWidth: z.int().positive(), frameHeight: z.int().positive(), frames: z.int().positive() }), + z.object({ + file: artPath, + frameWidth: z.int().positive(), + frameHeight: z.int().positive(), + frames: z.int().positive(), + /** The game plays it as a loop, texture and animation `sprite:`, at this rate. */ + frameRate: z.number().positive().default(8), + }), ) .default({}), /** The in-game bitmap font, drawn from a web font in an npm package. */ diff --git a/packages/kai/src/schema/engine.ts b/packages/kai/src/schema/engine.ts index 9731082..ddbd45f 100644 --- a/packages/kai/src/schema/engine.ts +++ b/packages/kai/src/schema/engine.ts @@ -95,6 +95,29 @@ export const unlockCondition = z.object({ stamps: z.literal("all") }); export type CharacterRecipe = z.output; +/** What a trigger does: play a knot (narrated), then grant an achievement and set a flag. */ +const effects = { + knot: text.optional(), + grant: id.optional(), + /** Show the achievement's banner. */ + announce: z.boolean().default(true), + flag: text.optional(), +}; + +/** + * A rule that needs no code. `enterMap` fires as the player arrives on `map` (after + * `delay` ms), `bumpEdge` when they walk into the map's edge (at most once per `cooldown` + * ms), and `passportFull` whenever the passport is checked and every stamp is in (on every + * map load and after each stamp), so keep it to granting. + */ +export const trigger = z.discriminatedUnion("on", [ + z.object({ on: z.literal("enterMap"), map: id, delay: z.int().min(0).default(0), ...effects }), + z.object({ on: z.literal("bumpEdge"), cooldown: z.int().min(0).default(0), ...effects }), + z.object({ on: z.literal("passportFull"), ...effects }), +]); + +export type Trigger = z.output; + export const credits = z.object({ title: text, byline: text, @@ -114,6 +137,8 @@ export const ENGINE_COLLECTIONS = { unlocks: json(z.record(id, unlockCondition).default({})), /** The credits page and the credits roll. */ credits: json(credits), + /** Declarative rules: achievements and flags without code. */ + triggers: json(z.object({ list: z.array(trigger) }).default({ list: [] })), /** Overrides of the engine's UI copy by key (@datagutt/kai/ui/strings). An unknown key is an error. */ strings: json( z From 1c538e3aaf00790977c0002ddd31371a5342d08b Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 21:39:00 +0200 Subject: [PATCH 130/153] refactor(kai): move the generic runtime into the kai package createGame() takes the game's config, compiled content, live data, link resolver, dialogue functions and plugins. Content reaches scenes and ui through a GameData object on the services instead of module imports of the app. The weather types move into the runtime so that kai-live can depend on it without a cycle. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- .../components/journal/PassportProgress.tsx | 10 +- apps/datagutt/e2e/game.spec.ts | 4 +- apps/datagutt/game/assets/manifest.test.ts | 19 --- apps/datagutt/game/assets/manifest.ts | 23 ---- apps/datagutt/game/audio/playlist.test.ts | 46 ------- apps/datagutt/game/audio/playlist.ts | 24 ---- apps/datagutt/game/data.test.ts | 108 +++++++++++++++ apps/datagutt/game/data.ts | 9 ++ .../game/dialogue/DialogueRunner.test.ts | 2 +- apps/datagutt/game/dialogue/externals.ts | 2 +- apps/datagutt/game/entities/LiveThomas.ts | 10 +- apps/datagutt/game/index.ts | 33 +++-- apps/datagutt/game/plugins/Intro.ts | 8 +- apps/datagutt/game/plugins/arcade.ts | 8 +- apps/datagutt/game/plugins/cat.ts | 4 +- apps/datagutt/game/plugins/ferryIntro.ts | 2 +- apps/datagutt/game/plugins/finale.ts | 6 +- apps/datagutt/game/plugins/github.ts | 10 +- apps/datagutt/game/plugins/journal.ts | 5 +- apps/datagutt/game/plugins/presence.ts | 11 +- apps/datagutt/game/progress/achievements.ts | 19 --- apps/datagutt/game/progress/passport.test.ts | 45 ------- apps/datagutt/game/progress/passport.ts | 30 ----- apps/datagutt/game/progress/unlocks.test.ts | 11 -- apps/datagutt/game/progress/unlocks.ts | 14 -- apps/datagutt/game/strings.ts | 8 -- apps/datagutt/kai.json | 2 + bun.lock | 11 ++ docs/kai/PLAN.md | 8 +- packages/kai-arcade/package.json | 9 +- .../kai-arcade/src}/ArcadeScreen.ts | 12 +- packages/kai-live/package.json | 1 + packages/kai-live/src/weather.ts | 40 +----- packages/kai-net/src/ghosts.test.ts | 6 +- packages/kai-net/src/ghosts.ts | 10 +- packages/kai/package.json | 12 +- .../kai/src}/audio/Ambience.ts | 4 +- .../game => packages/kai/src}/audio/Music.ts | 15 ++- .../kai/src}/audio/blips.test.ts | 12 +- .../game => packages/kai/src}/audio/blips.ts | 24 +--- .../kai/src}/audio/mix.test.ts | 2 +- .../game => packages/kai/src}/audio/mix.ts | 2 +- .../game => packages/kai/src}/audio/sfx.ts | 0 .../game => packages/kai/src}/boot.ts | 110 +++++++++------- .../game => packages/kai/src}/constants.ts | 0 packages/kai/src/data.ts | 124 ++++++++++++++++++ .../kai/src}/dev/simulatedGhosts.ts | 4 +- .../kai/src}/dialogue/DialogueRunner.ts | 0 .../kai/src}/entities/Actor.ts | 10 +- .../kai/src}/entities/Ghosts.ts | 8 +- .../game => packages/kai/src}/fx/Aurora.ts | 4 +- .../game => packages/kai/src}/fx/DayNight.ts | 6 +- .../game => packages/kai/src}/fx/Feel.ts | 6 +- .../game => packages/kai/src}/fx/Lights.ts | 6 +- .../game => packages/kai/src}/fx/Water.ts | 4 +- .../game => packages/kai/src}/fx/Weather.ts | 8 +- .../kai/src}/fx/quality.test.ts | 2 +- .../game => packages/kai/src}/fx/quality.ts | 2 +- packages/kai/src/index.ts | 9 ++ .../kai/src}/input/InputController.ts | 4 +- .../kai/src}/input/directionStack.ts | 2 +- .../game => packages/kai/src}/plugins/api.ts | 16 +-- .../kai/src}/plugins/triggers.ts | 7 +- .../kai/src}/progress/Progress.ts | 18 +-- .../kai/src}/save/save.test.ts | 32 ++--- .../game => packages/kai/src}/save/save.ts | 18 +-- .../kai/src}/scenes/BootScene.ts | 0 .../kai/src}/scenes/PreloadScene.ts | 25 ++-- .../kai/src}/scenes/WorldScene.ts | 117 ++++++++--------- packages/kai/src/schema/config.test.ts | 2 + packages/kai/src/schema/config.ts | 4 + .../game => packages/kai/src}/ui/Bubbles.ts | 4 +- .../kai/src}/ui/CreditsRoll.ts | 8 +- .../kai/src}/ui/DialogueBox.ts | 6 +- .../kai/src}/ui/EmoteWheel.ts | 4 +- .../kai/src}/ui/LinkOpener.ts | 0 .../game => packages/kai/src}/ui/Passport.ts | 30 +++-- .../game => packages/kai/src}/ui/Prompt.ts | 15 ++- .../game => packages/kai/src}/ui/StartMenu.ts | 53 ++++---- .../game => packages/kai/src}/ui/text.test.ts | 2 +- .../game => packages/kai/src}/ui/text.ts | 0 .../kai/src}/viewport.test.ts | 4 +- .../game => packages/kai/src}/viewport.ts | 2 +- .../kai/src}/world/dayNight.test.ts | 2 +- .../kai/src}/world/dayNight.ts | 0 .../kai/src}/world/distance.test.ts | 2 +- .../kai/src}/world/distance.ts | 0 .../game => packages/kai/src}/world/grid.ts | 2 +- .../kai/src}/world/movement.test.ts | 4 +- .../kai/src}/world/movement.ts | 4 +- .../kai/src}/world/pathfind.test.ts | 4 +- .../kai/src}/world/pathfind.ts | 2 +- packages/kai/src/world/weather-kinds.ts | 39 ++++++ .../kai/src}/world/weather.test.ts | 7 +- .../kai/src}/world/weather.ts | 10 +- 95 files changed, 724 insertions(+), 669 deletions(-) delete mode 100644 apps/datagutt/game/assets/manifest.test.ts delete mode 100644 apps/datagutt/game/assets/manifest.ts delete mode 100644 apps/datagutt/game/audio/playlist.test.ts delete mode 100644 apps/datagutt/game/audio/playlist.ts create mode 100644 apps/datagutt/game/data.test.ts create mode 100644 apps/datagutt/game/data.ts delete mode 100644 apps/datagutt/game/progress/achievements.ts delete mode 100644 apps/datagutt/game/progress/passport.test.ts delete mode 100644 apps/datagutt/game/progress/passport.ts delete mode 100644 apps/datagutt/game/progress/unlocks.test.ts delete mode 100644 apps/datagutt/game/progress/unlocks.ts delete mode 100644 apps/datagutt/game/strings.ts rename {apps/datagutt/game/ui => packages/kai-arcade/src}/ArcadeScreen.ts (91%) rename {apps/datagutt/game => packages/kai/src}/audio/Ambience.ts (98%) rename {apps/datagutt/game => packages/kai/src}/audio/Music.ts (89%) rename {apps/datagutt/game => packages/kai/src}/audio/blips.test.ts (56%) rename {apps/datagutt/game => packages/kai/src}/audio/blips.ts (68%) rename {apps/datagutt/game => packages/kai/src}/audio/mix.test.ts (96%) rename {apps/datagutt/game => packages/kai/src}/audio/mix.ts (97%) rename {apps/datagutt/game => packages/kai/src}/audio/sfx.ts (100%) rename {apps/datagutt/game => packages/kai/src}/boot.ts (67%) rename {apps/datagutt/game => packages/kai/src}/constants.ts (100%) create mode 100644 packages/kai/src/data.ts rename {apps/datagutt/game => packages/kai/src}/dev/simulatedGhosts.ts (93%) rename {apps/datagutt/game => packages/kai/src}/dialogue/DialogueRunner.ts (100%) rename {apps/datagutt/game => packages/kai/src}/entities/Actor.ts (88%) rename {apps/datagutt/game => packages/kai/src}/entities/Ghosts.ts (97%) rename {apps/datagutt/game => packages/kai/src}/fx/Aurora.ts (97%) rename {apps/datagutt/game => packages/kai/src}/fx/DayNight.ts (94%) rename {apps/datagutt/game => packages/kai/src}/fx/Feel.ts (94%) rename {apps/datagutt/game => packages/kai/src}/fx/Lights.ts (92%) rename {apps/datagutt/game => packages/kai/src}/fx/Water.ts (98%) rename {apps/datagutt/game => packages/kai/src}/fx/Weather.ts (98%) rename {apps/datagutt/game => packages/kai/src}/fx/quality.test.ts (93%) rename {apps/datagutt/game => packages/kai/src}/fx/quality.ts (96%) create mode 100644 packages/kai/src/index.ts rename {apps/datagutt/game => packages/kai/src}/input/InputController.ts (98%) rename {apps/datagutt/game => packages/kai/src}/input/directionStack.ts (90%) rename {apps/datagutt/game => packages/kai/src}/plugins/api.ts (93%) rename {apps/datagutt/game => packages/kai/src}/plugins/triggers.ts (87%) rename {apps/datagutt/game => packages/kai/src}/progress/Progress.ts (78%) rename {apps/datagutt/game => packages/kai/src}/save/save.test.ts (66%) rename {apps/datagutt/game => packages/kai/src}/save/save.ts (85%) rename {apps/datagutt/game => packages/kai/src}/scenes/BootScene.ts (100%) rename {apps/datagutt/game => packages/kai/src}/scenes/PreloadScene.ts (85%) rename {apps/datagutt/game => packages/kai/src}/scenes/WorldScene.ts (91%) rename {apps/datagutt/game => packages/kai/src}/ui/Bubbles.ts (98%) rename {apps/datagutt/game => packages/kai/src}/ui/CreditsRoll.ts (91%) rename {apps/datagutt/game => packages/kai/src}/ui/DialogueBox.ts (98%) rename {apps/datagutt/game => packages/kai/src}/ui/EmoteWheel.ts (95%) rename {apps/datagutt/game => packages/kai/src}/ui/LinkOpener.ts (100%) rename {apps/datagutt/game => packages/kai/src}/ui/Passport.ts (82%) rename {apps/datagutt/game => packages/kai/src}/ui/Prompt.ts (88%) rename {apps/datagutt/game => packages/kai/src}/ui/StartMenu.ts (77%) rename {apps/datagutt/game => packages/kai/src}/ui/text.test.ts (98%) rename {apps/datagutt/game => packages/kai/src}/ui/text.ts (100%) rename {apps/datagutt/game => packages/kai/src}/viewport.test.ts (93%) rename {apps/datagutt/game => packages/kai/src}/viewport.ts (97%) rename {apps/datagutt/game => packages/kai/src}/world/dayNight.test.ts (98%) rename {apps/datagutt/game => packages/kai/src}/world/dayNight.ts (100%) rename {apps/datagutt/game => packages/kai/src}/world/distance.test.ts (86%) rename {apps/datagutt/game => packages/kai/src}/world/distance.ts (100%) rename {apps/datagutt/game => packages/kai/src}/world/grid.ts (97%) rename {apps/datagutt/game => packages/kai/src}/world/movement.test.ts (97%) rename {apps/datagutt/game => packages/kai/src}/world/movement.ts (97%) rename {apps/datagutt/game => packages/kai/src}/world/pathfind.test.ts (96%) rename {apps/datagutt/game => packages/kai/src}/world/pathfind.ts (99%) create mode 100644 packages/kai/src/world/weather-kinds.ts rename {apps/datagutt/game => packages/kai/src}/world/weather.test.ts (94%) rename {apps/datagutt/game => packages/kai/src}/world/weather.ts (91%) diff --git a/apps/datagutt/components/journal/PassportProgress.tsx b/apps/datagutt/components/journal/PassportProgress.tsx index 44e7b7b..dd12311 100644 --- a/apps/datagutt/components/journal/PassportProgress.tsx +++ b/apps/datagutt/components/journal/PassportProgress.tsx @@ -1,8 +1,12 @@ "use client"; import { useSyncExternalStore } from "react"; -import { STAMP_PLACES } from "@/game/progress/passport"; -import { browserStorage, loadSave, SAVE_KEY } from "@/game/save/save"; +import { browserStorage, loadSave } from "@datagutt/kai/save/save"; +import { fjordData } from "@/game/data"; +import { kaiConfig } from "@/lib/kai"; + +const SAVE_KEY = kaiConfig.saveKey; +const STAMP_PLACES = fjordData.stampPlaces; function subscribe(onChange: () => void) { window.addEventListener("storage", onChange); @@ -16,7 +20,7 @@ const readSaveText = () => browserStorage()?.getItem(SAVE_KEY) ?? null; /** The visitor's Fjord Passport from their saved game, if they have played. Nothing without a save or JS. */ export function PassportProgress() { const saveText = useSyncExternalStore(subscribe, readSaveText, () => null); - const stamps = saveText ? loadSave(browserStorage())?.stamps : null; + const stamps = saveText ? loadSave(browserStorage(), SAVE_KEY)?.stamps : null; if (!stamps) return null; const count = STAMP_PLACES.filter((p) => stamps.includes(p.id)).length; diff --git a/apps/datagutt/e2e/game.spec.ts b/apps/datagutt/e2e/game.spec.ts index 13a4a4e..3678633 100644 --- a/apps/datagutt/e2e/game.spec.ts +++ b/apps/datagutt/e2e/game.spec.ts @@ -200,13 +200,13 @@ test.describe("world", () => { test("the last stamp leads to the finale: the pier at night, credits, then contact", async ({ page }) => { // Three conversations and the credits: under a full parallel run, slow machines need // longer than the default. - test.setTimeout(150_000); + test.setTimeout(240_000); // Every stamp but Thomas's, then talk to him at his desk. const others = ["boathouse", "radio-tower", "kiosk", "office", "town-hall", "gym", "library", "farm", "post-office"]; await continueAt(page, { map: "house-up", x: 7, y: 5, facing: "left" }, "coding", others); const readUntil = async (done: () => Promise) => { // A time budget rather than a count of presses: typing speed follows the frame rate. - const deadline = Date.now() + 40_000; + const deadline = Date.now() + 70_000; while (Date.now() < deadline && !(await done())) { const s = await state(page); // Say goodbye as soon as it's offered, then read on. diff --git a/apps/datagutt/game/assets/manifest.test.ts b/apps/datagutt/game/assets/manifest.test.ts deleted file mode 100644 index 4a10109..0000000 --- a/apps/datagutt/game/assets/manifest.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { NPCS } from "../npcs"; -import { CHARACTERS, type CharacterRecipe } from "./manifest"; - -const recipes: Record = CHARACTERS; - -describe("character recipes", () => { - it("give every NPC in the roster a sprite and a portrait", () => { - for (const npc of NPCS) { - expect(recipes[npc.id], npc.id).toBeDefined(); - expect(recipes[npc.id].portrait, npc.id).not.toBe(false); - } - }); - - it("give no two NPCs the same look", () => { - const looks = NPCS.map((npc) => JSON.stringify(recipes[npc.id].layers)); - expect(new Set(looks).size).toBe(looks.length); - }); -}); diff --git a/apps/datagutt/game/assets/manifest.ts b/apps/datagutt/game/assets/manifest.ts deleted file mode 100644 index e592394..0000000 --- a/apps/datagutt/game/assets/manifest.ts +++ /dev/null @@ -1,23 +0,0 @@ -// What the asset build produces, from content/characters.json and content/music.json. Read -// at build time by scripts/assets/build.mjs (through Node's type stripping, so only -// erasable TypeScript here) and at runtime by the game. Recipe paths are relative to -// limezu/characters/ in the art repository; check changes with `bun run characters:review`. -import { content } from "../../content/index.ts"; - -export type CharacterRecipe = (typeof content.characters)[string]; -export type CharacterLayer = CharacterRecipe["layers"][number]; - -export const CHARACTERS: Record = content.characters; - -/** A music track: a loop from the art repository, path relative to its music/ folder. */ -export type MusicTrack = (typeof content.music.tracks)[string]; - -// Towball's Crossing: Deluxe! by Towball, CC BY 4.0 (credited in content/credits.json). -// The assets repo's music/towballs-crossing-deluxe/TRACKS.md says why these seven. The -// build cuts each to its exact loop and writes public/game/music/.ogg and .mp3. -export const MUSIC: Record = content.music.tracks; - -export type MusicId = string; - -/** Where a built track loops, in seconds from the start of its file (assets.json `music`). */ -export type MusicLoop = { loopStart: number; loopEnd: number }; diff --git a/apps/datagutt/game/audio/playlist.test.ts b/apps/datagutt/game/audio/playlist.test.ts deleted file mode 100644 index a16e6a1..0000000 --- a/apps/datagutt/game/audio/playlist.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { GENERATED_MAPS } from "../../world/gen/maps/index"; -import { MUSIC } from "../assets/manifest"; -import { trackFor, type Moment } from "./playlist"; - -const town: Extract = { scene: "world", map: "town", outdoors: true, phase: "day", season: "summer", night: false }; -const inside = (map: string) => trackFor({ ...town, map, outdoors: false }); - -describe("music by place and time", () => { - it("plays the main theme on the title and under the credits", () => { - expect(trackFor({ scene: "title" })).toBe("welcome"); - expect(trackFor({ scene: "credits" })).toBe("welcome"); - }); - - it("follows the clock in town, crossing over when night falls and at dawn", () => { - expect(trackFor(town)).toBe("sunrise"); - expect(trackFor({ ...town, phase: "dawn" })).toBe("sunrise"); - expect(trackFor({ ...town, phase: "dusk" })).toBe("sunrise"); - expect(trackFor({ ...town, phase: "night" })).toBe("goodnight"); - }); - - it("snows in the town on winter days, and keeps the night track on winter nights", () => { - expect(trackFor({ ...town, season: "winter" })).toBe("snowedIn"); - expect(trackFor({ ...town, season: "winter", phase: "night" })).toBe("goodnight"); - expect(trackFor({ ...town, season: "autumn" })).toBe("sunrise"); - }); - - it("plays the night track on a story night, whatever the season", () => { - expect(trackFor({ ...town, night: true, season: "winter" })).toBe("goodnight"); - }); - - it("gives each room its mood, day or night", () => { - expect(inside("house")).toBe("market"); - expect(inside("kiosk")).toBe("market"); - expect(inside("office")).toBe("taxOffice"); - expect(inside("town-hall-basement")).toBe("taxOffice"); - expect(inside("library")).toBe("boredom"); - expect(trackFor({ ...town, map: "library", outdoors: false, phase: "night", season: "winter" })).toBe("boredom"); - expect(inside("somewhere-new")).toBe("market"); - }); - - it("plays a track that exists in every interior map", () => { - const interiors = GENERATED_MAPS.filter((m) => !m.outdoor).map((m) => m.id); - for (const map of interiors) expect(MUSIC, map).toHaveProperty(inside(map)); - }); -}); diff --git a/apps/datagutt/game/audio/playlist.ts b/apps/datagutt/game/audio/playlist.ts deleted file mode 100644 index d0e7b27..0000000 --- a/apps/datagutt/game/audio/playlist.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Which music plays where (docs/game/PLAN.md B4): the title and the credits have the main -// theme, each room its mood, and the town follows the clock and the season. Pure, so it can -// be tested; Music.ts plays and crossfades. -import { content } from "../../content/index.ts"; -import type { MusicId } from "../assets/manifest"; -import type { Phase } from "../world/dayNight"; -import type { Season } from "@datagutt/kai/world/season"; - -export type Moment = - | { scene: "title" } - | { scene: "credits" } - | { scene: "world"; map: string; outdoors: boolean; phase: Phase; season: Season; night: boolean }; - -/** content/music.json `playlist`: the title and credits tracks, the town's, and each room's mood. */ -const PLAYLIST = content.music.playlist; - -export function trackFor(moment: Moment): MusicId { - if (moment.scene === "title") return PLAYLIST.title; - if (moment.scene === "credits") return PLAYLIST.credits; - if (!moment.outdoors) return PLAYLIST.indoors.maps[moment.map] ?? PLAYLIST.indoors.default; - // Night wins over winter, so winter keeps a day and a night track like the rest of the year. - if (moment.night || moment.phase === "night") return PLAYLIST.outdoors.night; - return moment.season === "winter" ? PLAYLIST.outdoors.winter : PLAYLIST.outdoors.day; -} diff --git a/apps/datagutt/game/data.test.ts b/apps/datagutt/game/data.test.ts new file mode 100644 index 0000000..ba634cb --- /dev/null +++ b/apps/datagutt/game/data.test.ts @@ -0,0 +1,108 @@ +// Fjord Town's content through the engine's rules: stamps, unlocks, music and voices. +import type { Moment } from "@datagutt/kai/data"; +import { describe, expect, it } from "vitest"; +import { content } from "../content/index"; +import { GENERATED_MAPS } from "../world/gen/maps/index"; +import { fjordData } from "./data"; +import { NPCS } from "./npcs"; + +const STAMP_PLACES = fjordData.stampPlaces; +const awardStamp = (stamps: readonly string[], place: string | null) => fjordData.awardStamp(stamps, place); +const hasAll = (stamps: string[]) => ({ hasStamp: (place: string) => stamps.includes(place) }); + +describe("passport", () => { + it("has one stamp per place that presents content", () => { + expect(STAMP_PLACES.map((p) => p.id)).toEqual(["home", "boathouse", "radio-tower", "kiosk", "office", "town-hall", "gym", "library", "farm", "post-office"]); + }); + + it("maps every stamp place to exactly one NPC who gives it", () => { + for (const place of STAMP_PLACES) { + expect(NPCS.filter((n) => fjordData.stampForNpc(n.id) === place.id), place.id).toHaveLength(1); + } + expect(fjordData.stampForNpc("ferryman")).toBeNull(); // the dock gives no stamp + }); + + it("awards each stamp once and notices a full passport", () => { + const first = awardStamp([], "library"); + expect(first).toEqual({ stamps: ["library"], newStamp: "library", complete: false }); + expect(awardStamp(first.stamps, "library").newStamp).toBeNull(); + expect(awardStamp(first.stamps, "nowhere").newStamp).toBeNull(); + + let last = awardStamp([], null); + for (const p of STAMP_PLACES) last = awardStamp(last.stamps, p.id); + expect(last.complete).toBe(true); + expect(last.stamps).toHaveLength(STAMP_PLACES.length); + }); + + it("drops stamps for places that no longer exist", () => { + expect(awardStamp(["gone", "farm"], null).stamps).toEqual(["farm"]); + }); +}); + +describe("unlocks", () => { + it("opens the mountain trail only with every passport stamp", () => { + const all = STAMP_PLACES.map((p) => p.id); + expect(fjordData.isUnlocked("passport", hasAll(all.slice(1)))).toBe(false); + expect(fjordData.isUnlocked("passport", hasAll(all))).toBe(true); + }); +}); + +describe("music by place and time", () => { + const town: Extract = { scene: "world", map: "town", outdoors: true, phase: "day", season: "summer", night: false }; + const trackFor = (moment: Moment) => fjordData.trackFor(moment); + const inside = (map: string) => trackFor({ ...town, map, outdoors: false }); + + it("plays the main theme on the title and under the credits", () => { + expect(trackFor({ scene: "title" })).toBe("welcome"); + expect(trackFor({ scene: "credits" })).toBe("welcome"); + }); + + it("follows the clock in town, crossing over when night falls and at dawn", () => { + expect(trackFor(town)).toBe("sunrise"); + expect(trackFor({ ...town, phase: "dawn" })).toBe("sunrise"); + expect(trackFor({ ...town, phase: "dusk" })).toBe("sunrise"); + expect(trackFor({ ...town, phase: "night" })).toBe("goodnight"); + }); + + it("snows in the town on winter days, and keeps the night track on winter nights", () => { + expect(trackFor({ ...town, season: "winter" })).toBe("snowedIn"); + expect(trackFor({ ...town, season: "winter", phase: "night" })).toBe("goodnight"); + expect(trackFor({ ...town, season: "autumn" })).toBe("sunrise"); + }); + + it("plays the night track on a story night, whatever the season", () => { + expect(trackFor({ ...town, night: true, season: "winter" })).toBe("goodnight"); + }); + + it("gives each room its mood, day or night", () => { + expect(inside("house")).toBe("market"); + expect(inside("kiosk")).toBe("market"); + expect(inside("office")).toBe("taxOffice"); + expect(inside("town-hall-basement")).toBe("taxOffice"); + expect(inside("library")).toBe("boredom"); + expect(trackFor({ ...town, map: "library", outdoors: false, phase: "night", season: "winter" })).toBe("boredom"); + expect(inside("somewhere-new")).toBe("market"); + }); + + it("plays a track that exists in every interior map", () => { + const interiors = GENERATED_MAPS.filter((m) => !m.outdoor).map((m) => m.id); + for (const map of interiors) expect(content.music.tracks, map).toHaveProperty(inside(map)); + }); +}); + +describe("the cast", () => { + it("gives every NPC a sprite, a portrait and a voice of their own", () => { + const recipes = content.characters; + for (const npc of NPCS) { + expect(recipes[npc.id], npc.id).toBeDefined(); + expect(recipes[npc.id].portrait, npc.id).not.toBe(false); + expect(fjordData.voiceFor(npc.id), npc.id).toBe(npc.voice); + } + expect(fjordData.voiceFor("ferryman").pitch).not.toBe(fjordData.voiceFor("datagutt").pitch); + }); + + it("gives no two NPCs the same look", () => { + const looks = NPCS.map((npc) => JSON.stringify(content.characters[npc.id].layers)); + expect(new Set(looks).size).toBe(looks.length); + }); +}); diff --git a/apps/datagutt/game/data.ts b/apps/datagutt/game/data.ts new file mode 100644 index 0000000..c0d0bf4 --- /dev/null +++ b/apps/datagutt/game/data.ts @@ -0,0 +1,9 @@ +// Fjord Town's content as the engine reads it (GameData), for the app's own code outside +// the running game: the Journal's passport, the dialogue functions, tests. Deep import: +// the build loads this through the dialogue host, and the runtime's index pulls in Phaser. +import { GameData } from "@datagutt/kai/data"; +import { content } from "../content/index.ts"; + +export const fjordContent = { ...content, places: content.places.list }; + +export const fjordData = new GameData(fjordContent); diff --git a/apps/datagutt/game/dialogue/DialogueRunner.test.ts b/apps/datagutt/game/dialogue/DialogueRunner.test.ts index d0e9da5..f2a366a 100644 --- a/apps/datagutt/game/dialogue/DialogueRunner.test.ts +++ b/apps/datagutt/game/dialogue/DialogueRunner.test.ts @@ -7,7 +7,7 @@ import { profile } from "../../content/profile"; import { projects } from "../../content/projects"; import { externalDeclarations, fjordExternals } from "./externals"; import { NPCS } from "../npcs"; -import { DialogueRunner, type Beat } from "./DialogueRunner"; +import { DialogueRunner, type Beat } from "@datagutt/kai/dialogue/DialogueRunner"; const dir = path.join(__dirname, "ink"); let json = ""; diff --git a/apps/datagutt/game/dialogue/externals.ts b/apps/datagutt/game/dialogue/externals.ts index d1cbefd..b835524 100644 --- a/apps/datagutt/game/dialogue/externals.ts +++ b/apps/datagutt/game/dialogue/externals.ts @@ -41,7 +41,7 @@ export const EXTERNALS = { stat: { params: ["name"], arg: "stat", doc: "GitHub stat (live): public_repos, followers, total_stars, years_coding" }, contributions_total: { params: [], arg: "none", doc: "Contributions in the last year (live)" }, has_stamp: { params: ["place"], arg: "place", doc: "Whether the player has that place's passport stamp" }, - unlocked: { params: ["name"], arg: "unlock", doc: "Whether a locked way is open, e.g. unlocked(\"passport\") once every stamp is in (game/progress/unlocks.ts)" }, + unlocked: { params: ["name"], arg: "unlock", doc: "Whether a locked way is open, e.g. unlocked(\"passport\") once every stamp is in (content/unlocks.json)" }, lanyard_activity: { params: [], arg: "none", doc: "What Thomas is up to right now in his own words, or \"\" (live, game/live/datagutt.ts)" }, } as const satisfies Record; diff --git a/apps/datagutt/game/entities/LiveThomas.ts b/apps/datagutt/game/entities/LiveThomas.ts index 6590ffc..5bf7905 100644 --- a/apps/datagutt/game/entities/LiveThomas.ts +++ b/apps/datagutt/game/entities/LiveThomas.ts @@ -3,14 +3,14 @@ // to the new spot on this map, out through the door toward another map, or in through // the door he would come from. Off this map he simply is wherever his presence says. import type Phaser from "phaser"; -import { NPC_MOVEMENT, type MoverEvent } from "../world/movement"; -import { directionBetween, type CollisionGrid, type Point } from "../world/grid"; -import { findPath, findPathAdjacent } from "../world/pathfind"; +import { NPC_MOVEMENT, type MoverEvent } from "@datagutt/kai/world/movement"; +import { directionBetween, type CollisionGrid, type Point } from "@datagutt/kai/world/grid"; +import { findPath, findPathAdjacent } from "@datagutt/kai/world/pathfind"; import type { MapObject } from "@datagutt/kai/world/objects"; import { doingFor, nextMap, PLACE_MAPS, spotId, type Doing } from "../live/datagutt"; import type { Presence, PresenceFeed } from "@datagutt/kai-live"; -import { EmoteBubble, SpeechBubble } from "../ui/Bubbles"; -import { Actor } from "./Actor"; +import { EmoteBubble, SpeechBubble } from "@datagutt/kai/ui/Bubbles"; +import { Actor } from "@datagutt/kai/entities/Actor"; export const THOMAS_ID = "datagutt"; diff --git a/apps/datagutt/game/index.ts b/apps/datagutt/game/index.ts index 74f72a5..95972e7 100644 --- a/apps/datagutt/game/index.ts +++ b/apps/datagutt/game/index.ts @@ -1,10 +1,13 @@ // Fjord Town: the kai engine with this game's config, content, dialogue functions and // plugins. The site's game shell and the dev harness both start it here. +import { createGame, type BootOptions, type GameHandle } from "@datagutt/kai"; import type { KaiConfig } from "@datagutt/kai/schema"; +import { readWorldState } from "@datagutt/kai-live"; import config from "../.kai/config.json" with { type: "json" }; -import { content } from "../content/index.ts"; -import { bootGame, type BootOptions, type GameHandle } from "./boot"; +import { EMPTY_WORLD_STATE } from "../content/live"; +import { fjordContent } from "./data"; import { fjordExternals } from "./dialogue/externals"; +import { resolveLink } from "./dialogue/links"; import { arcadePlugin } from "./plugins/arcade"; import { catPlugin } from "./plugins/cat"; import { ferryIntroPlugin } from "./plugins/ferryIntro"; @@ -12,27 +15,21 @@ import { finalePlugin } from "./plugins/finale"; import { githubPlugin } from "./plugins/github"; import { journalPlugin } from "./plugins/journal"; import { presencePlugin } from "./plugins/presence"; -import { triggersPlugin } from "./plugins/triggers"; -import { isUnlocked } from "./progress/unlocks"; export type { GameHandle }; -export function startFjordTown(parent: HTMLElement, options: Omit = {}): GameHandle { - return bootGame(parent, { +export function startFjordTown(parent: HTMLElement, options: Omit = {}): GameHandle { + // The live data the page embedded (GitHub, the weather), or empty data in the dev harness. + const world = readWorldState(EMPTY_WORLD_STATE); + return createGame(parent, { ...options, config: config as KaiConfig, + content: fjordContent, + live: world, + links: resolveLink, // In the order their start menu items appear: the status page, then the Journal. - plugins: [ - triggersPlugin(content.triggers.list), - catPlugin(), - arcadePlugin(), - githubPlugin(), - ferryIntroPlugin(), - finalePlugin(), - presencePlugin(), - journalPlugin(), - ], - externals: ({ world, progress }) => - fjordExternals({ world, hasStamp: (place) => progress.hasStamp(place), isUnlocked: (name) => isUnlocked(name, progress) }), + plugins: [catPlugin(), arcadePlugin(), githubPlugin(world), ferryIntroPlugin(), finalePlugin(), presencePlugin(world), journalPlugin()], + externals: ({ progress, services }) => + fjordExternals({ world, hasStamp: (place) => progress.hasStamp(place), isUnlocked: (name) => services.data.isUnlocked(name, progress) }), }); } diff --git a/apps/datagutt/game/plugins/Intro.ts b/apps/datagutt/game/plugins/Intro.ts index 5fdb8d8..ce409d6 100644 --- a/apps/datagutt/game/plugins/Intro.ts +++ b/apps/datagutt/game/plugins/Intro.ts @@ -4,10 +4,10 @@ // The ferry is part of the map; for the voyage its tiles are lifted into a moving group // of images, and put back once it has docked. import Phaser from "phaser"; -import { TILE } from "../constants"; -import type { Actor } from "../entities/Actor"; -import type { FrameInput } from "../input/InputController"; -import type { Point } from "../world/grid"; +import { TILE } from "@datagutt/kai"; +import type { Actor } from "@datagutt/kai/entities/Actor"; +import type { FrameInput } from "@datagutt/kai/input/InputController"; +import type { Point } from "@datagutt/kai/world/grid"; /** How far out the ferry starts, in tiles, and how long the crossing takes. */ const START_TILES = 14; diff --git a/apps/datagutt/game/plugins/arcade.ts b/apps/datagutt/game/plugins/arcade.ts index 8821ba9..91db9b1 100644 --- a/apps/datagutt/game/plugins/arcade.ts +++ b/apps/datagutt/game/plugins/arcade.ts @@ -1,11 +1,11 @@ // Fjord Town's cabinets (the registry in ../arcade): step up to one and its game has the // frame until the player leaves. The binoculars on the radio hill show stars only after // dark, and Thomas's PC runs a screensaver. -import { ArcadeScreen } from "../ui/ArcadeScreen"; -import type { PromptAction } from "../ui/Prompt"; +import { ArcadeScreen } from "@datagutt/kai-arcade/screen"; +import type { PromptAction } from "@datagutt/kai/ui/Prompt"; import { makeArcade } from "../arcade"; import { isArcadeId, type ArcadeId } from "../arcade/ids"; -import { perWorld, tileKey, type KaiPlugin, type ObjectOf, type World } from "./api"; +import { perWorld, tileKey, type KaiPlugin, type ObjectOf, type World } from "@datagutt/kai"; /** The falling blocks score that earns "High score". */ export const BLOCKS_TARGET = 1000; @@ -28,7 +28,7 @@ export function arcadePlugin(): KaiPlugin { if (name === "blocks" && score >= BLOCKS_TARGET) world.achieve("blocks"); }, }); - const screen = new ArcadeScreen(world.scene, game, () => world.save()); + const screen = new ArcadeScreen(world.scene, game, world.services.data, () => world.save()); playing = game.title; world.takeOver({ name: game.title, diff --git a/apps/datagutt/game/plugins/cat.ts b/apps/datagutt/game/plugins/cat.ts index f4efc96..e50a42b 100644 --- a/apps/datagutt/game/plugins/cat.ts +++ b/apps/datagutt/game/plugins/cat.ts @@ -1,7 +1,7 @@ // The hidden cat (docs/game/PLAN.md B3): it lies across its tile and the next one east, and // petting it earns "cat". -import { TILE } from "../constants"; -import { perWorld, tileKey, type KaiPlugin } from "./api"; +import { TILE } from "@datagutt/kai"; +import { perWorld, tileKey, type KaiPlugin } from "@datagutt/kai"; export function catPlugin(): KaiPlugin { const cats = perWorld(() => new Set()); diff --git a/apps/datagutt/game/plugins/ferryIntro.ts b/apps/datagutt/game/plugins/ferryIntro.ts index 3bb86cf..8d6c62e 100644 --- a/apps/datagutt/game/plugins/ferryIntro.ts +++ b/apps/datagutt/game/plugins/ferryIntro.ts @@ -1,6 +1,6 @@ // On a first visit, arriving at the dock: sail in on the ferry and meet Arne (Intro.ts). // `?intro` plays it again over a save. -import type { KaiPlugin } from "./api"; +import type { KaiPlugin } from "@datagutt/kai"; import { Intro } from "./Intro"; export function ferryIntroPlugin(): KaiPlugin { diff --git a/apps/datagutt/game/plugins/finale.ts b/apps/datagutt/game/plugins/finale.ts index 65777f3..c625926 100644 --- a/apps/datagutt/game/plugins/finale.ts +++ b/apps/datagutt/game/plugins/finale.ts @@ -1,8 +1,8 @@ // Fjord Town's finale: the last stamp brings a note from Thomas, then night falls on every // map and he waits at the end of the pier; his goodbye, the credits, and how to reach him. // `?debug&finale` starts on the pier. -import { CreditsRoll } from "../ui/CreditsRoll"; -import type { KaiPlugin } from "./api"; +import { CreditsRoll } from "@datagutt/kai/ui/CreditsRoll"; +import type { KaiPlugin } from "@datagutt/kai"; export const FINALE_KNOT = "datagutt_finale"; @@ -29,7 +29,7 @@ export function finalePlugin(): KaiPlugin { talk(world, npc) { if (npc.dialogue !== FINALE_KNOT) return false; world.playKnot(npc.dialogue, npc, () => { - const roll = new CreditsRoll(world.scene, world.progress.reducedMotion, () => { + const roll = new CreditsRoll(world.scene, world.services.data.content.credits, world.progress.reducedMotion, () => { rolling = false; world.achieve("credits"); world.playKnot("datagutt_contact", npc, () => { diff --git a/apps/datagutt/game/plugins/github.ts b/apps/datagutt/game/plugins/github.ts index 252ac5b..a47c8d6 100644 --- a/apps/datagutt/game/plugins/github.ts +++ b/apps/datagutt/game/plugins/github.ts @@ -1,17 +1,17 @@ // Live GitHub data drawn into the maps: Ola's field (one tile per day of the last weeks, // crops as tall as the commits) and the library's featured shelf (a spine per pinned repo, // in its language colour). -import { TILE } from "../constants"; +import { TILE, type KaiPlugin } from "@datagutt/kai"; +import type { WorldState } from "@datagutt/kai-live"; import { fieldLevels } from "../live/field"; import { spines } from "../live/shelf"; -import type { KaiPlugin } from "./api"; -export function githubPlugin(): KaiPlugin { +export function githubPlugin(live: WorldState): KaiPlugin { return { name: "github", objects: { crops(world, area) { - const days = world.services.world.contributions; + const days = live.contributions; const decal = world.layers.get("decal"); // Without live data the generator's sample crops stay. if (!decal || !days.length) return; @@ -28,7 +28,7 @@ export function githubPlugin(): KaiPlugin { }, books(world, area) { const g = world.scene.add.graphics().setDepth(-0.5); - for (const s of spines(world.services.world.repos, area.w * TILE, area.h * TILE - 2)) { + for (const s of spines(live.repos, area.w * TILE, area.h * TILE - 2)) { const x = area.x * TILE + s.x; const y = area.y * TILE + s.y; g.fillStyle(s.edge).fillRect(x, y, s.w, s.h); diff --git a/apps/datagutt/game/plugins/journal.ts b/apps/datagutt/game/plugins/journal.ts index a48a278..cfa2eec 100644 --- a/apps/datagutt/game/plugins/journal.ts +++ b/apps/datagutt/game/plugins/journal.ts @@ -1,6 +1,5 @@ // The start menu's way out to the Journal, the site's plain-text twin of the game. -import { t } from "../strings"; -import type { KaiPlugin } from "./api"; +import type { KaiPlugin } from "@datagutt/kai"; export function journalPlugin(): KaiPlugin { return { @@ -8,7 +7,7 @@ export function journalPlugin(): KaiPlugin { menuItems: (world) => [ { id: "journal", - label: t("menu.journal"), + label: world.services.data.t("menu.journal"), run() { world.save(); window.location.href = "/journal"; diff --git a/apps/datagutt/game/plugins/presence.ts b/apps/datagutt/game/plugins/presence.ts index e3a140e..1ea3478 100644 --- a/apps/datagutt/game/plugins/presence.ts +++ b/apps/datagutt/game/plugins/presence.ts @@ -1,15 +1,14 @@ // The live datagutt NPC (LiveThomas): Thomas's Discord presence, read from Lanyard, picks // where he is and what he shows. `?debug&presence=` stands in a fixed presence // (MOCK_PRESENCES) for Lanyard, and window.__fjordPresence(name) switches it live. -import { LanyardClient, PresenceFeed } from "@datagutt/kai-live"; +import { LanyardClient, PresenceFeed, type WorldState } from "@datagutt/kai-live"; import { LiveThomas, THOMAS_ID } from "../entities/LiveThomas"; import { MOCK_PRESENCES, nowDoing, statusLines } from "../live/datagutt"; import { npc as rosterNpc } from "../npcs"; -import { t } from "../strings"; import { FINALE_KNOT } from "./finale"; -import type { KaiPlugin } from "./api"; +import type { KaiPlugin } from "@datagutt/kai"; -export function presencePlugin(): KaiPlugin { +export function presencePlugin(live: WorldState): KaiPlugin { const feed = new PresenceFeed(); let thomas: LiveThomas | null = null; return { @@ -17,7 +16,7 @@ export function presencePlugin(): KaiPlugin { boot(services, params) { const debug = params.has("debug"); if (debug) feed.subscribe((p) => console.info("[game] presence", p)); - const lanyard = new LanyardClient(services.world.discordId, (p) => feed.set(p)); + const lanyard = new LanyardClient(live.discordId, (p) => feed.set(p)); const mock = debug ? params.get("presence") : null; if (mock && MOCK_PRESENCES[mock]) feed.set(MOCK_PRESENCES[mock]); else lanyard.start(); @@ -68,7 +67,7 @@ export function presencePlugin(): KaiPlugin { thomas?.wokenByPlayer(); world.achieve("wake"); }, - menuItems: () => [{ id: "status", label: t("menu.status"), title: t("status.title"), lines: () => statusLines(feed.current) }], + menuItems: ({ services: { data } }) => [{ id: "status", label: data.t("menu.status"), title: data.t("status.title"), lines: () => statusLines(feed.current) }], debug: () => ({ presence: feed.current, thomas: thomas?.state }), }; } diff --git a/apps/datagutt/game/progress/achievements.ts b/apps/datagutt/game/progress/achievements.ts deleted file mode 100644 index 0a66ee9..0000000 --- a/apps/datagutt/game/progress/achievements.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Achievements (docs/game/PLAN.md B3): small things to find beyond the passport, listed -// on the passport's second page. Earned ones are kept as `achievement:` flags in the -// save, so old saves need nothing new. - -import { content } from "../../content/index.ts"; - -export type Achievement = (typeof content.achievements.list)[number]; -export type AchievementId = string; - -/** In the order the passport lists them (content/achievements.json). `how` shows once earned; before that, "???". */ -export const ACHIEVEMENTS: Achievement[] = content.achievements.list; - -export const achievementFlag = (id: AchievementId) => `achievement:${id}`; - -export function achievement(id: AchievementId): Achievement { - const found = ACHIEVEMENTS.find((a) => a.id === id); - if (!found) throw new Error(`Unknown achievement "${id}"`); - return found; -} diff --git a/apps/datagutt/game/progress/passport.test.ts b/apps/datagutt/game/progress/passport.test.ts deleted file mode 100644 index f043c5e..0000000 --- a/apps/datagutt/game/progress/passport.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { NPCS } from "../npcs"; -import { STAMP_PLACES, awardStamp, stampForNpc } from "./passport"; - -describe("passport", () => { - it("has one stamp per place that presents content", () => { - expect(STAMP_PLACES.map((p) => p.id)).toEqual([ - "home", - "boathouse", - "radio-tower", - "kiosk", - "office", - "town-hall", - "gym", - "library", - "farm", - "post-office", - ]); - }); - - it("maps every stamp place to exactly one NPC who gives it", () => { - for (const place of STAMP_PLACES) { - expect(NPCS.filter((n) => stampForNpc(n.id) === place.id), place.id).toHaveLength(1); - } - expect(stampForNpc("ferryman")).toBeNull(); // the dock gives no stamp - }); - - it("awards each stamp once and notices a full passport", () => { - let stamps: string[] = []; - const first = awardStamp(stamps, "library"); - expect(first).toEqual({ stamps: ["library"], newStamp: "library", complete: false }); - expect(awardStamp(first.stamps, "library").newStamp).toBeNull(); - expect(awardStamp(first.stamps, "nowhere").newStamp).toBeNull(); - - stamps = []; - let last = awardStamp(stamps, null); - for (const p of STAMP_PLACES) last = awardStamp(last.stamps, p.id); - expect(last.complete).toBe(true); - expect(last.stamps).toHaveLength(STAMP_PLACES.length); - }); - - it("drops stamps for places that no longer exist", () => { - expect(awardStamp(["gone", "farm"], null).stamps).toEqual(["farm"]); - }); -}); diff --git a/apps/datagutt/game/progress/passport.ts b/apps/datagutt/game/progress/passport.ts deleted file mode 100644 index dfad26d..0000000 --- a/apps/datagutt/game/progress/passport.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Fjord Passport (docs/game/DESIGN.md §6): one stamp per place, earned by finishing a -// conversation with that place's main NPC. Nothing is gated; stamps reward curiosity and -// a full passport unlocks the finale (M5.8). -import { places, type Place } from "../../content/places"; -import { NPCS } from "../npcs"; - -/** Places that give a stamp, in passport order. */ -export const STAMP_PLACES: Place[] = places.filter((p) => p.stamp); - -/** The place whose stamp an NPC gives, if they are that place's main NPC. */ -export function stampForNpc(npcId: string): string | null { - const place = NPCS.find((n) => n.id === npcId)?.place; - return place && STAMP_PLACES.some((p) => p.id === place) ? place : null; -} - -export type StampResult = { stamps: string[]; newStamp: string | null; complete: boolean }; - -/** Add a stamp if it is new. Unknown place ids are ignored. */ -export function awardStamp(stamps: readonly string[], place: string | null): StampResult { - const known = new Set(STAMP_PLACES.map((p) => p.id)); - const current = stamps.filter((s) => known.has(s)); - if (!place || !known.has(place) || current.includes(place)) { - return { stamps: current, newStamp: null, complete: current.length === known.size }; - } - const next = [...current, place]; - return { stamps: next, newStamp: place, complete: next.length === known.size }; -} - -/** Every stamp is in. */ -export const passportFull = (progress: { hasStamp(place: string): boolean }): boolean => STAMP_PLACES.every((p) => progress.hasStamp(p.id)); diff --git a/apps/datagutt/game/progress/unlocks.test.ts b/apps/datagutt/game/progress/unlocks.test.ts deleted file mode 100644 index bbb2a8d..0000000 --- a/apps/datagutt/game/progress/unlocks.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { STAMP_PLACES } from "./passport"; -import { isUnlocked } from "./unlocks"; - -describe("unlocks", () => { - it("opens the mountain trail only with every passport stamp", () => { - const all = STAMP_PLACES.map((p) => p.id); - expect(isUnlocked("passport", { stamps: all.slice(1), flags: {} })).toBe(false); - expect(isUnlocked("passport", { stamps: all, flags: {} })).toBe(true); - }); -}); diff --git a/apps/datagutt/game/progress/unlocks.ts b/apps/datagutt/game/progress/unlocks.ts deleted file mode 100644 index fbe0f3b..0000000 --- a/apps/datagutt/game/progress/unlocks.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Whether an unlock holds, from its condition in content/unlocks.json. The passport's -// unlock (every stamp) clears the mountain trail's rockfall. -import { content } from "../../content/index.ts"; -import { passportFull } from "./passport"; -import type { UnlockId } from "./unlockIds"; - -type Story = { stamps: readonly string[]; flags: Readonly> }; - -/** Whether `id` holds. Map objects name unlocks as strings; the asset build checks them. */ -export function isUnlocked(id: UnlockId, story: Story): boolean { - const condition = content.unlocks[id]; - if (!condition) return false; - return condition.stamps === "all" && passportFull({ hasStamp: (place) => story.stamps.includes(place) }); -} diff --git a/apps/datagutt/game/strings.ts b/apps/datagutt/game/strings.ts deleted file mode 100644 index 8874de8..0000000 --- a/apps/datagutt/game/strings.ts +++ /dev/null @@ -1,8 +0,0 @@ -// The UI copy: the engine's defaults with content/strings.json over them. -import { format, withDefaults, type StringKey } from "@datagutt/kai/ui/strings"; -import { content } from "../content/index.ts"; - -const STRINGS = withDefaults(content.strings); - -/** A UI text by key, with its `{name}` values filled in. */ -export const t = (key: StringKey, vars?: Record): string => (vars ? format(STRINGS[key], vars) : STRINGS[key]); diff --git a/apps/datagutt/kai.json b/apps/datagutt/kai.json index 0dfc6d6..70cbda3 100644 --- a/apps/datagutt/kai.json +++ b/apps/datagutt/kai.json @@ -3,6 +3,8 @@ "title": "Fjord Town", "timezone": "Europe/Oslo", "startPlace": "dock", + "saveKey": "fjordtown.save", + "visitorsOffKey": "rx_off", "basePath": "/game/", "paths": { "maps": "world/gen/maps/index.ts", diff --git a/bun.lock b/bun.lock index ee5df7b..84c9a7d 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "version": "0.0.0", "dependencies": { "@datagutt/kai-net": "workspace:*", + "inkjs": "^2.4.0", "zod": "^4", }, "devDependencies": { @@ -69,10 +70,16 @@ "typescript": "^5.9.3", "vitest": "^5", }, + "peerDependencies": { + "phaser": "^4.2.1", + }, }, "packages/kai-arcade": { "name": "@datagutt/kai-arcade", "version": "0.0.0", + "dependencies": { + "@datagutt/kai": "workspace:*", + }, "devDependencies": { "@datagutt/kai-eslint-config": "workspace:*", "@datagutt/kai-tsconfig": "workspace:*", @@ -81,6 +88,9 @@ "typescript": "^5.9.3", "vitest": "^5", }, + "peerDependencies": { + "phaser": "^4.2.1", + }, }, "packages/kai-assets": { "name": "@datagutt/kai-assets", @@ -133,6 +143,7 @@ "name": "@datagutt/kai-live", "version": "0.0.0", "dependencies": { + "@datagutt/kai": "workspace:*", "@datagutt/kai-net": "workspace:*", }, "devDependencies": { diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 201976f..3c57c93 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -147,9 +147,13 @@ each piece of Fjord Town behaviour moves into a plugin there (K5.4, K5.5) with e after each. Only then does the now generic runtime move into the package (K5.1): moving it first would make the package import app code. -- [ ] **K5.1** `@datagutt/kai`: move the engine parts of `game/`. `createGame({ config, +- [x] **K5.1** `@datagutt/kai`: move the engine parts of `game/`. `createGame({ config, content, plugins })` replaces `bootGame`. `ArcadeScreen` moves to `kai-arcade`, which - then depends on `kai`. *Done when* the app boots through `createGame`. + then depends on `kai`. *Done when* the app boots through `createGame`. (The runtime + reads content through `GameData` on `services.data`, not module imports. It takes + the live payload as `{ weather, ...slices }`; the weather types live in the runtime + and `kai-live` depends on it, so there is no package cycle. The save key and the + visitors kill switch come from `kai.json`. The triggers plugin is built in.) - [ ] **K5.2** `KaiPlugin` API: setup, map enter, interaction, update, map object types, Ink externals, start menu items. *Done when* the API has unit tests with a fake scene. (The API is in `game/plugins/api.ts` and runs Fjord Town; it moves with the diff --git a/packages/kai-arcade/package.json b/packages/kai-arcade/package.json index 6e4f4d1..867ba4d 100644 --- a/packages/kai-arcade/package.json +++ b/packages/kai-arcade/package.json @@ -5,7 +5,8 @@ "license": "GPL-3.0-or-later", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./screen": "./src/ArcadeScreen.ts" }, "scripts": { "lint": "eslint .", @@ -19,5 +20,11 @@ "eslint": "^9", "typescript": "^5.9.3", "vitest": "^5" + }, + "dependencies": { + "@datagutt/kai": "workspace:*" + }, + "peerDependencies": { + "phaser": "^4.2.1" } } diff --git a/apps/datagutt/game/ui/ArcadeScreen.ts b/packages/kai-arcade/src/ArcadeScreen.ts similarity index 91% rename from apps/datagutt/game/ui/ArcadeScreen.ts rename to packages/kai-arcade/src/ArcadeScreen.ts index d41ace9..956a2b4 100644 --- a/apps/datagutt/game/ui/ArcadeScreen.ts +++ b/packages/kai-arcade/src/ArcadeScreen.ts @@ -1,12 +1,10 @@ -// A cabinet up close (docs/game/PLAN.md B1): its screen drawn by a game from -// @datagutt/kai-arcade on a canvas texture, framed like a cabinet, over a dimmed room. Back +// A cabinet up close: its screen drawn by one of the games on a canvas texture, framed like a cabinet, over a dimmed room. Back // leaves. On touch, taps on the screen's sides steer, above turns (up), below presses A, // and a tap outside the cabinet leaves. import Phaser from "phaser"; -import type { ArcadeGame, ArcadeInput, Direction } from "@datagutt/kai-arcade"; -import { SCREEN_H, SCREEN_W } from "@datagutt/kai-arcade"; -import type { FrameInput, InputDevice } from "../input/InputController"; -import { t } from "../strings"; +import type { GameData } from "@datagutt/kai/data"; +import type { FrameInput, InputDevice } from "@datagutt/kai/input/InputController"; +import { SCREEN_H, SCREEN_W, type ArcadeGame, type ArcadeInput, type Direction } from "./types.ts"; const DEPTH = 114_000; const TEXTURE = "arcade:screen"; @@ -30,6 +28,7 @@ export class ArcadeScreen { constructor( private readonly scene: Phaser.Scene, private readonly game: ArcadeGame, + private readonly data: GameData, private readonly onClose: () => void, ) { const cam = scene.cameras.main; @@ -64,6 +63,7 @@ export class ArcadeScreen { } if (input.device !== this.device) { this.device = input.device; + const t = this.data.t.bind(this.data); const back = input.device === "touch" ? t("arcade.leaveTouch") : t("arcade.leave", { button: input.device === "gamepad" ? "B" : "Esc" }); const lines = this.game.hint.replace(/\bA:/g, `${BUTTON[input.device]}:`).split(/\s{2,}/); this.hint.setText([...lines, back].map((l) => l.toUpperCase())); diff --git a/packages/kai-live/package.json b/packages/kai-live/package.json index 989513a..9e83d62 100644 --- a/packages/kai-live/package.json +++ b/packages/kai-live/package.json @@ -13,6 +13,7 @@ "test": "vitest run" }, "dependencies": { + "@datagutt/kai": "workspace:*", "@datagutt/kai-net": "workspace:*" }, "devDependencies": { diff --git a/packages/kai-live/src/weather.ts b/packages/kai-live/src/weather.ts index 52aed31..1cd7dda 100644 --- a/packages/kai-live/src/weather.ts +++ b/packages/kai-live/src/weather.ts @@ -1,43 +1,11 @@ // The visitor's weather, from MET Norway's Locationforecast. Framework free: a host adds // its own caching around fetchWeather (MET asks clients not to poll more than they need). -const FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/2.0/compact"; -const TIMEOUT_MS = 5_000; +import { calmWeather, type WeatherKind, type WeatherNow } from "@datagutt/kai/world/weather-kinds"; -export const WEATHER_KINDS = ["clear", "cloudy", "rain", "heavyRain", "snow", "sleet", "fog", "storm"] as const; -export type WeatherKind = (typeof WEATHER_KINDS)[number]; +export { calmWeather, isWeatherKind, WEATHER_KINDS, type WeatherKind, type WeatherNow } from "@datagutt/kai/world/weather-kinds"; -export const isWeatherKind = (value: unknown): value is WeatherKind => WEATHER_KINDS.includes(value as WeatherKind); - -/** The weather now where the visitor is, or at the game's fallback place. */ -export type WeatherNow = { - kind: WeatherKind; - /** Wind speed in m/s. */ - wind: number; - /** Where the wind blows from, in degrees: 0 north, 90 east. */ - windFrom: number; - /** Air temperature in °C, or null when unknown. */ - temperature: number | null; - /** MET's symbol code it was read from ("clearsky_day", "heavyrain"); empty when not live. */ - symbol: string; - /** The city it is for. */ - place: string; - /** - * True when it came from the forecast. False is the calm fallback (MET was down, or - * there is no payload): the game then shows only its seasonal particles. - */ - live: boolean; -}; - -/** No weather to speak of: what the game shows when it has no forecast. */ -export const calmWeather = (place: string): WeatherNow => ({ - kind: "clear", - wind: 0, - windFrom: 0, - temperature: null, - symbol: "", - place, - live: false, -}); +const FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/2.0/compact"; +const TIMEOUT_MS = 5_000; export type WeatherPlace = { city: string; lat: number; lon: number }; diff --git a/packages/kai-net/src/ghosts.test.ts b/packages/kai-net/src/ghosts.test.ts index caa7cc5..97342b2 100644 --- a/packages/kai-net/src/ghosts.test.ts +++ b/packages/kai-net/src/ghosts.test.ts @@ -77,9 +77,9 @@ describe("GhostClient", () => { describe("ghost helpers", () => { it("read the kill switch and build the socket URL", () => { - expect(ghostsDisabled({ getItem: (k) => (k === "rx_off" ? "1" : null) })).toBe(true); - expect(ghostsDisabled({ getItem: () => null })).toBe(false); - expect(ghostsDisabled(null)).toBe(false); + expect(ghostsDisabled({ getItem: (k) => (k === "rx_off" ? "1" : null) }, "rx_off")).toBe(true); + expect(ghostsDisabled({ getItem: () => null }, "rx_off")).toBe(false); + expect(ghostsDisabled(null, "rx_off")).toBe(false); expect(worldSocketUrl({ protocol: "https:", host: "datagutt.no" })).toBe("wss://datagutt.no/api/world/ws"); expect(worldSocketUrl({ protocol: "http:", host: "localhost:3200" })).toBe("ws://localhost:3200/api/world/ws"); }); diff --git a/packages/kai-net/src/ghosts.ts b/packages/kai-net/src/ghosts.ts index 901756f..a931fba 100644 --- a/packages/kai-net/src/ghosts.ts +++ b/packages/kai-net/src/ghosts.ts @@ -1,7 +1,7 @@ // The game's end of the world socket (protocol in ./protocol.ts): reports which map the // player is on and where, passes on what the room says, and rejoins after a reconnect. -// Visitors can switch it off with localStorage `rx_off` (DESIGN §14, the old reactions -// kill switch), in which case nothing is sent or received. +// Visitors can switch it off with a localStorage key the game names (its kill switch), +// in which case nothing is sent or received. import { OPEN, Reconnecting, browserSocket, type SocketLike } from "./reconnect.ts"; import { parseServerMessage, @@ -14,10 +14,10 @@ import { type Where = { map: string; x: number; y: number; facing: Facing }; -/** Whether the visitor has other visitors switched off (`localStorage.rx_off = "1"`). */ -export function ghostsDisabled(storage: Pick | null): boolean { +/** Whether the visitor has other visitors switched off: `localStorage[key]` is "1". */ +export function ghostsDisabled(storage: Pick | null, key: string): boolean { try { - return storage?.getItem("rx_off") === "1"; + return storage?.getItem(key) === "1"; } catch { return false; } diff --git a/packages/kai/package.json b/packages/kai/package.json index 0096b25..3213407 100644 --- a/packages/kai/package.json +++ b/packages/kai/package.json @@ -5,12 +5,10 @@ "license": "GPL-3.0-or-later", "type": "module", "exports": { + ".": "./src/index.ts", "./schema": "./src/schema/index.ts", - "./world/*": "./src/world/*.ts", - "./fx/*": "./src/fx/*.ts", - "./characters/*": "./src/characters/*.ts", - "./ui/*": "./src/ui/*.ts", - "./schema/*": "./src/schema/*.ts" + "./schema/*": "./src/schema/*.ts", + "./*": "./src/*.ts" }, "scripts": { "lint": "eslint .", @@ -19,6 +17,7 @@ }, "dependencies": { "@datagutt/kai-net": "workspace:*", + "inkjs": "^2.4.0", "zod": "^4" }, "devDependencies": { @@ -29,5 +28,8 @@ "eslint": "^9", "typescript": "^5.9.3", "vitest": "^5" + }, + "peerDependencies": { + "phaser": "^4.2.1" } } diff --git a/apps/datagutt/game/audio/Ambience.ts b/packages/kai/src/audio/Ambience.ts similarity index 98% rename from apps/datagutt/game/audio/Ambience.ts rename to packages/kai/src/audio/Ambience.ts index 87a4c43..202f07d 100644 --- a/apps/datagutt/game/audio/Ambience.ts +++ b/packages/kai/src/audio/Ambience.ts @@ -4,8 +4,8 @@ // layers run all the time and glide to the levels mix.ts asks for, so walking about, and // through doors, crossfades by itself. A recorded CC0 loop can later replace any layer's // source. -import type { AudioOutput } from "./sfx"; -import { LAYERS, type Layer, type Mix } from "./mix"; +import type { AudioOutput } from "./sfx.ts"; +import { LAYERS, type Layer, type Mix } from "./mix.ts"; /** Loudest each layer gets, before the ambience bus. */ const PEAK: Mix = { waves: 0.3, wind: 0.1, gulls: 0.07, birds: 0.05, fire: 0.22, room: 0.05, rain: 0.12, thunder: 0.5 }; diff --git a/apps/datagutt/game/audio/Music.ts b/packages/kai/src/audio/Music.ts similarity index 89% rename from apps/datagutt/game/audio/Music.ts rename to packages/kai/src/audio/Music.ts index 16ad5d1..8b6d8f3 100644 --- a/apps/datagutt/game/audio/Music.ts +++ b/packages/kai/src/audio/Music.ts @@ -1,14 +1,17 @@ -// Background music (docs/game/PLAN.md B4; DESIGN §15): one looping track at a time on the -// music bus, into Phaser's output so the mute setting holds. Callers say which track should -// be playing, as often as they like (playlist.ts picks it), and a change crossfades. +// Background music: one looping track at a time on the music bus, into Phaser's output so +// the mute setting holds. Callers say which track should be playing, as often as they like +// (GameData.trackFor picks it), and a change crossfades. // // Nothing loads until a track is first wanted, so music adds nothing to the first load. // Each track is Opus, or MP3 where the browser has no Opus, with a tenth of a second of -// its own tail before the loop and head after it (scripts/assets/music.mjs). A decoder that +// its own tail before the loop and head after it (the asset build's music step). A decoder that // leaves a codec delay in front then only shifts where the loop starts: the join between // loopEnd and loopStart stays seamless. -import type { MusicId, MusicLoop } from "../assets/manifest"; -import type { AudioOutput } from "./sfx"; +/** A track id in the game's music (content/music.json). */ +type MusicId = string; +/** Where a built track loops, in seconds from the start of its file (assets.json `music`). */ +type MusicLoop = { loopStart: number; loopEnd: number }; +import type { AudioOutput } from "./sfx.ts"; /** The music bus, under the effects and well over the ambience (Ambience.ts PEAK). */ const LEVEL = 0.5; diff --git a/apps/datagutt/game/audio/blips.test.ts b/packages/kai/src/audio/blips.test.ts similarity index 56% rename from apps/datagutt/game/audio/blips.test.ts rename to packages/kai/src/audio/blips.test.ts index 07b39f9..da5ed59 100644 --- a/apps/datagutt/game/audio/blips.test.ts +++ b/packages/kai/src/audio/blips.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { npc } from "../npcs"; -import { shouldBlip, voiceFor, DEFAULT_VOICE } from "./blips"; +import { shouldBlip } from "./blips.ts"; describe("shouldBlip", () => { it("blips on letters only, on a steady rhythm", () => { @@ -15,12 +14,3 @@ describe("shouldBlip", () => { expect(shouldBlip(" ", 1, 1)).toBe(false); }); }); - -describe("voiceFor", () => { - it("gives known characters their own voice", () => { - expect(voiceFor("ferryman")).toBe(npc("ferryman")!.voice); - expect(voiceFor("ferryman").pitch).not.toBe(voiceFor("datagutt").pitch); - expect(voiceFor("nobody")).toBe(DEFAULT_VOICE); - expect(voiceFor(null)).toBe(DEFAULT_VOICE); - }); -}); diff --git a/apps/datagutt/game/audio/blips.ts b/packages/kai/src/audio/blips.ts similarity index 68% rename from apps/datagutt/game/audio/blips.ts rename to packages/kai/src/audio/blips.ts index d872070..928fca3 100644 --- a/apps/datagutt/game/audio/blips.ts +++ b/packages/kai/src/audio/blips.ts @@ -1,25 +1,9 @@ -// Undertale-style dialogue blips (docs/game/PLAN.md M2.6): a short synthesised tone per -// revealed letter, with a distinct voice per character. Plays through Phaser's Web Audio +// Undertale-style dialogue blips: a short synthesised tone per revealed letter, with a +// distinct voice per character (GameData.voiceFor). Plays through Phaser's Web Audio // context so it shares the browser's audio unlock and the master mute. -import { npc } from "../npcs"; +import type { Voice } from "../data.ts"; -export type Voice = { - wave: OscillatorType; - /** Base pitch in Hz. */ - pitch: number; - /** Random pitch spread, as a fraction of the base (0.1 = ±10%). */ - variance: number; - volume: number; - /** Blip on every n-th letter. */ - every: number; -}; - -export const DEFAULT_VOICE: Voice = { wave: "square", pitch: 440, variance: 0.06, volume: 0.05, every: 2 }; - -/** A character's voice from the NPC roster, or the default. */ -export function voiceFor(character: string | null): Voice { - return (character && npc(character)?.voice) || DEFAULT_VOICE; -} +export type { Voice }; /** Whether revealing `text[index]` should make a sound: letters and digits only, spaced out. */ export function shouldBlip(text: string, index: number, every: number): boolean { diff --git a/apps/datagutt/game/audio/mix.test.ts b/packages/kai/src/audio/mix.test.ts similarity index 96% rename from apps/datagutt/game/audio/mix.test.ts rename to packages/kai/src/audio/mix.test.ts index d0d405b..4f046d9 100644 --- a/apps/datagutt/game/audio/mix.test.ts +++ b/packages/kai/src/audio/mix.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ambienceMix, type Surroundings } from "./mix"; +import { ambienceMix, type Surroundings } from "./mix.ts"; const base: Surroundings = { outdoors: true, water: Infinity, forest: Infinity, fire: Infinity, dark: 0, season: "summer" }; diff --git a/apps/datagutt/game/audio/mix.ts b/packages/kai/src/audio/mix.ts similarity index 97% rename from apps/datagutt/game/audio/mix.ts rename to packages/kai/src/audio/mix.ts index 62b521d..06248bc 100644 --- a/apps/datagutt/game/audio/mix.ts +++ b/packages/kai/src/audio/mix.ts @@ -2,7 +2,7 @@ // a level from 0 to 1 for each layer, from how near the sea, the forest and a fire are, // whether it's indoors, the time of day, the season and the weather. Pure, so it can be // tested; the sounds themselves are in Ambience.ts. -import type { Season } from "@datagutt/kai/world/season"; +import type { Season } from "../world/season.ts"; export const LAYERS = ["waves", "wind", "gulls", "birds", "fire", "room", "rain", "thunder"] as const; export type Layer = (typeof LAYERS)[number]; diff --git a/apps/datagutt/game/audio/sfx.ts b/packages/kai/src/audio/sfx.ts similarity index 100% rename from apps/datagutt/game/audio/sfx.ts rename to packages/kai/src/audio/sfx.ts diff --git a/apps/datagutt/game/boot.ts b/packages/kai/src/boot.ts similarity index 67% rename from apps/datagutt/game/boot.ts rename to packages/kai/src/boot.ts index 21a8e8f..e9cbeee 100644 --- a/apps/datagutt/game/boot.ts +++ b/packages/kai/src/boot.ts @@ -1,24 +1,23 @@ -// Entry point for the game. Framework-free: the Next.js page and the standalone dev -// harness both call bootGame() with a container element. +// The engine's entry point, framework-free: a host page (or the dev harness) calls +// createGame() with a container element, the game's config and content, and its plugins. import Phaser from "phaser"; -import { BootScene } from "./scenes/BootScene"; -import { PreloadScene } from "./scenes/PreloadScene"; -import { WorldScene } from "./scenes/WorldScene"; -import { readWorldState, type WorldState } from "@datagutt/kai-live"; -import { EMPTY_WORLD_STATE } from "../content/live"; -import { browserStorage, clearSave, loadSave } from "./save/save"; -import { computeViewport } from "./viewport"; -import type { Point } from "./world/grid"; -import type { Facing } from "@datagutt/kai/world/objects"; -import { START_PLACE, place, placeFromSearch } from "../content/places"; -import { resolveSeason, type Season } from "@datagutt/kai/world/season"; -import { clock, monthNow } from "./world/dayNight"; import { GhostClient, ghostsDisabled, worldSocketUrl } from "@datagutt/kai-net/client"; -import type { KaiPlugin } from "./plugins/api"; -import type { Progress } from "./progress/Progress"; -import type { KaiConfig } from "@datagutt/kai/schema"; -import { Music } from "./audio/Music"; -import { trackFor } from "./audio/playlist"; +import { Music } from "./audio/Music.ts"; +import { GameData, type GameContent } from "./data.ts"; +import type { KaiPlugin } from "./plugins/api.ts"; +import { triggersPlugin } from "./plugins/triggers.ts"; +import type { Progress } from "./progress/Progress.ts"; +import { browserStorage, clearSave, loadSave } from "./save/save.ts"; +import { BootScene } from "./scenes/BootScene.ts"; +import { PreloadScene } from "./scenes/PreloadScene.ts"; +import { WorldScene } from "./scenes/WorldScene.ts"; +import type { KaiConfig } from "./schema/config.ts"; +import { computeViewport } from "./viewport.ts"; +import { clock, monthNow } from "./world/dayNight.ts"; +import type { Point } from "./world/grid.ts"; +import type { Facing } from "./world/objects.ts"; +import { resolveSeason, type Season } from "./world/season.ts"; +import type { WeatherNow } from "./world/weather-kinds.ts"; /** Where the World scene should put the player. */ export type WorldTarget = { map: string; spawn?: string; tile?: Point; facing?: Facing }; @@ -27,26 +26,47 @@ export type WorldTarget = { map: string; spawn?: string; tile?: Point; facing?: * Deep link first (`?at=`), then the saved position, then the start place's entrance. * `deepLinked` lets the page skip the title screen. */ -export function resolveStart(search: string): { target: WorldTarget; deepLinked: boolean; hasSave: boolean } { - const save = loadSave(browserStorage()); +export function resolveStart(search: string, data: GameData, config: KaiConfig): { target: WorldTarget; deepLinked: boolean; hasSave: boolean } { + const save = loadSave(browserStorage(), config.saveKey); // Debug only: `?debug&map=` opens any map at its first spawn (maps in progress). const params = new URLSearchParams(search); const debugMap = params.has("debug") ? params.get("map") : null; if (debugMap && /^[a-z0-9_-]+$/.test(debugMap)) return { target: { map: debugMap }, deepLinked: true, hasSave: save !== null }; - const linked = placeFromSearch(search); + const linked = data.placeFromSearch(search); if (linked?.entrance) return { target: { ...linked.entrance }, deepLinked: true, hasSave: save !== null }; if (save) return { target: { map: save.map, tile: { x: save.x, y: save.y }, facing: save.facing }, deepLinked: false, hasSave: true }; - return { target: { ...place(START_PLACE).entrance! }, deepLinked: false, hasSave: false }; + return { target: startEntrance(data, config), deepLinked: false, hasSave: false }; } -/** What the game's dialogue functions can read: the live data, the player's progress. */ -export type ExternalsContext = { world: WorldState; progress: Progress; services: GameServices }; +function startEntrance(data: GameData, config: KaiConfig): WorldTarget { + const entrance = data.place(config.startPlace).entrance; + if (!entrance) throw new Error(`The start place "${config.startPlace}" has no entrance`); + return { ...entrance }; +} + +/** What the game's dialogue functions can read: the player's progress and the services. */ +export type ExternalsContext = { progress: Progress; services: GameServices }; + +/** + * The live data the page embeds for the game. The engine reads the weather; any other + * slice (GitHub data, say) is for the game's own plugins. + */ +export type LiveData = { weather: WeatherNow }; + +/** A dialogue tag as a link: what to open, a problem with the tag, or null for no link. */ +export type LinkResolver = (tag: string) => { url: string; label: string } | { error: string } | null; /** The implementations of the game's Ink external functions, by name. */ export type Externals = Record unknown>; export type BootOptions = { /** kai.json as the content build validated it (.kai/config.json). */ config: KaiConfig; + /** The compiled content (.kai/content.json): the engine's collections and the places. */ + content: GameContent; + /** Live data the page embedded, or the game's empty defaults. */ + live: LiveData; + /** Turns `# link:` tags into links the player can open. */ + links?: LinkResolver; /** The game's own behaviour. */ plugins?: KaiPlugin[]; /** Binds the game's Ink external functions (its dialogue host names them). */ @@ -80,14 +100,13 @@ export type GameHandle = { hasSave: boolean; }; -export type GameServices = Required> & { +export type GameServices = Required> & { + data: GameData; onProgress: (progress: number) => void; onReady: () => void; /** Resolves when the player has asked to start. */ startRequested: Promise; start: WorldTarget; - /** Live GitHub data embedded by the page; empty in the dev harness. */ - world: WorldState; /** The season outdoors: today's in the game's time zone, or `?debug&season=`. */ season: Season; /** @@ -116,31 +135,36 @@ export type GameServices = Required void; const startRequested = new Promise((resolve) => (requestStart = resolve)); - const { target, deepLinked, hasSave } = resolveStart(window.location.search); + const { target, deepLinked, hasSave } = resolveStart(window.location.search, data, config); if (options.autoStart || deepLinked) requestStart(); - const assetBase = options.assetBase ?? "/game/"; + const assetBase = options.assetBase ?? config.basePath; const services: GameServices = { assetBase, - config: options.config, - plugins: options.plugins ?? [], + config, + data, + live: options.live, + links: options.links ?? (() => null), + // The content's declarative triggers run first, then the game's own plugins. + plugins: [triggersPlugin(options.content.triggers.list), ...(options.plugins ?? [])], externals: options.externals ?? (() => ({})), onProgress: options.onProgress ?? (() => {}), onReady: options.onReady ?? (() => {}), startRequested, start: target, - world: readWorldState(EMPTY_WORLD_STATE), - season: resolveSeason(window.location.search, options.config.timezone), + season: resolveSeason(window.location.search, config.timezone), night: false, autoLow: false, fresh: false, firstVisit: (!hasSave && !deepLinked) || (new URLSearchParams(window.location.search).has("debug") && new URLSearchParams(window.location.search).has("intro")), hours: clock(window.location.search), month: monthNow(window.location.search), - ghosts: ghostsDisabled(safeLocalStorage()) ? null : new GhostClient(worldSocketUrl(window.location)), + ghosts: ghostsDisabled(safeLocalStorage(), config.visitorsOffKey) ? null : new GhostClient(worldSocketUrl(window.location)), music: new Music(() => { const sound = game.sound; return sound instanceof Phaser.Sound.WebAudioSoundManager ? { context: sound.context, destination: sound.destination } : null; @@ -148,12 +172,6 @@ export function bootGame(parent: HTMLElement, options: BootOptions): GameHandle }; const params = new URLSearchParams(window.location.search); const debug = params.has("debug"); - if (debug) { - console.info( - `[game] world state from ${services.world.fetchedAt}: ${services.world.repos.length} repos`, - services.world.repos.map((r) => r.name), - ); - } const stops: (() => void)[] = []; for (const plugin of services.plugins) { const booted = plugin.boot?.(services, params); @@ -212,16 +230,16 @@ export function bootGame(parent: HTMLElement, options: BootOptions): GameHandle return { start(options) { if (options?.fresh) { - clearSave(browserStorage()); - services.start = { ...place(START_PLACE).entrance! }; + clearSave(browserStorage(), config.saveKey); + services.start = startEntrance(data, config); services.firstVisit = true; services.fresh = true; } requestStart(); }, playTitleMusic() { - const settings = loadSave(browserStorage())?.settings; - if (!settings?.muted && settings?.music !== false) services.music.play(trackFor({ scene: "title" })); + const settings = loadSave(browserStorage(), config.saveKey)?.settings; + if (!settings?.muted && settings?.music !== false) services.music.play(data.trackFor({ scene: "title" })); }, deepLinked, hasSave, diff --git a/apps/datagutt/game/constants.ts b/packages/kai/src/constants.ts similarity index 100% rename from apps/datagutt/game/constants.ts rename to packages/kai/src/constants.ts diff --git a/packages/kai/src/data.ts b/packages/kai/src/data.ts new file mode 100644 index 0000000..3bafabb --- /dev/null +++ b/packages/kai/src/data.ts @@ -0,0 +1,124 @@ +// A game's content as the runtime reads it: the engine's collections (the content build +// checked them) and the structural part of the game's places, with the rules that follow +// from them: who gives which stamp, what an unlock needs, which track plays where. +import type { ContentOf } from "./schema/content.ts"; +import type { ENGINE_COLLECTIONS } from "./schema/engine.ts"; +import { format, withDefaults, type StringKey, type Strings } from "./ui/strings.ts"; +import type { Season } from "./world/season.ts"; + +export type EngineContent = ContentOf; +export type Npc = EngineContent["npcs"][string] & { id: string }; +export type Voice = Npc["voice"]; +export type Achievement = EngineContent["achievements"]["list"][number]; +export type CharacterRecipe = EngineContent["characters"][string]; + +/** What the runtime needs of a place: the game's places may carry more. */ +export type PlaceInfo = { id: string; name: string; stamp: boolean; entrance?: { map: string; spawn: string } }; + +export type GameContent = EngineContent & { places: readonly PlaceInfo[] }; + +export type StampResult = { stamps: string[]; newStamp: string | null; complete: boolean }; + +/** Where music is wanted: the title, the credits, or the world at some moment. */ +export type Moment = + | { scene: "title" } + | { scene: "credits" } + | { scene: "world"; map: string; outdoors: boolean; phase: "dawn" | "day" | "dusk" | "night"; season: Season; night: boolean }; + +/** Voice for someone with no voice of their own. */ +export const DEFAULT_VOICE: Voice = { wave: "square", pitch: 440, variance: 0.06, volume: 0.05, every: 2 }; + +export const achievementFlag = (id: string) => `achievement:${id}`; + +export class GameData { + readonly npcs: readonly Npc[]; + /** Places that give a stamp, in passport order. */ + readonly stampPlaces: readonly PlaceInfo[]; + private readonly strings: Strings; + + constructor(readonly content: GameContent) { + this.npcs = Object.entries(content.npcs).map(([id, npc]) => ({ id, ...npc })); + this.stampPlaces = content.places.filter((p) => p.stamp); + this.strings = withDefaults(content.strings); + } + + /** A UI text by key, with its `{name}` values filled in. */ + t(key: StringKey, vars?: Record): string { + return vars ? format(this.strings[key], vars) : this.strings[key]; + } + + npc(id: string): Npc | undefined { + return this.npcs.find((n) => n.id === id); + } + + /** A character's voice from the cast, or the default. */ + voiceFor(character: string | null): Voice { + return (character && this.npc(character)?.voice) || DEFAULT_VOICE; + } + + get achievements(): readonly Achievement[] { + return this.content.achievements.list; + } + + achievement(id: string): Achievement { + const found = this.achievements.find((a) => a.id === id); + if (!found) throw new Error(`Unknown achievement "${id}"`); + return found; + } + + place(id: string): PlaceInfo { + const found = this.content.places.find((p) => p.id === id); + if (!found) throw new Error(`Unknown place "${id}"`); + return found; + } + + /** The place named by `?at=` in a URL search string, if it is known and has an entrance. */ + placeFromSearch(search: string): PlaceInfo | null { + const at = new URLSearchParams(search).get("at")?.trim().toLowerCase(); + const found = at ? this.content.places.find((p) => p.id === at) : undefined; + return found?.entrance ? found : null; + } + + placeName(id: string): string { + return this.stampPlaces.find((p) => p.id === id)?.name ?? id; + } + + /** The place whose stamp an NPC gives, if they are that place's main NPC. */ + stampForNpc(npcId: string): string | null { + const place = this.npc(npcId)?.place; + return place && this.stampPlaces.some((p) => p.id === place) ? place : null; + } + + /** Add a stamp if it is new. Unknown place ids are ignored, and dropped from `stamps`. */ + awardStamp(stamps: readonly string[], place: string | null): StampResult { + const known = new Set(this.stampPlaces.map((p) => p.id)); + const current = stamps.filter((s) => known.has(s)); + if (!place || !known.has(place) || current.includes(place)) { + return { stamps: current, newStamp: null, complete: current.length === known.size }; + } + const next = [...current, place]; + return { stamps: next, newStamp: place, complete: next.length === known.size }; + } + + /** Every stamp is in. */ + passportFull(progress: { hasStamp(place: string): boolean }): boolean { + return this.stampPlaces.every((p) => progress.hasStamp(p.id)); + } + + /** Whether an unlock (content/unlocks.json) holds for the player's stamps. */ + isUnlocked(id: string, progress: { hasStamp(place: string): boolean }): boolean { + const condition = this.content.unlocks[id]; + return condition?.stamps === "all" && this.passportFull(progress); + } + + /** The track for a moment, by the playlist (content/music.json). */ + trackFor(moment: Moment): string { + const playlist = this.content.music.playlist; + if (moment.scene === "title") return playlist.title; + if (moment.scene === "credits") return playlist.credits; + if (!moment.outdoors) return playlist.indoors.maps[moment.map] ?? playlist.indoors.default; + // Night wins over winter, so winter keeps a day and a night track like the rest of the year. + if (moment.night || moment.phase === "night") return playlist.outdoors.night; + return moment.season === "winter" ? playlist.outdoors.winter : playlist.outdoors.day; + } +} diff --git a/apps/datagutt/game/dev/simulatedGhosts.ts b/packages/kai/src/dev/simulatedGhosts.ts similarity index 93% rename from apps/datagutt/game/dev/simulatedGhosts.ts rename to packages/kai/src/dev/simulatedGhosts.ts index a4fcf41..0c2ae2c 100644 --- a/apps/datagutt/game/dev/simulatedGhosts.ts +++ b/packages/kai/src/dev/simulatedGhosts.ts @@ -1,8 +1,8 @@ // Fake visitors for `?debug&ghosts=` (docs/game/PLAN.md M4.4: smooth with 20 ghosts on // a phone). They join around the player and wander one tile at a time, fed to the ghost // layer as if the room had sent them. -import type { CollisionGrid, Point } from "../world/grid"; -import type { Facing } from "@datagutt/kai/world/objects"; +import type { CollisionGrid, Point } from "../world/grid.ts"; +import type { Facing } from "../world/objects.ts"; import type { ServerMessage } from "@datagutt/kai-net/protocol"; const STEP_MS = [250, 900] as const; diff --git a/apps/datagutt/game/dialogue/DialogueRunner.ts b/packages/kai/src/dialogue/DialogueRunner.ts similarity index 100% rename from apps/datagutt/game/dialogue/DialogueRunner.ts rename to packages/kai/src/dialogue/DialogueRunner.ts diff --git a/apps/datagutt/game/entities/Actor.ts b/packages/kai/src/entities/Actor.ts similarity index 88% rename from apps/datagutt/game/entities/Actor.ts rename to packages/kai/src/entities/Actor.ts index 7f6e60f..d831552 100644 --- a/apps/datagutt/game/entities/Actor.ts +++ b/packages/kai/src/entities/Actor.ts @@ -2,11 +2,11 @@ // on the bottom of its tile and it depth-sorts by its feet so it walks behind things. // Its origin is the middle of its feet, so squash and stretch keep it standing. import Phaser from "phaser"; -import { TILE } from "../constants"; -import { ANIMS, animKey, type AnimName } from "@datagutt/kai/characters/sheet"; -import { GridMover, type MoverConfig } from "../world/movement"; -import type { Point } from "../world/grid"; -import type { Facing } from "@datagutt/kai/world/objects"; +import { TILE } from "../constants.ts"; +import { ANIMS, animKey, type AnimName } from "../characters/sheet.ts"; +import { GridMover, type MoverConfig } from "../world/movement.ts"; +import type { Point } from "../world/grid.ts"; +import type { Facing } from "../world/objects.ts"; export class Actor { readonly sprite: Phaser.GameObjects.Sprite; diff --git a/apps/datagutt/game/entities/Ghosts.ts b/packages/kai/src/entities/Ghosts.ts similarity index 97% rename from apps/datagutt/game/entities/Ghosts.ts rename to packages/kai/src/entities/Ghosts.ts index 9058958..4b2aace 100644 --- a/apps/datagutt/game/entities/Ghosts.ts +++ b/packages/kai/src/entities/Ghosts.ts @@ -3,11 +3,11 @@ // pass through everything, and never block anyone. Only the nearest are drawn, and one // who stands still long enough fades away until they move again. import Phaser from "phaser"; -import { Actor } from "./Actor"; -import { PLAYER_MOVEMENT } from "../world/movement"; -import { directionBetween, type Point } from "../world/grid"; +import { Actor } from "./Actor.ts"; +import { PLAYER_MOVEMENT } from "../world/movement.ts"; +import { directionBetween, type Point } from "../world/grid.ts"; import type { Ghost, GhostEmote, ServerMessage } from "@datagutt/kai-net/protocol"; -import { EmoteBubble } from "../ui/Bubbles"; +import { EmoteBubble } from "../ui/Bubbles.ts"; /** Drawn at most, nearest to the player first. */ export const MAX_GHOSTS = 20; diff --git a/apps/datagutt/game/fx/Aurora.ts b/packages/kai/src/fx/Aurora.ts similarity index 97% rename from apps/datagutt/game/fx/Aurora.ts rename to packages/kai/src/fx/Aurora.ts index e953079..bb97bc3 100644 --- a/apps/datagutt/game/fx/Aurora.ts +++ b/packages/kai/src/fx/Aurora.ts @@ -4,8 +4,8 @@ // light summer nights. Added over the world (and reflected in the fjord's ripples, // fx/Water.ts). Reduced motion slows the drift. import Phaser from "phaser"; -import type { Season } from "@datagutt/kai/world/season"; -import { LIGHT_DEPTH } from "./Lights"; +import type { Season } from "../world/season.ts"; +import { LIGHT_DEPTH } from "./Lights.ts"; /** How bright the aurora gets in each season, on the darkest night. */ const SEASON_STRENGTH: Record = { winter: 1, autumn: 0.7, spring: 0.45, summer: 0 }; diff --git a/apps/datagutt/game/fx/DayNight.ts b/packages/kai/src/fx/DayNight.ts similarity index 94% rename from apps/datagutt/game/fx/DayNight.ts rename to packages/kai/src/fx/DayNight.ts index 5d182af..a770050 100644 --- a/apps/datagutt/game/fx/DayNight.ts +++ b/packages/kai/src/fx/DayNight.ts @@ -3,9 +3,9 @@ // add on top and so really glow after dark. Indoors nothing is tinted; daylight through // the windows fades at night. Timed lights follow the clock everywhere. import Phaser from "phaser"; -import type { LightObject } from "@datagutt/kai/world/objects"; -import { daylightAt, lightFactor, type Daylight } from "../world/dayNight"; -import { LIGHT_DEPTH } from "./Lights"; +import type { LightObject } from "../world/objects.ts"; +import { daylightAt, lightFactor, type Daylight } from "../world/dayNight.ts"; +import { LIGHT_DEPTH } from "./Lights.ts"; /** Refresh this often; the light changes over minutes, not frames. */ const REFRESH_MS = 1_000; diff --git a/apps/datagutt/game/fx/Feel.ts b/packages/kai/src/fx/Feel.ts similarity index 94% rename from apps/datagutt/game/fx/Feel.ts rename to packages/kai/src/fx/Feel.ts index a1c47d8..3d04a24 100644 --- a/apps/datagutt/game/fx/Feel.ts +++ b/packages/kai/src/fx/Feel.ts @@ -4,9 +4,9 @@ // after the player and looks a little ahead. Reduced motion drops the shake and the // look-ahead and thins the dust. import Phaser from "phaser"; -import { STEP, type Point } from "../world/grid"; -import type { Facing } from "@datagutt/kai/world/objects"; -import type { Actor } from "../entities/Actor"; +import { STEP, type Point } from "../world/grid.ts"; +import type { Facing } from "../world/objects.ts"; +import type { Actor } from "../entities/Actor.ts"; const DUST_KEY = "fx:dust"; /** How far ahead of the player the camera looks, in pixels, and how fast it gets there. */ diff --git a/apps/datagutt/game/fx/Lights.ts b/packages/kai/src/fx/Lights.ts similarity index 92% rename from apps/datagutt/game/fx/Lights.ts rename to packages/kai/src/fx/Lights.ts index 82080bc..53750e5 100644 --- a/apps/datagutt/game/fx/Lights.ts +++ b/packages/kai/src/fx/Lights.ts @@ -2,9 +2,9 @@ // come from lightShapes.ts, baked once into small textures and tinted per light, so any // number of lights overlap and add up smoothly. import Phaser from "phaser"; -import { TILE } from "../constants"; -import type { LightObject } from "@datagutt/kai/world/objects"; -import { beamAlpha, glowAlpha } from "@datagutt/kai/fx/lightShapes"; +import { TILE } from "../constants.ts"; +import type { LightObject } from "../world/objects.ts"; +import { beamAlpha, glowAlpha } from "./lightShapes.ts"; const GLOW_KEY = "fx:glow"; const BEAM_KEY = "fx:beam"; diff --git a/apps/datagutt/game/fx/Water.ts b/packages/kai/src/fx/Water.ts similarity index 98% rename from apps/datagutt/game/fx/Water.ts rename to packages/kai/src/fx/Water.ts index e6a8def..f499d33 100644 --- a/apps/datagutt/game/fx/Water.ts +++ b/packages/kai/src/fx/Water.ts @@ -3,8 +3,8 @@ // day, and slow glints (paler, moonlit ones at night), in whole pixels to match the art. Shore tiles are left alone: their art already has the beach and its foam. // Without WebGL there is no shader and the water stays as drawn. import Phaser from "phaser"; -import { TILE } from "../constants"; -import type { Daylight } from "../world/dayNight"; +import { TILE } from "../constants.ts"; +import type { Daylight } from "../world/dayNight.ts"; const MASK_KEY = "fx:water-mask"; diff --git a/apps/datagutt/game/fx/Weather.ts b/packages/kai/src/fx/Weather.ts similarity index 98% rename from apps/datagutt/game/fx/Weather.ts rename to packages/kai/src/fx/Weather.ts index 5794a86..01c6be7 100644 --- a/apps/datagutt/game/fx/Weather.ts +++ b/packages/kai/src/fx/Weather.ts @@ -6,10 +6,10 @@ // thins it out, stops the tumbling and the fog's drift, and never flashes. Low effects // quality thins it too and drops the frost, the splashes and one of the fog's layers. import Phaser from "phaser"; -import type { WeatherNow } from "@datagutt/kai-live"; -import type { Season } from "@datagutt/kai/world/season"; -import { skyFor, type FallKind, type Sky } from "../world/weather"; -import { LIGHT_DEPTH } from "./Lights"; +import type { WeatherNow } from "../world/weather-kinds.ts"; +import type { Season } from "../world/season.ts"; +import { skyFor, type FallKind, type Sky } from "../world/weather.ts"; +import { LIGHT_DEPTH } from "./Lights.ts"; const DEPTH = LIGHT_DEPTH - 2; const FOG_DEPTH = LIGHT_DEPTH - 3; diff --git a/apps/datagutt/game/fx/quality.test.ts b/packages/kai/src/fx/quality.test.ts similarity index 93% rename from apps/datagutt/game/fx/quality.test.ts rename to packages/kai/src/fx/quality.test.ts index 452eb5e..503d698 100644 --- a/apps/datagutt/game/fx/quality.test.ts +++ b/packages/kai/src/fx/quality.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { FrameWatch, qualityFor } from "./quality"; +import { FrameWatch, qualityFor } from "./quality.ts"; describe("effects quality", () => { it("follows the setting, auto dropping to low once frames were slow", () => { diff --git a/apps/datagutt/game/fx/quality.ts b/packages/kai/src/fx/quality.ts similarity index 96% rename from apps/datagutt/game/fx/quality.ts rename to packages/kai/src/fx/quality.ts index c10c985..f3a8610 100644 --- a/apps/datagutt/game/fx/quality.ts +++ b/packages/kai/src/fx/quality.ts @@ -2,7 +2,7 @@ // full weather; "low" drops the shaders and thins the weather. The "auto" setting starts // high and watches the first seconds on each map: if frames run slow it drops to low for // the rest of the visit. -import type { EffectsSetting } from "../save/save"; +import type { EffectsSetting } from "../save/save.ts"; export type Quality = "high" | "low"; diff --git a/packages/kai/src/index.ts b/packages/kai/src/index.ts new file mode 100644 index 0000000..c6c7a61 --- /dev/null +++ b/packages/kai/src/index.ts @@ -0,0 +1,9 @@ +// The kai runtime: createGame() and the types a game and its plugins build on. Everything +// else is reachable by path (`@datagutt/kai/ui/CreditsRoll`), for plugins that need it. +export { createGame, resolveStart } from "./boot.ts"; +export type { BootOptions, Externals, ExternalsContext, GameHandle, GameServices, LinkResolver, LiveData, WorldTarget } from "./boot.ts"; +export { GameData, DEFAULT_VOICE, achievementFlag } from "./data.ts"; +export type { Achievement, CharacterRecipe, EngineContent, GameContent, Moment, Npc, PlaceInfo, StampResult, Voice } from "./data.ts"; +export { perWorld, tileKey } from "./plugins/api.ts"; +export type { KaiPlugin, MenuItem, NpcDef, ObjectOf, Takeover, TileLayer, Usable, World } from "./plugins/api.ts"; +export { TILE } from "./constants.ts"; diff --git a/apps/datagutt/game/input/InputController.ts b/packages/kai/src/input/InputController.ts similarity index 98% rename from apps/datagutt/game/input/InputController.ts rename to packages/kai/src/input/InputController.ts index 625be20..e4f9f80 100644 --- a/apps/datagutt/game/input/InputController.ts +++ b/packages/kai/src/input/InputController.ts @@ -1,8 +1,8 @@ // Merges keyboard, gamepad and pointer into one set of actions per frame // (docs/game/PLAN.md M1.9). Scenes read `poll()` and never touch devices directly. import Phaser from "phaser"; -import type { Facing } from "@datagutt/kai/world/objects"; -import { DirectionStack } from "./directionStack"; +import type { Facing } from "../world/objects.ts"; +import { DirectionStack } from "./directionStack.ts"; export type FrameInput = { /** Held direction from keys, d-pad or stick. */ diff --git a/apps/datagutt/game/input/directionStack.ts b/packages/kai/src/input/directionStack.ts similarity index 90% rename from apps/datagutt/game/input/directionStack.ts rename to packages/kai/src/input/directionStack.ts index 711ffe8..9665b25 100644 --- a/apps/datagutt/game/input/directionStack.ts +++ b/packages/kai/src/input/directionStack.ts @@ -1,4 +1,4 @@ -import type { Facing } from "@datagutt/kai/world/objects"; +import type { Facing } from "../world/objects.ts"; /** * Tracks held direction keys so the most recently pressed one wins, and releasing it diff --git a/apps/datagutt/game/plugins/api.ts b/packages/kai/src/plugins/api.ts similarity index 93% rename from apps/datagutt/game/plugins/api.ts rename to packages/kai/src/plugins/api.ts index a99380c..628d005 100644 --- a/apps/datagutt/game/plugins/api.ts +++ b/packages/kai/src/plugins/api.ts @@ -3,14 +3,14 @@ // something, a conversation ending), places its own map object types, adds start menu // items, and can take over the frame for a while (a cutscene, a cabinet, the credits). import type Phaser from "phaser"; -import type { MapObject } from "@datagutt/kai/world/objects"; -import type { Externals, ExternalsContext, GameServices, WorldTarget } from "../boot"; -import type { Actor } from "../entities/Actor"; -import type { FrameInput } from "../input/InputController"; -import type { Progress } from "../progress/Progress"; -import type { PromptAction } from "../ui/Prompt"; -import type { CollisionGrid, Point } from "../world/grid"; -import type { Daylight } from "../world/dayNight"; +import type { MapObject } from "../world/objects.ts"; +import type { Externals, ExternalsContext, GameServices, WorldTarget } from "../boot.ts"; +import type { Actor } from "../entities/Actor.ts"; +import type { FrameInput } from "../input/InputController.ts"; +import type { Progress } from "../progress/Progress.ts"; +import type { PromptAction } from "../ui/Prompt.ts"; +import type { CollisionGrid, Point } from "../world/grid.ts"; +import type { Daylight } from "../world/dayNight.ts"; export type ObjectOf = Extract; export type NpcDef = ObjectOf<"npc">; diff --git a/apps/datagutt/game/plugins/triggers.ts b/packages/kai/src/plugins/triggers.ts similarity index 87% rename from apps/datagutt/game/plugins/triggers.ts rename to packages/kai/src/plugins/triggers.ts index 803c5fa..0442bec 100644 --- a/apps/datagutt/game/plugins/triggers.ts +++ b/packages/kai/src/plugins/triggers.ts @@ -1,8 +1,7 @@ // Runs the declarative triggers (content/triggers.json): arriving on a map, walking into // its edge, a full passport. -import type { Trigger } from "@datagutt/kai/schema/engine"; -import { passportFull } from "../progress/passport"; -import type { KaiPlugin, World } from "./api"; +import type { Trigger } from "../schema/engine.ts"; +import type { KaiPlugin, World } from "./api.ts"; export function triggersPlugin(triggers: readonly Trigger[]): KaiPlugin { /** When each bumpEdge trigger may fire again, by its index. */ @@ -19,7 +18,7 @@ export function triggersPlugin(triggers: readonly Trigger[]): KaiPlugin { } function checkPassport(world: World) { - if (!passportFull(world.progress)) return; + if (!world.services.data.passportFull(world.progress)) return; for (const trigger of triggers) if (trigger.on === "passportFull") fire(world, trigger); } diff --git a/apps/datagutt/game/progress/Progress.ts b/packages/kai/src/progress/Progress.ts similarity index 78% rename from apps/datagutt/game/progress/Progress.ts rename to packages/kai/src/progress/Progress.ts index a14d743..5b9c342 100644 --- a/apps/datagutt/game/progress/Progress.ts +++ b/packages/kai/src/progress/Progress.ts @@ -1,8 +1,7 @@ // The player's progress for this session, loaded once from the save and written back by // the world scene. Shared through the Phaser registry under PROGRESS_KEY. -import type { SaveData } from "../save/save"; -import { achievementFlag, type AchievementId } from "./achievements"; -import { awardStamp, type StampResult } from "./passport"; +import { achievementFlag, type GameData, type StampResult } from "../data.ts"; +import type { SaveData } from "../save/save.ts"; export const PROGRESS_KEY = "progress"; @@ -14,8 +13,11 @@ export class Progress { records: Record; settings: Settings; - constructor(saved: SaveData | null) { - this.stamps = awardStamp(saved?.stamps ?? [], null).stamps; + constructor( + saved: SaveData | null, + private readonly data: GameData, + ) { + this.stamps = data.awardStamp(saved?.stamps ?? [], null).stamps; this.flags = { ...(saved?.flags ?? {}) }; this.records = { ...(saved?.records ?? {}) }; this.settings = saved?.settings ?? { muted: false, music: true, showVisitors: true, reducedMotion: null, effects: "auto" }; @@ -26,17 +28,17 @@ export class Progress { } stamp(place: string | null): StampResult { - const result = awardStamp(this.stamps, place); + const result = this.data.awardStamp(this.stamps, place); this.stamps = result.stamps; return result; } - hasAchievement(id: AchievementId): boolean { + hasAchievement(id: string): boolean { return this.flags[achievementFlag(id)] === true; } /** Earn an achievement; true only the first time. */ - achieve(id: AchievementId): boolean { + achieve(id: string): boolean { if (this.hasAchievement(id)) return false; this.flags[achievementFlag(id)] = true; return true; diff --git a/apps/datagutt/game/save/save.test.ts b/packages/kai/src/save/save.test.ts similarity index 66% rename from apps/datagutt/game/save/save.test.ts rename to packages/kai/src/save/save.test.ts index fdad817..ded14cf 100644 --- a/apps/datagutt/game/save/save.test.ts +++ b/packages/kai/src/save/save.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; -import { DirectionStack } from "../input/directionStack"; -import { SAVE_KEY, clearSave, loadSave, writeSave, type StorageLike } from "./save"; +import { DirectionStack } from "../input/directionStack.ts"; +import { clearSave, loadSave, writeSave, type StorageLike } from "./save.ts"; + +const SAVE_KEY = "game.save"; function memoryStorage(initial: Record = {}): StorageLike & { data: Record } { const data = { ...initial }; @@ -27,18 +29,18 @@ const sample = { describe("save", () => { it("round-trips", () => { const storage = memoryStorage(); - expect(writeSave(storage, sample)).toBe(true); - expect(loadSave(storage)).toMatchObject(sample); - writeSave(storage, { ...sample, settings: { ...sample.settings, music: false } }); - expect(loadSave(storage)?.settings.music).toBe(false); + expect(writeSave(storage, SAVE_KEY, sample)).toBe(true); + expect(loadSave(storage, SAVE_KEY)).toMatchObject(sample); + writeSave(storage, SAVE_KEY, { ...sample, settings: { ...sample.settings, music: false } }); + expect(loadSave(storage, SAVE_KEY)?.settings.music).toBe(false); }); it("treats missing, corrupt or foreign data as no save", () => { - expect(loadSave(memoryStorage())).toBeNull(); - expect(loadSave(memoryStorage({ [SAVE_KEY]: "{not json" }))).toBeNull(); - expect(loadSave(memoryStorage({ [SAVE_KEY]: JSON.stringify({ version: 99, map: "town" }) }))).toBeNull(); - expect(loadSave(memoryStorage({ [SAVE_KEY]: JSON.stringify({ ...sample, version: 1, facing: "sideways" }) }))).toBeNull(); - expect(loadSave(undefined)).toBeNull(); + expect(loadSave(memoryStorage(), SAVE_KEY)).toBeNull(); + expect(loadSave(memoryStorage({ [SAVE_KEY]: "{not json" }), SAVE_KEY)).toBeNull(); + expect(loadSave(memoryStorage({ [SAVE_KEY]: JSON.stringify({ version: 99, map: "town" }) }), SAVE_KEY)).toBeNull(); + expect(loadSave(memoryStorage({ [SAVE_KEY]: JSON.stringify({ ...sample, version: 1, facing: "sideways" }) }), SAVE_KEY)).toBeNull(); + expect(loadSave(undefined, SAVE_KEY)).toBeNull(); }); it("survives storage that throws", () => { @@ -53,16 +55,16 @@ describe("save", () => { throw new Error("SecurityError"); }, }; - expect(loadSave(hostile)).toBeNull(); - expect(writeSave(hostile, sample)).toBe(false); - expect(() => clearSave(hostile)).not.toThrow(); + expect(loadSave(hostile, SAVE_KEY)).toBeNull(); + expect(writeSave(hostile, SAVE_KEY, sample)).toBe(false); + expect(() => clearSave(hostile, SAVE_KEY)).not.toThrow(); }); it("repairs sloppy fields instead of discarding progress", () => { const storage = memoryStorage({ [SAVE_KEY]: JSON.stringify({ ...sample, version: 1, stamps: ["a", "a", 3], records: { blocks: -3, pool: 2.5, lines: 7 }, settings: "nope" }), }); - const save = loadSave(storage)!; + const save = loadSave(storage, SAVE_KEY)!; expect(save.stamps).toEqual(["a"]); expect(save.records).toEqual({ lines: 7 }); expect(save.settings).toEqual({ muted: false, music: true, showVisitors: true, reducedMotion: null, effects: "auto" }); diff --git a/apps/datagutt/game/save/save.ts b/packages/kai/src/save/save.ts similarity index 85% rename from apps/datagutt/game/save/save.ts rename to packages/kai/src/save/save.ts index 364e8b5..a9b3cd3 100644 --- a/apps/datagutt/game/save/save.ts +++ b/packages/kai/src/save/save.ts @@ -1,8 +1,7 @@ -// Versioned save in localStorage (docs/game/PLAN.md M1.10). Every read and write is +// Versioned save in localStorage. Every read and write is // guarded: private windows, blocked storage and corrupt data all fall back to a new game. -import type { Facing } from "@datagutt/kai/world/objects"; +import type { Facing } from "../world/objects.ts"; -export const SAVE_KEY = "fjordtown.save"; export const SAVE_VERSION = 1; export type SaveData = { @@ -74,10 +73,11 @@ function migrate(raw: unknown): unknown { return raw; } -export function loadSave(storage: StorageLike | undefined): SaveData | null { +/** The save under `key` (kai.json `saveKey`), upgraded to today's shape, or null. */ +export function loadSave(storage: StorageLike | undefined, key: string): SaveData | null { if (!storage) return null; try { - const text = storage.getItem(SAVE_KEY); + const text = storage.getItem(key); if (!text) return null; return validate(migrate(JSON.parse(text))); } catch { @@ -86,20 +86,20 @@ export function loadSave(storage: StorageLike | undefined): SaveData | null { } /** Returns false when the browser refused to store it (quota, privacy mode). */ -export function writeSave(storage: StorageLike | undefined, data: Omit): boolean { +export function writeSave(storage: StorageLike | undefined, key: string, data: Omit): boolean { if (!storage) return false; try { const full: SaveData = { ...data, version: SAVE_VERSION, updatedAt: new Date().toISOString() }; - storage.setItem(SAVE_KEY, JSON.stringify(full)); + storage.setItem(key, JSON.stringify(full)); return true; } catch { return false; } } -export function clearSave(storage: StorageLike | undefined): void { +export function clearSave(storage: StorageLike | undefined, key: string): void { try { - storage?.removeItem(SAVE_KEY); + storage?.removeItem(key); } catch { // Nothing to do: storage is unavailable. } diff --git a/apps/datagutt/game/scenes/BootScene.ts b/packages/kai/src/scenes/BootScene.ts similarity index 100% rename from apps/datagutt/game/scenes/BootScene.ts rename to packages/kai/src/scenes/BootScene.ts diff --git a/apps/datagutt/game/scenes/PreloadScene.ts b/packages/kai/src/scenes/PreloadScene.ts similarity index 85% rename from apps/datagutt/game/scenes/PreloadScene.ts rename to packages/kai/src/scenes/PreloadScene.ts index 64d8129..bd0cec9 100644 --- a/apps/datagutt/game/scenes/PreloadScene.ts +++ b/packages/kai/src/scenes/PreloadScene.ts @@ -1,7 +1,6 @@ import Phaser from "phaser"; -import { SERVICES_KEY, type GameServices } from "../boot"; -import { EMOTE_FRAME } from "@datagutt/kai/ui/emotes"; -import { CHARACTERS } from "../assets/manifest"; +import { SERVICES_KEY, type GameServices } from "../boot.ts"; +import { EMOTE_FRAME } from "../ui/emotes.ts"; import { ANIMS, DIRECTIONS, @@ -15,10 +14,10 @@ import { portraitKey, type AnimName, type PortraitAnim, -} from "@datagutt/kai/characters/sheet"; -import { DialogueRunner } from "../dialogue/DialogueRunner"; -import { Progress, PROGRESS_KEY } from "../progress/Progress"; -import { browserStorage, loadSave, type SaveData } from "../save/save"; +} from "../characters/sheet.ts"; +import { DialogueRunner } from "../dialogue/DialogueRunner.ts"; +import { Progress, PROGRESS_KEY } from "../progress/Progress.ts"; +import { browserStorage, loadSave, type SaveData } from "../save/save.ts"; export const DIALOGUE_KEY = "dialogue"; @@ -41,7 +40,7 @@ export class PreloadScene extends Phaser.Scene { for (const [name, sprite] of Object.entries(services.config.sprites)) { this.load.spritesheet(`sprite:${name}`, `ui/${name}.png`, { frameWidth: sprite.frameWidth, frameHeight: sprite.frameHeight }); } - for (const [id, recipe] of Object.entries(CHARACTERS)) { + for (const [id, recipe] of Object.entries(services.data.content.characters)) { this.load.spritesheet(`char:${id}`, `characters/${id}.png`, { frameWidth: FRAME_WIDTH, frameHeight: FRAME_HEIGHT }); if (!("portrait" in recipe && recipe.portrait === false)) { const size = PORTRAIT_CROP.size; @@ -54,7 +53,7 @@ export class PreloadScene extends Phaser.Scene { create() { const services = this.registry.get(SERVICES_KEY) as GameServices; - for (const id of Object.keys(CHARACTERS)) { + for (const id of Object.keys(services.data.content.characters)) { for (const anim of Object.keys(ANIMS) as AnimName[]) { for (const dir of DIRECTIONS) { this.anims.create({ @@ -66,7 +65,7 @@ export class PreloadScene extends Phaser.Scene { } } } - for (const [id, recipe] of Object.entries(CHARACTERS)) { + for (const [id, recipe] of Object.entries(services.data.content.characters)) { if ("portrait" in recipe && recipe.portrait === false) continue; for (const anim of Object.keys(PORTRAIT_ANIMS) as PortraitAnim[]) { this.anims.create({ @@ -81,7 +80,7 @@ export class PreloadScene extends Phaser.Scene { this.anims.create({ key: `sprite:${name}`, frames: this.anims.generateFrameNumbers(`sprite:${name}`, {}), frameRate: sprite.frameRate, repeat: -1 }); } // One story for the whole game, so visit counts survive map changes and reloads. - const saved = loadSave(browserStorage()); + const saved = loadSave(browserStorage(), services.config.saveKey); this.beginStory(services, saved); this.sound.mute = saved?.settings.muted ?? false; @@ -97,10 +96,10 @@ export class PreloadScene extends Phaser.Scene { /** Progress and dialogue state from a save, or a new story that keeps the current settings. */ private beginStory(services: GameServices, saved: SaveData | null) { const settings = saved ? undefined : (this.registry.get(PROGRESS_KEY) as Progress | undefined)?.settings; - const progress = new Progress(saved); + const progress = new Progress(saved, services.data); if (settings) progress.settings = settings; this.registry.set(PROGRESS_KEY, progress); - const ctx = { world: services.world, progress, services }; + const ctx = { progress, services }; const externals = Object.assign({}, services.externals(ctx), ...services.plugins.map((p) => p.externals?.(ctx) ?? {})); const runner = new DialogueRunner(this.cache.json.get("dialogue"), externals, saved?.dialogue.main); this.registry.set(DIALOGUE_KEY, runner); diff --git a/apps/datagutt/game/scenes/WorldScene.ts b/packages/kai/src/scenes/WorldScene.ts similarity index 91% rename from apps/datagutt/game/scenes/WorldScene.ts rename to packages/kai/src/scenes/WorldScene.ts index 72034de..4e8f2a1 100644 --- a/apps/datagutt/game/scenes/WorldScene.ts +++ b/packages/kai/src/scenes/WorldScene.ts @@ -1,48 +1,41 @@ import Phaser from "phaser"; -import { SERVICES_KEY, type GameServices, type WorldTarget } from "../boot"; -import { TILE } from "../constants"; -import { Actor } from "../entities/Actor"; -import { GhostLayer } from "../entities/Ghosts"; -import { EmoteWheel } from "../ui/EmoteWheel"; -import { Prompt } from "../ui/Prompt"; -import { EmoteBubble } from "../ui/Bubbles"; -import { SimulatedGhosts } from "../dev/simulatedGhosts"; -import { InputController, type FrameInput, type InputDevice } from "../input/InputController"; -import { browserStorage, writeSave } from "../save/save"; -import { playBump, playPaper, playStamp, playTick, type AudioOutput } from "../audio/sfx"; -import { stampForNpc } from "../progress/passport"; -import type { KaiPlugin, NpcDef, ObjectOf, Takeover, TileLayer, Usable, World } from "../plugins/api"; -import { PROGRESS_KEY, type Progress } from "../progress/Progress"; -import { StampToast } from "../ui/Passport"; -import { MenuButton, StartMenu } from "../ui/StartMenu"; -import { BlipPlayer, shouldBlip, voiceFor } from "../audio/blips"; -import { DialogueBox } from "../ui/DialogueBox"; -import { LinkOpener } from "../ui/LinkOpener"; -import { resolveLink } from "../dialogue/links"; -import { npc as rosterNpc } from "../npcs"; -import type { DialogueRunner } from "../dialogue/DialogueRunner"; -import { DIALOGUE_KEY } from "./PreloadScene"; -import { CollisionGrid, directionBetween, neighbour, type Point } from "../world/grid"; -import { NPC_MOVEMENT, PLAYER_MOVEMENT, type MoverEvent } from "../world/movement"; -import { parseMapObject, type Facing, type LightObject, type MapObject, type TiledObject } from "@datagutt/kai/world/objects"; -import { addLights } from "../fx/Lights"; -import { DayNight } from "../fx/DayNight"; -import { Weather } from "../fx/Weather"; -import { Water } from "../fx/Water"; -import { Aurora } from "../fx/Aurora"; -import { Ambience } from "../audio/Ambience"; -import { ambienceMix } from "../audio/mix"; -import { trackFor } from "../audio/playlist"; -import { distanceField, FAR } from "../world/distance"; -import { Feel } from "../fx/Feel"; -import { FrameWatch, qualityFor, type Quality } from "../fx/quality"; -import { isUnlocked } from "../progress/unlocks"; -import { ACHIEVEMENTS, achievement } from "../progress/achievements"; -import { findPath, findPathAdjacent } from "../world/pathfind"; -import { applySeason } from "@datagutt/kai/world/season"; -import { resolveWeather, weatherSound } from "../world/weather"; -import { calmWeather, type WeatherNow } from "@datagutt/kai-live"; -import { t } from "../strings"; +import { SERVICES_KEY, type GameServices, type WorldTarget } from "../boot.ts"; +import { TILE } from "../constants.ts"; +import { Actor } from "../entities/Actor.ts"; +import { GhostLayer } from "../entities/Ghosts.ts"; +import { EmoteWheel } from "../ui/EmoteWheel.ts"; +import { Prompt } from "../ui/Prompt.ts"; +import { EmoteBubble } from "../ui/Bubbles.ts"; +import { SimulatedGhosts } from "../dev/simulatedGhosts.ts"; +import { InputController, type FrameInput, type InputDevice } from "../input/InputController.ts"; +import { browserStorage, writeSave } from "../save/save.ts"; +import { playBump, playPaper, playStamp, playTick, type AudioOutput } from "../audio/sfx.ts"; +import type { KaiPlugin, NpcDef, ObjectOf, Takeover, TileLayer, Usable, World } from "../plugins/api.ts"; +import { PROGRESS_KEY, type Progress } from "../progress/Progress.ts"; +import { StampToast } from "../ui/Passport.ts"; +import { MenuButton, StartMenu } from "../ui/StartMenu.ts"; +import { BlipPlayer, shouldBlip } from "../audio/blips.ts"; +import { DialogueBox } from "../ui/DialogueBox.ts"; +import { LinkOpener } from "../ui/LinkOpener.ts"; +import type { DialogueRunner } from "../dialogue/DialogueRunner.ts"; +import { DIALOGUE_KEY } from "./PreloadScene.ts"; +import { CollisionGrid, directionBetween, neighbour, type Point } from "../world/grid.ts"; +import { NPC_MOVEMENT, PLAYER_MOVEMENT, type MoverEvent } from "../world/movement.ts"; +import { parseMapObject, type Facing, type LightObject, type MapObject, type TiledObject } from "../world/objects.ts"; +import { addLights } from "../fx/Lights.ts"; +import { DayNight } from "../fx/DayNight.ts"; +import { Weather } from "../fx/Weather.ts"; +import { Water } from "../fx/Water.ts"; +import { Aurora } from "../fx/Aurora.ts"; +import { Ambience } from "../audio/Ambience.ts"; +import { ambienceMix } from "../audio/mix.ts"; +import { distanceField, FAR } from "../world/distance.ts"; +import { Feel } from "../fx/Feel.ts"; +import { FrameWatch, qualityFor, type Quality } from "../fx/quality.ts"; +import { findPath, findPathAdjacent } from "../world/pathfind.ts"; +import { applySeason } from "../world/season.ts"; +import { resolveWeather, weatherSound } from "../world/weather.ts"; +import { calmWeather, type WeatherNow } from "../world/weather-kinds.ts"; type Door = ObjectOf<"door">; type Sign = ObjectOf<"sign">; @@ -195,7 +188,7 @@ export class WorldScene extends Phaser.Scene { else if (obj.type === "door") { // A locked warp is solid ground until it opens, whatever the map around it // allows: nobody slips round a gate onto it. - if (obj.unlock && !isUnlocked(obj.unlock, this.progress)) this.grid.setBlocked(obj.x, obj.y); + if (obj.unlock && !this.gameData.isUnlocked(obj.unlock, this.progress)) this.grid.setBlocked(obj.x, obj.y); else this.doors.set(tileKey(obj), obj); } else if (obj.type === "sign") signs.push(obj); else if (obj.type === "gate") gates.push(obj); @@ -214,7 +207,7 @@ export class WorldScene extends Phaser.Scene { } // A shut gate reads like a sign; an open one clears its barriers off the map. for (const gate of gates) { - if (!isUnlocked(gate.unlock, this.progress)) { + if (!this.gameData.isUnlocked(gate.unlock, this.progress)) { signs.push({ type: "sign", x: gate.x, y: gate.y, w: gate.w, h: gate.h, text: gate.text }); continue; } @@ -240,7 +233,7 @@ export class WorldScene extends Phaser.Scene { const hours = this.services.night ? () => 23 : this.services.hours; this.dayNight = new DayNight(this, lights.map((light, i) => ({ light, image: images[i] })), outdoors, hours, this.services.month); const low = this.quality === "low"; - const weather = this.services.world.weather; + const weather = this.services.live.weather; this.weatherNow = this.services.night ? calmWeather(weather.place) : resolveWeather(window.location.search, weather); this.weather = outdoors ? new Weather(this, this.services.season, this.weatherNow, this.progress.reducedMotion, low) : null; const clearSky = this.weatherNow.kind === "clear"; @@ -264,7 +257,7 @@ export class WorldScene extends Phaser.Scene { const client = this.services.ghosts; const unsubscribe = client?.subscribe((m) => this.ghosts.handle(m)); this.wheel = new EmoteWheel(this); - this.prompt = new Prompt(this); + this.prompt = new Prompt(this, this.gameData); this.selfEmote = new EmoteBubble(this); this.applyVisitors(); const params = new URLSearchParams(window.location.search); @@ -301,10 +294,10 @@ export class WorldScene extends Phaser.Scene { this.audioOut = ambience.effects; this.surroundings = this.measureSurroundings(map, lights, outdoors); this.blips = new BlipPlayer(this.audioOut); - this.menuButton = new MenuButton(this); - this.menu = new StartMenu(this, { + this.menuButton = new MenuButton(this, this.gameData); + this.menu = new StartMenu(this, this.gameData, { stamps: () => this.progress.stamps, - achievements: () => ACHIEVEMENTS.filter((a) => this.progress.hasAchievement(a.id)).map((a) => a.id), + achievements: () => this.gameData.achievements.filter((a) => this.progress.hasAchievement(a.id)).map((a) => a.id), settings: () => { const { muted, music, reducedMotion, showVisitors, effects } = this.progress.settings; return { muted, music, reducedMotion, showVisitors, effects }; @@ -320,7 +313,7 @@ export class WorldScene extends Phaser.Scene { items: () => this.kaiPlugins.flatMap((p) => p.menuItems?.(this.world) ?? []), sound: (kind) => (kind === "open" ? playPaper(this.audioOut) : playTick(this.audioOut, kind === "select" ? 660 : 880)), }); - this.stampToast = new StampToast(this); + this.stampToast = new StampToast(this, this.gameData); this.fitCamera(); this.feel = new Feel(this, this.player, () => this.progress.reducedMotion); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.feel.destroy()); @@ -485,7 +478,7 @@ export class WorldScene extends Phaser.Scene { ? ({ scene: "credits" } as const) : { scene: "world" as const, map: this.target.map, outdoors: s.outdoors, phase: this.dayNight.current.phase, season: this.services.season, night: this.services.night }; // Muted or switched off: nothing plays, and nothing downloads. - this.services.music.play(muted || !music ? null : trackFor(moment)); + this.services.music.play(muted || !music ? null : this.gameData.trackFor(moment)); } private get quality(): Quality { @@ -750,7 +743,7 @@ export class WorldScene extends Phaser.Scene { /** Earn an achievement once: a banner (unless `announce` is false) and a save. */ private achieve(id: string, announce = true) { if (!this.progress.achieve(id)) return; - if (announce) this.stampToast.showAchievement(achievement(id).name, this.progress.reducedMotion); + if (announce) this.stampToast.showAchievement(this.gameData.achievement(id).name, this.progress.reducedMotion); this.save(); } @@ -794,11 +787,11 @@ export class WorldScene extends Phaser.Scene { // `# narration` is the narrator: no portrait, no name. const narration = beat.tags.includes("narration"); const character = npc && !beat.speaker && !narration ? npc.character : null; - const voice = voiceFor(character); + const voice = this.gameData.voiceFor(character); const onChar = (text: string, i: number) => shouldBlip(text, i, voice.every) && this.blips.play(voice); - const link = beat.tags.map(resolveLink).find((l) => l && !("error" in l)); + const link = beat.tags.map(this.services.links).find((l) => l && !("error" in l)); const next = link && !("error" in link) ? () => this.offerLink(link.url, link.label, step) : step; - const name = narration ? null : (beat.speaker ?? (npc ? (rosterNpc(npc.id)?.name ?? npc.name) : null)); + const name = narration ? null : (beat.speaker ?? (npc ? (this.gameData.npc(npc.id)?.name ?? npc.name) : null)); this.dialogue.say(beat.text, name, next, { portrait: character, gesture, onChar }); } else if (beat.type === "choices") { this.dialogue.choose(beat.choices, (i) => { @@ -813,13 +806,17 @@ export class WorldScene extends Phaser.Scene { step(); } + private get gameData() { + return this.services.data; + } + private get progress(): Progress { return this.registry.get(PROGRESS_KEY) as Progress; } /** Finishing a conversation with a place's main NPC stamps the passport. */ private finishedTalking(npcId: string) { - const result = this.progress.stamp(stampForNpc(npcId)); + const result = this.progress.stamp(this.gameData.stampForNpc(npcId)); if (!result.newStamp) return; playStamp(this.audioOut); this.feel.shake(); @@ -830,7 +827,7 @@ export class WorldScene extends Phaser.Scene { /** "Open github.com?" after a line with a `# link:` tag. */ private offerLink(url: string, label: string, then: () => void) { this.links.arm(url); - this.dialogue.choose([t("link.open", { label }), t("link.notNow")], (i) => { + this.dialogue.choose([this.gameData.t("link.open", { label }), this.gameData.t("link.notNow")], (i) => { if (i === 0) this.links.confirm(); else this.links.disarm(); then(); @@ -917,7 +914,7 @@ export class WorldScene extends Phaser.Scene { private save() { const { tile, facing } = this.player.mover; const progress = this.progress; - writeSave(browserStorage(), { + writeSave(browserStorage(), this.services.config.saveKey, { map: this.target.map, x: tile.x, y: tile.y, diff --git a/packages/kai/src/schema/config.test.ts b/packages/kai/src/schema/config.test.ts index 1e7584c..20c9e90 100644 --- a/packages/kai/src/schema/config.test.ts +++ b/packages/kai/src/schema/config.test.ts @@ -16,6 +16,8 @@ const valid = { timezone: "Europe/Oslo", startPlace: "dock", basePath: "/game/", + saveKey: "demo.save", + visitorsOffKey: "demo_visitors_off", paths: { maps: "world/maps.ts", ink: "dialogue", dialogueHost: "dialogue/host.ts" }, assets: { repo: "someone/art", localPath: "../art", tokenEnv: "ART_TOKEN", adapter: "@datagutt/kai-limezu/adapter" }, ui: { frame: { file: "ui.png", x: 0, y: 0, width: 8, height: 8 }, emotes: "emotes.png" }, diff --git a/packages/kai/src/schema/config.ts b/packages/kai/src/schema/config.ts index b6a8fe5..f4ffad7 100644 --- a/packages/kai/src/schema/config.ts +++ b/packages/kai/src/schema/config.ts @@ -23,6 +23,10 @@ export const kaiConfigSchema = z.object({ }, "not an IANA time zone"), /** Where a new game starts: a place id from the game's places. */ startPlace: id, + /** The localStorage key the game saves under. Changing it loses every visitor's save. */ + saveKey: z.string().min(1), + /** The localStorage key that switches other visitors off entirely when it is "1". */ + visitorsOffKey: z.string().min(1), /** The URL path the built assets are served from; the build writes them to public/ plus this. */ basePath: z.string().regex(/^\/([a-z0-9-]+\/)*$/, 'a path such as "/game/"'), /** Modules and folders of the game that the build loads, relative to the game's folder. */ diff --git a/apps/datagutt/game/ui/Bubbles.ts b/packages/kai/src/ui/Bubbles.ts similarity index 98% rename from apps/datagutt/game/ui/Bubbles.ts rename to packages/kai/src/ui/Bubbles.ts index e604dc3..2862176 100644 --- a/apps/datagutt/game/ui/Bubbles.ts +++ b/packages/kai/src/ui/Bubbles.ts @@ -2,8 +2,8 @@ // short line of text (a custom Discord status). Both sit above the world's roofs and // tree tops, anchored to the top of the head. import Phaser from "phaser"; -import { EMOTE_TAIL, EMOTES, emoteFrame, type EmoteName } from "@datagutt/kai/ui/emotes"; -import { wrapText } from "./text"; +import { EMOTE_TAIL, EMOTES, emoteFrame, type EmoteName } from "./emotes.ts"; +import { wrapText } from "./text.ts"; const DEPTH = 60_000; /** Each bubble flips between its two frames this often. */ diff --git a/apps/datagutt/game/ui/CreditsRoll.ts b/packages/kai/src/ui/CreditsRoll.ts similarity index 91% rename from apps/datagutt/game/ui/CreditsRoll.ts rename to packages/kai/src/ui/CreditsRoll.ts index 081c13e..2a3fc1b 100644 --- a/apps/datagutt/game/ui/CreditsRoll.ts +++ b/packages/kai/src/ui/CreditsRoll.ts @@ -1,10 +1,9 @@ -// The finale's credits (docs/game/PLAN.md M5.8), rolling up the screen over the night -// scene, from content/credits.ts. Interact or a tap speeds it up for a moment, back ends +// The credits rolling up the screen over the scene, from content/credits.json. Interact or a tap speeds it up for a moment, back ends // it, and it ends by itself. With reduced motion it doesn't scroll: the whole list shows // at once until dismissed. import Phaser from "phaser"; -import { credits } from "../../content/credits"; -import type { FrameInput } from "../input/InputController"; +import type { EngineContent } from "../data.ts"; +import type { FrameInput } from "../input/InputController.ts"; const DEPTH = 115_000; const INK = 0xf2eef7; @@ -31,6 +30,7 @@ export class CreditsRoll { constructor( private readonly scene: Phaser.Scene, + credits: EngineContent["credits"], private readonly reducedMotion: boolean, private readonly onDone: () => void, ) { diff --git a/apps/datagutt/game/ui/DialogueBox.ts b/packages/kai/src/ui/DialogueBox.ts similarity index 98% rename from apps/datagutt/game/ui/DialogueBox.ts rename to packages/kai/src/ui/DialogueBox.ts index 4f3830d..8bfde10 100644 --- a/apps/datagutt/game/ui/DialogueBox.ts +++ b/packages/kai/src/ui/DialogueBox.ts @@ -1,9 +1,9 @@ // In-engine dialogue box (docs/game/PLAN.md M1.6, growing into M2.4): a typewriter // reveal of paged text, and a choice list for Ink choices. Input is fed in by the scene. import Phaser from "phaser"; -import type { Facing } from "@datagutt/kai/world/objects"; -import { PORTRAIT_CROP, portraitKey } from "@datagutt/kai/characters/sheet"; -import { charDelayMs, choiceOfLine, paginate, wrapChoices, wrapText } from "./text"; +import type { Facing } from "../world/objects.ts"; +import { PORTRAIT_CROP, portraitKey } from "../characters/sheet.ts"; +import { charDelayMs, choiceOfLine, paginate, wrapChoices, wrapText } from "./text.ts"; const FONT = "pixel"; const FRAME = "ui:frame"; diff --git a/apps/datagutt/game/ui/EmoteWheel.ts b/packages/kai/src/ui/EmoteWheel.ts similarity index 95% rename from apps/datagutt/game/ui/EmoteWheel.ts rename to packages/kai/src/ui/EmoteWheel.ts index be441a3..ad8cbc8 100644 --- a/apps/datagutt/game/ui/EmoteWheel.ts +++ b/packages/kai/src/ui/EmoteWheel.ts @@ -2,9 +2,9 @@ // ring around the player's head. Opened by holding interact or a long press on the player; // arrows or the stick move round it, interact picks, back or a tap elsewhere cancels. import Phaser from "phaser"; -import { EMOTES, emoteFrame } from "@datagutt/kai/ui/emotes"; +import { EMOTES, emoteFrame } from "./emotes.ts"; import { GHOST_EMOTES, type GhostEmote } from "@datagutt/kai-net/protocol"; -import type { FrameInput } from "../input/InputController"; +import type { FrameInput } from "../input/InputController.ts"; const DEPTH = 110_000; const RADIUS = 26; diff --git a/apps/datagutt/game/ui/LinkOpener.ts b/packages/kai/src/ui/LinkOpener.ts similarity index 100% rename from apps/datagutt/game/ui/LinkOpener.ts rename to packages/kai/src/ui/LinkOpener.ts diff --git a/apps/datagutt/game/ui/Passport.ts b/packages/kai/src/ui/Passport.ts similarity index 82% rename from apps/datagutt/game/ui/Passport.ts rename to packages/kai/src/ui/Passport.ts index e5d32b8..3af7d8e 100644 --- a/apps/datagutt/game/ui/Passport.ts +++ b/packages/kai/src/ui/Passport.ts @@ -1,9 +1,7 @@ // Fjord Passport UI (docs/game/PLAN.md M2.10): the stamp banner shown when a stamp is // earned, and the passport page listing every place. import Phaser from "phaser"; -import { ACHIEVEMENTS } from "../progress/achievements"; -import { STAMP_PLACES } from "../progress/passport"; -import { t } from "../strings"; +import type { GameData } from "../data.ts"; const FONT = "pixel"; const FRAME = "ui:frame"; @@ -27,7 +25,6 @@ function drawStamp(g: Phaser.GameObjects.Graphics, x: number, y: number, earned: g.lineStyle(2, STAMP_RED, 1).beginPath().moveTo(x - 3, y).lineTo(x - 1, y + 2).lineTo(x + 3, y - 2).strokePath(); } -const placeName = (id: string) => STAMP_PLACES.find((p) => p.id === id)?.name ?? id; /** A mark for an earned achievement: a small star in the stamp's red. */ function drawStar(g: Phaser.GameObjects.Graphics, x: number, y: number, earned: boolean) { @@ -44,14 +41,18 @@ function drawStar(g: Phaser.GameObjects.Graphics, x: number, y: number, earned: export class StampToast { private container?: Phaser.GameObjects.Container; - constructor(private readonly scene: Phaser.Scene) {} + constructor( + private readonly scene: Phaser.Scene, + private readonly data: GameData, + ) {} show(place: string, count: number, reducedMotion: boolean): void { - this.banner(t("toast.stamped", { place: placeName(place), count, total: STAMP_PLACES.length }), drawStamp, reducedMotion); + const { data } = this; + this.banner(data.t("toast.stamped", { place: data.placeName(place), count, total: data.stampPlaces.length }), drawStamp, reducedMotion); } showAchievement(name: string, reducedMotion: boolean): void { - this.banner(t("toast.achievement", { name }), drawStar, reducedMotion); + this.banner(this.data.t("toast.achievement", { name }), drawStar, reducedMotion); } private banner(label: string, mark: typeof drawStamp, reducedMotion: boolean): void { @@ -88,7 +89,10 @@ export class StampToast { export class PassportPanel { private container?: Phaser.GameObjects.Container; - constructor(private readonly scene: Phaser.Scene) {} + constructor( + private readonly scene: Phaser.Scene, + private readonly data: GameData, + ) {} get open(): boolean { return Boolean(this.container); @@ -104,12 +108,12 @@ export class PassportPanel { const w = columns * columnWidth + 24; const entries = page === 0 - ? STAMP_PLACES.map((p) => ({ label: p.name, has: stamps.includes(p.id) })) - : ACHIEVEMENTS.map((a) => ({ label: achievements.includes(a.id) ? a.name : t("passport.unknown"), has: achievements.includes(a.id) })); + ? this.data.stampPlaces.map((p) => ({ label: p.name, has: stamps.includes(p.id) })) + : this.data.achievements.map((a) => ({ label: achievements.includes(a.id) ? a.name : this.data.t("passport.unknown"), has: achievements.includes(a.id) })); const rows = Math.ceil(entries.length / columns); const earned = entries.filter((e) => e.has).length; - const pageHint = t("passport.pageHint"); - const hintText = t(page === 1 ? "passport.achievementsHint" : earned === STAMP_PLACES.length ? "passport.fullHint" : "passport.stampsHint", { pageHint }); + const pageHint = this.data.t("passport.pageHint"); + const hintText = this.data.t(page === 1 ? "passport.achievementsHint" : earned === this.data.stampPlaces.length ? "passport.fullHint" : "passport.stampsHint", { pageHint }); const hint = scene.add.bitmapText(12, 0, FONT, hintText).setTint(FADED).setMaxWidth(w - 24); const hintHeight = Math.ceil(hint.getTextBounds().local.height); const listTop = 9 + Math.round(1.5 * lineHeight); @@ -122,7 +126,7 @@ export class PassportPanel { const parts: Phaser.GameObjects.GameObject[] = [frame(scene, w, h)]; const g = scene.add.graphics(); parts.push(g); - parts.push(scene.add.bitmapText(12, 9, FONT, t(page === 0 ? "passport.title" : "passport.achievements")).setTint(INK)); + parts.push(scene.add.bitmapText(12, 9, FONT, this.data.t(page === 0 ? "passport.title" : "passport.achievements")).setTint(INK)); const counter = scene.add.bitmapText(0, 9, FONT, `${earned}/${entries.length}`).setTint(STAMP_RED); counter.x = w - 12 - counter.getTextBounds().local.width; parts.push(counter); diff --git a/apps/datagutt/game/ui/Prompt.ts b/packages/kai/src/ui/Prompt.ts similarity index 88% rename from apps/datagutt/game/ui/Prompt.ts rename to packages/kai/src/ui/Prompt.ts index 4b92f91..5f375fc 100644 --- a/apps/datagutt/game/ui/Prompt.ts +++ b/packages/kai/src/ui/Prompt.ts @@ -1,10 +1,10 @@ -// Interaction prompt (docs/game/PLAN.md M5.10): when the player faces something usable, a +// Interaction prompt: when the player faces something usable, a // small tag over it names the button and the action ("E Talk", "A Enter", "Tap Read"), in // the input the player used last. import Phaser from "phaser"; -import type { InputDevice } from "../input/InputController"; -import type { StringKey } from "@datagutt/kai/ui/strings"; -import { t } from "../strings"; +import type { InputDevice } from "../input/InputController.ts"; +import type { StringKey } from "./strings.ts"; +import type { GameData } from "../data.ts"; export type PromptAction = "Talk" | "Read" | "Enter" | "Wake" | "Play" | "Look" | "Use" | "Pet"; @@ -39,7 +39,10 @@ export class Prompt { return this.visible ? this.shown : null; } - constructor(scene: Phaser.Scene) { + constructor( + scene: Phaser.Scene, + private readonly data: GameData, + ) { this.box = scene.add.graphics().setDepth(DEPTH); this.key = scene.add.bitmapText(0, 0, "pixel", "").setTint(INK).setDepth(DEPTH + 1); this.label = scene.add.bitmapText(0, 0, "pixel", "").setTint(PAPER).setDepth(DEPTH + 1); @@ -49,7 +52,7 @@ export class Prompt { /** Centred over (x, bottom) in world pixels. */ show(action: PromptAction, device: InputDevice, x: number, bottom: number): void { const button = BUTTON[device]; - const label = t(LABEL[action]); + const label = this.data.t(LABEL[action]); const text = `${button} ${label}`; if (text !== this.shown) { this.shown = text; diff --git a/apps/datagutt/game/ui/StartMenu.ts b/packages/kai/src/ui/StartMenu.ts similarity index 77% rename from apps/datagutt/game/ui/StartMenu.ts rename to packages/kai/src/ui/StartMenu.ts index 00f3f27..c658bdc 100644 --- a/apps/datagutt/game/ui/StartMenu.ts +++ b/packages/kai/src/ui/StartMenu.ts @@ -2,12 +2,11 @@ // a gamepad, or the on-screen Menu button. A plugin's item runs an action or opens a page // of text. import Phaser from "phaser"; -import type { Facing } from "@datagutt/kai/world/objects"; -import { PassportPanel } from "./Passport"; -import { credits } from "../../content/credits"; -import type { MenuItem } from "../plugins/api"; -import type { EffectsSetting } from "../save/save"; -import { t } from "../strings"; +import type { Facing } from "../world/objects.ts"; +import { PassportPanel } from "./Passport.ts"; +import type { MenuItem } from "../plugins/api.ts"; +import type { EffectsSetting } from "../save/save.ts"; +import type { GameData } from "../data.ts"; const FONT = "pixel"; const FRAME = "ui:frame"; @@ -21,7 +20,7 @@ export type MenuSettings = { muted: boolean; music: boolean; reducedMotion: bool export type MenuHooks = { stamps(): readonly string[]; - /** Earned achievement ids (game/progress/achievements.ts). */ + /** Earned achievement ids. */ achievements(): readonly string[]; settings(): MenuSettings; changeSettings(next: MenuSettings): void; @@ -35,7 +34,10 @@ type View = "closed" | "main" | "passport" | "settings" | "credits" | (string & type Page = { title: string; lines: string[] }; -const CREDITS = [`${credits.title}, ${credits.byline.toLowerCase()}.`, ...credits.sections.map((s) => `${s.heading}: ${s.lines.join(" ")}`)]; +/** The credits as one paragraph per section, for the menu's page. */ +function creditLines({ title, byline, sections }: GameData["content"]["credits"]): string[] { + return [`${title}, ${byline.toLowerCase()}.`, ...sections.map((s) => `${s.heading}: ${s.lines.join(" ")}`)]; +} export class StartMenu { private view: View = "closed"; @@ -49,9 +51,10 @@ export class StartMenu { constructor( private readonly scene: Phaser.Scene, + private readonly data: GameData, private readonly hooks: MenuHooks, ) { - this.passport = new PassportPanel(scene); + this.passport = new PassportPanel(scene, data); } get open(): boolean { @@ -127,21 +130,21 @@ export class StartMenu { private items(): { label: string; run: () => void }[] { if (this.view === "settings") { const s = this.hooks.settings(); - const onOff = (on: boolean) => ({ value: t(on ? "value.on" : "value.off") }); - const motion = s.reducedMotion === null ? t("value.auto") : t(s.reducedMotion ? "value.on" : "value.off"); + const onOff = (on: boolean) => ({ value: this.data.t(on ? "value.on" : "value.off") }); + const motion = s.reducedMotion === null ? this.data.t("value.auto") : this.data.t(s.reducedMotion ? "value.on" : "value.off"); return [ - { label: t("settings.sound", onOff(!s.muted)), run: () => this.hooks.changeSettings({ ...s, muted: !s.muted }) }, - { label: t("settings.music", onOff(s.music)), run: () => this.hooks.changeSettings({ ...s, music: !s.music }) }, - { label: t("settings.visitors", onOff(s.showVisitors)), run: () => this.hooks.changeSettings({ ...s, showVisitors: !s.showVisitors }) }, + { label: this.data.t("settings.sound", onOff(!s.muted)), run: () => this.hooks.changeSettings({ ...s, muted: !s.muted }) }, + { label: this.data.t("settings.music", onOff(s.music)), run: () => this.hooks.changeSettings({ ...s, music: !s.music }) }, + { label: this.data.t("settings.visitors", onOff(s.showVisitors)), run: () => this.hooks.changeSettings({ ...s, showVisitors: !s.showVisitors }) }, { - label: t("settings.effects", { value: t(s.effects === "auto" ? "value.auto" : s.effects === "high" ? "value.high" : "value.low") }), + label: this.data.t("settings.effects", { value: this.data.t(s.effects === "auto" ? "value.auto" : s.effects === "high" ? "value.high" : "value.low") }), run: () => this.hooks.changeSettings({ ...s, effects: s.effects === "auto" ? "high" : s.effects === "high" ? "low" : "auto" }), }, { - label: t("settings.reducedMotion", { value: motion }), + label: this.data.t("settings.reducedMotion", { value: motion }), run: () => this.hooks.changeSettings({ ...s, reducedMotion: s.reducedMotion === null ? true : s.reducedMotion ? false : null }), }, - { label: t("menu.back"), run: () => this.backToMain() }, + { label: this.data.t("menu.back"), run: () => this.backToMain() }, ]; } const own = this.hooks.items().map((item) => ({ @@ -149,11 +152,11 @@ export class StartMenu { run: "run" in item ? () => item.run() : () => this.openPage(item.id, { title: item.title, lines: item.lines() }), })); return [ - { label: t("menu.passport"), run: () => this.showPassport() }, + { label: this.data.t("menu.passport"), run: () => this.showPassport() }, ...own, - { label: t("menu.settings"), run: () => this.openView("settings") }, - { label: t("menu.credits"), run: () => this.openPage("credits", { title: t("credits.title"), lines: CREDITS }) }, - { label: t("menu.close"), run: () => this.close() }, + { label: this.data.t("menu.settings"), run: () => this.openView("settings") }, + { label: this.data.t("menu.credits"), run: () => this.openPage("credits", { title: this.data.t("credits.title"), lines: creditLines(this.data.content.credits) }) }, + { label: this.data.t("menu.close"), run: () => this.close() }, ]; } @@ -198,7 +201,7 @@ export class StartMenu { const w = Math.min(cam.width - 16, 300); const body = scene.add.bitmapText(12, 9 + lh + 6, FONT, text).setTint(INK).setMaxWidth(w - 24); const bodyBottom = 9 + lh + 6 + Math.ceil(body.getTextBounds().local.height); - const hint = scene.add.bitmapText(12, bodyBottom + 8, FONT, t("menu.backHint")).setTint(FADED).setMaxWidth(w - 24); + const hint = scene.add.bitmapText(12, bodyBottom + 8, FONT, this.data.t("menu.backHint")).setTint(FADED).setMaxWidth(w - 24); const h = bodyBottom + 8 + Math.ceil(hint.getTextBounds().local.height) + 12; parts.push(scene.add.nineslice(0, 0, FRAME, undefined, w, h, ...SLICE).setOrigin(0), body, hint); parts.push(scene.add.bitmapText(12, 9, FONT, this.page.title).setTint(ACCENT)); @@ -210,7 +213,7 @@ export class StartMenu { } const items = this.items(); - const title = t(this.view === "settings" ? "settings.title" : "menu.title"); + const title = this.data.t(this.view === "settings" ? "settings.title" : "menu.title"); const w = Math.min(cam.width - 16, 200); const h = 9 + lh + 4 + items.length * lh + 10; const x = Math.floor((cam.width - w) / 2); @@ -233,8 +236,8 @@ export class MenuButton { private readonly container: Phaser.GameObjects.Container; private bounds = { x: 0, y: 0, w: 0, h: 0 }; - constructor(scene: Phaser.Scene) { - const label = scene.add.bitmapText(8, 5, FONT, t("menu.button")).setTint(INK); + constructor(scene: Phaser.Scene, data: GameData) { + const label = scene.add.bitmapText(8, 5, FONT, data.t("menu.button")).setTint(INK); const w = Math.ceil(label.getTextBounds().local.width) + 16; const h = 22; const box = scene.add.nineslice(0, 0, FRAME, undefined, w, h, ...SLICE).setOrigin(0); diff --git a/apps/datagutt/game/ui/text.test.ts b/packages/kai/src/ui/text.test.ts similarity index 98% rename from apps/datagutt/game/ui/text.test.ts rename to packages/kai/src/ui/text.test.ts index fc1107d..3ea6fbd 100644 --- a/apps/datagutt/game/ui/text.test.ts +++ b/packages/kai/src/ui/text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { charDelayMs, choiceOfLine, paginate, wrapChoices, wrapText } from "./text"; +import { charDelayMs, choiceOfLine, paginate, wrapChoices, wrapText } from "./text.ts"; const mono: (t: string) => number = (t) => t.length * 5; diff --git a/apps/datagutt/game/ui/text.ts b/packages/kai/src/ui/text.ts similarity index 100% rename from apps/datagutt/game/ui/text.ts rename to packages/kai/src/ui/text.ts diff --git a/apps/datagutt/game/viewport.test.ts b/packages/kai/src/viewport.test.ts similarity index 93% rename from apps/datagutt/game/viewport.test.ts rename to packages/kai/src/viewport.test.ts index c1a516d..00bcc84 100644 --- a/apps/datagutt/game/viewport.test.ts +++ b/packages/kai/src/viewport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { TILE } from "./constants"; -import { MIN_SHORT_TILES, computeViewport } from "./viewport"; +import { TILE } from "./constants.ts"; +import { MIN_SHORT_TILES, computeViewport } from "./viewport.ts"; const screens = [ { name: "iPhone portrait", w: 390, h: 844, dpr: 3 }, diff --git a/apps/datagutt/game/viewport.ts b/packages/kai/src/viewport.ts similarity index 97% rename from apps/datagutt/game/viewport.ts rename to packages/kai/src/viewport.ts index 24be867..f2e928e 100644 --- a/apps/datagutt/game/viewport.ts +++ b/packages/kai/src/viewport.ts @@ -1,7 +1,7 @@ // How big the low-resolution game canvas is and how far the browser scales it up. // The canvas is scaled by a whole number of *device* pixels, so every game pixel is // an exact square on screen whatever the devicePixelRatio (docs/game/DESIGN.md §13). -import { TILE } from "./constants"; +import { TILE } from "./constants.ts"; /** Never show fewer tiles than this on the short screen axis. */ export const MIN_SHORT_TILES = 11; diff --git a/apps/datagutt/game/world/dayNight.test.ts b/packages/kai/src/world/dayNight.test.ts similarity index 98% rename from apps/datagutt/game/world/dayNight.test.ts rename to packages/kai/src/world/dayNight.test.ts index 6b8c2bc..40d69c3 100644 --- a/apps/datagutt/game/world/dayNight.test.ts +++ b/packages/kai/src/world/dayNight.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { clock, daylightAt, lightFactor, monthNow, parseTime } from "./dayNight"; +import { clock, daylightAt, lightFactor, monthNow, parseTime } from "./dayNight.ts"; describe("time of day", () => { it("runs from night through dawn, day and dusk", () => { diff --git a/apps/datagutt/game/world/dayNight.ts b/packages/kai/src/world/dayNight.ts similarity index 100% rename from apps/datagutt/game/world/dayNight.ts rename to packages/kai/src/world/dayNight.ts diff --git a/apps/datagutt/game/world/distance.test.ts b/packages/kai/src/world/distance.test.ts similarity index 86% rename from apps/datagutt/game/world/distance.test.ts rename to packages/kai/src/world/distance.test.ts index e069b58..bcce1ab 100644 --- a/apps/datagutt/game/world/distance.test.ts +++ b/packages/kai/src/world/distance.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { distanceField, FAR } from "./distance"; +import { distanceField, FAR } from "./distance.ts"; describe("distanceField", () => { it("counts steps to the nearest source, or FAR without any", () => { diff --git a/apps/datagutt/game/world/distance.ts b/packages/kai/src/world/distance.ts similarity index 100% rename from apps/datagutt/game/world/distance.ts rename to packages/kai/src/world/distance.ts diff --git a/apps/datagutt/game/world/grid.ts b/packages/kai/src/world/grid.ts similarity index 97% rename from apps/datagutt/game/world/grid.ts rename to packages/kai/src/world/grid.ts index 3a0cb92..4555e09 100644 --- a/apps/datagutt/game/world/grid.ts +++ b/packages/kai/src/world/grid.ts @@ -1,4 +1,4 @@ -import type { Facing } from "@datagutt/kai/world/objects"; +import type { Facing } from "./objects.ts"; export type Point = { x: number; y: number }; diff --git a/apps/datagutt/game/world/movement.test.ts b/packages/kai/src/world/movement.test.ts similarity index 97% rename from apps/datagutt/game/world/movement.test.ts rename to packages/kai/src/world/movement.test.ts index 9736465..96bfa35 100644 --- a/apps/datagutt/game/world/movement.test.ts +++ b/packages/kai/src/world/movement.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { GridMover, type MoverConfig } from "./movement"; -import type { Point } from "./grid"; +import { GridMover, type MoverConfig } from "./movement.ts"; +import type { Point } from "./grid.ts"; const config: MoverConfig = { walkSpeed: 4, runSpeed: 8, turnDelayMs: 90 }; const open = () => true; diff --git a/apps/datagutt/game/world/movement.ts b/packages/kai/src/world/movement.ts similarity index 97% rename from apps/datagutt/game/world/movement.ts rename to packages/kai/src/world/movement.ts index cf84bc5..c137c4f 100644 --- a/apps/datagutt/game/world/movement.ts +++ b/packages/kai/src/world/movement.ts @@ -6,8 +6,8 @@ // - A held direction continues into the next step on the same frame the last one ends, // so walking never stutters and no input is dropped. // - Walking into something blocked reports a single "bumped" per hold. -import type { Facing } from "@datagutt/kai/world/objects"; -import { neighbour, type Point } from "./grid"; +import type { Facing } from "./objects.ts"; +import { neighbour, type Point } from "./grid.ts"; export type MoverConfig = { /** Tiles per second. */ diff --git a/apps/datagutt/game/world/pathfind.test.ts b/packages/kai/src/world/pathfind.test.ts similarity index 96% rename from apps/datagutt/game/world/pathfind.test.ts rename to packages/kai/src/world/pathfind.test.ts index 8e7541e..013b4e5 100644 --- a/apps/datagutt/game/world/pathfind.test.ts +++ b/packages/kai/src/world/pathfind.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { CollisionGrid, directionBetween } from "./grid"; -import { findPath, findPathAdjacent } from "./pathfind"; +import { CollisionGrid, directionBetween } from "./grid.ts"; +import { findPath, findPathAdjacent } from "./pathfind.ts"; /** Build a grid from ASCII: '#' is a wall, anything else is floor. */ function gridFrom(rows: string[]): CollisionGrid { diff --git a/apps/datagutt/game/world/pathfind.ts b/packages/kai/src/world/pathfind.ts similarity index 99% rename from apps/datagutt/game/world/pathfind.ts rename to packages/kai/src/world/pathfind.ts index e5a2eb6..ace0ed9 100644 --- a/apps/datagutt/game/world/pathfind.ts +++ b/packages/kai/src/world/pathfind.ts @@ -1,7 +1,7 @@ // A* on the tile grid for tap/click-to-move (docs/game/PLAN.md M1.8). 4-directional, // with a small penalty for turning so paths prefer long straight runs, which reads as // deliberate walking rather than zig-zagging. -import type { Point } from "./grid"; +import type { Point } from "./grid.ts"; export type Walkable = { width: number; height: number; isWalkable(x: number, y: number): boolean }; diff --git a/packages/kai/src/world/weather-kinds.ts b/packages/kai/src/world/weather-kinds.ts new file mode 100644 index 0000000..722926b --- /dev/null +++ b/packages/kai/src/world/weather-kinds.ts @@ -0,0 +1,39 @@ +// The weather as the game knows it: a kind of sky, the wind and the temperature. A live +// source (such as @datagutt/kai-live's MET Norway fetcher) fills it in; the runtime draws +// and hears it. + +export const WEATHER_KINDS = ["clear", "cloudy", "rain", "heavyRain", "snow", "sleet", "fog", "storm"] as const; +export type WeatherKind = (typeof WEATHER_KINDS)[number]; + +export const isWeatherKind = (value: unknown): value is WeatherKind => WEATHER_KINDS.includes(value as WeatherKind); + +/** The weather now where the visitor is, or at the game's fallback place. */ +export type WeatherNow = { + kind: WeatherKind; + /** Wind speed in m/s. */ + wind: number; + /** Where the wind blows from, in degrees: 0 north, 90 east. */ + windFrom: number; + /** Air temperature in °C, or null when unknown. */ + temperature: number | null; + /** MET's symbol code it was read from ("clearsky_day", "heavyrain"); empty when not live. */ + symbol: string; + /** The city it is for. */ + place: string; + /** + * True when it came from the forecast. False is the calm fallback (MET was down, or + * there is no payload): the game then shows only its seasonal particles. + */ + live: boolean; +}; + +/** No weather to speak of: what the game shows when it has no forecast. */ +export const calmWeather = (place: string): WeatherNow => ({ + kind: "clear", + wind: 0, + windFrom: 0, + temperature: null, + symbol: "", + place, + live: false, +}); diff --git a/apps/datagutt/game/world/weather.test.ts b/packages/kai/src/world/weather.test.ts similarity index 94% rename from apps/datagutt/game/world/weather.test.ts rename to packages/kai/src/world/weather.test.ts index b48a2f0..5be5487 100644 --- a/apps/datagutt/game/world/weather.test.ts +++ b/packages/kai/src/world/weather.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { WeatherNow } from "@datagutt/kai-live"; -import { CALM_WEATHER } from "../../content/live"; -import { resolveWeather, skyFor, weatherSound } from "./weather"; +import { calmWeather, type WeatherNow } from "./weather-kinds.ts"; + +const CALM_WEATHER = calmWeather("Oslo"); +import { resolveWeather, skyFor, weatherSound } from "./weather.ts"; const live = (kind: WeatherNow["kind"], wind = 3, windFrom = 270): WeatherNow => ({ kind, wind, windFrom, temperature: 8, symbol: kind, place: "Oslo", live: true }); diff --git a/apps/datagutt/game/world/weather.ts b/packages/kai/src/world/weather.ts similarity index 91% rename from apps/datagutt/game/world/weather.ts rename to packages/kai/src/world/weather.ts index e57cc03..ff73606 100644 --- a/apps/datagutt/game/world/weather.ts +++ b/packages/kai/src/world/weather.ts @@ -1,8 +1,8 @@ -// The weather outdoors (docs/game/PLAN.md C2): Oslo's real weather, which the page embeds -// (lib/weather.ts), or `?debug&weather=`. This file decides what the sky should hold; -// fx/Weather.ts draws it and audio/mix.ts hears it. -import { isWeatherKind, type WeatherKind, type WeatherNow } from "@datagutt/kai-live"; -import type { Season } from "@datagutt/kai/world/season"; +// The weather outdoors: the visitor's real weather, which the page embeds, or +// `?debug&weather=`. This file decides what the sky should hold; fx/Weather.ts draws +// it and audio/mix.ts hears it. +import { isWeatherKind, type WeatherKind, type WeatherNow } from "./weather-kinds.ts"; +import type { Season } from "./season.ts"; /** Wind (m/s) for a debug override, which names only the kind. */ const DEBUG_WIND: Record = { clear: 2, cloudy: 4, rain: 5, heavyRain: 8, snow: 3, sleet: 6, fog: 1, storm: 18 }; From 94e02795ff9f5bb63ac1e45c2e6740313e4500ca Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 21:39:32 +0200 Subject: [PATCH 131/153] docs(kai): hand off after the runtime move Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- docs/kai/HANDOFF.md | 53 ++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/docs/kai/HANDOFF.md b/docs/kai/HANDOFF.md index e7b06c3..660b0ee 100644 --- a/docs/kai/HANDOFF.md +++ b/docs/kai/HANDOFF.md @@ -6,33 +6,46 @@ Last updated: 2026-09-24 (kai session 1) - Decisions are in DESIGN.md (with a changelog of the corrections made while building), tasks in PLAN.md. Work happens on `kai`; `game` was fast-forwarded to it at K1.6 and - the Vercel project builds from `apps/datagutt` (Root Directory flipped with the user's - approval, preview verified with the licensed art). -- Done: K1 (Bun, Turborepo, CI, Vercel), K2 (tooling presets, kai-net, kai-arcade, - kai-live), K3.1 to K3.3 (kai.json, kai-worldgen, kai-limezu), K4.1 to K4.3 (`kai - content`, engine and site content as JSON and Markdown). -- Every stage was checked the same way: the asset output byte-identical to before, the - generated maps unchanged, the Journal's visible text unchanged, e2e green. + the Vercel project builds from `apps/datagutt`. +- Done: K1, K2, K3.1 to K3.4, K4, K5.1, K5.3, K5.4. +- The runtime is `@datagutt/kai`: `createGame(parent, { config, content, live, links, + externals, plugins })`. Content reaches scenes and UI through `services.data` + (`GameData`), never through module imports. Fjord Town is `apps/datagutt/game/index.ts` + plus its plugins in `game/plugins/` (cat, arcade, github, ferry intro, finale, + presence, journal); summit, the edge lines and the passport achievement are triggers + in `content/triggers.json`. +- Every stage was checked the same way: asset output byte-identical, generated maps + unchanged, the Journal's text unchanged, e2e green. + +## Waiting on the user + +K3.5: moving `seasons/` to `games/datagutt/seasons/` in `datagutt/datagutt-assets` (a +push to another repository). After it, `seasonOverridesDir` in +`packages/kai-assets/src/art/source.ts` returns `games//seasons`. ## Next step -K3.4: move the asset pipeline (`scripts/assets`, `scripts/world`) into the `kai` CLI in -`@datagutt/kai-assets`, running on Bun. The link preview image and the title strip stay -an app script. Then K3.5 (assets repo move, needs the user), K4.4, K4.5, K5. +K5.5 (presence and GitHub plugins into `kai-live`, configured by content), K5.2's unit +tests, K5.6 (`window.__kai`, the old-save e2e), then K6 (kai-next, the sandbox, the +boundary lint) and K7 (docs). ## Gotchas -- `packageManager` pins Bun 1.4.2. Vercel's image ships Bun 1.3, which cannot read the - version 2 lockfile, so `apps/datagutt/vercel.ts` installs with `npx bun@1.4.2`. -- `assets`, `build`, `test:e2e` and `content` are never cached by Turborepo: the art - sits outside the repository, and the content bundle also depends on the engine's - schemas in `packages/`. -- Node's type stripping loads package `.ts` files through the workspace symlinks, so - imports need explicit `.ts` extensions and erasable syntax. JSON imports that Node can - reach (content/index.ts, content/places.ts) need `with { type: "json" }`. -- Content types come from `ContentOf` the schemas (`content/schema.ts` in the app), so - ids that were literal unions (place, music, achievement) are plain strings now. +- Vercel's image ships Bun 1.3, which cannot read the version 2 lockfile, so + `apps/datagutt/vercel.ts` installs with `npx bun@1.4.2`. The `kai` CLI itself runs on + the image's Bun. +- `assets`, `build`, `test:e2e` and `content` are never cached by Turborepo. +- `Phaser.Scene` owns `plugins` and `data`: the world scene's fields are `kaiPlugins` + and `gameData`. +- Code the build loads (the dialogue host and what it imports) must not import + `@datagutt/kai`'s index: that pulls in Phaser, which needs a browser. Use deep paths + such as `@datagutt/kai/data`. +- Package imports need explicit `.ts` extensions (Node and Bun load them directly). JSON + imports that Node can reach need `with { type: "json" }`. +- `kai content` writes `.kai/content.json` and `.kai/config.json`; the game reads both. - `pkill -f next-server` in a Bash call kills the call's own shell. Use `pkill -f "[n]ext-server"`. - `game:dev` from `apps/datagutt` does not build assets or content first. Run it from the root (`turbo run game:dev`) or run `bun run content && bun run assets` before it. +- The finale e2e is slow under a full parallel run on WSL; it has a 70 s budget per + conversation. From 28e9eaa743cacca548abb81ebca282d9a8f8ff9a Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 21:57:52 +0200 Subject: [PATCH 132/153] feat(kai-live): run the live npc and the github objects as configurable plugins presenceNpc takes its places, routes, rules, words, story-night spot and mock presences from content/presence.json, so any game can have a live developer walking its town. githubObjects draws the contribution field and the repo shelf. The debug hook becomes window.__kai, and an e2e test checks that saves from before the split still load. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/content/presence.json | 157 ++++++++++++++++++ apps/datagutt/content/schema.ts | 3 + apps/datagutt/e2e/a11y.spec.ts | 4 +- apps/datagutt/e2e/game.spec.ts | 45 ++++- apps/datagutt/game/dialogue/externals.ts | 2 +- apps/datagutt/game/index.ts | 22 ++- apps/datagutt/game/live/datagutt.test.ts | 44 ----- apps/datagutt/game/live/datagutt.ts | 118 ------------- apps/datagutt/game/plugins/finale.ts | 7 +- apps/datagutt/game/plugins/presence.ts | 73 -------- apps/datagutt/world/gen/maps/maps.test.ts | 12 ++ bun.lock | 3 + docs/kai/PLAN.md | 13 +- packages/kai-live/package.json | 6 +- .../kai-live/src/github}/field.test.ts | 2 +- .../kai-live/src/github}/field.ts | 8 +- .../kai-live/src/github/plugin.ts | 14 +- .../kai-live/src/github}/shelf.ts | 8 +- .../kai-live/src/presence/LiveNpc.ts | 126 +++++++------- packages/kai-live/src/presence/config.ts | 93 +++++++++++ packages/kai-live/src/presence/plugin.ts | 80 +++++++++ packages/kai-live/src/presence/rules.test.ts | 72 ++++++++ packages/kai-live/src/presence/rules.ts | 97 +++++++++++ packages/kai/src/plugins/api.ts | 2 +- packages/kai/src/scenes/WorldScene.ts | 4 +- 25 files changed, 681 insertions(+), 334 deletions(-) create mode 100644 apps/datagutt/content/presence.json delete mode 100644 apps/datagutt/game/live/datagutt.test.ts delete mode 100644 apps/datagutt/game/live/datagutt.ts delete mode 100644 apps/datagutt/game/plugins/presence.ts rename {apps/datagutt/game/live => packages/kai-live/src/github}/field.test.ts (95%) rename {apps/datagutt/game/live => packages/kai-live/src/github}/field.ts (74%) rename apps/datagutt/game/plugins/github.ts => packages/kai-live/src/github/plugin.ts (66%) rename {apps/datagutt/game/live => packages/kai-live/src/github}/shelf.ts (72%) rename apps/datagutt/game/entities/LiveThomas.ts => packages/kai-live/src/presence/LiveNpc.ts (67%) create mode 100644 packages/kai-live/src/presence/config.ts create mode 100644 packages/kai-live/src/presence/plugin.ts create mode 100644 packages/kai-live/src/presence/rules.test.ts create mode 100644 packages/kai-live/src/presence/rules.ts diff --git a/apps/datagutt/content/presence.json b/apps/datagutt/content/presence.json new file mode 100644 index 0000000..f15fd34 --- /dev/null +++ b/apps/datagutt/content/presence.json @@ -0,0 +1,157 @@ +{ + "$schema": "../.kai/schema/presence.json", + "npc": "datagutt", + "asleepKnot": "datagutt_asleep", + "places": { + "desk": { + "map": "house-up", + "where": "At his desk, upstairs at home." + }, + "bed": { + "map": "house-up", + "where": "Asleep, upstairs at home." + }, + "fjord": { + "map": "town", + "where": "By the fjord, down at the harbour." + }, + "square": { + "map": "town", + "where": "Around the town square." + }, + "pier": { + "map": "town", + "where": "At the end of the pier, waiting for you." + } + }, + "routes": { + "house-up": [ + "house" + ], + "house": [ + "house-up", + "town" + ], + "town": [ + "house" + ] + }, + "coding": "visual studio|vs ?code|\\bcode\\b|cursor|zed|intellij|webstorm|phpstorm|pycharm|rustrover|goland|clion|rider|android studio|xcode|n?vim|neovim|emacs|sublime|windsurf|fleet|terminal|iterm|warp|ghostty|wezterm|alacritty|kitty", + "rules": [ + { + "when": "none", + "place": "desk" + }, + { + "when": "offline", + "place": "bed", + "emote": "sleep", + "asleep": true, + "quiet": true + }, + { + "when": "coding", + "place": "desk", + "emote": "computer" + }, + { + "when": "playing", + "place": "desk", + "emote": "computer" + }, + { + "when": "music", + "place": "fjord", + "emote": "music" + }, + { + "when": "idle", + "place": "square", + "emote": "dots" + }, + { + "when": "online", + "place": "square", + "wanders": true + } + ], + "lines": { + "codingDetails": "Just in the middle of something in {app}. {details}, to be exact.", + "coding": "Just writing some code in {app}.", + "streaming": "I'm streaming {game} right now, so wave at the chat.", + "playing": "Taking a break with a bit of {game}.", + "music": "I'm listening to \"{song}\" by {artist}. Good song for the fjord." + }, + "status": { + "none": "No word from Thomas yet.", + "discord": "Discord: {status}", + "names": { + "online": "Online", + "idle": "Away", + "dnd": "Do not disturb", + "offline": "Offline" + }, + "listening": "Listening to {song}", + "listeningBy": "Listening to {song} by {artist}" + }, + "night": { + "place": "pier", + "dialogue": "datagutt_finale" + }, + "wakeGrants": "wake", + "mocks": { + "offline": { + "status": "offline", + "customStatus": null, + "spotify": null, + "activities": [] + }, + "coding": { + "status": "dnd", + "customStatus": "shipping it", + "spotify": null, + "activities": [ + { + "kind": "playing", + "name": "Visual Studio Code", + "details": "Editing WorldScene.ts", + "state": "Workspace: datagutt" + } + ] + }, + "gaming": { + "status": "online", + "customStatus": null, + "spotify": null, + "activities": [ + { + "kind": "playing", + "name": "Factorio", + "details": null, + "state": null + } + ] + }, + "music": { + "status": "online", + "customStatus": null, + "spotify": { + "song": "Midnight City", + "artist": "M83" + }, + "activities": [] + }, + "idle": { + "status": "idle", + "customStatus": null, + "spotify": null, + "activities": [] + }, + "online": { + "status": "online", + "customStatus": "touching grass", + "spotify": null, + "activities": [] + } + } +} diff --git a/apps/datagutt/content/schema.ts b/apps/datagutt/content/schema.ts index 8d813e1..03847d2 100644 --- a/apps/datagutt/content/schema.ts +++ b/apps/datagutt/content/schema.ts @@ -1,6 +1,7 @@ // The schemas of Fjord Town's own content collections, beside the engine's. `kai content` // checks content/ against them; content/index.ts gives the compiled bundle their types. import { ENGINE_COLLECTIONS, json, markdown, z, type ContentOf } from "@datagutt/kai/schema"; +import { presenceConfig } from "@datagutt/kai-live/presence/config"; const text = z.string().min(1); /** A path under public/, such as "/images/avatar.png". */ @@ -82,6 +83,8 @@ export const collections = { * and shut doors, by map id and key (world/gen/text.ts). */ mapText: json(z.object({ names: z.record(text, text), signs: z.record(text, z.record(text, text)) })), + /** The live datagutt NPC: Thomas's Discord presence picks where he is (@datagutt/kai-live). */ + presence: json(presenceConfig), /** Jobs, one Markdown file each. */ experience: markdown(z.object({ id: text, company: text, role: text, period: text, description: text, tech: z.array(text) }), { body: "description", diff --git a/apps/datagutt/e2e/a11y.spec.ts b/apps/datagutt/e2e/a11y.spec.ts index 7c8678b..1a3ed28 100644 --- a/apps/datagutt/e2e/a11y.spec.ts +++ b/apps/datagutt/e2e/a11y.spec.ts @@ -1,7 +1,7 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; -const moving = (page: Page) => page.evaluate(() => (window as unknown as { __fjord?: { tile: { x: number } } }).__fjord?.tile.x ?? null); +const moving = (page: Page) => page.evaluate(() => (window as unknown as { __kai?: { tile: { x: number } } }).__kai?.tile.x ?? null); test.describe("game shell accessibility", () => { test.skip(({ isMobile }) => isMobile, "keyboard walkthrough"); @@ -33,7 +33,7 @@ test.describe("game shell accessibility", () => { // In the world, on the ferry: skip the crossing and Arne's welcome, then walk. await expect.poll(() => moving(page), { timeout: 30_000 }).not.toBeNull(); - const talking = () => page.evaluate(() => (window as unknown as { __fjord?: { dialogueOpen: boolean; intro: boolean } }).__fjord); + const talking = () => page.evaluate(() => (window as unknown as { __kai?: { dialogueOpen: boolean; intro: boolean } }).__kai); for (let i = 0; i < 30; i++) { const s = await talking(); if (i > 2 && s && !s.dialogueOpen && !s.intro) break; diff --git a/apps/datagutt/e2e/game.spec.ts b/apps/datagutt/e2e/game.spec.ts index 3678633..86f757e 100644 --- a/apps/datagutt/e2e/game.spec.ts +++ b/apps/datagutt/e2e/game.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; -type FjordState = { +type KaiState = { map: string; tile: { x: number; y: number }; facing: string; @@ -13,7 +13,8 @@ type FjordState = { stamps: string[]; passportOpen: boolean; menu: string; - thomas: { place: string; tile: { x: number; y: number } | null; asleep: boolean }; + /** The live datagutt NPC (the presence plugin). */ + liveNpc: { place: string; tile: { x: number; y: number } | null; asleep: boolean }; emoteWheel: boolean; prompt: string | null; intro: boolean; @@ -21,7 +22,7 @@ type FjordState = { finale: boolean; }; -const state = (page: Page) => page.evaluate(() => (window as unknown as { __fjord?: FjordState }).__fjord ?? null); +const state = (page: Page) => page.evaluate(() => (window as unknown as { __kai?: KaiState }).__kai ?? null); /** Past "Press start" to the title menu, then pick an item once loading allows it. */ async function choose(page: Page, item: RegExp) { @@ -39,7 +40,7 @@ async function holdKey(page: Page, key: string, ms: number) { /** * Start from a save at the given spot, through the title screen's Continue button. - * `presence` fixes Thomas's Discord presence (game/live/datagutt.ts MOCK_PRESENCES), so + * `presence` fixes Thomas's Discord presence (content/presence.json mocks), so * where he stands doesn't depend on the real Lanyard feed. */ async function continueAt(page: Page, at: { map: string; x: number; y: number; facing: string }, presence = "coding", stamps: string[] = []) { @@ -57,6 +58,31 @@ test.describe("world", () => { // Keyboard play only; the phone project covers taps through the title-screen test. test.skip(({ isMobile }) => isMobile, "keyboard walkthrough"); + test("a save from before the engine split still loads, stamps, achievements and records kept", async ({ page }) => { + // As the Fjord Town build before kai wrote it: the same key, version 1. + const save = { + version: 1, + updatedAt: "2026-09-20T12:00:00.000Z", + map: "town", + x: 18, + y: 40, + facing: "down", + stamps: ["library", "farm"], + flags: { intro: true, "achievement:cat": true }, + records: { blocks: 420 }, + dialogue: {}, + settings: { muted: false, music: true, showVisitors: true, reducedMotion: null, effects: "auto" }, + }; + await page.goto("/?debug&presence=coding"); + await page.evaluate((s) => localStorage.setItem("fjordtown.save", JSON.stringify(s)), save); + await page.goto("/?debug&presence=coding"); + await choose(page, /continue/i); + await expect.poll(() => state(page), { timeout: 30_000 }).toMatchObject({ map: "town", tile: { x: 18, y: 40 }, stamps: ["library", "farm"] }); + // The world writes the save back as it starts: still under the same key, nothing lost. + const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("fjordtown.save") ?? "{}")); + expect(stored).toMatchObject({ version: 1, stamps: ["library", "farm"], records: { blocks: 420 }, flags: { intro: true, "achievement:cat": true } }); + }); + test("deep link skips the title and doors connect both ways", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (err) => errors.push(err.message)); @@ -110,7 +136,7 @@ test.describe("world", () => { test("Thomas follows his presence: asleep in bed, awake to talk, then off out", async ({ page }) => { await continueAt(page, { map: "house-up", x: 4, y: 7, facing: "left" }, "offline"); - await expect.poll(async () => (await state(page))?.thomas).toMatchObject({ place: "bed", asleep: true, tile: { x: 3, y: 7 } }); + await expect.poll(async () => (await state(page))?.liveNpc).toMatchObject({ place: "bed", asleep: true, tile: { x: 3, y: 7 } }); // Talking to him asleep offers to wake him. await page.keyboard.press("e"); @@ -128,9 +154,9 @@ test.describe("world", () => { expect((await state(page))?.stamps).not.toContain("home"); // He comes online with music on: out of bed and off down the stairs to the fjord. - await page.evaluate(() => (window as unknown as { __fjordPresence(name: string): void }).__fjordPresence("music")); - await expect.poll(async () => (await state(page))?.thomas.asleep).toBe(false); - await expect.poll(async () => (await state(page))?.thomas.tile, { timeout: 15_000 }).toBeNull(); + await page.evaluate(() => (window as unknown as { __kaiPresence(name: string): void }).__kaiPresence("music")); + await expect.poll(async () => (await state(page))?.liveNpc.asleep).toBe(false); + await expect.poll(async () => (await state(page))?.liveNpc.tile, { timeout: 15_000 }).toBeNull(); // The START menu says where he went. await page.keyboard.press("Enter"); @@ -218,6 +244,7 @@ test.describe("world", () => { await page.keyboard.press("e"); await page.waitForTimeout(200); } + if (!(await done())) throw new Error(`Gave up reading; the game is at ${JSON.stringify(await state(page))}`); }; await page.keyboard.press("e"); // His conversation, the stamp, then the note in the back of the passport. @@ -229,7 +256,7 @@ test.describe("world", () => { await readUntil(async () => (await state(page))?.credits === true); await page.keyboard.press("x"); // skip the credits await readUntil(async () => !(await state(page))?.dialogueOpen && !(await state(page))?.credits); - expect((await state(page))?.finale).toBe(false); + await expect.poll(async () => (await state(page))?.finale, { message: JSON.stringify(await state(page)) }).toBe(false); const flags = await page.evaluate(() => JSON.parse(localStorage.getItem("fjordtown.save") ?? "{}").flags); expect(flags.finale).toBe(true); }); diff --git a/apps/datagutt/game/dialogue/externals.ts b/apps/datagutt/game/dialogue/externals.ts index b835524..58d84b9 100644 --- a/apps/datagutt/game/dialogue/externals.ts +++ b/apps/datagutt/game/dialogue/externals.ts @@ -42,7 +42,7 @@ export const EXTERNALS = { contributions_total: { params: [], arg: "none", doc: "Contributions in the last year (live)" }, has_stamp: { params: ["place"], arg: "place", doc: "Whether the player has that place's passport stamp" }, unlocked: { params: ["name"], arg: "unlock", doc: "Whether a locked way is open, e.g. unlocked(\"passport\") once every stamp is in (content/unlocks.json)" }, - lanyard_activity: { params: [], arg: "none", doc: "What Thomas is up to right now in his own words, or \"\" (live, game/live/datagutt.ts)" }, + lanyard_activity: { params: [], arg: "none", doc: "What Thomas is up to right now in his own words, or \"\" (live, content/presence.json lines)" }, } as const satisfies Record; export type ExternalName = keyof typeof EXTERNALS; diff --git a/apps/datagutt/game/index.ts b/apps/datagutt/game/index.ts index 95972e7..0201038 100644 --- a/apps/datagutt/game/index.ts +++ b/apps/datagutt/game/index.ts @@ -3,7 +3,10 @@ import { createGame, type BootOptions, type GameHandle } from "@datagutt/kai"; import type { KaiConfig } from "@datagutt/kai/schema"; import { readWorldState } from "@datagutt/kai-live"; +import { githubObjects } from "@datagutt/kai-live/github/plugin"; +import { presenceNpc } from "@datagutt/kai-live/presence/plugin"; import config from "../.kai/config.json" with { type: "json" }; +import { content } from "../content/index.ts"; import { EMPTY_WORLD_STATE } from "../content/live"; import { fjordContent } from "./data"; import { fjordExternals } from "./dialogue/externals"; @@ -12,12 +15,17 @@ import { arcadePlugin } from "./plugins/arcade"; import { catPlugin } from "./plugins/cat"; import { ferryIntroPlugin } from "./plugins/ferryIntro"; import { finalePlugin } from "./plugins/finale"; -import { githubPlugin } from "./plugins/github"; import { journalPlugin } from "./plugins/journal"; -import { presencePlugin } from "./plugins/presence"; export type { GameHandle }; +/** On the finale's night Thomas waits on the pier with these words (content/presence.json). */ +function finaleKnot(): string { + const knot = content.presence.night?.dialogue; + if (!knot) throw new Error("content/presence.json has no `night`: the finale needs Thomas on the pier"); + return knot; +} + export function startFjordTown(parent: HTMLElement, options: Omit = {}): GameHandle { // The live data the page embedded (GitHub, the weather), or empty data in the dev harness. const world = readWorldState(EMPTY_WORLD_STATE); @@ -28,7 +36,15 @@ export function startFjordTown(parent: HTMLElement, options: Omit fjordExternals({ world, hasStamp: (place) => progress.hasStamp(place), isUnlocked: (name) => services.data.isUnlocked(name, progress) }), }); diff --git a/apps/datagutt/game/live/datagutt.test.ts b/apps/datagutt/game/live/datagutt.test.ts deleted file mode 100644 index eacdf6e..0000000 --- a/apps/datagutt/game/live/datagutt.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { GENERATED_MAPS } from "../../world/gen/maps/index"; -import { doingFor, MOCK_PRESENCES, nextMap, nowDoing, PLACE_MAPS, spotId, statusLines, type Place } from "./datagutt"; - -describe("the datagutt NPC", () => { - it("goes where his presence says, first rule first", () => { - const place = (name: string) => doingFor(MOCK_PRESENCES[name]); - expect(place("offline")).toMatchObject({ place: "bed", asleep: true, emote: "sleep", says: null }); - expect(place("coding")).toMatchObject({ place: "desk", emote: "computer", says: "shipping it" }); - expect(place("gaming")).toMatchObject({ place: "desk", emote: "computer" }); - expect(place("music")).toMatchObject({ place: "fjord", emote: "music" }); - expect(place("idle")).toMatchObject({ place: "square", emote: "dots", wanders: false }); - expect(place("online")).toMatchObject({ place: "square", wanders: true, says: "touching grass" }); - expect(doingFor(null)).toMatchObject({ place: "desk", emote: null }); - // Coding beats music. - expect(doingFor({ ...MOCK_PRESENCES.coding, spotify: { song: "x", artist: "y" } }).place).toBe("desk"); - }); - - it("finds the way between maps through their doors", () => { - expect(nextMap("house-up", "town")).toBe("house"); - expect(nextMap("house", "town")).toBe("town"); - expect(nextMap("town", "house-up")).toBe("house"); - expect(nextMap("town", "town")).toBeNull(); - expect(nextMap("town", "library")).toBeNull(); - }); - - it("has a spot for every place, on the map it says, and doors along the way", () => { - const built = new Map(GENERATED_MAPS.map((m) => [m.id, m.build()])); - for (const [place, map] of Object.entries(PLACE_MAPS)) { - const spots = built.get(map)!.objects.filter((o) => o.type === "spot" && o.id === spotId(place as Place)); - expect(spots, `${spotId(place as Place)} on ${map}`).toHaveLength(1); - } - for (const [from, to] of [["house-up", "house"], ["house", "house-up"], ["house", "town"], ["town", "house"]]) { - expect(built.get(from)!.objects.some((o) => o.type === "door" && o.toMap === to), `door ${from} → ${to}`).toBe(true); - } - }); - - it("says what he is up to", () => { - expect(nowDoing(MOCK_PRESENCES.coding)).toContain("Visual Studio Code"); - expect(nowDoing(MOCK_PRESENCES.music)).toContain('"Midnight City" by M83'); - expect(nowDoing(MOCK_PRESENCES.offline)).toBe(""); - expect(statusLines(MOCK_PRESENCES.music)).toEqual(["Discord: Online", "Listening to Midnight City by M83", "By the fjord, down at the harbour."]); - }); -}); diff --git a/apps/datagutt/game/live/datagutt.ts b/apps/datagutt/game/live/datagutt.ts deleted file mode 100644 index 5be639f..0000000 --- a/apps/datagutt/game/live/datagutt.ts +++ /dev/null @@ -1,118 +0,0 @@ -// The live datagutt NPC (docs/game/PLAN.md M4.2): where Thomas is in town, how he looks -// and what bubble he shows, from his Discord presence (@datagutt/kai-live). The rules -// are the user's, in DESIGN's open-questions table; the first that matches wins. -import type { Presence } from "@datagutt/kai-live"; - -/** Places he can be. Each is a `spot` map object named "datagutt-". */ -export type Place = "desk" | "bed" | "fjord" | "square" | "pier"; -/** Emote bubbles, from LimeZu's emote sheet (game/ui/Bubbles.ts). */ -export type Emote = "computer" | "music" | "sleep" | "dots"; - -export type Doing = { - place: Place; - /** Lying in bed instead of standing. */ - asleep: boolean; - /** Walks about near his place instead of standing still. */ - wanders: boolean; - emote: Emote | null; - /** His custom status, shown in a speech bubble. */ - says: string | null; - /** Ink knot to talk with instead of his own (the finale). */ - dialogue?: string; -}; - -export const spotId = (place: Place) => `datagutt-${place}`; - -/** Which map each place is on. world/gen tests check the spots exist there. */ -export const PLACE_MAPS: Record = { desk: "house-up", bed: "house-up", fjord: "town", square: "town", pier: "town" }; - -/** - * Maps he walks between, by their doors. He is the only NPC who changes maps, so a small - * table beats loading every map to search their doors. - */ -const MAP_LINKS: Record = { "house-up": ["house"], house: ["house-up", "town"], town: ["house"] }; - -/** The next map on the way from `from` to `to`, or null when there is no way. */ -export function nextMap(from: string, to: string): string | null { - if (from === to) return null; - const cameFrom = new Map([[from, from]]); - const queue = [from]; - while (queue.length) { - const map = queue.shift()!; - for (const next of MAP_LINKS[map] ?? []) { - if (cameFrom.has(next)) continue; - cameFrom.set(next, map); - if (next === to) { - let step = next; - while (cameFrom.get(step) !== from) step = cameFrom.get(step)!; - return step; - } - queue.push(next); - } - } - return null; -} - -/** Apps that mean he is writing code: editors, IDEs and terminals, as Discord names them. */ -const CODING = /visual studio|vs ?code|\bcode\b|cursor|zed|intellij|webstorm|phpstorm|pycharm|rustrover|goland|clion|rider|android studio|xcode|n?vim|neovim|emacs|sublime|windsurf|fleet|terminal|iterm|warp|ghostty|wezterm|alacritty|kitty/i; - -export const isCoding = (p: Presence) => p.activities.some((a) => CODING.test(a.name)); - -/** Where he is and what he looks like doing it. Without presence he sits at his desk. */ -export function doingFor(p: Presence | null): Doing { - const says = p?.customStatus ?? null; - const at = (place: Place, emote: Emote | null, extra: Partial = {}): Doing => ({ place, asleep: false, wanders: false, emote, says, ...extra }); - if (!p) return at("desk", null); - if (p.status === "offline") return at("bed", "sleep", { asleep: true, says: null }); - if (isCoding(p)) return at("desk", "computer"); - if (p.activities.some((a) => a.kind === "playing" || a.kind === "streaming")) return at("desk", "computer"); - if (p.spotify) return at("fjord", "music"); - if (p.status === "idle") return at("square", "dots"); - return at("square", null, { wanders: true }); -} - -/** What he is up to, as he would put it in conversation, or "" when nothing stands out. */ -export function nowDoing(p: Presence | null): string { - if (!p || p.status === "offline") return ""; - const coding = p.activities.find((a) => CODING.test(a.name)); - if (coding) return coding.details ? `Just in the middle of something in ${coding.name}. ${coding.details}, to be exact.` : `Just writing some code in ${coding.name}.`; - const game = p.activities.find((a) => a.kind === "playing" || a.kind === "streaming"); - if (game) return game.kind === "streaming" ? `I'm streaming ${game.name} right now, so wave at the chat.` : `Taking a break with a bit of ${game.name}.`; - if (p.spotify) return `I'm listening to "${p.spotify.song}" by ${p.spotify.artist}. Good song for the fjord.`; - return ""; -} - -const STATUS: Record = { online: "Online", idle: "Away", dnd: "Do not disturb", offline: "Offline" }; -const WHERE: Record = { - desk: "At his desk, upstairs at home.", - bed: "Asleep, upstairs at home.", - fjord: "By the fjord, down at the harbour.", - square: "Around the town square.", - pier: "At the end of the pier, waiting for you.", -}; - -/** The START menu's status screen, one line each. */ -export function statusLines(p: Presence | null): string[] { - if (!p) return ["No word from Thomas yet.", WHERE.desk]; - const lines = [`Discord: ${STATUS[p.status]}`]; - if (p.customStatus) lines.push(`"${p.customStatus}"`); - for (const a of p.activities) lines.push(a.details ? `${a.name}: ${a.details}` : a.name); - if (p.spotify) lines.push(`Listening to ${p.spotify.song}${p.spotify.artist ? ` by ${p.spotify.artist}` : ""}`); - lines.push(WHERE[doingFor(p).place]); - return lines; -} - -/** Stand-in presences for `?debug&presence=` and the e2e tests. */ -export const MOCK_PRESENCES: Record = { - offline: { status: "offline", customStatus: null, spotify: null, activities: [] }, - coding: { - status: "dnd", - customStatus: "shipping it", - spotify: null, - activities: [{ kind: "playing", name: "Visual Studio Code", details: "Editing WorldScene.ts", state: "Workspace: datagutt" }], - }, - gaming: { status: "online", customStatus: null, spotify: null, activities: [{ kind: "playing", name: "Factorio", details: null, state: null }] }, - music: { status: "online", customStatus: null, spotify: { song: "Midnight City", artist: "M83" }, activities: [] }, - idle: { status: "idle", customStatus: null, spotify: null, activities: [] }, - online: { status: "online", customStatus: "touching grass", spotify: null, activities: [] }, -}; diff --git a/apps/datagutt/game/plugins/finale.ts b/apps/datagutt/game/plugins/finale.ts index c625926..211bbda 100644 --- a/apps/datagutt/game/plugins/finale.ts +++ b/apps/datagutt/game/plugins/finale.ts @@ -4,9 +4,8 @@ import { CreditsRoll } from "@datagutt/kai/ui/CreditsRoll"; import type { KaiPlugin } from "@datagutt/kai"; -export const FINALE_KNOT = "datagutt_finale"; - -export function finalePlugin(): KaiPlugin { +/** `knot`: Thomas's words on the pier (content/presence.json `night.dialogue`). */ +export function finalePlugin({ knot }: { knot: string }): KaiPlugin { let rolling = false; return { name: "finale", @@ -27,7 +26,7 @@ export function finalePlugin(): KaiPlugin { ); }, talk(world, npc) { - if (npc.dialogue !== FINALE_KNOT) return false; + if (npc.dialogue !== knot) return false; world.playKnot(npc.dialogue, npc, () => { const roll = new CreditsRoll(world.scene, world.services.data.content.credits, world.progress.reducedMotion, () => { rolling = false; diff --git a/apps/datagutt/game/plugins/presence.ts b/apps/datagutt/game/plugins/presence.ts deleted file mode 100644 index 1ea3478..0000000 --- a/apps/datagutt/game/plugins/presence.ts +++ /dev/null @@ -1,73 +0,0 @@ -// The live datagutt NPC (LiveThomas): Thomas's Discord presence, read from Lanyard, picks -// where he is and what he shows. `?debug&presence=` stands in a fixed presence -// (MOCK_PRESENCES) for Lanyard, and window.__fjordPresence(name) switches it live. -import { LanyardClient, PresenceFeed, type WorldState } from "@datagutt/kai-live"; -import { LiveThomas, THOMAS_ID } from "../entities/LiveThomas"; -import { MOCK_PRESENCES, nowDoing, statusLines } from "../live/datagutt"; -import { npc as rosterNpc } from "../npcs"; -import { FINALE_KNOT } from "./finale"; -import type { KaiPlugin } from "@datagutt/kai"; - -export function presencePlugin(live: WorldState): KaiPlugin { - const feed = new PresenceFeed(); - let thomas: LiveThomas | null = null; - return { - name: "presence", - boot(services, params) { - const debug = params.has("debug"); - if (debug) feed.subscribe((p) => console.info("[game] presence", p)); - const lanyard = new LanyardClient(live.discordId, (p) => feed.set(p)); - const mock = debug ? params.get("presence") : null; - if (mock && MOCK_PRESENCES[mock]) feed.set(MOCK_PRESENCES[mock]); - else lanyard.start(); - if (debug) { - (window as unknown as { __fjordPresence?: (name: string) => void }).__fjordPresence = (name) => { - if (!MOCK_PRESENCES[name]) throw new Error(`No mock presence "${name}": ${Object.keys(MOCK_PRESENCES).join(", ")}`); - lanyard.stop(); - services.ghosts?.stop(); - feed.set(MOCK_PRESENCES[name]); - }; - } - return { stop: () => lanyard.stop() }; - }, - externals: () => ({ lanyard_activity: () => nowDoing(feed.current) }), - mapCreated(world) { - const live = new LiveThomas( - { - scene: world.scene, - map: world.map, - grid: world.grid, - spots: world.spots, - doors: world.doors, - addNpc: (def, actor) => world.addNpc(def, actor, { managed: true }), - removeNpc: (id) => world.removeNpc(id), - playerTile: () => world.player.mover.tile, - }, - feed, - rosterNpc(THOMAS_ID)?.name ?? "Thomas", - // On the finale's night he waits at the end of the pier, whatever his presence. - world.services.night ? { place: "pier", asleep: false, wanders: false, emote: null, says: null, dialogue: FINALE_KNOT } : null, - ); - thomas = live; - world.onShutdown(() => { - live.destroy(); - if (thomas === live) thomas = null; - }); - }, - update(world, dt, time) { - if (!thomas) return; - // His bubbles step aside for the interaction prompt over him. - thomas.quiet = world.promptNpc === THOMAS_ID; - thomas.update(dt, time); - }, - promptFor: (_world, npc) => (npc.id === THOMAS_ID && npc.dialogue.endsWith("_asleep") ? "Wake" : null), - talked(world, npc, knot, reached) { - // Woken up and talked to: he gets out of bed rather than lying back down. - if (!reached || npc.id !== THOMAS_ID || knot !== "datagutt_asleep") return; - thomas?.wokenByPlayer(); - world.achieve("wake"); - }, - menuItems: ({ services: { data } }) => [{ id: "status", label: data.t("menu.status"), title: data.t("status.title"), lines: () => statusLines(feed.current) }], - debug: () => ({ presence: feed.current, thomas: thomas?.state }), - }; -} diff --git a/apps/datagutt/world/gen/maps/maps.test.ts b/apps/datagutt/world/gen/maps/maps.test.ts index a90dde5..dc905ab 100644 --- a/apps/datagutt/world/gen/maps/maps.test.ts +++ b/apps/datagutt/world/gen/maps/maps.test.ts @@ -33,4 +33,16 @@ describe("generated maps", () => { expect(all.filter((key) => !usedMapText().has(key))).toEqual([]); for (const map of GENERATED_MAPS) expect(map.properties?.name, map.id).toBeTruthy(); }); + + it("have a spot for each of Thomas's places, on its map, and doors along his routes", () => { + const { presence } = content; + const built = new Map(GENERATED_MAPS.map((m) => [m.id, m.build()])); + for (const [place, { map }] of Object.entries(presence.places)) { + const spots = built.get(map)!.objects.filter((o) => o.type === "spot" && o.id === `${presence.npc}-${place}`); + expect(spots, `${presence.npc}-${place} on ${map}`).toHaveLength(1); + } + for (const [from, tos] of Object.entries(presence.routes)) { + for (const to of tos) expect(built.get(from)!.objects.some((o) => o.type === "door" && o.toMap === to), `door ${from} → ${to}`).toBe(true); + } + }); }); diff --git a/bun.lock b/bun.lock index 84c9a7d..f047392 100644 --- a/bun.lock +++ b/bun.lock @@ -155,6 +155,9 @@ "typescript": "^5.9.3", "vitest": "^5", }, + "peerDependencies": { + "phaser": "^4.2.1", + }, }, "packages/kai-net": { "name": "@datagutt/kai-net", diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 3c57c93..5dc4179 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -169,11 +169,16 @@ it first would make the package import app code. `game/plugins/`: triggers, cat, arcade, github, ferry intro, finale, presence, journal. The finale's night is the engine's generic `services.night`. The passport itself stays engine: stamps are a place's `stamp` flag.) -- [ ] **K5.5** `presenceNpc`, `githubField` and `repoShelf` plugins in `kai-live`, +- [x] **K5.5** `presenceNpc`, `githubField` and `repoShelf` plugins in `kai-live`, configured by `content/presence.json` and `kai.json`. The mock presences move to - JSON. *Done when* the `?presence=` e2e pass. -- [ ] **K5.6** `window.__fjord` becomes `window.__kai`. A new e2e test loads a save - in the old format and checks the stamps survive. *Done when* all e2e pass. + JSON. *Done when* the `?presence=` e2e pass. (`presenceNpc` with its rules, words, + routes, story-night place and mocks in `presence.json`; the field and the shelf are + one `githubObjects` plugin, since both only draw map objects from the same data. + The plugins sit at `@datagutt/kai-live/presence/plugin` and `.../github/plugin`, + off the index the Next server imports.) +- [x] **K5.6** `window.__fjord` becomes `window.__kai`. A new e2e test loads a save + in the old format and checks the stamps survive. *Done when* all e2e pass. (Also + `__fjordPresence` → `__kaiPresence` and the debug field `thomas` → `liveNpc`.) ## K6: Next glue, the sandbox and the boundaries diff --git a/packages/kai-live/package.json b/packages/kai-live/package.json index 9e83d62..8c277f2 100644 --- a/packages/kai-live/package.json +++ b/packages/kai-live/package.json @@ -5,7 +5,8 @@ "license": "GPL-3.0-or-later", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./*": "./src/*.ts" }, "scripts": { "lint": "eslint .", @@ -24,5 +25,8 @@ "eslint": "^9", "typescript": "^5.9.3", "vitest": "^5" + }, + "peerDependencies": { + "phaser": "^4.2.1" } } diff --git a/apps/datagutt/game/live/field.test.ts b/packages/kai-live/src/github/field.test.ts similarity index 95% rename from apps/datagutt/game/live/field.test.ts rename to packages/kai-live/src/github/field.test.ts index 837b674..b902f7b 100644 --- a/apps/datagutt/game/live/field.test.ts +++ b/packages/kai-live/src/github/field.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { fieldLevels } from "./field"; +import { fieldLevels } from "./field.ts"; describe("fieldLevels", () => { it("lays days out by week and weekday, newest week on the right", () => { diff --git a/apps/datagutt/game/live/field.ts b/packages/kai-live/src/github/field.ts similarity index 74% rename from apps/datagutt/game/live/field.ts rename to packages/kai-live/src/github/field.ts index a6de0e1..7f9678b 100644 --- a/apps/datagutt/game/live/field.ts +++ b/packages/kai-live/src/github/field.ts @@ -1,6 +1,6 @@ -// Ola's field from the contribution calendar (docs/game/PLAN.md M3.11): columns are -// weeks (oldest on the left), rows are weekdays (Sunday on top), like GitHub's grid. -import type { ContributionDay } from "@datagutt/kai-live"; +// A field of crops from the contribution calendar: columns are weeks (oldest on the +// left), rows are weekdays (Sunday on top), like GitHub's grid. +import type { ContributionDay } from "../github.ts"; export type Level = 0 | 1 | 2 | 3 | 4; @@ -8,7 +8,7 @@ export type Level = 0 | 1 | 2 | 3 | 4; * The level of each cell of a `weeks`×7 field ending with the week of the newest day, * as rows of columns. Days after the newest one (the rest of this week) are null. */ -export function fieldLevels(days: ContributionDay[], weeks: number): (Level | null)[][] { +export function fieldLevels(days: readonly ContributionDay[], weeks: number): (Level | null)[][] { const grid: (Level | null)[][] = Array.from({ length: 7 }, () => Array(weeks).fill(0)); if (!days.length) return grid; const byDate = new Map(days.map((d) => [d.date, d.level])); diff --git a/apps/datagutt/game/plugins/github.ts b/packages/kai-live/src/github/plugin.ts similarity index 66% rename from apps/datagutt/game/plugins/github.ts rename to packages/kai-live/src/github/plugin.ts index a47c8d6..f74e849 100644 --- a/apps/datagutt/game/plugins/github.ts +++ b/packages/kai-live/src/github/plugin.ts @@ -1,12 +1,12 @@ -// Live GitHub data drawn into the maps: Ola's field (one tile per day of the last weeks, -// crops as tall as the commits) and the library's featured shelf (a spine per pinned repo, -// in its language colour). +// Live GitHub data drawn into the maps: a field of crops (map `crops` objects: one tile +// per day of the last weeks, crops as tall as the commits) and a featured shelf (map +// `books` objects: a spine per pinned repo, in its language colour). import { TILE, type KaiPlugin } from "@datagutt/kai"; -import type { WorldState } from "@datagutt/kai-live"; -import { fieldLevels } from "../live/field"; -import { spines } from "../live/shelf"; +import type { ContributionDay, PinnedRepo } from "../github.ts"; +import { fieldLevels } from "./field.ts"; +import { spines } from "./shelf.ts"; -export function githubPlugin(live: WorldState): KaiPlugin { +export function githubObjects(live: { contributions: readonly ContributionDay[]; repos: readonly PinnedRepo[] }): KaiPlugin { return { name: "github", objects: { diff --git a/apps/datagutt/game/live/shelf.ts b/packages/kai-live/src/github/shelf.ts similarity index 72% rename from apps/datagutt/game/live/shelf.ts rename to packages/kai-live/src/github/shelf.ts index 8dccdca..90abc01 100644 --- a/apps/datagutt/game/live/shelf.ts +++ b/packages/kai-live/src/github/shelf.ts @@ -1,6 +1,6 @@ -// The library's featured shelf (M3.11): one book spine per pinned repo, in the repo's -// language colour, standing on the shelf with a little variety in height and width. -import type { PinnedRepo } from "@datagutt/kai-live"; +// A featured shelf: one book spine per pinned repo, in the repo's language colour, +// standing on the shelf with a little variety in height and width. +import type { PinnedRepo } from "../github.ts"; export type Spine = { x: number; y: number; w: number; h: number; color: number; edge: number }; @@ -13,7 +13,7 @@ function shade(hex: string, f: number): number { } /** Spines in pixels, relative to the shelf area's top-left; bottoms rest on `baseline`. */ -export function spines(repos: PinnedRepo[], width: number, baseline: number): Spine[] { +export function spines(repos: readonly PinnedRepo[], width: number, baseline: number): Spine[] { const out: Spine[] = []; let x = 3; repos.forEach((repo, i) => { diff --git a/apps/datagutt/game/entities/LiveThomas.ts b/packages/kai-live/src/presence/LiveNpc.ts similarity index 67% rename from apps/datagutt/game/entities/LiveThomas.ts rename to packages/kai-live/src/presence/LiveNpc.ts index 5bf7905..d2123be 100644 --- a/apps/datagutt/game/entities/LiveThomas.ts +++ b/packages/kai-live/src/presence/LiveNpc.ts @@ -1,19 +1,18 @@ -// The live datagutt NPC on the current map (docs/game/PLAN.md M4.2). His presence picks a -// place (game/live/datagutt.ts); when it changes while the player is watching, he walks: -// to the new spot on this map, out through the door toward another map, or in through -// the door he would come from. Off this map he simply is wherever his presence says. +// A live NPC on the current map. Their presence picks a place (rules.ts); when it changes +// while the player is watching, they walk: to the new spot on this map, out through the +// door toward another map, or in through the door they would come from. Off this map they +// simply are wherever their presence says. import type Phaser from "phaser"; import { NPC_MOVEMENT, type MoverEvent } from "@datagutt/kai/world/movement"; import { directionBetween, type CollisionGrid, type Point } from "@datagutt/kai/world/grid"; import { findPath, findPathAdjacent } from "@datagutt/kai/world/pathfind"; import type { MapObject } from "@datagutt/kai/world/objects"; -import { doingFor, nextMap, PLACE_MAPS, spotId, type Doing } from "../live/datagutt"; -import type { Presence, PresenceFeed } from "@datagutt/kai-live"; +import type { Presence, PresenceFeed } from "../lanyard.ts"; +import type { PresenceConfig } from "./config.ts"; +import { doingFor, nextMap, spotId, type Doing } from "./rules.ts"; import { EmoteBubble, SpeechBubble } from "@datagutt/kai/ui/Bubbles"; import { Actor } from "@datagutt/kai/entities/Actor"; -export const THOMAS_ID = "datagutt"; - type NpcDef = Extract; type Spot = Extract; type Door = Extract; @@ -24,24 +23,24 @@ export type LiveHost = { grid: CollisionGrid; spots: ReadonlyMap; doors: ReadonlyMap; - /** Make him talkable (or not) like any other NPC. */ + /** Make them talkable (or not) like any other NPC. */ addNpc(def: NpcDef, actor: Actor): void; removeNpc(id: string): void; playerTile(): Point; }; -/** He shows his custom status in words only when the player is this close, in tiles. */ +/** Their custom status shows in words only when the player is this close, in tiles. */ const SPEECH_RANGE = 7; -/** How far from the square's spot he strolls, in tiles, and how long he stands between strolls. */ +/** How far from their spot they stroll, in tiles, and how long they stand between strolls. */ const WANDER = { radius: 3, restMs: [2500, 6000] as const }; /** Standing in someone's way: give up on the path and look for another after this long. */ const BLOCKED_MS = 1500; -/** The head on the pillow: pixels from the bed spot's tile to where the sprite goes. */ +/** The head on the pillow: pixels from a bed spot's tile to where the sprite goes. */ const PILLOW = { x: -8, y: 4 }; -export class LiveThomas { +export class LiveNpc { private doing: Doing; - /** The player woke him (his asleep dialogue): up and about while his presence stays the same. */ + /** The player woke them (their asleep dialogue): up and about while their presence stays the same. */ private woken = false; private actor: Actor | null = null; private def: NpcDef | null = null; @@ -49,7 +48,7 @@ export class LiveThomas { private onArrive: (() => void) | null = null; private blockedMs = 0; private restMs = 0; - /** Something else is shown over him (the interaction prompt): his bubbles step aside. */ + /** Something else is shown over them (the interaction prompt): their bubbles step aside. */ quiet = false; private readonly emote: EmoteBubble; private readonly speech: SpeechBubble; @@ -57,19 +56,21 @@ export class LiveThomas { constructor( private readonly host: LiveHost, + private readonly config: PresenceConfig, feed: PresenceFeed, private readonly name: string, - /** Where he is regardless of his presence (the finale's pier). */ + /** Where they are regardless of their presence (a story night). */ private readonly fixed: Doing | null = null, ) { - this.doing = fixed ?? doingFor(feed.current); + this.doing = fixed ?? doingFor(config, feed.current); this.emote = new EmoteBubble(host.scene); this.speech = new SpeechBubble(host.scene); - if (PLACE_MAPS[this.doing.place] === host.map) this.appear(this.spot(this.doing)!, this.doing); + const spot = this.mapOf(this.doing.place) === host.map ? this.spot(this.doing) : undefined; + if (spot) this.appear(spot, this.doing); this.unsubscribe = feed.subscribe((p) => this.onPresence(p)); } - /** For the ?debug readout: where he should be, and where he is on this map. */ + /** For the ?debug readout: where they should be, and where they are on this map. */ get state() { return { place: this.doing.place, tile: this.actor ? { ...this.actor.mover.tile } : null, asleep: this.actor?.asleep ?? false }; } @@ -80,31 +81,44 @@ export class LiveThomas { this.speech.destroy(); } + private get id(): string { + return this.config.npc; + } + + private mapOf(place: string): string | undefined { + return this.config.places[place]?.map; + } + private spot(doing: Doing): Spot | undefined { - return this.host.spots.get(spotId(doing.place)); + return this.host.spots.get(spotId(this.config, doing.place)); + } + + /** Their own knot, or the asleep one. */ + private knot(asleep: boolean): string { + return asleep ? this.config.asleepKnot : this.id; } private onPresence(presence: Presence): void { if (this.fixed) return; - const next = doingFor(presence); + const next = doingFor(this.config, presence); const before = this.doing; this.doing = next; if (next.place === before.place) { - // Same place, maybe a new bubble or custom status. Woken by the player, he stays - // up until his presence sends him somewhere else. - if (this.actor && this.def) this.def.dialogue = next.asleep && !this.woken ? "datagutt_asleep" : "datagutt"; + // Same place, maybe a new bubble or custom status. Woken by the player, they stay + // up until their presence sends them somewhere else. + if (this.actor && this.def) this.def.dialogue = this.knot(next.asleep && !this.woken); return; } this.woken = false; const here = this.host.map; - const target = PLACE_MAPS[next.place] === here ? this.spot(next) : undefined; + const target = this.mapOf(next.place) === here ? this.spot(next) : undefined; if (this.actor) { this.wake(); if (target) this.walkTo(target, next); - else this.leaveToward(PLACE_MAPS[next.place]); + else this.leaveToward(this.mapOf(next.place)); } else if (target) { - // Coming in: through the door on the way from where he was. - const from = nextMap(here, PLACE_MAPS[before.place]); + // Coming in: through the door on the way from where they were. + const from = nextMap(this.config.routes, here, this.mapOf(before.place) ?? here); const door = [...this.host.doors.values()].find((d) => d.toMap === from); const entry = door ? { x: door.x, y: door.y + 1 } : null; if (entry && this.host.grid.isWalkable(entry.x, entry.y)) { @@ -117,17 +131,17 @@ export class LiveThomas { } private appear(spot: Spot, doing: Doing): void { - // Someone standing on his spot: he takes the free tile next to it. + // Someone standing on their spot: they take the free tile next to it. const free = (p: Point) => this.host.grid.occupantAt(p.x, p.y) === undefined; const around = [spot, { x: spot.x, y: spot.y + 1 }, { x: spot.x + 1, y: spot.y }, { x: spot.x - 1, y: spot.y }, { x: spot.x, y: spot.y - 1 }]; const found = around.find((p, i) => free(p) && (i === 0 || this.host.grid.isWalkable(p.x, p.y))); if (!found) return; const tile = { x: found.x, y: found.y }; const at = { ...spot, x: tile.x, y: tile.y }; - const actor = new Actor(this.host.scene, THOMAS_ID, "datagutt", tile, at.facing, NPC_MOVEMENT); - this.def = { type: "npc", id: THOMAS_ID, character: "datagutt", x: at.x, y: at.y, facing: at.facing, name: this.name, dialogue: doing.dialogue ?? "datagutt" }; + const actor = new Actor(this.host.scene, this.id, this.id, tile, at.facing, NPC_MOVEMENT); + this.def = { type: "npc", id: this.id, character: this.id, x: at.x, y: at.y, facing: at.facing, name: this.name, dialogue: doing.dialogue ?? this.id }; this.actor = actor; - this.host.grid.occupy(at.x, at.y, THOMAS_ID); + this.host.grid.occupy(at.x, at.y, this.id); this.host.addNpc(this.def, actor); if (doing.asleep) this.lieDown(); } @@ -135,8 +149,8 @@ export class LiveThomas { private vanish(): void { if (!this.actor) return; const t = this.actor.mover.tile; - this.host.grid.vacate(t.x, t.y, THOMAS_ID); - this.host.removeNpc(THOMAS_ID); + this.host.grid.vacate(t.x, t.y, this.id); + this.host.removeNpc(this.id); this.actor.destroy(); this.actor = this.def = null; this.path = []; @@ -149,10 +163,10 @@ export class LiveThomas { if (!this.actor || !this.def) return; this.actor.asleep = true; this.actor.offset = { ...PILLOW }; - this.def.dialogue = "datagutt_asleep"; + this.def.dialogue = this.config.asleepKnot; } - /** The player woke him in conversation: out of bed, and awake while he stays put. */ + /** The player woke them in conversation: out of bed, and awake while they stay put. */ wokenByPlayer(): void { this.woken = true; this.wake(); @@ -164,29 +178,29 @@ export class LiveThomas { if (!actor?.asleep) return; actor.asleep = false; actor.offset = { x: 0, y: 0 }; - if (this.def) this.def.dialogue = "datagutt"; + if (this.def) this.def.dialogue = this.id; const bed = actor.mover.tile; const floor = [ { x: bed.x + 1, y: bed.y }, { x: bed.x, y: bed.y + 2 }, { x: bed.x - 1, y: bed.y }, { x: bed.x + 1, y: bed.y + 1 }, - ].find((p) => this.host.grid.isWalkable(p.x, p.y, THOMAS_ID)); + ].find((p) => this.host.grid.isWalkable(p.x, p.y, this.id)); if (!floor) return; - this.host.grid.vacate(bed.x, bed.y, THOMAS_ID); - this.host.grid.occupy(floor.x, floor.y, THOMAS_ID); + this.host.grid.vacate(bed.x, bed.y, this.id); + this.host.grid.occupy(floor.x, floor.y, this.id); actor.mover.place(floor, "down"); } private walkTo(target: Spot, doing: Doing): void { - // A spot on furniture (the bed) is reached from beside it, then he lies down on it. - const onFurniture = !this.host.grid.isWalkable(target.x, target.y, THOMAS_ID); + // A spot on furniture (a bed) is reached from beside it, then they lie down on it. + const onFurniture = !this.host.grid.isWalkable(target.x, target.y, this.id); this.setPath(target, onFurniture, () => { if (!this.actor) return; if (onFurniture) { const t = this.actor.mover.tile; - this.host.grid.vacate(t.x, t.y, THOMAS_ID); - this.host.grid.occupy(target.x, target.y, THOMAS_ID); + this.host.grid.vacate(t.x, t.y, this.id); + this.host.grid.occupy(target.x, target.y, this.id); this.actor.mover.place(target, target.facing); } else { this.actor.mover.face(target.facing); @@ -195,8 +209,8 @@ export class LiveThomas { }); } - private leaveToward(map: string): void { - const next = nextMap(this.host.map, map); + private leaveToward(map: string | undefined): void { + const next = map ? nextMap(this.config.routes, this.host.map, map) : null; const door = [...this.host.doors.values()].find((d) => d.toMap === next); if (!door) { this.vanish(); @@ -205,14 +219,14 @@ export class LiveThomas { this.setPath(door, false, () => this.vanish(), true); } - /** `ontoDoor`: the last step is onto a door tile, which he may enter though the player can't stop on it. */ + /** `ontoDoor`: the last step is onto a door tile, which they may enter though the player can't stop on it. */ private setPath(to: Point, adjacent: boolean, onArrive: () => void, ontoDoor = false): void { if (!this.actor) return; const from = this.actor.mover.destination; const walkable = { width: this.host.grid.width, height: this.host.grid.height, - isWalkable: (x: number, y: number) => this.host.grid.isWalkable(x, y, THOMAS_ID) || (ontoDoor && x === to.x && y === to.y), + isWalkable: (x: number, y: number) => this.host.grid.isWalkable(x, y, this.id) || (ontoDoor && x === to.x && y === to.y), }; const path = adjacent ? findPathAdjacent(walkable, from, to) : findPath(walkable, from, to); this.path = path ?? []; @@ -220,9 +234,9 @@ export class LiveThomas { this.blockedMs = 0; if (!path && this.host.grid.occupantAt(to.x, to.y) === undefined) { // No way through (someone in a doorway): get there anyway rather than stand stuck. - // With the end itself taken, onArrive runs where he stands on the next update. - this.host.grid.vacate(from.x, from.y, THOMAS_ID); - this.host.grid.occupy(to.x, to.y, THOMAS_ID); + // With the end itself taken, onArrive runs where they stand on the next update. + this.host.grid.vacate(from.x, from.y, this.id); + this.host.grid.occupy(to.x, to.y, this.id); this.actor.mover.place(to, "down"); } } @@ -230,7 +244,7 @@ export class LiveThomas { update(dtMs: number, timeMs: number): void { const actor = this.actor; if (!actor) return; - const canEnter = (p: Point) => this.host.grid.isWalkable(p.x, p.y, THOMAS_ID) || this.isDoor(p); + const canEnter = (p: Point) => this.host.grid.isWalkable(p.x, p.y, this.id) || this.isDoor(p); this.handle(actor.mover.update(dtMs, { dir: null, run: false }, canEnter)); if (!this.actor) return; if (!actor.mover.moving && this.path.length) { @@ -265,13 +279,13 @@ export class LiveThomas { private handle(events: MoverEvent[]): void { for (const e of events) { if (e.type !== "stepStarted") continue; - this.host.grid.vacate(e.from.x, e.from.y, THOMAS_ID); - this.host.grid.occupy(e.to.x, e.to.y, THOMAS_ID); + this.host.grid.vacate(e.from.x, e.from.y, this.id); + this.host.grid.occupy(e.to.x, e.to.y, this.id); if (this.def) Object.assign(this.def, { x: e.to.x, y: e.to.y }); } } - /** A short stroll near the square's spot, then a rest. */ + /** A short stroll near their spot, then a rest. */ private wander(dtMs: number): void { if ((this.restMs -= dtMs) > 0) return; const [min, max] = WANDER.restMs; @@ -281,7 +295,7 @@ export class LiveThomas { for (let tries = 0; tries < 8; tries++) { const x = home.x + Math.round((Math.random() * 2 - 1) * WANDER.radius); const y = home.y + Math.round((Math.random() * 2 - 1) * WANDER.radius); - if (!this.host.grid.isWalkable(x, y, THOMAS_ID)) continue; + if (!this.host.grid.isWalkable(x, y, this.id)) continue; this.setPath({ x, y }, false, () => this.actor?.mover.face("down")); return; } diff --git a/packages/kai-live/src/presence/config.ts b/packages/kai-live/src/presence/config.ts new file mode 100644 index 0000000..352b139 --- /dev/null +++ b/packages/kai-live/src/presence/config.ts @@ -0,0 +1,93 @@ +// A live NPC's configuration (a game's content/presence.json): where they can be, how +// their Discord presence picks the place, and the words for it. The content build checks +// it with this schema; the runtime only reads the PresenceConfig type. +import { z } from "@datagutt/kai/schema"; +import { EMOTES } from "@datagutt/kai/ui/emotes"; + +const text = z.string().min(1); +const emote = z.enum(Object.keys(EMOTES) as [keyof typeof EMOTES, ...(keyof typeof EMOTES)[]]); + +const presence = z.object({ + status: z.enum(["online", "idle", "dnd", "offline"]), + customStatus: z.string().nullable(), + spotify: z.object({ song: z.string(), artist: z.string() }).nullable(), + activities: z.array( + z.object({ + kind: z.enum(["playing", "streaming", "listening", "watching", "custom", "competing"]), + name: z.string(), + details: z.string().nullable(), + state: z.string().nullable(), + }), + ), +}); + +/** + * What a presence can be, for the rules: none yet, offline, writing code (an app that + * matches `coding`), playing or streaming, listening to music, away, or anything else + * online. + */ +export const PRESENCE_CASES = ["none", "offline", "coding", "playing", "music", "idle", "online"] as const; + +export const presenceConfig = z + .object({ + /** The live NPC's id: their character, their own Ink knot, and their spots (`-`). */ + npc: text, + /** The knot for talking to them while they are asleep; talking wakes them. */ + asleepKnot: text, + /** Places they can be, each with its map and how the status page says it. */ + places: z.record(text, z.object({ map: text, where: text })), + /** Maps they walk between, by the maps each has a door to. */ + routes: z.record(text, z.array(text)), + /** Apps that mean writing code, as a case-insensitive regular expression over the app's name. */ + coding: z.string().refine((source) => { + try { + new RegExp(source, "i"); + return true; + } catch { + return false; + } + }, "not a regular expression"), + /** The first rule whose case matches picks where they are and how they look. */ + rules: z + .array( + z.object({ + when: z.enum(PRESENCE_CASES), + place: text, + emote: emote.nullable().default(null), + /** Lying in bed instead of standing. */ + asleep: z.boolean().default(false), + /** Walks about near the place instead of standing still. */ + wanders: z.boolean().default(false), + /** Keeps their custom status to themselves. */ + quiet: z.boolean().default(false), + }), + ) + .min(1), + /** What they say they are up to (the `lanyard_activity` dialogue function). */ + lines: z.object({ codingDetails: text, coding: text, streaming: text, playing: text, music: text }), + /** The start menu's status page. */ + status: z.object({ + none: text, + discord: text, + names: z.object({ online: text, idle: text, dnd: text, offline: text }), + listening: text, + listeningBy: text, + }), + /** Where they wait on a story night, whatever their presence, and what they say. */ + night: z.object({ place: text, dialogue: text }).optional(), + /** Earned for waking them. */ + wakeGrants: text.optional(), + /** Stand-in presences for `?debug&presence=` and tests. */ + mocks: z.record(text, presence).default({}), + }) + .superRefine((config, ctx) => { + const places = Object.keys(config.places); + config.rules.forEach((rule, i) => { + if (!places.includes(rule.place)) ctx.addIssue({ code: "custom", path: ["rules", i, "place"], message: `no place "${rule.place}" in places` }); + }); + if (config.night && !places.includes(config.night.place)) ctx.addIssue({ code: "custom", path: ["night", "place"], message: `no place "${config.night.place}" in places` }); + if (!config.rules.some((r) => r.when === "none")) ctx.addIssue({ code: "custom", path: ["rules"], message: 'needs a rule for "none" (no presence yet)' }); + }); + +export type PresenceConfig = z.output; +export type PresenceRule = PresenceConfig["rules"][number]; diff --git a/packages/kai-live/src/presence/plugin.ts b/packages/kai-live/src/presence/plugin.ts new file mode 100644 index 0000000..0310b25 --- /dev/null +++ b/packages/kai-live/src/presence/plugin.ts @@ -0,0 +1,80 @@ +// A live NPC (LiveNpc.ts) whose Discord presence, read from Lanyard, picks where they are +// and what they show, configured by a game's content/presence.json. `?debug&presence=` +// stands in one of the configured mocks for Lanyard, and window.__kaiPresence(name) +// switches it live. +import type { KaiPlugin } from "@datagutt/kai"; +import { LanyardClient, PresenceFeed } from "../lanyard.ts"; +import type { PresenceConfig } from "./config.ts"; +import { LiveNpc } from "./LiveNpc.ts"; +import { nowDoing, statusLines } from "./rules.ts"; + +export type PresenceNpcOptions = { + /** The Discord user whose presence drives the NPC. */ + discordId: string; +}; + +export function presenceNpc(config: PresenceConfig, { discordId }: PresenceNpcOptions): KaiPlugin { + const feed = new PresenceFeed(); + let live: LiveNpc | null = null; + return { + name: "presence", + boot(services, params) { + const debug = params.has("debug"); + if (debug) feed.subscribe((p) => console.info("[game] presence", p)); + const lanyard = new LanyardClient(discordId, (p) => feed.set(p)); + const mock = debug ? params.get("presence") : null; + if (mock && config.mocks[mock]) feed.set(config.mocks[mock]); + else lanyard.start(); + if (debug) { + (window as unknown as { __kaiPresence?: (name: string) => void }).__kaiPresence = (name) => { + if (!config.mocks[name]) throw new Error(`No mock presence "${name}": ${Object.keys(config.mocks).join(", ")}`); + lanyard.stop(); + services.ghosts?.stop(); + feed.set(config.mocks[name]); + }; + } + return { stop: () => lanyard.stop() }; + }, + externals: () => ({ lanyard_activity: () => nowDoing(config, feed.current) }), + mapCreated(world) { + const night = world.services.night && config.night; + const npc = new LiveNpc( + { + scene: world.scene, + map: world.map, + grid: world.grid, + spots: world.spots, + doors: world.doors, + addNpc: (def, actor) => world.addNpc(def, actor, { managed: true }), + removeNpc: (id) => world.removeNpc(id), + playerTile: () => world.player.mover.tile, + }, + config, + feed, + world.services.data.npc(config.npc)?.name ?? config.npc, + // On a story night they wait at the configured place, whatever their presence. + night ? { place: night.place, asleep: false, wanders: false, emote: null, says: null, dialogue: night.dialogue } : null, + ); + live = npc; + world.onShutdown(() => { + npc.destroy(); + if (live === npc) live = null; + }); + }, + update(world, dt, time) { + if (!live) return; + // Their bubbles step aside for the interaction prompt over them. + live.quiet = world.promptNpc === config.npc; + live.update(dt, time); + }, + promptFor: (_world, npc) => (npc.id === config.npc && npc.dialogue === config.asleepKnot ? "Wake" : null), + talked(world, npc, knot, reached) { + // Woken up and talked to: out of bed rather than lying back down. + if (!reached || npc.id !== config.npc || knot !== config.asleepKnot) return; + live?.wokenByPlayer(); + if (config.wakeGrants) world.achieve(config.wakeGrants); + }, + menuItems: ({ services: { data } }) => [{ id: "status", label: data.t("menu.status"), title: data.t("status.title"), lines: () => statusLines(config, feed.current) }], + debug: () => ({ presence: feed.current, liveNpc: live?.state }), + }; +} diff --git a/packages/kai-live/src/presence/rules.test.ts b/packages/kai-live/src/presence/rules.test.ts new file mode 100644 index 0000000..fec8e3c --- /dev/null +++ b/packages/kai-live/src/presence/rules.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { Presence } from "../lanyard.ts"; +import { presenceConfig } from "./config.ts"; +import { doingFor, nextMap, nowDoing, statusLines } from "./rules.ts"; + +const config = presenceConfig.parse({ + npc: "dev", + asleepKnot: "dev_asleep", + places: { + desk: { map: "home-up", where: "At the desk." }, + bed: { map: "home-up", where: "Asleep." }, + fjord: { map: "town", where: "By the water." }, + square: { map: "town", where: "In the square." }, + }, + routes: { "home-up": ["home"], home: ["home-up", "town"], town: ["home"] }, + coding: "visual studio|vs ?code|n?vim", + rules: [ + { when: "none", place: "desk" }, + { when: "offline", place: "bed", emote: "sleep", asleep: true, quiet: true }, + { when: "coding", place: "desk", emote: "computer" }, + { when: "playing", place: "desk", emote: "computer" }, + { when: "music", place: "fjord", emote: "music" }, + { when: "idle", place: "square", emote: "dots" }, + { when: "online", place: "square", wanders: true }, + ], + lines: { + codingDetails: "Busy in {app}: {details}.", + coding: "Writing code in {app}.", + streaming: "Streaming {game}.", + playing: "Playing {game}.", + music: 'Listening to "{song}" by {artist}.', + }, + status: { none: "No word yet.", discord: "Discord: {status}", names: { online: "Online", idle: "Away", dnd: "Busy", offline: "Offline" }, listening: "Listening to {song}", listeningBy: "Listening to {song} by {artist}" }, +}); + +const presence = (p: Partial): Presence => ({ status: "online", customStatus: null, spotify: null, activities: [], ...p }); +const coding = presence({ status: "dnd", customStatus: "shipping it", activities: [{ kind: "playing", name: "Visual Studio Code", details: "Editing a.ts", state: null }] }); +const music = presence({ spotify: { song: "Midnight City", artist: "M83" } }); + +describe("a live NPC's rules", () => { + it("pick the place, first rule first", () => { + expect(doingFor(config, presence({ status: "offline", customStatus: "zzz" }))).toMatchObject({ place: "bed", asleep: true, emote: "sleep", says: null }); + expect(doingFor(config, coding)).toMatchObject({ place: "desk", emote: "computer", says: "shipping it" }); + expect(doingFor(config, presence({ activities: [{ kind: "playing", name: "Factorio", details: null, state: null }] }))).toMatchObject({ place: "desk" }); + expect(doingFor(config, music)).toMatchObject({ place: "fjord", emote: "music" }); + expect(doingFor(config, presence({ status: "idle" }))).toMatchObject({ place: "square", emote: "dots", wanders: false }); + expect(doingFor(config, presence({ customStatus: "touching grass" }))).toMatchObject({ place: "square", wanders: true, says: "touching grass" }); + expect(doingFor(config, null)).toMatchObject({ place: "desk", emote: null }); + expect(doingFor(config, { ...coding, spotify: { song: "x", artist: "y" } }).place).toBe("desk"); + }); + + it("find the way between maps by the routes", () => { + expect(nextMap(config.routes, "home-up", "town")).toBe("home"); + expect(nextMap(config.routes, "home", "town")).toBe("town"); + expect(nextMap(config.routes, "town", "home-up")).toBe("home"); + expect(nextMap(config.routes, "town", "town")).toBeNull(); + expect(nextMap(config.routes, "town", "library")).toBeNull(); + }); + + it("say what they are up to, and fill the status page", () => { + expect(nowDoing(config, coding)).toBe("Busy in Visual Studio Code: Editing a.ts."); + expect(nowDoing(config, music)).toBe('Listening to "Midnight City" by M83.'); + expect(nowDoing(config, presence({ status: "offline" }))).toBe(""); + expect(statusLines(config, music)).toEqual(["Discord: Online", "Listening to Midnight City by M83", "By the water."]); + expect(statusLines(config, null)).toEqual(["No word yet.", "At the desk."]); + }); + + it("refuse rules for places that do not exist", () => { + const broken = presenceConfig.safeParse({ ...config, rules: [...config.rules, { when: "idle", place: "moon" }] }); + expect(broken.error?.issues.map((i) => i.message)).toEqual(['no place "moon" in places']); + }); +}); diff --git a/packages/kai-live/src/presence/rules.ts b/packages/kai-live/src/presence/rules.ts new file mode 100644 index 0000000..4b96cfe --- /dev/null +++ b/packages/kai-live/src/presence/rules.ts @@ -0,0 +1,97 @@ +// How a presence becomes a place, a look and words, by a live NPC's configuration. Pure, +// so it can be tested; LiveNpc.ts acts on it. +import { format } from "@datagutt/kai/ui/strings"; +import type { Presence } from "../lanyard.ts"; +import type { PresenceConfig, PresenceRule } from "./config.ts"; + +export type Doing = { + place: string; + /** Lying in bed instead of standing. */ + asleep: boolean; + /** Walks about near their place instead of standing still. */ + wanders: boolean; + emote: PresenceRule["emote"]; + /** Their custom status, shown in a speech bubble. */ + says: string | null; + /** Ink knot to talk with instead of their own (a story night). */ + dialogue?: string; +}; + +/** The spot map object for a place: `-`. */ +export const spotId = (config: Pick, place: string) => `${config.npc}-${place}`; + +const codingApp = (config: PresenceConfig, p: Presence) => { + const coding = new RegExp(config.coding, "i"); + return p.activities.find((a) => coding.test(a.name)); +}; + +function matches(config: PresenceConfig, when: PresenceRule["when"], p: Presence | null): boolean { + if (when === "none") return p === null; + if (!p) return false; + switch (when) { + case "offline": + return p.status === "offline"; + case "coding": + return codingApp(config, p) !== undefined; + case "playing": + return p.activities.some((a) => a.kind === "playing" || a.kind === "streaming"); + case "music": + return p.spotify !== null; + case "idle": + return p.status === "idle"; + case "online": + return true; + } +} + +/** Where they are and what they look like doing it: the first rule that matches. */ +export function doingFor(config: PresenceConfig, p: Presence | null): Doing { + const rule = config.rules.find((r) => matches(config, r.when, p)) ?? config.rules[0]; + return { place: rule.place, asleep: rule.asleep, wanders: rule.wanders, emote: rule.emote, says: rule.quiet ? null : (p?.customStatus ?? null) }; +} + +/** What they are up to, as they would put it in conversation, or "" when nothing stands out. */ +export function nowDoing(config: PresenceConfig, p: Presence | null): string { + if (!p || p.status === "offline") return ""; + const { lines } = config; + const coding = codingApp(config, p); + if (coding) return coding.details ? format(lines.codingDetails, { app: coding.name, details: coding.details }) : format(lines.coding, { app: coding.name }); + const game = p.activities.find((a) => a.kind === "playing" || a.kind === "streaming"); + if (game) return format(game.kind === "streaming" ? lines.streaming : lines.playing, { game: game.name }); + if (p.spotify) return format(lines.music, { song: p.spotify.song, artist: p.spotify.artist }); + return ""; +} + +/** The start menu's status page, one line each. */ +export function statusLines(config: PresenceConfig, p: Presence | null): string[] { + const where = (place: string) => config.places[place]?.where ?? place; + const { status } = config; + if (!p) return [status.none, where(doingFor(config, null).place)]; + const lines = [format(status.discord, { status: status.names[p.status] })]; + if (p.customStatus) lines.push(`"${p.customStatus}"`); + for (const a of p.activities) lines.push(a.details ? `${a.name}: ${a.details}` : a.name); + if (p.spotify) lines.push(format(p.spotify.artist ? status.listeningBy : status.listening, { song: p.spotify.song, artist: p.spotify.artist })); + lines.push(where(doingFor(config, p).place)); + return lines; +} + +/** The next map on the way from `from` to `to` by the configured routes, or null when there is no way. */ +export function nextMap(routes: PresenceConfig["routes"], from: string, to: string): string | null { + if (from === to) return null; + const cameFrom = new Map([[from, from]]); + const queue = [from]; + while (queue.length) { + const map = queue.shift()!; + for (const next of routes[map] ?? []) { + if (cameFrom.has(next)) continue; + cameFrom.set(next, map); + if (next === to) { + let step = next; + while (cameFrom.get(step) !== from) step = cameFrom.get(step)!; + return step; + } + queue.push(next); + } + } + return null; +} diff --git a/packages/kai/src/plugins/api.ts b/packages/kai/src/plugins/api.ts index 628d005..c9e64c6 100644 --- a/packages/kai/src/plugins/api.ts +++ b/packages/kai/src/plugins/api.ts @@ -109,7 +109,7 @@ export interface KaiPlugin { update?(world: World, dt: number, time: number): void; /** Start menu items, after the Passport. */ menuItems?(world: World): MenuItem[]; - /** Extra fields for the ?debug state (window.__fjord). */ + /** Extra fields for the ?debug state (window.__kai). */ debug?(world: World): Record; } diff --git a/packages/kai/src/scenes/WorldScene.ts b/packages/kai/src/scenes/WorldScene.ts index 4e8f2a1..63d715b 100644 --- a/packages/kai/src/scenes/WorldScene.ts +++ b/packages/kai/src/scenes/WorldScene.ts @@ -548,13 +548,13 @@ export class WorldScene extends Phaser.Scene { return seasonal; } - /** FPS overlay and window.__fjord state, only with ?debug. Runs every frame. */ + /** FPS overlay and window.__kai state, only with ?debug. Runs every frame. */ private updateDebug() { if (!this.debugText) return; const p = this.player.mover.tile; this.debugText.setText(`${Math.round(this.game.loop.actualFps)} fps ${this.target.map} ${p.x},${p.y}`); // Read by e2e tests and dev tooling. - (window as unknown as { __fjord?: object }).__fjord = { + (window as unknown as { __kai?: object }).__kai = { map: this.target.map, season: this.services.season, weather: this.weatherNow.kind, From ede1373a0949d309ed4d9d9c1a5c7bc4aee1c568 Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:00:21 +0200 Subject: [PATCH 133/153] test(kai): cover game data, the triggers and per-world plugin state Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- docs/kai/PLAN.md | 7 +- packages/kai/src/data.test.ts | 57 ++++++++++++ packages/kai/src/plugins/triggers.test.ts | 102 ++++++++++++++++++++++ 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 packages/kai/src/data.test.ts create mode 100644 packages/kai/src/plugins/triggers.test.ts diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 5dc4179..83cd6af 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -154,10 +154,11 @@ it first would make the package import app code. the live payload as `{ weather, ...slices }`; the weather types live in the runtime and `kai-live` depends on it, so there is no package cycle. The save key and the visitors kill switch come from `kai.json`. The triggers plugin is built in.) -- [ ] **K5.2** `KaiPlugin` API: setup, map enter, interaction, update, map object types, +- [x] **K5.2** `KaiPlugin` API: setup, map enter, interaction, update, map object types, Ink externals, start menu items. *Done when* the API has unit tests with a fake - scene. (The API is in `game/plugins/api.ts` and runs Fjord Town; it moves with the - runtime in K5.1. Still open: its unit tests.) + scene. (`@datagutt/kai/plugins/api`. Unit tests drive the triggers plugin through a + fake `World` and cover `GameData`; the world scene's dispatch to the hooks needs + Phaser, so the e2e suite covers it through Fjord Town's plugins.) - [x] **K5.3** Declarative triggers in content (enter map, talk to NPC, finish knot, flag set) that grant achievements and set flags. *Done when* `summit` comes from a trigger. (`content/triggers.json`: `enterMap`, `bumpEdge`, `passportFull`, each able diff --git a/packages/kai/src/data.test.ts b/packages/kai/src/data.test.ts new file mode 100644 index 0000000..8b62b87 --- /dev/null +++ b/packages/kai/src/data.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_VOICE, GameData, type GameContent } from "./data.ts"; + +const voice = { wave: "sine" as const, pitch: 300, variance: 0, volume: 0.1, every: 2 }; +const data = new GameData({ + places: [ + { id: "dock", name: "Dock", stamp: false, entrance: { map: "town", spawn: "ferry" } }, + { id: "shop", name: "Shop", stamp: true, entrance: { map: "town", spawn: "shop_door" } }, + { id: "hall", name: "Hall", stamp: true }, + ], + npcs: { clerk: { name: "Clerk", place: "shop", voice }, mayor: { name: "Mayor", place: "hall", voice }, sailor: { name: "Sailor", place: "dock", voice } }, + achievements: { list: [{ id: "all", name: "All of it", how: "Everything." }] }, + unlocks: { gate: { stamps: "all" } }, + music: { + tracks: { theme: { file: "a" }, night: { file: "b" }, snow: { file: "c" }, work: { file: "d" } }, + playlist: { title: "theme", credits: "theme", outdoors: { day: "theme", night: "night", winter: "snow" }, indoors: { default: "work", maps: {} } }, + }, + strings: { "passport.title": "The Book" }, +} as unknown as GameContent); + +describe("GameData", () => { + it("stamps the places that give one, through their NPC", () => { + expect(data.stampPlaces.map((p) => p.id)).toEqual(["shop", "hall"]); + expect(data.stampForNpc("clerk")).toBe("shop"); + expect(data.stampForNpc("sailor")).toBeNull(); + const one = data.awardStamp([], "shop"); + expect(one).toEqual({ stamps: ["shop"], newStamp: "shop", complete: false }); + expect(data.awardStamp(one.stamps, "hall").complete).toBe(true); + }); + + it("opens an unlock with every stamp", () => { + expect(data.isUnlocked("gate", { hasStamp: (p) => p === "shop" })).toBe(false); + expect(data.isUnlocked("gate", { hasStamp: () => true })).toBe(true); + expect(data.isUnlocked("nowhere", { hasStamp: () => true })).toBe(false); + }); + + it("finds deep-linked places with an entrance only", () => { + expect(data.placeFromSearch("?at=Shop")?.id).toBe("shop"); + expect(data.placeFromSearch("?at=hall")).toBeNull(); + expect(data.placeFromSearch("?at=constructor")).toBeNull(); + }); + + it("picks the track by the playlist", () => { + const town = { scene: "world" as const, map: "town", outdoors: true, phase: "day" as const, season: "summer" as const, night: false }; + expect(data.trackFor(town)).toBe("theme"); + expect(data.trackFor({ ...town, season: "winter" })).toBe("snow"); + expect(data.trackFor({ ...town, night: true })).toBe("night"); + expect(data.trackFor({ ...town, outdoors: false })).toBe("work"); + }); + + it("overrides the engine's words and gives voices", () => { + expect(data.t("passport.title")).toBe("The Book"); + expect(data.t("toast.stamped", { place: "Shop", count: 1, total: 2 })).toBe("Stamped: Shop 1/2"); + expect(data.voiceFor("clerk")).toBe(voice); + expect(data.voiceFor(null)).toBe(DEFAULT_VOICE); + }); +}); diff --git a/packages/kai/src/plugins/triggers.test.ts b/packages/kai/src/plugins/triggers.test.ts new file mode 100644 index 0000000..d7cf97d --- /dev/null +++ b/packages/kai/src/plugins/triggers.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { GameData, type GameContent } from "../data.ts"; +import type { Trigger } from "../schema/engine.ts"; +import { perWorld, type World } from "./api.ts"; +import { triggersPlugin } from "./triggers.ts"; + +const content = { + places: [ + { id: "a", name: "A", stamp: true }, + { id: "b", name: "B", stamp: true }, + ], + unlocks: {}, + npcs: {}, + strings: {}, +} as unknown as GameContent; + +/** A world with a clock you move by hand, recording what the triggers do. */ +function fakeWorld(map = "town", stamps: string[] = []) { + const log: string[] = []; + const timers: { at: number; fn: () => void }[] = []; + const clock = { now: 0 }; + const flags: Record = {}; + const world = { + map, + dialogueOpen: false, + scene: { time: { get now() { return clock.now; }, delayedCall: (ms: number, fn: () => void) => void timers.push({ at: clock.now + ms, fn }) } }, + services: { data: new GameData(content) }, + progress: { flags, hasStamp: (place: string) => stamps.includes(place) }, + playKnot: (knot: string, _npc: unknown, onEnd: () => void) => { + log.push(`knot:${knot}`); + onEnd(); + }, + achieve: (id: string, announce = true) => void log.push(`achieve:${id}${announce ? "" : " (quiet)"}`), + save: () => void log.push("save"), + } as unknown as World; + const advance = (ms: number) => { + clock.now += ms; + for (const t of timers.splice(0)) (t.at <= clock.now ? t.fn() : timers.push(t)); + }; + return { world, log, flags, advance }; +} + +const triggers = (list: unknown[]) => triggersPlugin(list as Trigger[]); + +describe("triggers", () => { + it("fire on arriving at their map, after the delay", () => { + const plugin = triggers([{ on: "enterMap", map: "mountain", delay: 600, grant: "summit", announce: true }]); + const town = fakeWorld("town"); + plugin.mapCreated!(town.world); + town.advance(1000); + expect(town.log).toEqual([]); + + const mountain = fakeWorld("mountain"); + plugin.mapCreated!(mountain.world); + mountain.advance(500); + expect(mountain.log).toEqual([]); + mountain.advance(100); + expect(mountain.log).toEqual(["achieve:summit"]); + }); + + it("play their knot, then grant, and keep quiet for the cooldown", () => { + const plugin = triggers([{ on: "bumpEdge", cooldown: 20_000, knot: "edge", grant: "edge", announce: true }]); + const { world, log, advance } = fakeWorld(); + plugin.bumpedEdge!(world); + plugin.bumpedEdge!(world); + expect(log).toEqual(["knot:edge", "achieve:edge"]); + advance(20_000); + plugin.bumpedEdge!(world); + expect(log).toHaveLength(4); + }); + + it("check a full passport on every map and after the last stamp, quietly when asked", () => { + const plugin = triggers([{ on: "passportFull", grant: "passport", announce: false }]); + const half = fakeWorld("town", ["a"]); + plugin.mapCreated!(half.world); + expect(half.log).toEqual([]); + const full = fakeWorld("town", ["a", "b"]); + plugin.stamped!(full.world, "b", true); + expect(full.log).toEqual(["achieve:passport (quiet)"]); + }); + + it("set a flag and save when they grant nothing", () => { + const plugin = triggers([{ on: "enterMap", map: "town", delay: 0, flag: "visited", announce: true }]); + const { world, log, flags, advance } = fakeWorld(); + plugin.mapCreated!(world); + advance(0); + expect(flags.visited).toBe(true); + expect(log).toEqual(["save"]); + }); +}); + +describe("perWorld", () => { + it("keeps one state per world", () => { + const count = perWorld(() => ({ n: 0 })); + const a = {} as World; + const b = {} as World; + count(a).n++; + count(a).n++; + expect(count(a).n).toBe(2); + expect(count(b).n).toBe(0); + }); +}); From fb3c8128e0511799d6b61902eae313b994a02c1e Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:12:28 +0200 Subject: [PATCH 134/153] feat(kai-next): host a kai game in next with hooks, live data and the world socket useKaiGame and useMenuKeys carry the title screen's lifecycle and keyboard; the site keeps its look. LiveDataScript embeds the live payload, worldSocketHandler serves the ghost rooms, and cached wrappers sit over kai-live's GitHub and weather fetchers, which no longer know about Next. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/app/api/world/ws/route.ts | 48 +--------- apps/datagutt/components/game/GameShell.tsx | 90 ++++--------------- .../components/game/WorldStateScript.tsx | 7 +- apps/datagutt/lib/weather.ts | 26 +----- apps/datagutt/lib/world-state.ts | 7 +- apps/datagutt/package.json | 2 +- bun.lock | 27 +++++- docs/kai/PLAN.md | 8 +- packages/kai-live/package.json | 3 +- .../kai-live/src/github/fetch.ts | 69 ++++---------- packages/kai-next/eslint.config.js | 3 + packages/kai-next/package.json | 37 ++++++++ packages/kai-next/src/LiveDataScript.tsx | 10 +++ packages/kai-next/src/github.ts | 30 +++++++ packages/kai-next/src/useKaiGame.ts | 68 ++++++++++++++ packages/kai-next/src/useMenuKeys.ts | 45 ++++++++++ packages/kai-next/src/weather.ts | 21 +++++ packages/kai-next/src/worldSocket.ts | 56 ++++++++++++ packages/kai-next/tsconfig.json | 7 ++ 19 files changed, 362 insertions(+), 202 deletions(-) rename apps/datagutt/lib/github.ts => packages/kai-live/src/github/fetch.ts (58%) create mode 100644 packages/kai-next/eslint.config.js create mode 100644 packages/kai-next/package.json create mode 100644 packages/kai-next/src/LiveDataScript.tsx create mode 100644 packages/kai-next/src/github.ts create mode 100644 packages/kai-next/src/useKaiGame.ts create mode 100644 packages/kai-next/src/useMenuKeys.ts create mode 100644 packages/kai-next/src/weather.ts create mode 100644 packages/kai-next/src/worldSocket.ts create mode 100644 packages/kai-next/tsconfig.json diff --git a/apps/datagutt/app/api/world/ws/route.ts b/apps/datagutt/app/api/world/ws/route.ts index 2eb7819..ffa5152 100644 --- a/apps/datagutt/app/api/world/ws/route.ts +++ b/apps/datagutt/app/api/world/ws/route.ts @@ -1,49 +1,5 @@ -import { experimental_upgradeWebSocket, type WebSocketData } from "@vercel/functions"; -import { connection } from "next/server"; -import { WorldRooms } from "@datagutt/kai-net/rooms"; +import { worldSocketHandler } from "@datagutt/kai-next/world-socket"; export const maxDuration = 300; -// A ping every 30 s keeps proxies and NATs from dropping idle connections; the browser -// answers at the protocol level. -const PING_INTERVAL_MS = 30_000; - -declare global { - // One set of rooms per server instance, across module reloads. - var __worldRooms: WorldRooms | undefined; -} -const rooms = (globalThis.__worldRooms ??= new WorldRooms()); - -function toText(data: WebSocketData): string { - if (typeof data === "string") return data; - if (Buffer.isBuffer(data)) return data.toString("utf8"); - if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); - return Buffer.from(data as ArrayBuffer).toString("utf8"); -} - -export async function GET() { - // cacheComponents is on: opt out of prerendering so the upgrade runs per request. - await connection(); - - return experimental_upgradeWebSocket( - (ws) => { - const socket = { send: (text: string) => ws.send(text) }; - rooms.connect(socket); - const ping = setInterval(() => { - try { - ws.ping(); - } catch { - // Closing anyway; the close handler cleans up. - } - }, PING_INTERVAL_MS); - const teardown = () => { - clearInterval(ping); - rooms.disconnect(socket); - }; - ws.on("message", (data: WebSocketData) => rooms.receive(socket, toText(data))); - ws.on("close", teardown); - ws.on("error", teardown); - }, - { maxPayload: 1024 }, - ); -} +export const GET = worldSocketHandler(); diff --git a/apps/datagutt/components/game/GameShell.tsx b/apps/datagutt/components/game/GameShell.tsx index 0dbbc99..7825ac4 100644 --- a/apps/datagutt/components/game/GameShell.tsx +++ b/apps/datagutt/components/game/GameShell.tsx @@ -1,14 +1,16 @@ "use client"; import Link from "next/link"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useKaiGame } from "@datagutt/kai-next/game"; +import { useMenuKeys } from "@datagutt/kai-next/menu-keys"; import { credits } from "@/content/credits"; -import type { GameHandle } from "@/game"; -type Phase = "loading" | "ready" | "playing" | "failed"; /** Where the title screen is: the "Press start" splash, its menu, or a page off the menu. */ type Screen = "splash" | "menu" | "confirm" | "credits"; +const loadGame = () => import("@/game").then((game) => game.startFjordTown); + const JOURNAL_LABEL = "Read it as a normal website"; /** @@ -18,70 +20,18 @@ const JOURNAL_LABEL = "Read it as a normal website"; */ export function GameShell({ titleArt }: { titleArt: ReactNode }) { const rootRef = useRef(null); - const containerRef = useRef(null); const menuRef = useRef(null); - const handleRef = useRef(null); - const [phase, setPhase] = useState("loading"); const [screen, setScreen] = useState("splash"); - const [progress, setProgress] = useState(0); - const [hasSave, setHasSave] = useState(false); - - useEffect(() => { - let cancelled = false; - import("@/game") - .then(({ startFjordTown }) => { - if (cancelled || !containerRef.current) return; - const handle = startFjordTown(containerRef.current, { - onProgress: (p) => setProgress(p), - onReady: () => setPhase((current) => (current === "loading" ? "ready" : current)), - }); - handleRef.current = handle; - setHasSave(handle.hasSave); - // A shared `?at=place` link goes straight into the world. - if (handle.deepLinked) setPhase("playing"); - }) - .catch((err) => { - console.error("[game] failed to load", err); - if (!cancelled) setPhase("failed"); - }); - return () => { - cancelled = true; - handleRef.current?.destroy(); - handleRef.current = null; - }; - }, []); - + const { containerRef, phase, progress, hasSave, start, playTitleMusic } = useKaiGame(loadGame); const playing = phase === "playing"; - // The splash takes any key; the menu moves its cursor with the arrow keys and goes back - // with Escape. Buttons do the rest, so Tab and Enter work as on any page. - useEffect(() => { - if (playing) return; - const onKey = (e: KeyboardEvent) => { - if (e.metaKey || e.ctrlKey || e.altKey) return; - if (screen === "splash") { - // Tab moves focus, and Enter on the Journal link follows it. - if (e.key === "Tab" || document.activeElement instanceof HTMLAnchorElement) return; - e.preventDefault(); - setScreen("menu"); - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setScreen(screen === "menu" ? "splash" : "menu"); - return; - } - if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; - const items = [...(menuRef.current?.querySelectorAll("[data-menu-item]") ?? [])]; - if (!items.length) return; - e.preventDefault(); - const at = items.indexOf(document.activeElement as HTMLElement); - const step = e.key === "ArrowDown" ? 1 : -1; - items[(at + step + items.length) % items.length].focus(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [playing, screen]); + useMenuKeys({ + enabled: !playing, + splash: screen === "splash", + menuRef, + openMenu: useCallback(() => setScreen("menu"), []), + back: useCallback(() => setScreen((current) => (current === "menu" ? "splash" : "menu")), []), + }); // Each page of the menu starts with its first item under the cursor. useEffect(() => { @@ -93,8 +43,8 @@ export function GameShell({ titleArt }: { titleArt: ReactNode }) { // Press start is the page's first gesture, and browsers play sound only after one, so the // title music starts with the menu (or once the game has loaded, if that comes later). useEffect(() => { - if (screen !== "splash" && phase !== "playing") handleRef.current?.playTitleMusic(); - }, [screen, phase]); + if (screen !== "splash" && phase !== "playing") playTitleMusic(); + }, [screen, phase, playTitleMusic]); // Parallax: the backdrop's layers lean away from the pointer (TitleArt.tsx). useEffect(() => { @@ -109,13 +59,9 @@ export function GameShell({ titleArt }: { titleArt: ReactNode }) { return () => window.removeEventListener("pointermove", onMove); }, [playing]); - const enter = (fresh: boolean) => { - // Focus moves from the menu to the game: the game ignores keys while a page control - // has focus, and Tab from the game goes on to the Journal link after it. - containerRef.current?.focus({ preventScroll: true }); - handleRef.current?.start({ fresh }); - setPhase("playing"); - }; + // Focus moves from the menu to the game, and Tab from the game goes on to the Journal + // link after it. + const enter = (fresh: boolean) => start(fresh); const ready = phase === "ready"; const loadingLabel = phase === "failed" ? "Could not load" : `Loading ${Math.round(progress * 100)}%`; diff --git a/apps/datagutt/components/game/WorldStateScript.tsx b/apps/datagutt/components/game/WorldStateScript.tsx index 9d55ad6..0d1c7dd 100644 --- a/apps/datagutt/components/game/WorldStateScript.tsx +++ b/apps/datagutt/components/game/WorldStateScript.tsx @@ -1,5 +1,6 @@ import { headers } from "next/headers"; -import { WORLD_STATE_ELEMENT_ID, type WorldState } from "@datagutt/kai-live"; +import type { WorldState } from "@datagutt/kai-live"; +import { LiveDataScript } from "@datagutt/kai-next/live-data"; import { getWeather, visitorPlace } from "@/lib/weather"; import { getWorldState } from "@/lib/world-state"; @@ -12,7 +13,5 @@ export async function WorldStateScript() { const place = visitorPlace(await headers()); const [world, weather] = await Promise.all([getWorldState(), getWeather(place)]); const state: WorldState = { ...world, weather }; - // Escape "<" so repo descriptions can never close the script tag. - const json = JSON.stringify(state).replace(/; + return ; } diff --git a/apps/datagutt/lib/weather.ts b/apps/datagutt/lib/weather.ts index 29d87ad..d783e26 100644 --- a/apps/datagutt/lib/weather.ts +++ b/apps/datagutt/lib/weather.ts @@ -1,5 +1,5 @@ -import { cacheLife, cacheTag } from "next/cache"; -import { fetchWeather, placeFromHeaders, type WeatherNow, type WeatherPlace } from "@datagutt/kai-live"; +import { placeFromHeaders, type WeatherNow, type WeatherPlace } from "@datagutt/kai-live"; +import { getWeather as cachedWeather } from "@datagutt/kai-next/weather"; import { FALLBACK_PLACE } from "@/content/live"; import { kaiConfig } from "@/lib/kai"; @@ -7,26 +7,6 @@ import { kaiConfig } from "@/lib/kai"; // IP location headers. Without those (local runs, or a place Vercel can't tell) the sky // is Oslo's. -const USER_AGENT = kaiConfig.live.weather.userAgent; - -/** - * How long a good forecast is kept. MET updates hourly and asks clients not to poll more - * than they need; half an hour keeps the sky current without that. Unvisited for two - * hours, the next visitor waits for a fresh one rather than seeing a stale sky. - */ -const WEATHER_CACHE_LIFE = { stale: 300, revalidate: 1_800, expire: 7_200 }; - export const visitorPlace = (headers: Pick): WeatherPlace => placeFromHeaders(headers, FALLBACK_PLACE); -/** - * The weather now at a place, cached per place (the arguments are the cache key). Half an - * hour on success; minutes after a failure, which gets the calm fallback. - */ -export async function getWeather(place: WeatherPlace = FALLBACK_PLACE): Promise { - "use cache"; - cacheTag("weather"); - const weather = await fetchWeather(place, USER_AGENT); - if (weather.live) cacheLife(WEATHER_CACHE_LIFE); - else cacheLife("minutes"); - return weather; -} +export const getWeather = (place: WeatherPlace = FALLBACK_PLACE): Promise => cachedWeather(place, kaiConfig.live.weather.userAgent); diff --git a/apps/datagutt/lib/world-state.ts b/apps/datagutt/lib/world-state.ts index 9ade1ce..24f5d60 100644 --- a/apps/datagutt/lib/world-state.ts +++ b/apps/datagutt/lib/world-state.ts @@ -1,6 +1,8 @@ import { cacheLife, cacheTag } from "next/cache"; import type { WorldState } from "@datagutt/kai-live"; -import { getContributions, getGitHubStats, getPinnedRepos } from "./github"; +import { getContributions, getGitHubStats, getPinnedRepos } from "@datagutt/kai-next/github"; +import { profile } from "@/content/profile"; +import { kaiConfig } from "@/lib/kai"; import { getDiscordId } from "./lanyard"; /** @@ -12,7 +14,8 @@ import { getDiscordId } from "./lanyard"; export async function getWorldState(): Promise> { "use cache"; cacheTag("github"); - const [repos, stats, contributions] = await Promise.all([getPinnedRepos(), getGitHubStats(), getContributions()]); + const user = kaiConfig.live.github.user; + const [repos, stats, contributions] = await Promise.all([getPinnedRepos(user), getGitHubStats(user, profile.codingSince), getContributions(user)]); const complete = repos.length > 0 && stats.public_repos > 0 && contributions.length > 0; if (complete) cacheLife("hours"); else cacheLife("minutes"); diff --git a/apps/datagutt/package.json b/apps/datagutt/package.json index 1860707..e112b69 100644 --- a/apps/datagutt/package.json +++ b/apps/datagutt/package.json @@ -28,13 +28,13 @@ "@datagutt/kai-arcade": "workspace:*", "@datagutt/kai-live": "workspace:*", "@datagutt/kai-net": "workspace:*", + "@datagutt/kai-next": "workspace:*", "@vercel/functions": "^3.7.3", "babel-plugin-react-compiler": "1.0.0", "critters": "^0.0.25", "geist": "^1.7.0", "inkjs": "^2.4.0", "next": "16.3.6", - "node-html-parser": "^7.0.2", "phaser": "^4.2.1", "react": "19.3.0", "react-dom": "19.3.0", diff --git a/bun.lock b/bun.lock index f047392..2749cc2 100644 --- a/bun.lock +++ b/bun.lock @@ -18,13 +18,13 @@ "@datagutt/kai-arcade": "workspace:*", "@datagutt/kai-live": "workspace:*", "@datagutt/kai-net": "workspace:*", + "@datagutt/kai-next": "workspace:*", "@vercel/functions": "^3.7.3", "babel-plugin-react-compiler": "1.0.0", "critters": "^0.0.25", "geist": "^1.7.0", "inkjs": "^2.4.0", "next": "16.3.6", - "node-html-parser": "^7.0.2", "phaser": "^4.2.1", "react": "19.3.0", "react-dom": "19.3.0", @@ -145,6 +145,7 @@ "dependencies": { "@datagutt/kai": "workspace:*", "@datagutt/kai-net": "workspace:*", + "node-html-parser": "^7.0.2", }, "devDependencies": { "@datagutt/kai-eslint-config": "workspace:*", @@ -176,6 +177,28 @@ "vitest": "^5", }, }, + "packages/kai-next": { + "name": "@datagutt/kai-next", + "version": "0.0.0", + "dependencies": { + "@datagutt/kai": "workspace:*", + "@datagutt/kai-live": "workspace:*", + "@datagutt/kai-net": "workspace:*", + }, + "devDependencies": { + "@datagutt/kai-eslint-config": "workspace:*", + "@datagutt/kai-tsconfig": "workspace:*", + "@types/node": "^24.13.6", + "@types/react": "19.3.0", + "eslint": "^9", + "typescript": "^5.9.3", + }, + "peerDependencies": { + "@vercel/functions": "^3.7.3", + "next": "16.3.6", + "react": "19.3.0", + }, + }, "packages/kai-worldgen": { "name": "@datagutt/kai-worldgen", "version": "0.0.0", @@ -269,6 +292,8 @@ "@datagutt/kai-net": ["@datagutt/kai-net@workspace:packages/kai-net"], + "@datagutt/kai-next": ["@datagutt/kai-next@workspace:packages/kai-next"], + "@datagutt/kai-tsconfig": ["@datagutt/kai-tsconfig@workspace:tooling/tsconfig"], "@datagutt/kai-vitest-config": ["@datagutt/kai-vitest-config@workspace:tooling/vitest-config"], diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 83cd6af..c4e64a8 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -185,9 +185,13 @@ it first would make the package import app code. Goal: a second app proves the engine needs nothing from datagutt. -- [ ] **K6.1** `@datagutt/kai-next`: the game shell core, the world state script, the +- [x] **K6.1** `@datagutt/kai-next`: the game shell core, the world state script, the WebSocket route helper, caching wrappers. *Done when* the datagutt app keeps only - its title art, Journal and site pages. + its title art, Journal and site pages. (`useKaiGame` and `useMenuKeys` are the + shell's core; the title screen's look stays the site's. `LiveDataScript`, + `worldSocketHandler`, and `'use cache'` wrappers over `kai-live`'s now + framework-free GitHub and MET fetchers. The app keeps its choice of data: which + GitHub user, the fallback place.) - [ ] **K6.2** `apps/sandbox`: one generated map, one NPC, one Ink knot, one trigger, placeholder art, booted by the kai dev harness. *Done when* it runs without the assets token. diff --git a/packages/kai-live/package.json b/packages/kai-live/package.json index 8c277f2..71fa302 100644 --- a/packages/kai-live/package.json +++ b/packages/kai-live/package.json @@ -15,7 +15,8 @@ }, "dependencies": { "@datagutt/kai": "workspace:*", - "@datagutt/kai-net": "workspace:*" + "@datagutt/kai-net": "workspace:*", + "node-html-parser": "^7.0.2" }, "devDependencies": { "@datagutt/kai-eslint-config": "workspace:*", diff --git a/apps/datagutt/lib/github.ts b/packages/kai-live/src/github/fetch.ts similarity index 58% rename from apps/datagutt/lib/github.ts rename to packages/kai-live/src/github/fetch.ts index cc03b01..960f9df 100644 --- a/apps/datagutt/lib/github.ts +++ b/packages/kai-live/src/github/fetch.ts @@ -1,19 +1,14 @@ -import { cacheLife, cacheTag } from "next/cache"; +// A GitHub user's live data: pinned repos (scraped from the profile page, which the API +// does not offer), stats from the REST API, and the contribution calendar from a public +// mirror. Framework free: a host caches the results (@datagutt/kai-next/github). import { parse } from "node-html-parser"; -import type { ContributionDay, GitHubStats, PinnedRepo } from "@datagutt/kai-live"; -import { kaiConfig } from "@/lib/kai"; -import { profile } from "@/content/profile"; - -const GITHUB_USERNAME = kaiConfig.live.github.user; -const YEARS_CODING_SINCE = profile.codingSince; - -export type { PinnedRepo, GitHubStats, ContributionDay }; +import type { ContributionDay, GitHubStats, PinnedRepo } from "../github.ts"; /** - * Each fetcher reports whether it got real data. Successes are cached for hours; - * failures only for minutes, so one network hiccup doesn't blank the site for an hour. + * What a fetch got, and whether it was real data: a host keeps a success for long and a + * failure only briefly, so one network hiccup doesn't blank the site for an hour. */ -type Fetched = { ok: boolean; data: T }; +export type Fetched = { ok: boolean; data: T }; function warn(what: string, err: unknown) { const cause = err instanceof Error ? (err.cause ?? err.message) : err; @@ -22,10 +17,10 @@ function warn(what: string, err: unknown) { // --- Pinned repos (scraped from GitHub profile) --- -async function fetchPinnedRepos(): Promise> { +export async function fetchPinnedRepos(user: string): Promise> { let html: string; try { - const res = await fetch(`https://github.com/${GITHUB_USERNAME}`, { + const res = await fetch(`https://github.com/${user}`, { headers: { "User-Agent": "Mozilla/5.0" }, }); if (!res.ok) { @@ -86,43 +81,35 @@ async function fetchPinnedRepos(): Promise> { return { ok: true, data: repos }; } -export async function getPinnedRepos(): Promise { - "use cache"; - cacheTag("github"); - const result = await fetchPinnedRepos(); - if (result.ok) cacheLife("hours"); - else cacheLife("minutes"); - return result.data; -} - // --- GitHub stats --- -async function fetchGitHubStats(): Promise> { +/** `codingSince`: the year the user started, for `years_coding`. */ +export async function fetchGitHubStats(user: string, codingSince: number): Promise> { const defaults: GitHubStats = { public_repos: 0, followers: 0, total_stars: 0, - years_coding: new Date().getFullYear() - YEARS_CODING_SINCE, + years_coding: new Date().getFullYear() - codingSince, }; try { const [userRes, reposRes] = await Promise.all([ - fetch(`https://api.github.com/users/${GITHUB_USERNAME}`), - fetch(`https://api.github.com/users/${GITHUB_USERNAME}/repos?per_page=100`), + fetch(`https://api.github.com/users/${user}`), + fetch(`https://api.github.com/users/${user}/repos?per_page=100`), ]); if (!userRes.ok || !reposRes.ok) { warn("stats", `HTTP ${userRes.status}/${reposRes.status}`); return { ok: false, data: defaults }; } - const user = await userRes.json(); + const account = await userRes.json(); const repos: { stargazers_count: number }[] = await reposRes.json(); const total_stars = repos.reduce((sum, r) => sum + r.stargazers_count, 0); return { ok: true, data: { - public_repos: user.public_repos ?? defaults.public_repos, - followers: user.followers ?? defaults.followers, + public_repos: account.public_repos ?? defaults.public_repos, + followers: account.followers ?? defaults.followers, total_stars, years_coding: defaults.years_coding, }, @@ -133,21 +120,12 @@ async function fetchGitHubStats(): Promise> { } } -export async function getGitHubStats(): Promise { - "use cache"; - cacheTag("github"); - const result = await fetchGitHubStats(); - if (result.ok) cacheLife("hours"); - else cacheLife("minutes"); - return result.data; -} - // --- Contributions --- -async function fetchContributions(): Promise> { +export async function fetchContributions(user: string): Promise> { try { const res = await fetch( - `https://github-contributions-api.jogruber.de/v4/${GITHUB_USERNAME}?y=last`, + `https://github-contributions-api.jogruber.de/v4/${user}?y=last`, ); if (!res.ok) { warn("contributions", `HTTP ${res.status}`); @@ -160,12 +138,3 @@ async function fetchContributions(): Promise> { return { ok: false, data: [] }; } } - -export async function getContributions(): Promise { - "use cache"; - cacheTag("github"); - const result = await fetchContributions(); - if (result.ok) cacheLife("hours"); - else cacheLife("minutes"); - return result.data; -} diff --git a/packages/kai-next/eslint.config.js b/packages/kai-next/eslint.config.js new file mode 100644 index 0000000..2edc4f1 --- /dev/null +++ b/packages/kai-next/eslint.config.js @@ -0,0 +1,3 @@ +import next from "@datagutt/kai-eslint-config/next"; + +export default next; diff --git a/packages/kai-next/package.json b/packages/kai-next/package.json new file mode 100644 index 0000000..5493195 --- /dev/null +++ b/packages/kai-next/package.json @@ -0,0 +1,37 @@ +{ + "name": "@datagutt/kai-next", + "version": "0.0.0", + "private": true, + "license": "GPL-3.0-or-later", + "type": "module", + "exports": { + "./game": "./src/useKaiGame.ts", + "./menu-keys": "./src/useMenuKeys.ts", + "./live-data": "./src/LiveDataScript.tsx", + "./github": "./src/github.ts", + "./weather": "./src/weather.ts", + "./world-socket": "./src/worldSocket.ts" + }, + "scripts": { + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@datagutt/kai": "workspace:*", + "@datagutt/kai-live": "workspace:*", + "@datagutt/kai-net": "workspace:*" + }, + "peerDependencies": { + "@vercel/functions": "^3.7.3", + "next": "16.3.6", + "react": "19.3.0" + }, + "devDependencies": { + "@datagutt/kai-eslint-config": "workspace:*", + "@datagutt/kai-tsconfig": "workspace:*", + "@types/node": "^24.13.6", + "@types/react": "19.3.0", + "eslint": "^9", + "typescript": "^5.9.3" + } +} diff --git a/packages/kai-next/src/LiveDataScript.tsx b/packages/kai-next/src/LiveDataScript.tsx new file mode 100644 index 0000000..3671ccb --- /dev/null +++ b/packages/kai-next/src/LiveDataScript.tsx @@ -0,0 +1,10 @@ +import { WORLD_STATE_ELEMENT_ID } from "@datagutt/kai-live"; + +/** + * Embeds live data as JSON for the game to read at boot (@datagutt/kai-live + * readWorldState). "<" is escaped so no value (a repo description, say) can close the tag. + */ +export function LiveDataScript({ data }: { data: object }) { + const json = JSON.stringify(data).replace(/; +} diff --git a/packages/kai-next/src/github.ts b/packages/kai-next/src/github.ts new file mode 100644 index 0000000..5b25aa2 --- /dev/null +++ b/packages/kai-next/src/github.ts @@ -0,0 +1,30 @@ +// The GitHub fetchers of @datagutt/kai-live, cached by Next: hours after a success, +// minutes after a failure, so a failed fetch is retried soon. The arguments are the cache +// key. Failures are logged as `[github] … failed`. +import { cacheLife, cacheTag } from "next/cache"; +import type { ContributionDay, GitHubStats, PinnedRepo } from "@datagutt/kai-live"; +import { fetchContributions, fetchGitHubStats, fetchPinnedRepos, type Fetched } from "@datagutt/kai-live/github/fetch"; + +function keep(result: Fetched): T { + if (result.ok) cacheLife("hours"); + else cacheLife("minutes"); + return result.data; +} + +export async function getPinnedRepos(user: string): Promise { + "use cache"; + cacheTag("github"); + return keep(await fetchPinnedRepos(user)); +} + +export async function getGitHubStats(user: string, codingSince: number): Promise { + "use cache"; + cacheTag("github"); + return keep(await fetchGitHubStats(user, codingSince)); +} + +export async function getContributions(user: string): Promise { + "use cache"; + cacheTag("github"); + return keep(await fetchContributions(user)); +} diff --git a/packages/kai-next/src/useKaiGame.ts b/packages/kai-next/src/useKaiGame.ts new file mode 100644 index 0000000..2d73daa --- /dev/null +++ b/packages/kai-next/src/useKaiGame.ts @@ -0,0 +1,68 @@ +"use client"; + +// A kai game behind a React page: the game module downloads as soon as the page is +// interactive (dynamic import), and the page shows its own title screen meanwhile. The +// hook reports loading, and starts or continues the game when the page asks. +import { useEffect, useRef, useState } from "react"; +import type { BootOptions, GameHandle } from "@datagutt/kai"; + +export type GamePhase = "loading" | "ready" | "playing" | "failed"; + +/** A game's own start function (such as Fjord Town's startFjordTown). */ +export type StartGame = (parent: HTMLElement, options: Pick) => GameHandle; + +/** `load` imports the game module; keep it stable (a module-level function). */ +export function useKaiGame(load: () => Promise) { + const containerRef = useRef(null); + const handleRef = useRef(null); + const [phase, setPhase] = useState("loading"); + const [progress, setProgress] = useState(0); + const [hasSave, setHasSave] = useState(false); + + useEffect(() => { + let cancelled = false; + load() + .then((startGame) => { + if (cancelled || !containerRef.current) return; + const handle = startGame(containerRef.current, { + onProgress: (p) => setProgress(p), + onReady: () => setPhase((current) => (current === "loading" ? "ready" : current)), + }); + handleRef.current = handle; + setHasSave(handle.hasSave); + // A shared deep link goes straight into the world. + if (handle.deepLinked) setPhase("playing"); + }) + .catch((err) => { + console.error("[game] failed to load", err); + if (!cancelled) setPhase("failed"); + }); + return () => { + cancelled = true; + handleRef.current?.destroy(); + handleRef.current = null; + }; + }, [load]); + + return { + /** The element the game draws into. */ + containerRef, + phase, + /** Loading progress from 0 to 1. */ + progress, + hasSave, + /** + * Enter the world; `fresh` forgets the save first. Focus moves to the game: it ignores + * keys while a page control has focus. + */ + start(fresh: boolean) { + containerRef.current?.focus({ preventScroll: true }); + handleRef.current?.start({ fresh }); + setPhase("playing"); + }, + /** Call it from the title's first gesture: browsers play sound only after one. */ + playTitleMusic() { + handleRef.current?.playTitleMusic(); + }, + }; +} diff --git a/packages/kai-next/src/useMenuKeys.ts b/packages/kai-next/src/useMenuKeys.ts new file mode 100644 index 0000000..216f162 --- /dev/null +++ b/packages/kai-next/src/useMenuKeys.ts @@ -0,0 +1,45 @@ +"use client"; + +// Keyboard for a title screen: on the splash any key opens the menu; in the menu the arrow +// keys move between items marked `data-menu-item` and Escape goes back. Buttons and links +// do the rest, so Tab and Enter work as on any page. +import { useEffect, type RefObject } from "react"; + +export function useMenuKeys(options: { + /** Off while the game is being played. */ + enabled: boolean; + /** The splash ("Press start") is showing. */ + splash: boolean; + menuRef: RefObject; + openMenu(): void; + back(): void; +}) { + const { enabled, splash, menuRef, openMenu, back } = options; + useEffect(() => { + if (!enabled) return; + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (splash) { + // Tab moves focus, and Enter on a focused link follows it. + if (e.key === "Tab" || document.activeElement instanceof HTMLAnchorElement) return; + e.preventDefault(); + openMenu(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + back(); + return; + } + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + const items = [...(menuRef.current?.querySelectorAll("[data-menu-item]") ?? [])]; + if (!items.length) return; + e.preventDefault(); + const at = items.indexOf(document.activeElement as HTMLElement); + const step = e.key === "ArrowDown" ? 1 : -1; + items[(at + step + items.length) % items.length].focus(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [enabled, splash, menuRef, openMenu, back]); +} diff --git a/packages/kai-next/src/weather.ts b/packages/kai-next/src/weather.ts new file mode 100644 index 0000000..72daf30 --- /dev/null +++ b/packages/kai-next/src/weather.ts @@ -0,0 +1,21 @@ +// The weather at a place (@datagutt/kai-live's MET Norway fetcher), cached by Next per +// place: half an hour on success, minutes after a failure, which gets calm weather. +import { cacheLife, cacheTag } from "next/cache"; +import { fetchWeather, type WeatherNow, type WeatherPlace } from "@datagutt/kai-live"; + +/** + * MET updates hourly and asks clients not to poll more than they need; half an hour keeps + * the sky current without that. Unvisited for two hours, the next visitor waits for a + * fresh forecast rather than seeing a stale sky. + */ +const WEATHER_CACHE_LIFE = { stale: 300, revalidate: 1_800, expire: 7_200 }; + +/** `userAgent`: MET's terms require one that names the site and a contact. */ +export async function getWeather(place: WeatherPlace, userAgent: string): Promise { + "use cache"; + cacheTag("weather"); + const weather = await fetchWeather(place, userAgent); + if (weather.live) cacheLife(WEATHER_CACHE_LIFE); + else cacheLife("minutes"); + return weather; +} diff --git a/packages/kai-next/src/worldSocket.ts b/packages/kai-next/src/worldSocket.ts new file mode 100644 index 0000000..62a73fe --- /dev/null +++ b/packages/kai-next/src/worldSocket.ts @@ -0,0 +1,56 @@ +// The world socket (@datagutt/kai-net rooms) as a Next route handler on Vercel's WebSocket +// upgrade. A route file re-exports it with its own static segment config: +// +// export const maxDuration = 300; +// export const GET = worldSocketHandler(); +// +// `next dev` and `next start` can't upgrade; the kai dev harness serves the same rooms. +import { experimental_upgradeWebSocket, type WebSocketData } from "@vercel/functions"; +import { connection } from "next/server"; +import { WorldRooms } from "@datagutt/kai-net/rooms"; + +// A ping every 30 s keeps proxies and NATs from dropping idle connections; the browser +// answers at the protocol level. +const PING_INTERVAL_MS = 30_000; + +declare global { + // One set of rooms per server instance, across module reloads. + var __worldRooms: WorldRooms | undefined; +} + +function toText(data: WebSocketData): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return Buffer.from(data as ArrayBuffer).toString("utf8"); +} + +export function worldSocketHandler() { + const rooms = (globalThis.__worldRooms ??= new WorldRooms()); + return async function GET() { + // With cacheComponents on, opt out of prerendering so the upgrade runs per request. + await connection(); + + return experimental_upgradeWebSocket( + (ws) => { + const socket = { send: (text: string) => ws.send(text) }; + rooms.connect(socket); + const ping = setInterval(() => { + try { + ws.ping(); + } catch { + // Closing anyway; the close handler cleans up. + } + }, PING_INTERVAL_MS); + const teardown = () => { + clearInterval(ping); + rooms.disconnect(socket); + }; + ws.on("message", (data: WebSocketData) => rooms.receive(socket, toText(data))); + ws.on("close", teardown); + ws.on("error", teardown); + }, + { maxPayload: 1024 }, + ); + }; +} diff --git a/packages/kai-next/tsconfig.json b/packages/kai-next/tsconfig.json new file mode 100644 index 0000000..532f190 --- /dev/null +++ b/packages/kai-next/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@datagutt/kai-tsconfig/base.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["src"] +} From f533e0d2d8cf21501726093733041a2681c903cb Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:25:24 +0200 Subject: [PATCH 135/153] fix(kai-assets): count visits to every ink knot The runtime decides that a conversation reached an NPC's own knot by its visit count, but Ink only counts knots the story refers to. Fjord Town's knots happened to be referenced; a new game's were not, so talking to its NPCs never gave a stamp. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/game/dialogue/DialogueRunner.test.ts | 3 ++- packages/kai-assets/src/build/ink.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/datagutt/game/dialogue/DialogueRunner.test.ts b/apps/datagutt/game/dialogue/DialogueRunner.test.ts index f2a366a..11c9522 100644 --- a/apps/datagutt/game/dialogue/DialogueRunner.test.ts +++ b/apps/datagutt/game/dialogue/DialogueRunner.test.ts @@ -19,7 +19,8 @@ beforeAll(() => { name === "__externals.ink" ? externalDeclarations() : fs.readFileSync(path.join(dir, name), "utf8"), }; const main = `INCLUDE __externals.ink\n${fs.readFileSync(path.join(dir, "main.ink"), "utf8")}`; - json = new Compiler(main, new CompilerOptions("main.ink", [], false, null, fileHandler)).Compile().ToJson()!; + // As the asset build compiles it: every knot's visits counted. + json = new Compiler(main, new CompilerOptions("main.ink", [], true, null, fileHandler)).Compile().ToJson()!; }); const ctx = { world: EMPTY_WORLD_STATE, hasStamp: () => false, isUnlocked: () => false }; diff --git a/packages/kai-assets/src/build/ink.ts b/packages/kai-assets/src/build/ink.ts index b2cd3f2..69c4176 100644 --- a/packages/kai-assets/src/build/ink.ts +++ b/packages/kai-assets/src/build/ink.ts @@ -63,7 +63,9 @@ export function compileDialogue(dir: string, host: DialogueHost): CompiledDialog else if (type === 1) console.warn(`[ink] ${message}`); }; const main = `INCLUDE ${EXTERNALS_FILE}\n${fs.readFileSync(path.join(dir, "main.ink"), "utf8")}`; - const compiler = new Compiler(main, new CompilerOptions("main.ink", [], false, errorHandler, fileHandler)); + // Count visits to every knot: the runtime knows a conversation reached an NPC's own knot + // by its visit count, and Ink only counts the knots a story refers to otherwise. + const compiler = new Compiler(main, new CompilerOptions("main.ink", [], true, errorHandler, fileHandler)); let story = null; try { story = compiler.Compile(); From 3c644c68d3ebae22890b67b277de1dfec0037687 Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:25:24 +0200 Subject: [PATCH 136/153] fix(kai-assets): find the repository root by its workspaces, not turbo.json An app with its own turbo.json was taken for the repository root, so the local art checkout next to the repository was not found. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- packages/kai-assets/src/art/source.test.ts | 9 ++++++--- packages/kai-assets/src/art/source.ts | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/kai-assets/src/art/source.test.ts b/packages/kai-assets/src/art/source.test.ts index 4d6d367..a0b2204 100644 --- a/packages/kai-assets/src/art/source.test.ts +++ b/packages/kai-assets/src/art/source.test.ts @@ -57,8 +57,11 @@ describe("resolveAssetSource", () => { }); describe("findRepoRoot", () => { - it("walks up to the directory with turbo.json", () => { - const exists = (file: string) => file === "/work/datagutt/turbo.json"; - expect(findRepoRoot("/work/datagutt/apps/datagutt", exists)).toBe("/work/datagutt"); + it("walks up to the workspace root, past an app's own package.json", () => { + const packages: Record = { + "/work/datagutt/apps/sandbox/package.json": JSON.stringify({ name: "sandbox" }), + "/work/datagutt/package.json": JSON.stringify({ workspaces: ["apps/*"] }), + }; + expect(findRepoRoot("/work/datagutt/apps/sandbox", (file) => packages[file] ?? null)).toBe("/work/datagutt"); }); }); diff --git a/packages/kai-assets/src/art/source.ts b/packages/kai-assets/src/art/source.ts index 00b0373..e0c17f4 100644 --- a/packages/kai-assets/src/art/source.ts +++ b/packages/kai-assets/src/art/source.ts @@ -54,14 +54,22 @@ export function resolveAssetSource(opts: { return { mode: "placeholder", dir: null }; } -/** The monorepo root: the nearest directory above `from` that holds turbo.json. */ -export function findRepoRoot(from: string, exists: (file: string) => boolean = fs.existsSync): string { +/** + * The monorepo root: the nearest directory above `from` whose package.json declares + * `workspaces`. (Not the nearest turbo.json: an app may have its own.) + */ +export function findRepoRoot(from: string, readPackage: (file: string) => string | null = readIfExists): string { for (let dir = path.resolve(from); ; dir = path.dirname(dir)) { - if (exists(path.join(dir, "turbo.json"))) return dir; - if (path.dirname(dir) === dir) throw new Error(`No turbo.json above ${from}.`); + const text = readPackage(path.join(dir, "package.json")); + if (text && "workspaces" in JSON.parse(text)) return dir; + if (path.dirname(dir) === dir) throw new Error(`No workspace root (a package.json with "workspaces") above ${from}.`); } } +function readIfExists(file: string): string | null { + return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; +} + /** * The local art checkout for the tools that need real art (catalog, snow drafts, the * character review), or null. They never clone. From 44c9badf5592944521854fac66806801c8f96516 Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:25:26 +0200 Subject: [PATCH 137/153] feat(sandbox): add the smallest kai game as a second app One map, one NPC, one conversation, two triggers and placeholder art without the token, played by a smoke test in the dev harness. It proves the engine needs nothing from datagutt, and game 2 starts as a copy. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/content/schema.ts | 10 +- apps/sandbox/.gitignore | 4 + apps/sandbox/.kai/config.json | 39 +++ apps/sandbox/.kai/content.json | 127 ++++++++ apps/sandbox/.kai/schema/achievements.json | 37 +++ apps/sandbox/.kai/schema/characters.json | 84 +++++ apps/sandbox/.kai/schema/credits.json | 49 +++ apps/sandbox/.kai/schema/music.json | 97 ++++++ apps/sandbox/.kai/schema/npcs.json | 76 +++++ apps/sandbox/.kai/schema/places.json | 53 ++++ apps/sandbox/.kai/schema/strings.json | 187 +++++++++++ apps/sandbox/.kai/schema/triggers.json | 123 ++++++++ apps/sandbox/.kai/schema/unlocks.json | 26 ++ apps/sandbox/content/achievements.json | 7 + apps/sandbox/content/characters.json | 14 + apps/sandbox/content/credits.json | 10 + apps/sandbox/content/index.ts | 5 + apps/sandbox/content/music.json | 12 + apps/sandbox/content/npcs.json | 9 + apps/sandbox/content/places.json | 4 + apps/sandbox/content/schema.ts | 9 + apps/sandbox/content/strings.json | 4 + apps/sandbox/content/triggers.json | 7 + apps/sandbox/content/unlocks.json | 3 + apps/sandbox/dialogue/host.ts | 9 + apps/sandbox/dialogue/main.ink | 14 + apps/sandbox/e2e/smoke.spec.ts | 30 ++ apps/sandbox/eslint.config.js | 4 + apps/sandbox/kai.json | 27 ++ apps/sandbox/package.json | 33 ++ apps/sandbox/playwright.config.ts | 18 ++ apps/sandbox/src/main.ts | 16 + apps/sandbox/tsconfig.json | 5 + apps/sandbox/turbo.json | 10 + apps/sandbox/world/maps.ts | 21 ++ apps/sandbox/world/maps/workshop.tmj | 347 +++++++++++++++++++++ apps/sandbox/world/tile-colors.json | 26 ++ apps/sandbox/world/tile-ids.json | 30 ++ bun.lock | 22 ++ docs/kai/PLAN.md | 12 +- packages/kai/src/schema/engine.ts | 13 + 41 files changed, 1622 insertions(+), 11 deletions(-) create mode 100644 apps/sandbox/.gitignore create mode 100644 apps/sandbox/.kai/config.json create mode 100644 apps/sandbox/.kai/content.json create mode 100644 apps/sandbox/.kai/schema/achievements.json create mode 100644 apps/sandbox/.kai/schema/characters.json create mode 100644 apps/sandbox/.kai/schema/credits.json create mode 100644 apps/sandbox/.kai/schema/music.json create mode 100644 apps/sandbox/.kai/schema/npcs.json create mode 100644 apps/sandbox/.kai/schema/places.json create mode 100644 apps/sandbox/.kai/schema/strings.json create mode 100644 apps/sandbox/.kai/schema/triggers.json create mode 100644 apps/sandbox/.kai/schema/unlocks.json create mode 100644 apps/sandbox/content/achievements.json create mode 100644 apps/sandbox/content/characters.json create mode 100644 apps/sandbox/content/credits.json create mode 100644 apps/sandbox/content/index.ts create mode 100644 apps/sandbox/content/music.json create mode 100644 apps/sandbox/content/npcs.json create mode 100644 apps/sandbox/content/places.json create mode 100644 apps/sandbox/content/schema.ts create mode 100644 apps/sandbox/content/strings.json create mode 100644 apps/sandbox/content/triggers.json create mode 100644 apps/sandbox/content/unlocks.json create mode 100644 apps/sandbox/dialogue/host.ts create mode 100644 apps/sandbox/dialogue/main.ink create mode 100644 apps/sandbox/e2e/smoke.spec.ts create mode 100644 apps/sandbox/eslint.config.js create mode 100644 apps/sandbox/kai.json create mode 100644 apps/sandbox/package.json create mode 100644 apps/sandbox/playwright.config.ts create mode 100644 apps/sandbox/src/main.ts create mode 100644 apps/sandbox/tsconfig.json create mode 100644 apps/sandbox/turbo.json create mode 100644 apps/sandbox/world/maps.ts create mode 100644 apps/sandbox/world/maps/workshop.tmj create mode 100644 apps/sandbox/world/tile-colors.json create mode 100644 apps/sandbox/world/tile-ids.json diff --git a/apps/datagutt/content/schema.ts b/apps/datagutt/content/schema.ts index 03847d2..db3248c 100644 --- a/apps/datagutt/content/schema.ts +++ b/apps/datagutt/content/schema.ts @@ -1,6 +1,7 @@ // The schemas of Fjord Town's own content collections, beside the engine's. `kai content` // checks content/ against them; content/index.ts gives the compiled bundle their types. import { ENGINE_COLLECTIONS, json, markdown, z, type ContentOf } from "@datagutt/kai/schema"; +import { placeInfo } from "@datagutt/kai/schema/engine"; import { presenceConfig } from "@datagutt/kai-live/presence/config"; const text = z.string().min(1); @@ -61,19 +62,14 @@ export const collections = { places: json( z.object({ list: z.array( - z.object({ - id: text, - name: text, + placeInfo.extend({ + /** The site's content this place shows in town. */ presents: z.array( z.discriminatedUnion("kind", [ z.object({ kind: z.enum(["profile", "skills", "repos", "stats", "contact"]) }), z.object({ kind: z.enum(["project", "experience"]), id: text }), ]), ), - /** Gives a Fjord Passport stamp when its main NPC has been talked to. */ - stamp: z.boolean(), - /** Where `?at=` puts the player: outside its door. */ - entrance: z.object({ map: text, spawn: text }).optional(), }), ), }), diff --git a/apps/sandbox/.gitignore b/apps/sandbox/.gitignore new file mode 100644 index 0000000..043690e --- /dev/null +++ b/apps/sandbox/.gitignore @@ -0,0 +1,4 @@ +/public/game/ +/.assets-cache/ +/world/out/ +/world/tilesets/ diff --git a/apps/sandbox/.kai/config.json b/apps/sandbox/.kai/config.json new file mode 100644 index 0000000..f61196c --- /dev/null +++ b/apps/sandbox/.kai/config.json @@ -0,0 +1,39 @@ +{ + "id": "sandbox", + "title": "Kai Sandbox", + "timezone": "UTC", + "startPlace": "workshop", + "saveKey": "kai-sandbox.save", + "visitorsOffKey": "kai_sandbox_visitors_off", + "basePath": "/game/", + "paths": { + "maps": "world/maps.ts", + "ink": "dialogue", + "dialogueHost": "dialogue/host.ts", + "harness": "src/main.ts" + }, + "assets": { + "repo": "datagutt/datagutt-assets", + "branch": "main", + "localPath": "../datagutt-assets", + "tokenEnv": "KAI_SANDBOX_ASSETS_TOKEN", + "adapter": "@datagutt/kai-limezu/adapter" + }, + "ui": { + "frame": { + "file": "limezu/ui/Modern_UI_Style_1.png", + "x": 58, + "y": 129, + "width": 28, + "height": 29 + }, + "emotes": "limezu/interiors/ui_elements/UI_thinking_emotes_animation_16x16.png" + }, + "sprites": {}, + "font": { + "module": "geist/font/pixel", + "file": "fonts/geist-pixel/GeistPixel-Square.woff2", + "unitsPerPixel": 76 + }, + "live": {} +} diff --git a/apps/sandbox/.kai/content.json b/apps/sandbox/.kai/content.json new file mode 100644 index 0000000..52b0afc --- /dev/null +++ b/apps/sandbox/.kai/content.json @@ -0,0 +1,127 @@ +{ + "characters": { + "player": { + "description": "The visitor.", + "layers": [ + "Bodies/Body_03.png", + "Eyes/Eyes_01.png", + "Outfits/Outfit_01_05.png", + "Hairstyles/Hairstyle_05_03.png" + ], + "placeholder": "4f8a5b", + "portrait": false + }, + "keeper": { + "description": "The keeper: an apron over a checked shirt.", + "layers": [ + "Bodies/Body_02.png", + "Eyes/Eyes_04.png", + "Outfits/Outfit_09_01.png", + "Hairstyles/Hairstyle_11_01.png" + ], + "placeholder": "c46a3a" + } + }, + "npcs": { + "keeper": { + "name": "Keeper", + "place": "workshop", + "personality": "Keeps the workshop tidy and explains the engine to anyone who asks.", + "voice": { + "wave": "triangle", + "pitch": 330, + "variance": 0.05, + "volume": 0.07, + "every": 2 + } + } + }, + "music": { + "tracks": { + "theme": { + "file": "towballs-crossing-deluxe/Towballs Crossing Deluxe! Loopable Tracks/05 At the Farmers Market (Loopable Version).mp3" + } + }, + "playlist": { + "title": "theme", + "credits": "theme", + "outdoors": { + "day": "theme", + "night": "theme", + "winter": "theme" + }, + "indoors": { + "default": "theme", + "maps": {} + } + } + }, + "achievements": { + "list": [ + { + "id": "arrived", + "name": "Arrived", + "how": "Walked into the workshop." + }, + { + "id": "passport", + "name": "Stamped", + "how": "Got the workshop's stamp." + } + ] + }, + "unlocks": {}, + "credits": { + "title": "Kai Sandbox", + "byline": "The smallest kai game", + "sections": [ + { + "heading": "Art", + "lines": [ + "LimeZu (limezu.itch.io)", + "or placeholders" + ] + }, + { + "heading": "Code", + "lines": [ + "GPL-3.0 on GitHub", + "datagutt/datagutt.no" + ] + } + ], + "thanks": "Now make your own." + }, + "triggers": { + "list": [ + { + "on": "enterMap", + "map": "workshop", + "delay": 300, + "grant": "arrived", + "announce": true + }, + { + "on": "passportFull", + "grant": "passport", + "announce": true + } + ] + }, + "strings": { + "passport.title": "Workshop pass" + }, + "places": { + "list": [ + { + "id": "workshop", + "name": "Workshop", + "stamp": true, + "entrance": { + "map": "workshop", + "spawn": "start" + } + } + ] + } +} diff --git a/apps/sandbox/.kai/schema/achievements.json b/apps/sandbox/.kai/schema/achievements.json new file mode 100644 index 0000000..37550ca --- /dev/null +++ b/apps/sandbox/.kai/schema/achievements.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "how": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "name", + "how" + ] + } + } + }, + "required": [ + "list" + ] +} diff --git a/apps/sandbox/.kai/schema/characters.json b/apps/sandbox/.kai/schema/characters.json new file mode 100644 index 0000000..1b78a6d --- /dev/null +++ b/apps/sandbox/.kai/schema/characters.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "layers": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "portrait": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "file", + "portrait" + ] + } + ] + } + }, + "recolor": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[0-9a-f]{6}([0-9a-f]{2})?$" + }, + "additionalProperties": { + "type": "string", + "pattern": "^[0-9a-f]{6}([0-9a-f]{2})?$" + } + }, + "placeholder": { + "type": "string", + "pattern": "^[0-9a-f]{6}([0-9a-f]{2})?$" + }, + "portrait": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + { + "type": "boolean", + "const": false + } + ] + } + }, + "required": [ + "layers", + "placeholder" + ] + }, + "properties": { + "$schema": { + "type": "string" + } + } +} diff --git a/apps/sandbox/.kai/schema/credits.json b/apps/sandbox/.kai/schema/credits.json new file mode 100644 index 0000000..842c17e --- /dev/null +++ b/apps/sandbox/.kai/schema/credits.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "byline": { + "type": "string", + "minLength": 1 + }, + "sections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "heading": { + "type": "string", + "minLength": 1 + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "heading", + "lines" + ] + } + }, + "thanks": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "title", + "byline", + "sections", + "thanks" + ] +} diff --git a/apps/sandbox/.kai/schema/music.json b/apps/sandbox/.kai/schema/music.json new file mode 100644 index 0000000..7421aed --- /dev/null +++ b/apps/sandbox/.kai/schema/music.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "tracks": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "file" + ] + } + }, + "playlist": { + "type": "object", + "properties": { + "title": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "credits": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "outdoors": { + "type": "object", + "properties": { + "day": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "night": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "winter": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + } + }, + "required": [ + "day", + "night", + "winter" + ] + }, + "indoors": { + "type": "object", + "properties": { + "default": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "maps": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "additionalProperties": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + } + } + }, + "required": [ + "default" + ] + } + }, + "required": [ + "title", + "credits", + "outdoors", + "indoors" + ] + } + }, + "required": [ + "tracks", + "playlist" + ] +} diff --git a/apps/sandbox/.kai/schema/npcs.json b/apps/sandbox/.kai/schema/npcs.json new file mode 100644 index 0000000..253d1c0 --- /dev/null +++ b/apps/sandbox/.kai/schema/npcs.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "place": { + "type": "string", + "minLength": 1 + }, + "personality": { + "type": "string" + }, + "presents": { + "type": "string" + }, + "voice": { + "type": "object", + "properties": { + "wave": { + "type": "string", + "enum": [ + "sine", + "square", + "sawtooth", + "triangle" + ] + }, + "pitch": { + "type": "number", + "exclusiveMinimum": 0 + }, + "variance": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "volume": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "every": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "wave", + "pitch", + "variance", + "volume", + "every" + ] + } + }, + "required": [ + "name", + "voice" + ] + }, + "properties": { + "$schema": { + "type": "string" + } + } +} diff --git a/apps/sandbox/.kai/schema/places.json b/apps/sandbox/.kai/schema/places.json new file mode 100644 index 0000000..ac5b5c5 --- /dev/null +++ b/apps/sandbox/.kai/schema/places.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "stamp": { + "type": "boolean" + }, + "entrance": { + "type": "object", + "properties": { + "map": { + "type": "string", + "minLength": 1 + }, + "spawn": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "map", + "spawn" + ] + } + }, + "required": [ + "id", + "name", + "stamp" + ] + } + } + }, + "required": [ + "list" + ] +} diff --git a/apps/sandbox/.kai/schema/strings.json b/apps/sandbox/.kai/schema/strings.json new file mode 100644 index 0000000..52e8815 --- /dev/null +++ b/apps/sandbox/.kai/schema/strings.json @@ -0,0 +1,187 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "default": {}, + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "menu.title": { + "type": "string", + "minLength": 1 + }, + "menu.button": { + "type": "string", + "minLength": 1 + }, + "menu.passport": { + "type": "string", + "minLength": 1 + }, + "menu.status": { + "type": "string", + "minLength": 1 + }, + "menu.journal": { + "type": "string", + "minLength": 1 + }, + "menu.settings": { + "type": "string", + "minLength": 1 + }, + "menu.credits": { + "type": "string", + "minLength": 1 + }, + "menu.close": { + "type": "string", + "minLength": 1 + }, + "menu.back": { + "type": "string", + "minLength": 1 + }, + "menu.backHint": { + "type": "string", + "minLength": 1 + }, + "status.title": { + "type": "string", + "minLength": 1 + }, + "credits.title": { + "type": "string", + "minLength": 1 + }, + "settings.title": { + "type": "string", + "minLength": 1 + }, + "settings.sound": { + "type": "string", + "minLength": 1 + }, + "settings.music": { + "type": "string", + "minLength": 1 + }, + "settings.visitors": { + "type": "string", + "minLength": 1 + }, + "settings.effects": { + "type": "string", + "minLength": 1 + }, + "settings.reducedMotion": { + "type": "string", + "minLength": 1 + }, + "value.on": { + "type": "string", + "minLength": 1 + }, + "value.off": { + "type": "string", + "minLength": 1 + }, + "value.auto": { + "type": "string", + "minLength": 1 + }, + "value.high": { + "type": "string", + "minLength": 1 + }, + "value.low": { + "type": "string", + "minLength": 1 + }, + "passport.title": { + "type": "string", + "minLength": 1 + }, + "passport.achievements": { + "type": "string", + "minLength": 1 + }, + "passport.pageHint": { + "type": "string", + "minLength": 1 + }, + "passport.stampsHint": { + "type": "string", + "minLength": 1 + }, + "passport.fullHint": { + "type": "string", + "minLength": 1 + }, + "passport.achievementsHint": { + "type": "string", + "minLength": 1 + }, + "passport.unknown": { + "type": "string", + "minLength": 1 + }, + "toast.stamped": { + "type": "string", + "minLength": 1 + }, + "toast.achievement": { + "type": "string", + "minLength": 1 + }, + "prompt.talk": { + "type": "string", + "minLength": 1 + }, + "prompt.read": { + "type": "string", + "minLength": 1 + }, + "prompt.enter": { + "type": "string", + "minLength": 1 + }, + "prompt.wake": { + "type": "string", + "minLength": 1 + }, + "prompt.play": { + "type": "string", + "minLength": 1 + }, + "prompt.look": { + "type": "string", + "minLength": 1 + }, + "prompt.use": { + "type": "string", + "minLength": 1 + }, + "prompt.pet": { + "type": "string", + "minLength": 1 + }, + "link.open": { + "type": "string", + "minLength": 1 + }, + "link.notNow": { + "type": "string", + "minLength": 1 + }, + "arcade.leave": { + "type": "string", + "minLength": 1 + }, + "arcade.leaveTouch": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false +} diff --git a/apps/sandbox/.kai/schema/triggers.json b/apps/sandbox/.kai/schema/triggers.json new file mode 100644 index 0000000..ae8142e --- /dev/null +++ b/apps/sandbox/.kai/schema/triggers.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "default": { + "list": [] + }, + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "list": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "on": { + "type": "string", + "const": "enterMap" + }, + "map": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "delay": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "knot": { + "type": "string", + "minLength": 1 + }, + "grant": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "announce": { + "default": true, + "type": "boolean" + }, + "flag": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "on", + "map" + ] + }, + { + "type": "object", + "properties": { + "on": { + "type": "string", + "const": "bumpEdge" + }, + "cooldown": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "knot": { + "type": "string", + "minLength": 1 + }, + "grant": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "announce": { + "default": true, + "type": "boolean" + }, + "flag": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "on" + ] + }, + { + "type": "object", + "properties": { + "on": { + "type": "string", + "const": "passportFull" + }, + "knot": { + "type": "string", + "minLength": 1 + }, + "grant": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "announce": { + "default": true, + "type": "boolean" + }, + "flag": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "on" + ] + } + ] + } + } + }, + "required": [ + "list" + ] +} diff --git a/apps/sandbox/.kai/schema/unlocks.json b/apps/sandbox/.kai/schema/unlocks.json new file mode 100644 index 0000000..3d6d68c --- /dev/null +++ b/apps/sandbox/.kai/schema/unlocks.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "stamps": { + "type": "string", + "const": "all" + } + }, + "required": [ + "stamps" + ] + }, + "properties": { + "$schema": { + "type": "string" + } + } +} diff --git a/apps/sandbox/content/achievements.json b/apps/sandbox/content/achievements.json new file mode 100644 index 0000000..a33eafc --- /dev/null +++ b/apps/sandbox/content/achievements.json @@ -0,0 +1,7 @@ +{ + "$schema": "../.kai/schema/achievements.json", + "list": [ + { "id": "arrived", "name": "Arrived", "how": "Walked into the workshop." }, + { "id": "passport", "name": "Stamped", "how": "Got the workshop's stamp." } + ] +} diff --git a/apps/sandbox/content/characters.json b/apps/sandbox/content/characters.json new file mode 100644 index 0000000..19b077f --- /dev/null +++ b/apps/sandbox/content/characters.json @@ -0,0 +1,14 @@ +{ + "$schema": "../.kai/schema/characters.json", + "player": { + "description": "The visitor.", + "layers": ["Bodies/Body_03.png", "Eyes/Eyes_01.png", "Outfits/Outfit_01_05.png", "Hairstyles/Hairstyle_05_03.png"], + "portrait": false, + "placeholder": "4f8a5b" + }, + "keeper": { + "description": "The keeper: an apron over a checked shirt.", + "layers": ["Bodies/Body_02.png", "Eyes/Eyes_04.png", "Outfits/Outfit_09_01.png", "Hairstyles/Hairstyle_11_01.png"], + "placeholder": "c46a3a" + } +} diff --git a/apps/sandbox/content/credits.json b/apps/sandbox/content/credits.json new file mode 100644 index 0000000..6b24cbc --- /dev/null +++ b/apps/sandbox/content/credits.json @@ -0,0 +1,10 @@ +{ + "$schema": "../.kai/schema/credits.json", + "title": "Kai Sandbox", + "byline": "The smallest kai game", + "sections": [ + { "heading": "Art", "lines": ["LimeZu (limezu.itch.io)", "or placeholders"] }, + { "heading": "Code", "lines": ["GPL-3.0 on GitHub", "datagutt/datagutt.no"] } + ], + "thanks": "Now make your own." +} diff --git a/apps/sandbox/content/index.ts b/apps/sandbox/content/index.ts new file mode 100644 index 0000000..debd570 --- /dev/null +++ b/apps/sandbox/content/index.ts @@ -0,0 +1,5 @@ +import bundle from "../.kai/content.json" with { type: "json" }; +import type { Content } from "./schema"; + +/** The compiled content (`bun run content`), already checked against content/schema.ts. */ +export const content = bundle as unknown as Content; diff --git a/apps/sandbox/content/music.json b/apps/sandbox/content/music.json new file mode 100644 index 0000000..4f5e6ac --- /dev/null +++ b/apps/sandbox/content/music.json @@ -0,0 +1,12 @@ +{ + "$schema": "../.kai/schema/music.json", + "tracks": { + "theme": { "file": "towballs-crossing-deluxe/Towballs Crossing Deluxe! Loopable Tracks/05 At the Farmers Market (Loopable Version).mp3" } + }, + "playlist": { + "title": "theme", + "credits": "theme", + "outdoors": { "day": "theme", "night": "theme", "winter": "theme" }, + "indoors": { "default": "theme" } + } +} diff --git a/apps/sandbox/content/npcs.json b/apps/sandbox/content/npcs.json new file mode 100644 index 0000000..de4f14d --- /dev/null +++ b/apps/sandbox/content/npcs.json @@ -0,0 +1,9 @@ +{ + "$schema": "../.kai/schema/npcs.json", + "keeper": { + "name": "Keeper", + "place": "workshop", + "personality": "Keeps the workshop tidy and explains the engine to anyone who asks.", + "voice": { "wave": "triangle", "pitch": 330, "variance": 0.05, "volume": 0.07, "every": 2 } + } +} diff --git a/apps/sandbox/content/places.json b/apps/sandbox/content/places.json new file mode 100644 index 0000000..30f7e6f --- /dev/null +++ b/apps/sandbox/content/places.json @@ -0,0 +1,4 @@ +{ + "$schema": "../.kai/schema/places.json", + "list": [{ "id": "workshop", "name": "Workshop", "stamp": true, "entrance": { "map": "workshop", "spawn": "start" } }] +} diff --git a/apps/sandbox/content/schema.ts b/apps/sandbox/content/schema.ts new file mode 100644 index 0000000..fdfbbe5 --- /dev/null +++ b/apps/sandbox/content/schema.ts @@ -0,0 +1,9 @@ +// The sandbox's own collections beside the engine's: only its places. +import { ENGINE_COLLECTIONS, json, z, type ContentOf } from "@datagutt/kai/schema"; +import { placeInfo } from "@datagutt/kai/schema/engine"; + +export const collections = { + places: json(z.object({ list: z.array(placeInfo) })), +}; + +export type Content = ContentOf; diff --git a/apps/sandbox/content/strings.json b/apps/sandbox/content/strings.json new file mode 100644 index 0000000..6e7f068 --- /dev/null +++ b/apps/sandbox/content/strings.json @@ -0,0 +1,4 @@ +{ + "$schema": "../.kai/schema/strings.json", + "passport.title": "Workshop pass" +} diff --git a/apps/sandbox/content/triggers.json b/apps/sandbox/content/triggers.json new file mode 100644 index 0000000..4c40c6e --- /dev/null +++ b/apps/sandbox/content/triggers.json @@ -0,0 +1,7 @@ +{ + "$schema": "../.kai/schema/triggers.json", + "list": [ + { "on": "enterMap", "map": "workshop", "delay": 300, "grant": "arrived" }, + { "on": "passportFull", "grant": "passport" } + ] +} diff --git a/apps/sandbox/content/unlocks.json b/apps/sandbox/content/unlocks.json new file mode 100644 index 0000000..55ba528 --- /dev/null +++ b/apps/sandbox/content/unlocks.json @@ -0,0 +1,3 @@ +{ + "$schema": "../.kai/schema/unlocks.json" +} diff --git a/apps/sandbox/dialogue/host.ts b/apps/sandbox/dialogue/host.ts new file mode 100644 index 0000000..566ba76 --- /dev/null +++ b/apps/sandbox/dialogue/host.ts @@ -0,0 +1,9 @@ +// The sandbox's dialogue has no external functions and no link tags. +import type { DialogueHost } from "@datagutt/kai-assets/build/ink"; + +export const dialogueHost: DialogueHost = { + externals: {}, + declarations: () => "", + validIds: () => null, + resolveLink: () => null, +}; diff --git a/apps/sandbox/dialogue/main.ink b/apps/sandbox/dialogue/main.ink new file mode 100644 index 0000000..343f990 --- /dev/null +++ b/apps/sandbox/dialogue/main.ink @@ -0,0 +1,14 @@ +// The sandbox's whole story: the keeper, who explains what a kai game is made of. + +=== keeper === +Welcome to the workshop. Everything here is a kai game at its smallest. +* [What is this place made of?] + A map built in code, content in JSON, and this conversation in Ink. + -> keeper_bye +* [How do I make my own?] + Copy this app, change kai.json and the content, and draw your own map. + -> keeper_bye + += keeper_bye +Have a look around. The sign by the door says the same, only shorter. # nod +-> END diff --git a/apps/sandbox/e2e/smoke.spec.ts b/apps/sandbox/e2e/smoke.spec.ts new file mode 100644 index 0000000..fba94da --- /dev/null +++ b/apps/sandbox/e2e/smoke.spec.ts @@ -0,0 +1,30 @@ +import { expect, test, type Page } from "@playwright/test"; + +type KaiState = { map: string; tile: { x: number; y: number }; prompt: string | null; dialogueOpen: boolean; stamps: string[]; choices: string[] | null }; + +const state = (page: Page) => page.evaluate(() => (window as unknown as { __kai?: KaiState }).__kai ?? null); + +test("the engine runs a game of its own: walk to the keeper, talk, get the stamp", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + await page.goto("/game/dev.html?debug"); + await expect.poll(() => state(page), { timeout: 30_000 }).toMatchObject({ map: "workshop", tile: { x: 7, y: 7 } }); + + // Up to the keeper, who stands two tiles ahead. + for (let i = 0; i < 10 && (await state(page))?.tile.y !== 5; i++) { + await page.keyboard.down("ArrowUp"); + await page.waitForTimeout(120); + await page.keyboard.up("ArrowUp"); + await page.waitForTimeout(150); + } + await expect.poll(async () => (await state(page))?.prompt).toBe("E Talk"); + + await page.keyboard.press("e"); + await expect.poll(async () => (await state(page))?.dialogueOpen).toBe(true); + for (let i = 0; i < 40 && (await state(page))?.dialogueOpen; i++) { + await page.keyboard.press("e"); + await page.waitForTimeout(200); + } + await expect.poll(async () => (await state(page))?.stamps).toEqual(["workshop"]); + expect(errors).toEqual([]); +}); diff --git a/apps/sandbox/eslint.config.js b/apps/sandbox/eslint.config.js new file mode 100644 index 0000000..3aa9bf2 --- /dev/null +++ b/apps/sandbox/eslint.config.js @@ -0,0 +1,4 @@ +import base from "@datagutt/kai-eslint-config/base"; +import { phaser } from "@datagutt/kai-eslint-config/phaser"; + +export default [...base, ...phaser({ files: ["src/**/*.ts"] }), { ignores: ["public/**"] }]; diff --git a/apps/sandbox/kai.json b/apps/sandbox/kai.json new file mode 100644 index 0000000..0ca23ed --- /dev/null +++ b/apps/sandbox/kai.json @@ -0,0 +1,27 @@ +{ + "id": "sandbox", + "title": "Kai Sandbox", + "timezone": "UTC", + "startPlace": "workshop", + "saveKey": "kai-sandbox.save", + "visitorsOffKey": "kai_sandbox_visitors_off", + "basePath": "/game/", + "paths": { + "maps": "world/maps.ts", + "ink": "dialogue", + "dialogueHost": "dialogue/host.ts", + "harness": "src/main.ts" + }, + "assets": { + "repo": "datagutt/datagutt-assets", + "branch": "main", + "localPath": "../datagutt-assets", + "tokenEnv": "KAI_SANDBOX_ASSETS_TOKEN", + "adapter": "@datagutt/kai-limezu/adapter" + }, + "ui": { + "frame": { "file": "limezu/ui/Modern_UI_Style_1.png", "x": 58, "y": 129, "width": 28, "height": 29 }, + "emotes": "limezu/interiors/ui_elements/UI_thinking_emotes_animation_16x16.png" + }, + "font": { "module": "geist/font/pixel", "file": "fonts/geist-pixel/GeistPixel-Square.woff2", "unitsPerPixel": 76 } +} diff --git a/apps/sandbox/package.json b/apps/sandbox/package.json new file mode 100644 index 0000000..32d8893 --- /dev/null +++ b/apps/sandbox/package.json @@ -0,0 +1,33 @@ +{ + "name": "sandbox", + "version": "0.0.0", + "private": true, + "license": "GPL-3.0-or-later", + "type": "module", + "scripts": { + "content": "kai content", + "assets": "kai assets", + "dev": "kai dev", + "world:gen": "kai world gen", + "world:check": "kai world check", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "@datagutt/kai": "workspace:*", + "geist": "^1.7.0", + "phaser": "^4.2.1" + }, + "devDependencies": { + "@datagutt/kai-assets": "workspace:*", + "@datagutt/kai-eslint-config": "workspace:*", + "@datagutt/kai-limezu": "workspace:*", + "@datagutt/kai-tsconfig": "workspace:*", + "@datagutt/kai-worldgen": "workspace:*", + "@playwright/test": "^1.63", + "@types/node": "^24.13.6", + "eslint": "^9", + "typescript": "^5.9.3" + } +} diff --git a/apps/sandbox/playwright.config.ts b/apps/sandbox/playwright.config.ts new file mode 100644 index 0000000..ac17b22 --- /dev/null +++ b/apps/sandbox/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.PORT ?? 3300); + +// The sandbox has no site: the smoke test plays it in the kai dev harness. +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + use: { baseURL: `http://localhost:${port}`, trace: "retain-on-failure" }, + projects: [{ name: "desktop", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: `PORT=${port} bun run dev`, + url: `http://localhost:${port}/game/dev.html`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/sandbox/src/main.ts b/apps/sandbox/src/main.ts new file mode 100644 index 0000000..005685b --- /dev/null +++ b/apps/sandbox/src/main.ts @@ -0,0 +1,16 @@ +// Boots the sandbox straight into its world: `bun run dev` bundles this for the harness. +import { createGame } from "@datagutt/kai"; +import type { KaiConfig } from "@datagutt/kai/schema"; +import { calmWeather } from "@datagutt/kai/world/weather-kinds"; +import config from "../.kai/config.json" with { type: "json" }; +import { content } from "../content/index.ts"; + +const container = document.getElementById("game"); +if (!container) throw new Error("#game container missing"); + +createGame(container, { + config: config as KaiConfig, + content: { ...content, places: content.places.list }, + live: { weather: calmWeather("Nowhere") }, + autoStart: true, +}); diff --git a/apps/sandbox/tsconfig.json b/apps/sandbox/tsconfig.json new file mode 100644 index 0000000..3487e5a --- /dev/null +++ b/apps/sandbox/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@datagutt/kai-tsconfig/base.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/sandbox/turbo.json b/apps/sandbox/turbo.json new file mode 100644 index 0000000..d51baa0 --- /dev/null +++ b/apps/sandbox/turbo.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + "tasks": { + "test:e2e": { + "dependsOn": ["assets", "content"], + "cache": false + } + } +} diff --git a/apps/sandbox/world/maps.ts b/apps/sandbox/world/maps.ts new file mode 100644 index 0000000..34874ac --- /dev/null +++ b/apps/sandbox/world/maps.ts @@ -0,0 +1,21 @@ +// The sandbox's one map: a small workshop, built the way every kai map is, in code, from +// the art adapter's pieces. `bun run world:gen` writes it to world/maps/workshop.tmj. +import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; +import { FLOORS, room, WALLS } from "@datagutt/kai-limezu/interior"; +import { glow, GLOWS } from "@datagutt/kai-limezu/lighting"; +import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; +import type { GeneratedMap } from "@datagutt/kai-worldgen/maps"; + +function workshop(): MapCanvas { + const c = new MapCanvas(14, 10); + const r = { x: 2, y: 1, w: 10, h: 7 }; + room(c, r, { wall: WALLS.cream, floor: FLOORS.oak }); + c.stamp(F.retroPc, 2, 5).stamp(F.pcTower, 4, 5); + c.add(glow(3, 6, GLOWS.screen)); + c.add({ type: "npc", id: "keeper", character: "keeper", x: 7, y: 4, facing: "down", name: "Keeper", dialogue: "keeper" }); + c.add({ type: "sign", x: 3, y: 6, text: "* A computer. On screen: kai.json, content/, dialogue/, world/. That's a game." }); + c.add({ type: "spawn", id: "start", x: 7, y: 7, facing: "up" }); + return c; +} + +export const GENERATED_MAPS: GeneratedMap[] = [{ id: "workshop", properties: { name: "Workshop" }, build: workshop }]; diff --git a/apps/sandbox/world/maps/workshop.tmj b/apps/sandbox/world/maps/workshop.tmj new file mode 100644 index 0000000..5bd319d --- /dev/null +++ b/apps/sandbox/world/maps/workshop.tmj @@ -0,0 +1,347 @@ +{ + "type": "map", + "version": "1.10", + "tiledversion": "1.11.0", + "orientation": "orthogonal", + "renderorder": "right-down", + "infinite": false, + "width": 14, + "height": 10, + "tilewidth": 16, + "tileheight": 16, + "nextlayerid": 9, + "nextobjectid": 5, + "properties": [ + { + "name": "name", + "type": "string", + "value": "Workshop" + } + ], + "layers": [ + { + "id": 1, + "name": "ground", + "type": "tilelayer", + "x": 0, + "y": 0, + "width": 14, + "height": 10, + "opacity": 1, + "visible": true, + "data": [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,3,4,4,4,4,4,4,4,4,5,0,0, + 0,0,6,7,7,7,7,7,7,7,7,8,0,0, + 0,0,9,10,10,10,10,10,10,10,10,10,0,0, + 0,0,11,12,12,12,12,12,12,12,12,12,0,0, + 0,0,11,12,12,12,12,12,12,12,12,12,0,0, + 0,0,11,12,12,12,12,12,12,12,12,12,0,0, + 0,0,11,12,12,12,12,12,12,12,12,12,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0 + ] + }, + { + "id": 2, + "name": "ground2", + "type": "tilelayer", + "x": 0, + "y": 0, + "width": 14, + "height": 10, + "opacity": 1, + "visible": true, + "data": [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0 + ] + }, + { + "id": 3, + "name": "decal", + "type": "tilelayer", + "x": 0, + "y": 0, + "width": 14, + "height": 10, + "opacity": 1, + "visible": true, + "data": [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0 + ] + }, + { + "id": 4, + "name": "shade", + "type": "tilelayer", + "x": 0, + "y": 0, + "width": 14, + "height": 10, + "opacity": 1, + "visible": true, + "data": [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0 + ], + "properties": [ + { + "name": "blend", + "type": "string", + "value": "multiply" + } + ] + }, + { + "id": 5, + "name": "below", + "type": "tilelayer", + "x": 0, + "y": 0, + "width": 14, + "height": 10, + "opacity": 1, + "visible": true, + "data": [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,13,14,15,0,0,0,0,0,0,0,0,0, + 0,0,16,17,18,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0 + ] + }, + { + "id": 6, + "name": "above", + "type": "tilelayer", + "x": 0, + "y": 0, + "width": 14, + "height": 10, + "opacity": 1, + "visible": true, + "data": [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,19,20,20,20,20,20,20,20,20,20,20,21,0, + 0,22,0,0,0,0,0,0,0,0,0,0,23,0, + 0,22,0,0,0,0,0,0,0,0,0,0,23,0, + 0,22,0,0,0,0,0,0,0,0,0,0,23,0, + 0,22,0,0,0,0,0,0,0,0,0,0,23,0, + 0,22,0,0,0,0,0,0,0,0,0,0,23,0, + 0,22,0,0,0,0,0,0,0,0,0,0,23,0, + 0,24,25,25,25,25,25,25,25,25,25,25,26,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0 + ] + }, + { + "id": 7, + "name": "collision", + "type": "tilelayer", + "x": 0, + "y": 0, + "width": 14, + "height": 10, + "opacity": 1, + "visible": false, + "data": [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,1,1,1,1,1,1,1,1,1,1,1,1,0, + 0,1,1,1,1,1,1,1,1,1,1,1,1,0, + 0,1,0,0,0,0,0,0,0,0,0,0,1,0, + 0,1,0,0,0,0,0,0,0,0,0,0,1,0, + 0,1,0,0,1,0,0,0,0,0,0,0,1,0, + 0,1,1,1,1,0,0,0,0,0,0,0,1,0, + 0,1,0,0,0,0,0,0,0,0,0,0,1,0, + 0,1,1,1,1,1,1,1,1,1,1,1,1,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0 + ] + }, + { + "id": 8, + "name": "objects", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "id": 1, + "name": "", + "type": "light", + "x": 48, + "y": 96, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "shape", + "type": "string", + "value": "glow" + }, + { + "name": "radius", + "type": "string", + "value": "1.6" + }, + { + "name": "color", + "type": "string", + "value": "78b4ff" + }, + { + "name": "intensity", + "type": "string", + "value": "0.35" + }, + { + "name": "flicker", + "type": "string", + "value": "false" + } + ] + }, + { + "id": 2, + "name": "keeper", + "type": "npc", + "x": 112, + "y": 64, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "character", + "type": "string", + "value": "keeper" + }, + { + "name": "facing", + "type": "string", + "value": "down" + }, + { + "name": "name", + "type": "string", + "value": "Keeper" + }, + { + "name": "dialogue", + "type": "string", + "value": "keeper" + } + ] + }, + { + "id": 3, + "name": "", + "type": "sign", + "x": 32, + "y": 96, + "width": 32, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "text", + "type": "string", + "value": "* A computer. On screen: kai.json, content/, dialogue/, world/. That's a game." + } + ] + }, + { + "id": 4, + "name": "start", + "type": "spawn", + "x": 112, + "y": 112, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "facing", + "type": "string", + "value": "up" + } + ] + } + ] + } + ], + "tilesets": [ + { + "firstgid": 1, + "name": "world", + "image": "../tilesets/world.png", + "imagewidth": 2048, + "imageheight": 2048, + "tilewidth": 16, + "tileheight": 16, + "tilecount": 16384, + "columns": 128, + "margin": 0, + "spacing": 0, + "tiles": [ + { + "id": 0, + "properties": [ + { + "name": "collides", + "type": "bool", + "value": true + } + ] + }, + { + "id": 1, + "properties": [ + { + "name": "clears", + "type": "bool", + "value": true + } + ] + } + ] + } + ] +} diff --git a/apps/sandbox/world/tile-colors.json b/apps/sandbox/world/tile-colors.json new file mode 100644 index 0000000..b8b4564 --- /dev/null +++ b/apps/sandbox/world/tile-colors.json @@ -0,0 +1,26 @@ +{ + "rbWalls:0,16": "b0acadffc0bcbaffc7b9a3ffdbcaadff", + "rbWalls:1,16": "c1bdbbffc0bcbaffdbccafffdbcaadff", + "rbWalls:2,16": "c1bdbbffafacacffdbccafffc6b8a1ff", + "rbWalls:0,17": "c6b8a2ffdbcbaeffada398ffbcaf9dff", + "rbWalls:1,17": "dacbadffdbcbaeffbdb2a2ffbcaf9dff", + "rbWalls:2,17": "dacbadffc7b8a2ffbdb2a2ffaba093ff", + "rbFloors:4,12": "88725cff88725cff8e7862ff9f866dff", + "rbFloors:5,12": "8a745dff88725cff9c856eff9a836cff", + "rbFloors:4,13": "8e7964ff9f876fff897561ff9a836cff", + "rbFloors:5,13": "a28a72ff9f876fff9c856eff9a836cff", + "classroom:1,10": "000000003a3a5014000000007d7b7ecf", + "classroom:2,10": "3a3a5014000000007c7a7ecf00000000", + "classroom:0,10": "3a3a50143a3a50147d7b7ecf7c7a7ecf", + "classroom:1,11": "000000008d8889ff000000007f787edf", + "classroom:2,11": "858184ff00000000756f78cf00000000", + "classroom:0,11": "8d8889ff858184ff817b80d7756f78cf", + "roomBuilder:11,1": "000000008a868adf00000000a49f9edf", + "roomBuilder:12,1": "9d9999bf9d9999bf0000000000000000", + "roomBuilder:13,1": "8a868adf00000000a49f9edf00000000", + "roomBuilder:11,2": "00000000a49f9edf00000000a49f9edf", + "roomBuilder:13,2": "a49f9edf00000000a49f9edf00000000", + "roomBuilder:11,3": "00000000817e84a70000000000000000", + "roomBuilder:12,3": "9d9999bf9d9999bf0000000000000000", + "roomBuilder:13,3": "817e84a7000000000000000000000000" +} diff --git a/apps/sandbox/world/tile-ids.json b/apps/sandbox/world/tile-ids.json new file mode 100644 index 0000000..8abecab --- /dev/null +++ b/apps/sandbox/world/tile-ids.json @@ -0,0 +1,30 @@ +{ + "tiles": [ + "@collision", + "@clear", + "rbWalls:0,16", + "rbWalls:1,16", + "rbWalls:2,16", + "rbWalls:0,17", + "rbWalls:1,17", + "rbWalls:2,17", + "rbFloors:4,12", + "rbFloors:5,12", + "rbFloors:4,13", + "rbFloors:5,13", + "classroom:1,10", + "classroom:2,10", + "classroom:0,10", + "classroom:1,11", + "classroom:2,11", + "classroom:0,11", + "roomBuilder:11,1", + "roomBuilder:12,1", + "roomBuilder:13,1", + "roomBuilder:11,2", + "roomBuilder:13,2", + "roomBuilder:11,3", + "roomBuilder:12,3", + "roomBuilder:13,3" + ] +} diff --git a/bun.lock b/bun.lock index 2749cc2..599e192 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,26 @@ "vitest": "^5", }, }, + "apps/sandbox": { + "name": "sandbox", + "version": "0.0.0", + "dependencies": { + "@datagutt/kai": "workspace:*", + "geist": "^1.7.0", + "phaser": "^4.2.1", + }, + "devDependencies": { + "@datagutt/kai-assets": "workspace:*", + "@datagutt/kai-eslint-config": "workspace:*", + "@datagutt/kai-limezu": "workspace:*", + "@datagutt/kai-tsconfig": "workspace:*", + "@datagutt/kai-worldgen": "workspace:*", + "@playwright/test": "^1.63", + "@types/node": "^24.13.6", + "eslint": "^9", + "typescript": "^5.9.3", + }, + }, "packages/kai": { "name": "@datagutt/kai", "version": "0.0.0", @@ -1246,6 +1266,8 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "sandbox": ["sandbox@workspace:apps/sandbox"], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index c4e64a8..9779c09 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -192,11 +192,15 @@ Goal: a second app proves the engine needs nothing from datagutt. `worldSocketHandler`, and `'use cache'` wrappers over `kai-live`'s now framework-free GitHub and MET fetchers. The app keeps its choice of data: which GitHub user, the fallback place.) -- [ ] **K6.2** `apps/sandbox`: one generated map, one NPC, one Ink knot, one trigger, +- [x] **K6.2** `apps/sandbox`: one generated map, one NPC, one Ink knot, one trigger, placeholder art, booted by the kai dev harness. *Done when* it runs without the - assets token. -- [ ] **K6.3** A Playwright smoke test for the sandbox (walk to the NPC, talk) in CI. - *Done when* CI runs it. + assets token. (It uses the licensed art when the checkout is there, placeholders + otherwise. Building it found two engine bugs: Ink visits were only counted for knots + the story referred to, and an app's own turbo.json was taken for the repository + root.) +- [x] **K6.3** A Playwright smoke test for the sandbox (walk to the NPC, talk) in CI. + *Done when* CI runs it. (`turbo run test:e2e` runs it beside datagutt's, against + the kai dev harness; it also checks the stamp.) - [ ] **K6.4** Boundary lint: `packages/** ↛ apps/**`, runtime packages ↛ build-time packages, `kai` ↛ Next and React. *Done when* a deliberate bad import fails CI. diff --git a/packages/kai/src/schema/engine.ts b/packages/kai/src/schema/engine.ts index ddbd45f..4ea0780 100644 --- a/packages/kai/src/schema/engine.ts +++ b/packages/kai/src/schema/engine.ts @@ -118,6 +118,19 @@ export const trigger = z.discriminatedUnion("on", [ export type Trigger = z.output; +/** + * What the engine reads of a place, for a game's own places collection (which may extend + * it): a stamp for the passport, and an entrance for `?at=` and the start place. + */ +export const placeInfo = z.object({ + id, + name: text, + /** Gives a passport stamp when its main NPC has been talked to. */ + stamp: z.boolean(), + /** Where `?at=` puts the player: outside its door. */ + entrance: z.object({ map: text, spawn: text }).optional(), +}); + export const credits = z.object({ title: text, byline: text, From b30aa2d8b3920945b9b6cf93abcbb6049149ff30 Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:31:06 +0200 Subject: [PATCH 138/153] build(eslint-config): lint the import boundaries between packages packages never import apps, runtime code never imports the build-time packages or schemas (except as types), and the runtime stays free of next and react. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- docs/kai/PLAN.md | 2 +- packages/kai-arcade/eslint.config.js | 6 ++- packages/kai-assets/eslint.config.js | 3 +- packages/kai-limezu/eslint.config.js | 3 +- packages/kai-live/eslint.config.js | 11 ++++- packages/kai-net/eslint.config.js | 6 ++- packages/kai-next/eslint.config.js | 4 +- packages/kai-worldgen/eslint.config.js | 3 +- packages/kai/eslint.config.js | 7 +++- tooling/eslint-config/base.js | 28 ++++++++----- tooling/eslint-config/boundaries.js | 57 ++++++++++++++++++++++++++ tooling/eslint-config/package.json | 3 +- tooling/eslint-config/phaser.js | 17 ++++++-- 13 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 tooling/eslint-config/boundaries.js diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 9779c09..f825a5f 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -201,7 +201,7 @@ Goal: a second app proves the engine needs nothing from datagutt. - [x] **K6.3** A Playwright smoke test for the sandbox (walk to the NPC, talk) in CI. *Done when* CI runs it. (`turbo run test:e2e` runs it beside datagutt's, against the kai dev harness; it also checks the stamp.) -- [ ] **K6.4** Boundary lint: `packages/** ↛ apps/**`, runtime packages ↛ build-time +- [x] **K6.4** Boundary lint: `packages/** ↛ apps/**`, runtime packages ↛ build-time packages, `kai` ↛ Next and React. *Done when* a deliberate bad import fails CI. ## K7: docs diff --git a/packages/kai-arcade/eslint.config.js b/packages/kai-arcade/eslint.config.js index 6bff985..8514d3e 100644 --- a/packages/kai-arcade/eslint.config.js +++ b/packages/kai-arcade/eslint.config.js @@ -1,3 +1,7 @@ import base from "@datagutt/kai-eslint-config/base"; +import { runtimePackage } from "@datagutt/kai-eslint-config/boundaries"; -export default base; +export default [ + ...base, + ...runtimePackage(["src/**/*.ts"], ["src/**/*.test.ts"]), +]; diff --git a/packages/kai-assets/eslint.config.js b/packages/kai-assets/eslint.config.js index 6bff985..5463a3b 100644 --- a/packages/kai-assets/eslint.config.js +++ b/packages/kai-assets/eslint.config.js @@ -1,3 +1,4 @@ import base from "@datagutt/kai-eslint-config/base"; +import { buildTimePackage } from "@datagutt/kai-eslint-config/boundaries"; -export default base; +export default [...base, ...buildTimePackage(["src/**/*.{ts,mjs}"])]; diff --git a/packages/kai-limezu/eslint.config.js b/packages/kai-limezu/eslint.config.js index 6bff985..5463a3b 100644 --- a/packages/kai-limezu/eslint.config.js +++ b/packages/kai-limezu/eslint.config.js @@ -1,3 +1,4 @@ import base from "@datagutt/kai-eslint-config/base"; +import { buildTimePackage } from "@datagutt/kai-eslint-config/boundaries"; -export default base; +export default [...base, ...buildTimePackage(["src/**/*.{ts,mjs}"])]; diff --git a/packages/kai-live/eslint.config.js b/packages/kai-live/eslint.config.js index 6bff985..450ec0c 100644 --- a/packages/kai-live/eslint.config.js +++ b/packages/kai-live/eslint.config.js @@ -1,3 +1,12 @@ import base from "@datagutt/kai-eslint-config/base"; +import { runtimePackage } from "@datagutt/kai-eslint-config/boundaries"; -export default base; +// presence/config.ts is the content build's schema (Zod); the rest runs in the game or on +// the server. +export default [ + ...base, + ...runtimePackage( + ["src/**/*.ts"], + ["src/presence/config.ts", "src/**/*.test.ts"], + ), +]; diff --git a/packages/kai-net/eslint.config.js b/packages/kai-net/eslint.config.js index 6bff985..8514d3e 100644 --- a/packages/kai-net/eslint.config.js +++ b/packages/kai-net/eslint.config.js @@ -1,3 +1,7 @@ import base from "@datagutt/kai-eslint-config/base"; +import { runtimePackage } from "@datagutt/kai-eslint-config/boundaries"; -export default base; +export default [ + ...base, + ...runtimePackage(["src/**/*.ts"], ["src/**/*.test.ts"]), +]; diff --git a/packages/kai-next/eslint.config.js b/packages/kai-next/eslint.config.js index 2edc4f1..290bc67 100644 --- a/packages/kai-next/eslint.config.js +++ b/packages/kai-next/eslint.config.js @@ -1,3 +1,5 @@ import next from "@datagutt/kai-eslint-config/next"; +import { hostPackage } from "@datagutt/kai-eslint-config/boundaries"; -export default next; +const config = [...next, ...hostPackage(["src/**/*.{ts,tsx}"])]; +export default config; diff --git a/packages/kai-worldgen/eslint.config.js b/packages/kai-worldgen/eslint.config.js index 6bff985..5463a3b 100644 --- a/packages/kai-worldgen/eslint.config.js +++ b/packages/kai-worldgen/eslint.config.js @@ -1,3 +1,4 @@ import base from "@datagutt/kai-eslint-config/base"; +import { buildTimePackage } from "@datagutt/kai-eslint-config/boundaries"; -export default base; +export default [...base, ...buildTimePackage(["src/**/*.{ts,mjs}"])]; diff --git a/packages/kai/eslint.config.js b/packages/kai/eslint.config.js index 6bff985..62a99b2 100644 --- a/packages/kai/eslint.config.js +++ b/packages/kai/eslint.config.js @@ -1,3 +1,8 @@ import base from "@datagutt/kai-eslint-config/base"; +import { runtimePackage } from "@datagutt/kai-eslint-config/boundaries"; -export default base; +// src/schema/ is the build's (Zod); everything else ships to the browser. +export default [ + ...base, + ...runtimePackage(["src/**/*.ts"], ["src/schema/**", "src/**/*.test.ts"]), +]; diff --git a/tooling/eslint-config/base.js b/tooling/eslint-config/base.js index a35e9a5..d2e87da 100644 --- a/tooling/eslint-config/base.js +++ b/tooling/eslint-config/base.js @@ -2,17 +2,23 @@ import tseslint from "typescript-eslint"; /** Generated or licensed output that no workspace lints. */ export const ignores = { - ignores: [ - "**/node_modules/**", - "**/.next/**", - "**/.turbo/**", - "**/.kai/**", - "**/.assets-cache/**", - "**/public/game/**", - "**/game/generated/**", - "**/next-env.d.ts", - ], + ignores: [ + "**/node_modules/**", + "**/.next/**", + "**/.turbo/**", + "**/.kai/**", + "**/.assets-cache/**", + "**/public/game/**", + "**/game/generated/**", + "**/next-env.d.ts", + ], }; /** For packages and scripts outside Next: parses TypeScript, no framework rules. */ -export default [ignores, { files: ["**/*.{ts,mts,tsx}"], languageOptions: { parser: tseslint.parser } }]; +export default [ + ignores, + { + files: ["**/*.{ts,mts,tsx}"], + languageOptions: { parser: tseslint.parser }, + }, +]; diff --git a/tooling/eslint-config/boundaries.js b/tooling/eslint-config/boundaries.js new file mode 100644 index 0000000..35df6fe --- /dev/null +++ b/tooling/eslint-config/boundaries.js @@ -0,0 +1,57 @@ +// Import boundaries between the workspaces. Packages never reach into apps. Runtime code +// (what a game ships to the browser) never imports the build-time packages, and imports +// schema modules for their types only, so Zod stays out of the bundle. The runtime also +// stays free of Next and React, so any host page can run it. +import tseslint from "typescript-eslint"; + +const apps = { + group: ["**/apps/**", "datagutt", "datagutt/*", "sandbox", "sandbox/*"], + message: "Packages are shared by every game: they never import an app.", +}; + +const buildTime = { + group: [ + "@datagutt/kai-worldgen", + "@datagutt/kai-worldgen/*", + "@datagutt/kai-limezu", + "@datagutt/kai-limezu/*", + "@datagutt/kai-assets", + "@datagutt/kai-assets/*", + ], + message: + "Runtime code never imports the build-time packages (world generation, the art adapter, the asset build).", + allowTypeImports: true, +}; + +const schemas = { + group: ["@datagutt/kai/schema", "@datagutt/kai/schema/*", "**/schema/*"], + message: + "Schemas are for the build: runtime code imports only their types (`import type`), so Zod stays out of the bundle.", + allowTypeImports: true, +}; + +const framework = { + group: ["next", "next/*", "react", "react/*", "react-dom", "react-dom/*"], + message: + "The runtime stays free of Next and React so any host page can run it.", +}; + +const rule = (patterns) => ({ + plugins: { "@typescript-eslint": tseslint.plugin }, + rules: { + "@typescript-eslint/no-restricted-imports": ["error", { patterns }], + }, +}); + +/** Build-time package code: it may import anything but an app. */ +export const buildTimePackage = (files) => [{ files, ...rule([apps]) }]; + +/** Runtime package code, shipped to the browser. */ +export const runtimePackage = (files, ignores = []) => [ + { files, ignores, ...rule([apps, buildTime, schemas, framework]) }, +]; + +/** Host glue for Next: runtime rules, but React and Next are its job. */ +export const hostPackage = (files) => [ + { files, ...rule([apps, buildTime, schemas]) }, +]; diff --git a/tooling/eslint-config/package.json b/tooling/eslint-config/package.json index e006b90..5e7064a 100644 --- a/tooling/eslint-config/package.json +++ b/tooling/eslint-config/package.json @@ -8,7 +8,8 @@ "exports": { "./base": "./base.js", "./phaser": "./phaser.js", - "./next": "./next.js" + "./next": "./next.js", + "./boundaries": "./boundaries.js" }, "license": "GPL-3.0-or-later", "private": true, diff --git a/tooling/eslint-config/phaser.js b/tooling/eslint-config/phaser.js index 93c5cde..500836a 100644 --- a/tooling/eslint-config/phaser.js +++ b/tooling/eslint-config/phaser.js @@ -1,8 +1,9 @@ import base from "./base.js"; const frameworkImports = { - group: ["next", "next/*", "react", "react/*", "react-dom", "react-dom/*"], - message: "The game runtime stays free of Next and React so any host page can run it.", + group: ["next", "next/*", "react", "react/*", "react-dom", "react-dom/*"], + message: + "The game runtime stays free of Next and React so any host page can run it.", }; /** @@ -11,7 +12,17 @@ const frameworkImports = { * restriction on these files has to come through here. */ export function phaser({ files = ["**/*.ts"], patterns = [] } = {}) { - return [{ files, rules: { "no-restricted-imports": ["error", { patterns: [frameworkImports, ...patterns] }] } }]; + return [ + { + files, + rules: { + "no-restricted-imports": [ + "error", + { patterns: [frameworkImports, ...patterns] }, + ], + }, + }, + ]; } export default [...base, ...phaser()]; From 74e4c8b4d7acf5cd76e5dc78c517f400e8f6956b Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:33:25 +0200 Subject: [PATCH 139/153] docs(datagutt): move the fjord town docs into the app Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- .claude/hooks/game-session-start.sh | 2 +- .claude/hooks/game-stop-check.sh | 2 +- CLAUDE.md | 2 +- README.md | 2 +- apps/datagutt/.gitignore | 2 +- apps/datagutt/app/journal/page.tsx | 2 +- apps/datagutt/components/game/GameShell.tsx | 2 +- apps/datagutt/components/game/TitleArt.tsx | 2 +- apps/datagutt/content/places.ts | 2 +- apps/datagutt/content/profile.ts | 2 +- apps/datagutt/content/schema.ts | 2 +- {docs/game => apps/datagutt/docs}/ART.md | 0 {docs/game => apps/datagutt/docs}/DESIGN.md | 0 {docs/game => apps/datagutt/docs}/HANDOFF.md | 4 ++-- {docs/game => apps/datagutt/docs}/PLAN.md | 2 +- {docs/game => apps/datagutt/docs}/README.md | 0 apps/datagutt/eslint.config.mjs | 2 +- apps/datagutt/game/arcade/ids.ts | 2 +- apps/datagutt/game/dialogue/externals.ts | 2 +- apps/datagutt/game/dialogue/ink/datagutt.ink | 2 +- apps/datagutt/game/dialogue/ink/ferryman.ink | 2 +- apps/datagutt/game/dialogue/ink/main.ink | 2 +- apps/datagutt/game/dialogue/links.ts | 2 +- apps/datagutt/game/plugins/Intro.ts | 2 +- apps/datagutt/game/plugins/cat.ts | 2 +- apps/datagutt/lib/weather.ts | 2 +- apps/datagutt/lib/world-state.ts | 2 +- apps/datagutt/playwright.config.ts | 2 +- apps/datagutt/world/gen/maps/house.ts | 2 +- apps/datagutt/world/gen/maps/mountain.ts | 2 +- apps/datagutt/world/gen/maps/overworld.ts | 2 +- apps/datagutt/world/gen/maps/youthClub.ts | 2 +- apps/datagutt/world/gen/title.ts | 2 +- docs/kai/PLAN.md | 2 +- packages/kai-limezu/src/interior.ts | 2 +- packages/kai-net/src/protocol.ts | 2 +- packages/kai-worldgen/src/registry.ts | 2 +- packages/kai-worldgen/src/validate.ts | 2 +- packages/kai/src/audio/Ambience.ts | 2 +- packages/kai/src/audio/mix.ts | 2 +- packages/kai/src/audio/sfx.ts | 2 +- packages/kai/src/dev/simulatedGhosts.ts | 2 +- packages/kai/src/entities/Ghosts.ts | 2 +- packages/kai/src/fx/Aurora.ts | 2 +- packages/kai/src/fx/DayNight.ts | 2 +- packages/kai/src/fx/Feel.ts | 2 +- packages/kai/src/fx/Water.ts | 2 +- packages/kai/src/fx/Weather.ts | 2 +- packages/kai/src/fx/quality.ts | 2 +- packages/kai/src/input/InputController.ts | 2 +- packages/kai/src/ui/DialogueBox.ts | 2 +- packages/kai/src/ui/EmoteWheel.ts | 2 +- packages/kai/src/ui/Passport.ts | 2 +- packages/kai/src/viewport.ts | 2 +- packages/kai/src/world/dayNight.ts | 2 +- packages/kai/src/world/movement.ts | 2 +- packages/kai/src/world/pathfind.ts | 2 +- 57 files changed, 55 insertions(+), 55 deletions(-) rename {docs/game => apps/datagutt/docs}/ART.md (100%) rename {docs/game => apps/datagutt/docs}/DESIGN.md (100%) rename {docs/game => apps/datagutt/docs}/HANDOFF.md (99%) rename {docs/game => apps/datagutt/docs}/PLAN.md (99%) rename {docs/game => apps/datagutt/docs}/README.md (100%) diff --git a/.claude/hooks/game-session-start.sh b/.claude/hooks/game-session-start.sh index 0a778d0..2aaf5c1 100644 --- a/.claude/hooks/game-session-start.sh +++ b/.claude/hooks/game-session-start.sh @@ -9,7 +9,7 @@ branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown # The kai branches work on the engine split, which keeps its own plan and handoff. case "$branch" in kai | kai/*) rel="docs/kai"; title="kai engine split" ;; - *) rel="docs/game"; title="Fjord Town game project" ;; + *) rel="apps/datagutt/docs"; title="Fjord Town game project" ;; esac docs="$root/$rel" [ -f "$docs/HANDOFF.md" ] || exit 0 diff --git a/.claude/hooks/game-stop-check.sh b/.claude/hooks/game-stop-check.sh index e04b6e4..99405e9 100644 --- a/.claude/hooks/game-stop-check.sh +++ b/.claude/hooks/game-stop-check.sh @@ -13,7 +13,7 @@ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)} branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo)" case "$branch" in - game | game/*) rel="docs/game" ;; + game | game/*) rel="apps/datagutt/docs" ;; kai | kai/*) rel="docs/kai" ;; *) exit 0 ;; esac diff --git a/CLAUDE.md b/CLAUDE.md index b4d50c6..c5b7677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Fjord Town -The site is a top-down pixel-art game, "Fjord Town", with a plain-text twin, the Journal. Before working on the game, read `docs/game/README.md` (workflow), `docs/game/HANDOFF.md` (where the last session stopped) and `docs/game/PLAN.md` (tasks). Decisions in `docs/game/DESIGN.md` are settled. +The site is a top-down pixel-art game, "Fjord Town", with a plain-text twin, the Journal. Before working on the game, read `apps/datagutt/docs/README.md` (workflow), `apps/datagutt/docs/HANDOFF.md` (where the last session stopped) and `apps/datagutt/docs/PLAN.md` (tasks). Decisions in `apps/datagutt/docs/DESIGN.md` are settled. The engine is being split out as **kai** (`packages/`), with the site as `apps/datagutt`. On the `kai` branch, read `docs/kai/HANDOFF.md`, `docs/kai/PLAN.md` and `docs/kai/DESIGN.md` instead. diff --git a/README.md b/README.md index 6504f74..c98da40 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,6 @@ Not covered by the GPL: [licence](./fonts/EULA-PangramPangram-FreeForPersonalUse-MAY2021.pdf). - `public/images/`: the avatar and project screenshots. All rights reserved. - Game art: the LimeZu tilesets and characters are licensed separately, live in a private - repository and are never committed here (see `docs/game/DESIGN.md` §9). + repository and are never committed here (see `apps/datagutt/docs/DESIGN.md` §9). - Geist and Geist Pixel fonts come from the `geist` package under the SIL Open Font License. diff --git a/apps/datagutt/.gitignore b/apps/datagutt/.gitignore index 07c5b9a..eb3144f 100644 --- a/apps/datagutt/.gitignore +++ b/apps/datagutt/.gitignore @@ -1,7 +1,7 @@ # Next.js static export and build output /out/ /build/ -# Licensed or generated asset output. Never commit LimeZu pixels (docs/game/DESIGN.md §9). +# Licensed or generated asset output. Never commit LimeZu pixels (docs/DESIGN.md §9). /public/game/ /.assets-cache/ /game/generated/ diff --git a/apps/datagutt/app/journal/page.tsx b/apps/datagutt/app/journal/page.tsx index bcfa069..dc3bfc1 100644 --- a/apps/datagutt/app/journal/page.tsx +++ b/apps/datagutt/app/journal/page.tsx @@ -32,7 +32,7 @@ const CONTENTS = [ ] as const; /** - * The Journal (docs/game/PLAN.md M6.1): the whole site as a plain page, for anyone who + * The Journal (docs/PLAN.md M6.1): the whole site as a plain page, for anyone who * would rather read than play, and for screen readers and search engines. Everything * comes from content/ and the same live data as the game, so the two never disagree. */ diff --git a/apps/datagutt/components/game/GameShell.tsx b/apps/datagutt/components/game/GameShell.tsx index 7825ac4..f565a06 100644 --- a/apps/datagutt/components/game/GameShell.tsx +++ b/apps/datagutt/components/game/GameShell.tsx @@ -14,7 +14,7 @@ const loadGame = () => import("@/game").then((game) => game.startFjordTown); const JOURNAL_LABEL = "Read it as a normal website"; /** - * Hosts the game canvas behind the server-rendered title screen (docs/game/PLAN.md M5.11). + * Hosts the game canvas behind the server-rendered title screen (docs/PLAN.md M5.11). * The game module starts downloading as soon as the page is interactive. "Press start" * opens the menu straight away; picking a way in waits for loading to finish. */ diff --git a/apps/datagutt/components/game/TitleArt.tsx b/apps/datagutt/components/game/TitleArt.tsx index fa5429f..0207245 100644 --- a/apps/datagutt/components/game/TitleArt.tsx +++ b/apps/datagutt/components/game/TitleArt.tsx @@ -1,4 +1,4 @@ -// The title screen's backdrop (docs/game/PLAN.md M5.11): a summer day on the fjord. +// The title screen's backdrop (docs/PLAN.md M5.11): a summer day on the fjord. // The sky, clouds and mountains are drawn here on a pixel grid where one unit is one game // pixel: a wide screen sees the bottom 512×288 (18 tiles tall, like the game's own view), // a tall phone screen sees more sky above it at about the game's phone zoom. The waterfront in front is the diff --git a/apps/datagutt/content/places.ts b/apps/datagutt/content/places.ts index 57d81da..5d9312c 100644 --- a/apps/datagutt/content/places.ts +++ b/apps/datagutt/content/places.ts @@ -1,4 +1,4 @@ -// The town's places (content/places.json) and what each one presents (docs/game/DESIGN.md +// The town's places (content/places.json) and what each one presents (docs/DESIGN.md // §5). Used for passport stamps, `?at=` deep links, the Journal's "visit in game" links // and the validator that checks every piece of content has a home. diff --git a/apps/datagutt/content/profile.ts b/apps/datagutt/content/profile.ts index 28461a9..fafd39d 100644 --- a/apps/datagutt/content/profile.ts +++ b/apps/datagutt/content/profile.ts @@ -1,5 +1,5 @@ // Who datagutt is (content/profile.json). Single source for the game's dialogue and the -// Journal (docs/game/DESIGN.md §12). Keep facts there, not in components or Ink files. +// Journal (docs/DESIGN.md §12). Keep facts there, not in components or Ink files. import { content } from "./index.ts"; export type Profile = typeof content.profile; diff --git a/apps/datagutt/content/schema.ts b/apps/datagutt/content/schema.ts index db3248c..88f1a5e 100644 --- a/apps/datagutt/content/schema.ts +++ b/apps/datagutt/content/schema.ts @@ -55,7 +55,7 @@ export const collections = { { body: "description" }, ), /** - * The town's places and what each one presents (docs/game/DESIGN.md §5): passport + * The town's places and what each one presents (docs/DESIGN.md §5): passport * stamps, `?at=` deep links, the Journal's "visit in game" links, and the check that * every piece of content has a home. */ diff --git a/docs/game/ART.md b/apps/datagutt/docs/ART.md similarity index 100% rename from docs/game/ART.md rename to apps/datagutt/docs/ART.md diff --git a/docs/game/DESIGN.md b/apps/datagutt/docs/DESIGN.md similarity index 100% rename from docs/game/DESIGN.md rename to apps/datagutt/docs/DESIGN.md diff --git a/docs/game/HANDOFF.md b/apps/datagutt/docs/HANDOFF.md similarity index 99% rename from docs/game/HANDOFF.md rename to apps/datagutt/docs/HANDOFF.md index a5968a4..21c76cd 100644 --- a/docs/game/HANDOFF.md +++ b/apps/datagutt/docs/HANDOFF.md @@ -336,7 +336,7 @@ Last updated: 2026-09-23 (session 1: design through M5; the Nettbureau office on - LimeZu has no straw hat that leaves the eyes visible in the portrait (the "Detective" hat covers them), no headphones, no tank top and no hard hat. -- **M3.9 seasons done** (docs/game/ART.md, "Seasons"). The generator writes a swap +- **M3.9 seasons done** (docs/ART.md, "Seasons"). The generator writes a swap table per season into outdoor maps (`season:` map properties; only `town` is outdoor) and `WorldScene` applies it to a cached copy of the map (`game/world/season.ts`: `seasonOn` uses Europe/Oslo, `?debug&season=` overrides). @@ -444,7 +444,7 @@ Last updated: 2026-09-23 (session 1: design through M5; the Nettbureau office on block (user hit an invisible wall along the farmhouse's shadow). - User ideas queued in PLAN.md: M3.12 closed doors read as closed, M5.10 interaction prompts, M5.11 title screen v2. - - **Art handling rules are in docs/game/ART.md** (user found half-cut sprites): use + - **Art handling rules are in docs/ART.md** (user found half-cut sprites): use LimeZu singles via `single(sheet, key)`, pick them from the contact sheets that `pnpm world:catalog` writes, and `world:gen` fails on prefabs that cut an object. - Every building has a notice-board sign with its name and a line of flavour diff --git a/docs/game/PLAN.md b/apps/datagutt/docs/PLAN.md similarity index 99% rename from docs/game/PLAN.md rename to apps/datagutt/docs/PLAN.md index 0ab9007..2d7efe5 100644 --- a/docs/game/PLAN.md +++ b/apps/datagutt/docs/PLAN.md @@ -24,7 +24,7 @@ Goal: repo, assets and toolchain ready so game code can start. Issue: see README.md (issue links table). - [x] **M0.1** Create the `game` branch. *Done when* the branch exists. -- [x] **M0.2** Write DESIGN, PLAN, HANDOFF and README under `docs/game/`. *Done when* +- [x] **M0.2** Write DESIGN, PLAN, HANDOFF and README under `docs/`. *Done when* committed. - [x] **M0.3** Session continuity: SessionStart hook injects HANDOFF and open tasks; Stop hook blocks when code commits are newer than the last HANDOFF commit. *Done when* diff --git a/docs/game/README.md b/apps/datagutt/docs/README.md similarity index 100% rename from docs/game/README.md rename to apps/datagutt/docs/README.md diff --git a/apps/datagutt/eslint.config.mjs b/apps/datagutt/eslint.config.mjs index f76c607..e852187 100644 --- a/apps/datagutt/eslint.config.mjs +++ b/apps/datagutt/eslint.config.mjs @@ -11,7 +11,7 @@ const config = [ patterns: [ { group: ["@/app", "@/app/*", "@/components", "@/components/*", "@/hooks", "@/hooks/*", "@/lib", "@/lib/*"], - message: "game/ may not import Next.js app code. Pass data in through the WorldState payload (docs/game/PLAN.md M2.2).", + message: "game/ may not import Next.js app code. Pass data in through the WorldState payload (docs/PLAN.md M2.2).", }, ], }), diff --git a/apps/datagutt/game/arcade/ids.ts b/apps/datagutt/game/arcade/ids.ts index 1bee42a..9a3279a 100644 --- a/apps/datagutt/game/arcade/ids.ts +++ b/apps/datagutt/game/arcade/ids.ts @@ -1,7 +1,7 @@ // The cabinets a map can place (the `arcade` map object's `game`). Kept apart from the // games themselves, so the map build can check ids without loading any of them. // Besides the youth club's five: Thomas's PC screensaver and the binoculars on the radio -// hill (docs/game/PLAN.md B2). +// hill (docs/PLAN.md B2). export const ARCADE_IDS = ["blocks", "life", "terrain", "dungeon", "starfield", "screensaver", "stargazing"] as const; export type ArcadeId = (typeof ARCADE_IDS)[number]; diff --git a/apps/datagutt/game/dialogue/externals.ts b/apps/datagutt/game/dialogue/externals.ts index 58d84b9..8f87b62 100644 --- a/apps/datagutt/game/dialogue/externals.ts +++ b/apps/datagutt/game/dialogue/externals.ts @@ -1,4 +1,4 @@ -// Every function dialogue can call to read content or live data (docs/game/DESIGN.md §11). +// Every function dialogue can call to read content or live data (docs/DESIGN.md §11). // The build turns this registry into Ink EXTERNAL declarations and validates literal ids // in the .ink sources against it; the game binds the implementations at runtime. // Imported by Node build scripts too, hence the explicit .ts extensions. diff --git a/apps/datagutt/game/dialogue/ink/datagutt.ink b/apps/datagutt/game/dialogue/ink/datagutt.ink index 1b6adb9..723e559 100644 --- a/apps/datagutt/game/dialogue/ink/datagutt.ink +++ b/apps/datagutt/game/dialogue/ink/datagutt.ink @@ -56,7 +56,7 @@ + [Let him sleep.] -> END -// The finale (docs/game/PLAN.md M5.8). A full passport: a note, then Thomas at the end of +// The finale (docs/PLAN.md M5.8). A full passport: a note, then Thomas at the end of // the pier at night, the credits, and last of all how to reach him. === finale_note === \* Your passport is full! Tucked in the back is a note. diff --git a/apps/datagutt/game/dialogue/ink/ferryman.ink b/apps/datagutt/game/dialogue/ink/ferryman.ink index 02fbc9a..0a3fe7d 100644 --- a/apps/datagutt/game/dialogue/ink/ferryman.ink +++ b/apps/datagutt/game/dialogue/ink/ferryman.ink @@ -2,7 +2,7 @@ === ferryman === { - ferryman > 1: - // The mountain trail (docs/game/PLAN.md B6): Arne keeps the player posted. + // The mountain trail (docs/PLAN.md B6): Arne keeps the player posted. {unlocked("passport"): Heard the news? The council cleared the rockfall. The mountain trail's open again: up past the town hall, all the way to the hytte. - else: diff --git a/apps/datagutt/game/dialogue/ink/main.ink b/apps/datagutt/game/dialogue/ink/main.ink index c3b1a67..ca6accc 100644 --- a/apps/datagutt/game/dialogue/ink/main.ink +++ b/apps/datagutt/game/dialogue/ink/main.ink @@ -1,4 +1,4 @@ -// Fjord Town dialogue (docs/game/DESIGN.md §11). +// Fjord Town dialogue (docs/DESIGN.md §11). // Each NPC is a knot; the game jumps to it by name from the map's `dialogue` property. // Facts come from functions such as project_desc("irlserver") so the game and the // Journal can never disagree. Never paste a fact in here. Available functions are diff --git a/apps/datagutt/game/dialogue/links.ts b/apps/datagutt/game/dialogue/links.ts index 8a02506..d1f71b2 100644 --- a/apps/datagutt/game/dialogue/links.ts +++ b/apps/datagutt/game/dialogue/links.ts @@ -1,4 +1,4 @@ -// `# link:` tags in Ink (docs/game/PLAN.md M2.7): a line can offer to open something. +// `# link:` tags in Ink (docs/PLAN.md M2.7): a line can offer to open something. // # link: project irlserver the project's site // # link: social github a social profile // # link: email a mail to datagutt diff --git a/apps/datagutt/game/plugins/Intro.ts b/apps/datagutt/game/plugins/Intro.ts index ce409d6..aa778b6 100644 --- a/apps/datagutt/game/plugins/Intro.ts +++ b/apps/datagutt/game/plugins/Intro.ts @@ -1,4 +1,4 @@ -// The ferry intro (docs/game/PLAN.md M5.7), on a first visit only: the ferry sails in to +// The ferry intro (docs/PLAN.md M5.7), on a first visit only: the ferry sails in to // the pier with the player aboard, the player hops ashore, and Arne says hello (with the // passport, the controls and the Journal). Any key, button or tap skips the sailing. // The ferry is part of the map; for the voyage its tiles are lifted into a moving group diff --git a/apps/datagutt/game/plugins/cat.ts b/apps/datagutt/game/plugins/cat.ts index e50a42b..d5abbb6 100644 --- a/apps/datagutt/game/plugins/cat.ts +++ b/apps/datagutt/game/plugins/cat.ts @@ -1,4 +1,4 @@ -// The hidden cat (docs/game/PLAN.md B3): it lies across its tile and the next one east, and +// The hidden cat (docs/PLAN.md B3): it lies across its tile and the next one east, and // petting it earns "cat". import { TILE } from "@datagutt/kai"; import { perWorld, tileKey, type KaiPlugin } from "@datagutt/kai"; diff --git a/apps/datagutt/lib/weather.ts b/apps/datagutt/lib/weather.ts index d783e26..bffe166 100644 --- a/apps/datagutt/lib/weather.ts +++ b/apps/datagutt/lib/weather.ts @@ -3,7 +3,7 @@ import { getWeather as cachedWeather } from "@datagutt/kai-next/weather"; import { FALLBACK_PLACE } from "@/content/live"; import { kaiConfig } from "@/lib/kai"; -// The game's sky follows the visitor's weather (docs/game/PLAN.md C2), placed by Vercel's +// The game's sky follows the visitor's weather (docs/PLAN.md C2), placed by Vercel's // IP location headers. Without those (local runs, or a place Vercel can't tell) the sky // is Oslo's. diff --git a/apps/datagutt/lib/world-state.ts b/apps/datagutt/lib/world-state.ts index 24f5d60..8245348 100644 --- a/apps/datagutt/lib/world-state.ts +++ b/apps/datagutt/lib/world-state.ts @@ -6,7 +6,7 @@ import { kaiConfig } from "@/lib/kai"; import { getDiscordId } from "./lanyard"; /** - * Live data for the game and the Journal (docs/game/PLAN.md M2.2), apart from the weather, + * Live data for the game and the Journal (docs/PLAN.md M2.2), apart from the weather, * which depends on the visitor (components/game/WorldStateScript.tsx adds it). Its explicit * lifetime overrides the inner caches', so it mirrors their rule: hours when everything * arrived, minutes if any source came back empty, so a failed fetch is retried soon. diff --git a/apps/datagutt/playwright.config.ts b/apps/datagutt/playwright.config.ts index af31e36..213db19 100644 --- a/apps/datagutt/playwright.config.ts +++ b/apps/datagutt/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ projects: [ { name: "desktop", use: { ...devices["Desktop Chrome"] } }, { name: "phone", use: { ...devices["Pixel 7"] } }, - // The QA matrix (docs/game/PLAN.md M6.6): `bun run test:e2e:all`. Off by default, since + // The QA matrix (docs/PLAN.md M6.6): `bun run test:e2e:all`. Off by default, since // WebKit needs system libraries (`sudo bunx playwright install-deps webkit`). ...(process.env.E2E_ALL_BROWSERS ? [ diff --git a/apps/datagutt/world/gen/maps/house.ts b/apps/datagutt/world/gen/maps/house.ts index 1ede1c7..aae37be 100644 --- a/apps/datagutt/world/gen/maps/house.ts +++ b/apps/datagutt/world/gen/maps/house.ts @@ -85,7 +85,7 @@ export function houseUpstairs(): MapCanvas { // when offline, out in town otherwise. c.add({ type: "spot", id: "datagutt-desk", x: 6, y: 5, facing: "up" }); c.add({ type: "spot", id: "datagutt-bed", x: 3, y: 7, facing: "down" }); - // The left computer idles on a Game of Life screensaver (docs/game/PLAN.md B2). + // The left computer idles on a Game of Life screensaver (docs/PLAN.md B2). c.add({ type: "arcade", x: 5, y: 4, game: "screensaver" }); c.add({ type: "sign", x: 7, y: 4, text: say("computers") }); c.add({ type: "sign", x: 9, y: 3, text: say("mini-fridge") }); diff --git a/apps/datagutt/world/gen/maps/mountain.ts b/apps/datagutt/world/gen/maps/mountain.ts index f338094..089717d 100644 --- a/apps/datagutt/world/gen/maps/mountain.ts +++ b/apps/datagutt/world/gen/maps/mountain.ts @@ -1,4 +1,4 @@ -// Up the mountain trail (docs/game/PLAN.md B6): open once the passport is full. A path +// Up the mountain trail (docs/PLAN.md B6): open once the passport is full. A path // climbs from the trailhead through a meadow ringed with pines to the hytte, with a bench // at a lookout over the fjord. The hytte is locked for now: later quests start here. import { variant } from "@datagutt/kai-worldgen/autotile"; diff --git a/apps/datagutt/world/gen/maps/overworld.ts b/apps/datagutt/world/gen/maps/overworld.ts index 2e1ff3b..2cf3da7 100644 --- a/apps/datagutt/world/gen/maps/overworld.ts +++ b/apps/datagutt/world/gen/maps/overworld.ts @@ -1,5 +1,5 @@ // The overworld, map id `town`: Fjord Town on the north shore of a fjord -// (docs/game/DESIGN.md §5). Approved by the user on 2026-09-23 (M3.7). +// (docs/DESIGN.md §5). Approved by the user on 2026-09-23 (M3.7). // // x: 0-4 forest | 5-34 farm, datagutt's street, boathouse | 35-62 library, town hall, // square, post office, harbour | 63-90 radio hill, office, gym | 91-95 forest diff --git a/apps/datagutt/world/gen/maps/youthClub.ts b/apps/datagutt/world/gen/maps/youthClub.ts index 2ca9fac..a5f7a5e 100644 --- a/apps/datagutt/world/gen/maps/youthClub.ts +++ b/apps/datagutt/world/gen/maps/youthClub.ts @@ -1,5 +1,5 @@ // The youth club (places: youth-club): a site cabin on the harbour road where the old -// website's five canvases live on as arcade cabinets (docs/game/PLAN.md B1). Teal walls, +// website's five canvases live on as arcade cabinets (docs/PLAN.md B1). Teal walls, // dark boards; the cabinets along the back wall, the TV corner with its rug and bean bags, // a pool table, and Siv who runs the place. import type { MapObject } from "@datagutt/kai/world/objects"; diff --git a/apps/datagutt/world/gen/title.ts b/apps/datagutt/world/gen/title.ts index bd11bf8..2565815 100644 --- a/apps/datagutt/world/gen/title.ts +++ b/apps/datagutt/world/gen/title.ts @@ -1,4 +1,4 @@ -// The title screen's foreground (docs/game/PLAN.md M5.11): the waterfront drawn from the +// The title screen's foreground (docs/PLAN.md M5.11): the waterfront drawn from the // game's own tiles, rendered to public/game/ui/title.png by `bun run assets`. Nothing is // painted above the tree line, so the page's drawn sky and mountains show through it // (components/game/TitleArt.tsx). Its horizontal middle, the pier, lines up with the diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index f825a5f..6536759 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -206,7 +206,7 @@ Goal: a second app proves the engine needs nothing from datagutt. ## K7: docs -- [ ] **K7.1** Move `docs/game/` to `apps/datagutt/docs/` and update the `.claude` hooks. +- [x] **K7.1** Move `docs/game/` to `apps/datagutt/docs/` and update the `.claude` hooks. *Done when* the SessionStart hook prints the right handoff on both branches. - [ ] **K7.2** `docs/kai/`: architecture and package map, plugin API, content schemas, the CLI, "start a new game from the sandbox". *Done when* written. diff --git a/packages/kai-limezu/src/interior.ts b/packages/kai-limezu/src/interior.ts index a54b352..ef85ad9 100644 --- a/packages/kai-limezu/src/interior.ts +++ b/packages/kai-limezu/src/interior.ts @@ -1,4 +1,4 @@ -// Interior rooms from LimeZu's Room Builder (docs/game/PLAN.md M3.8): a two-tile wall +// Interior rooms from LimeZu's Room Builder (apps/datagutt/docs/PLAN.md M3.8): a two-tile wall // face along the top, a patterned floor, a thin wall-top border ringing the room and // black void outside it. Doors are gaps in the bottom border. import type { TileRef } from "@datagutt/kai-worldgen/autotile"; diff --git a/packages/kai-net/src/protocol.ts b/packages/kai-net/src/protocol.ts index e616e23..4bd8996 100644 --- a/packages/kai-net/src/protocol.ts +++ b/packages/kai-net/src/protocol.ts @@ -1,4 +1,4 @@ -// The world socket's messages (docs/game/PLAN.md M4.3): visitors see each other as ghosts. +// The world socket's messages (apps/datagutt/docs/PLAN.md M4.3): visitors see each other as ghosts. // One room per map. A client joins the room of the map it is on and reports its tile when // it changes; the server gives each connection a random name and tint, tells the room // who came, moved, emoted or left, and never echoes a message back to its sender. diff --git a/packages/kai-worldgen/src/registry.ts b/packages/kai-worldgen/src/registry.ts index 8bd17e6..332bd48 100644 --- a/packages/kai-worldgen/src/registry.ts +++ b/packages/kai-worldgen/src/registry.ts @@ -1,4 +1,4 @@ -// Stable tile ids for generated maps (docs/game/PLAN.md M3.1). Maps store ids into one +// Stable tile ids for generated maps (apps/datagutt/docs/PLAN.md M3.1). Maps store ids into one // packed tileset; this registry says which LimeZu tile each id is. It is append-only, so // committed maps keep meaning the same tiles when new tiles are registered. import type { TileRef } from "./autotile.ts"; diff --git a/packages/kai-worldgen/src/validate.ts b/packages/kai-worldgen/src/validate.ts index 83911a8..1db8812 100644 --- a/packages/kai-worldgen/src/validate.ts +++ b/packages/kai-worldgen/src/validate.ts @@ -1,4 +1,4 @@ -// Map checks run on every generation (docs/game/PLAN.md M3.6): catches NPCs in walls, +// Map checks run on every generation (apps/datagutt/docs/PLAN.md M3.6): catches NPCs in walls, // signs nobody can reach, blocked doors and stacked objects. import { parseMapObject, type MapObject, type TiledObject } from "@datagutt/kai/world/objects"; import { collisionOf } from "./render.ts"; diff --git a/packages/kai/src/audio/Ambience.ts b/packages/kai/src/audio/Ambience.ts index 202f07d..c19b855 100644 --- a/packages/kai/src/audio/Ambience.ts +++ b/packages/kai/src/audio/Ambience.ts @@ -1,4 +1,4 @@ -// The sound of the town (docs/game/PLAN.md M5.6; DESIGN §15), all synthesised with Web +// The sound of the town (apps/datagutt/docs/PLAN.md M5.6; DESIGN §15), all synthesised with Web // Audio: no recordings to load or license. Buses: master into Phaser's output (so the mute // setting holds), then ambience and effects; the music has its own (Music.ts). Ambience // layers run all the time and glide to the levels mix.ts asks for, so walking about, and diff --git a/packages/kai/src/audio/mix.ts b/packages/kai/src/audio/mix.ts index 06248bc..77225ea 100644 --- a/packages/kai/src/audio/mix.ts +++ b/packages/kai/src/audio/mix.ts @@ -1,4 +1,4 @@ -// What the ambience should sound like where the player stands (docs/game/PLAN.md M5.6): +// What the ambience should sound like where the player stands (apps/datagutt/docs/PLAN.md M5.6): // a level from 0 to 1 for each layer, from how near the sea, the forest and a fire are, // whether it's indoors, the time of day, the season and the weather. Pure, so it can be // tested; the sounds themselves are in Ambience.ts. diff --git a/packages/kai/src/audio/sfx.ts b/packages/kai/src/audio/sfx.ts index 6e48255..b1accd9 100644 --- a/packages/kai/src/audio/sfx.ts +++ b/packages/kai/src/audio/sfx.ts @@ -1,4 +1,4 @@ -// Small synthesised sound effects (docs/game/DESIGN.md §15: effects are made in code). +// Small synthesised sound effects (apps/datagutt/docs/DESIGN.md §15: effects are made in code). // Same output rule as blips: read Phaser's Web Audio context lazily, it can change. export type AudioOutput = () => { context: AudioContext; destination: AudioNode } | null; diff --git a/packages/kai/src/dev/simulatedGhosts.ts b/packages/kai/src/dev/simulatedGhosts.ts index 0c2ae2c..fabb05e 100644 --- a/packages/kai/src/dev/simulatedGhosts.ts +++ b/packages/kai/src/dev/simulatedGhosts.ts @@ -1,4 +1,4 @@ -// Fake visitors for `?debug&ghosts=` (docs/game/PLAN.md M4.4: smooth with 20 ghosts on +// Fake visitors for `?debug&ghosts=` (apps/datagutt/docs/PLAN.md M4.4: smooth with 20 ghosts on // a phone). They join around the player and wander one tile at a time, fed to the ghost // layer as if the room had sent them. import type { CollisionGrid, Point } from "../world/grid.ts"; diff --git a/packages/kai/src/entities/Ghosts.ts b/packages/kai/src/entities/Ghosts.ts index 4b2aace..350613c 100644 --- a/packages/kai/src/entities/Ghosts.ts +++ b/packages/kai/src/entities/Ghosts.ts @@ -1,5 +1,5 @@ // Other visitors on this map, drawn as translucent tinted copies of the player sprite -// (docs/game/PLAN.md M4.4). They walk from tile to tile as the room reports their moves, +// (apps/datagutt/docs/PLAN.md M4.4). They walk from tile to tile as the room reports their moves, // pass through everything, and never block anyone. Only the nearest are drawn, and one // who stands still long enough fades away until they move again. import Phaser from "phaser"; diff --git a/packages/kai/src/fx/Aurora.ts b/packages/kai/src/fx/Aurora.ts index bb97bc3..13cd2e3 100644 --- a/packages/kai/src/fx/Aurora.ts +++ b/packages/kai/src/fx/Aurora.ts @@ -1,4 +1,4 @@ -// Northern lights (docs/game/PLAN.md M5.3). The game looks straight down, so the aurora is +// Northern lights (apps/datagutt/docs/PLAN.md M5.3). The game looks straight down, so the aurora is // what you would glimpse overhead: curtains of light in the site's greens, drifting down // from the top of the screen on dark nights outdoors, strongest in winter and gone in the // light summer nights. Added over the world (and reflected in the fjord's ripples, diff --git a/packages/kai/src/fx/DayNight.ts b/packages/kai/src/fx/DayNight.ts index a770050..a565713 100644 --- a/packages/kai/src/fx/DayNight.ts +++ b/packages/kai/src/fx/DayNight.ts @@ -1,4 +1,4 @@ -// Time of day on screen (docs/game/PLAN.md M5.1; the clock is game/world/dayNight.ts). +// Time of day on screen (apps/datagutt/docs/PLAN.md M5.1; the clock is game/world/dayNight.ts). // Outdoors, a colour multiplied over the world tints it. It sits below the lights, which // add on top and so really glow after dark. Indoors nothing is tinted; daylight through // the windows fades at night. Timed lights follow the clock everywhere. diff --git a/packages/kai/src/fx/Feel.ts b/packages/kai/src/fx/Feel.ts index 3d04a24..7b487a1 100644 --- a/packages/kai/src/fx/Feel.ts +++ b/packages/kai/src/fx/Feel.ts @@ -1,4 +1,4 @@ -// Game feel (docs/game/PLAN.md M5.5): the small reactions that make moving around read as +// Game feel (apps/datagutt/docs/PLAN.md M5.5): the small reactions that make moving around read as // physical. Dust at each footstep, a nudge and a thud when walking into something, a // squash on stepping through a door, a shake when a stamp lands, and a camera that eases // after the player and looks a little ahead. Reduced motion drops the shake and the diff --git a/packages/kai/src/fx/Water.ts b/packages/kai/src/fx/Water.ts index f499d33..f08947c 100644 --- a/packages/kai/src/fx/Water.ts +++ b/packages/kai/src/fx/Water.ts @@ -1,4 +1,4 @@ -// The fjord (docs/game/PLAN.md M5.2): a shader over open water, which the generator marks +// The fjord (apps/datagutt/docs/PLAN.md M5.2): a shader over open water, which the generator marks // in a hidden `water` layer. It adds drifting ripples in the sky's colour from the time of // day, and slow glints (paler, moonlit ones at night), in whole pixels to match the art. Shore tiles are left alone: their art already has the beach and its foam. // Without WebGL there is no shader and the water stays as drawn. diff --git a/packages/kai/src/fx/Weather.ts b/packages/kai/src/fx/Weather.ts index 01c6be7..56979bf 100644 --- a/packages/kai/src/fx/Weather.ts +++ b/packages/kai/src/fx/Weather.ts @@ -1,4 +1,4 @@ -// The sky outdoors: the season's particles (docs/game/PLAN.md M5.4: snow and a frosty rim +// The sky outdoors: the season's particles (apps/datagutt/docs/PLAN.md M5.4: snow and a frosty rim // in winter, petals in spring, pollen in summer, leaves in autumn) and Oslo's real weather // (C2: rain, sleet, snow, fog, storms; world/weather.ts decides what shows). Everything // sits under the time-of-day tint (fx/DayNight.ts), so it darkens at night like the rest, diff --git a/packages/kai/src/fx/quality.ts b/packages/kai/src/fx/quality.ts index f3a8610..b12f45d 100644 --- a/packages/kai/src/fx/quality.ts +++ b/packages/kai/src/fx/quality.ts @@ -1,4 +1,4 @@ -// Effects quality (docs/game/PLAN.md M5.9). "high" has the water and aurora shaders and +// Effects quality (apps/datagutt/docs/PLAN.md M5.9). "high" has the water and aurora shaders and // full weather; "low" drops the shaders and thins the weather. The "auto" setting starts // high and watches the first seconds on each map: if frames run slow it drops to low for // the rest of the visit. diff --git a/packages/kai/src/input/InputController.ts b/packages/kai/src/input/InputController.ts index e4f9f80..7209999 100644 --- a/packages/kai/src/input/InputController.ts +++ b/packages/kai/src/input/InputController.ts @@ -1,5 +1,5 @@ // Merges keyboard, gamepad and pointer into one set of actions per frame -// (docs/game/PLAN.md M1.9). Scenes read `poll()` and never touch devices directly. +// (apps/datagutt/docs/PLAN.md M1.9). Scenes read `poll()` and never touch devices directly. import Phaser from "phaser"; import type { Facing } from "../world/objects.ts"; import { DirectionStack } from "./directionStack.ts"; diff --git a/packages/kai/src/ui/DialogueBox.ts b/packages/kai/src/ui/DialogueBox.ts index 8bfde10..ff6df64 100644 --- a/packages/kai/src/ui/DialogueBox.ts +++ b/packages/kai/src/ui/DialogueBox.ts @@ -1,4 +1,4 @@ -// In-engine dialogue box (docs/game/PLAN.md M1.6, growing into M2.4): a typewriter +// In-engine dialogue box (apps/datagutt/docs/PLAN.md M1.6, growing into M2.4): a typewriter // reveal of paged text, and a choice list for Ink choices. Input is fed in by the scene. import Phaser from "phaser"; import type { Facing } from "../world/objects.ts"; diff --git a/packages/kai/src/ui/EmoteWheel.ts b/packages/kai/src/ui/EmoteWheel.ts index ad8cbc8..80c7589 100644 --- a/packages/kai/src/ui/EmoteWheel.ts +++ b/packages/kai/src/ui/EmoteWheel.ts @@ -1,4 +1,4 @@ -// The emote wheel (docs/game/PLAN.md M4.5): the emotes a visitor can show others, in a +// The emote wheel (apps/datagutt/docs/PLAN.md M4.5): the emotes a visitor can show others, in a // ring around the player's head. Opened by holding interact or a long press on the player; // arrows or the stick move round it, interact picks, back or a tap elsewhere cancels. import Phaser from "phaser"; diff --git a/packages/kai/src/ui/Passport.ts b/packages/kai/src/ui/Passport.ts index 3af7d8e..0a8e67d 100644 --- a/packages/kai/src/ui/Passport.ts +++ b/packages/kai/src/ui/Passport.ts @@ -1,4 +1,4 @@ -// Fjord Passport UI (docs/game/PLAN.md M2.10): the stamp banner shown when a stamp is +// Fjord Passport UI (apps/datagutt/docs/PLAN.md M2.10): the stamp banner shown when a stamp is // earned, and the passport page listing every place. import Phaser from "phaser"; import type { GameData } from "../data.ts"; diff --git a/packages/kai/src/viewport.ts b/packages/kai/src/viewport.ts index f2e928e..44fb894 100644 --- a/packages/kai/src/viewport.ts +++ b/packages/kai/src/viewport.ts @@ -1,6 +1,6 @@ // How big the low-resolution game canvas is and how far the browser scales it up. // The canvas is scaled by a whole number of *device* pixels, so every game pixel is -// an exact square on screen whatever the devicePixelRatio (docs/game/DESIGN.md §13). +// an exact square on screen whatever the devicePixelRatio (apps/datagutt/docs/DESIGN.md §13). import { TILE } from "./constants.ts"; /** Never show fewer tiles than this on the short screen axis. */ diff --git a/packages/kai/src/world/dayNight.ts b/packages/kai/src/world/dayNight.ts index 0657442..ac5a855 100644 --- a/packages/kai/src/world/dayNight.ts +++ b/packages/kai/src/world/dayNight.ts @@ -1,4 +1,4 @@ -// Time of day (docs/game/PLAN.md M5.1), from the visitor's own clock: outdoors the world +// Time of day (apps/datagutt/docs/PLAN.md M5.1), from the visitor's own clock: outdoors the world // is tinted (a multiplied colour, fx/DayNight.ts) through dawn, day, dusk and night, and // lights marked for the night come on as it gets dark while daylight through windows fades. diff --git a/packages/kai/src/world/movement.ts b/packages/kai/src/world/movement.ts index c137c4f..1d53fe1 100644 --- a/packages/kai/src/world/movement.ts +++ b/packages/kai/src/world/movement.ts @@ -1,4 +1,4 @@ -// Tile-locked movement for the player and NPCs (docs/game/PLAN.md M1.5). Pure state, no +// Tile-locked movement for the player and NPCs (apps/datagutt/docs/PLAN.md M1.5). Pure state, no // Phaser, so the feel can be unit tested: // - Pressing the direction you already face walks immediately. // - Pressing a new direction while standing turns in place; keep holding it past the diff --git a/packages/kai/src/world/pathfind.ts b/packages/kai/src/world/pathfind.ts index ace0ed9..8d37e17 100644 --- a/packages/kai/src/world/pathfind.ts +++ b/packages/kai/src/world/pathfind.ts @@ -1,4 +1,4 @@ -// A* on the tile grid for tap/click-to-move (docs/game/PLAN.md M1.8). 4-directional, +// A* on the tile grid for tap/click-to-move (apps/datagutt/docs/PLAN.md M1.8). 4-directional, // with a small penalty for turning so paths prefer long straight runs, which reads as // deliberate walking rather than zig-zagging. import type { Point } from "./grid.ts"; From 493fb5799c46219e1b80b617d68f784ea7a6774c Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:34:09 +0200 Subject: [PATCH 140/153] chore(sandbox): stop tracking the generated content bundle Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- .gitignore | 2 + apps/datagutt/.gitignore | 1 - apps/sandbox/.kai/config.json | 39 ----- apps/sandbox/.kai/content.json | 127 -------------- apps/sandbox/.kai/schema/achievements.json | 37 ---- apps/sandbox/.kai/schema/characters.json | 84 --------- apps/sandbox/.kai/schema/credits.json | 49 ------ apps/sandbox/.kai/schema/music.json | 97 ----------- apps/sandbox/.kai/schema/npcs.json | 76 --------- apps/sandbox/.kai/schema/places.json | 53 ------ apps/sandbox/.kai/schema/strings.json | 187 --------------------- apps/sandbox/.kai/schema/triggers.json | 123 -------------- apps/sandbox/.kai/schema/unlocks.json | 26 --- 13 files changed, 2 insertions(+), 899 deletions(-) delete mode 100644 apps/sandbox/.kai/config.json delete mode 100644 apps/sandbox/.kai/content.json delete mode 100644 apps/sandbox/.kai/schema/achievements.json delete mode 100644 apps/sandbox/.kai/schema/characters.json delete mode 100644 apps/sandbox/.kai/schema/credits.json delete mode 100644 apps/sandbox/.kai/schema/music.json delete mode 100644 apps/sandbox/.kai/schema/npcs.json delete mode 100644 apps/sandbox/.kai/schema/places.json delete mode 100644 apps/sandbox/.kai/schema/strings.json delete mode 100644 apps/sandbox/.kai/schema/triggers.json delete mode 100644 apps/sandbox/.kai/schema/unlocks.json diff --git a/.gitignore b/.gitignore index 97d4fbb..43ddb29 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ node_modules/ coverage/ *.tsbuildinfo next-env.d.ts +# kai content output (`kai content`), written into each app +.kai/ # misc .DS_Store diff --git a/apps/datagutt/.gitignore b/apps/datagutt/.gitignore index eb3144f..b47efa7 100644 --- a/apps/datagutt/.gitignore +++ b/apps/datagutt/.gitignore @@ -8,4 +8,3 @@ # World generator renders and the Tiled tileset contain LimeZu pixels. /world/out/ /world/tilesets/ -/.kai/ diff --git a/apps/sandbox/.kai/config.json b/apps/sandbox/.kai/config.json deleted file mode 100644 index f61196c..0000000 --- a/apps/sandbox/.kai/config.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "sandbox", - "title": "Kai Sandbox", - "timezone": "UTC", - "startPlace": "workshop", - "saveKey": "kai-sandbox.save", - "visitorsOffKey": "kai_sandbox_visitors_off", - "basePath": "/game/", - "paths": { - "maps": "world/maps.ts", - "ink": "dialogue", - "dialogueHost": "dialogue/host.ts", - "harness": "src/main.ts" - }, - "assets": { - "repo": "datagutt/datagutt-assets", - "branch": "main", - "localPath": "../datagutt-assets", - "tokenEnv": "KAI_SANDBOX_ASSETS_TOKEN", - "adapter": "@datagutt/kai-limezu/adapter" - }, - "ui": { - "frame": { - "file": "limezu/ui/Modern_UI_Style_1.png", - "x": 58, - "y": 129, - "width": 28, - "height": 29 - }, - "emotes": "limezu/interiors/ui_elements/UI_thinking_emotes_animation_16x16.png" - }, - "sprites": {}, - "font": { - "module": "geist/font/pixel", - "file": "fonts/geist-pixel/GeistPixel-Square.woff2", - "unitsPerPixel": 76 - }, - "live": {} -} diff --git a/apps/sandbox/.kai/content.json b/apps/sandbox/.kai/content.json deleted file mode 100644 index 52b0afc..0000000 --- a/apps/sandbox/.kai/content.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "characters": { - "player": { - "description": "The visitor.", - "layers": [ - "Bodies/Body_03.png", - "Eyes/Eyes_01.png", - "Outfits/Outfit_01_05.png", - "Hairstyles/Hairstyle_05_03.png" - ], - "placeholder": "4f8a5b", - "portrait": false - }, - "keeper": { - "description": "The keeper: an apron over a checked shirt.", - "layers": [ - "Bodies/Body_02.png", - "Eyes/Eyes_04.png", - "Outfits/Outfit_09_01.png", - "Hairstyles/Hairstyle_11_01.png" - ], - "placeholder": "c46a3a" - } - }, - "npcs": { - "keeper": { - "name": "Keeper", - "place": "workshop", - "personality": "Keeps the workshop tidy and explains the engine to anyone who asks.", - "voice": { - "wave": "triangle", - "pitch": 330, - "variance": 0.05, - "volume": 0.07, - "every": 2 - } - } - }, - "music": { - "tracks": { - "theme": { - "file": "towballs-crossing-deluxe/Towballs Crossing Deluxe! Loopable Tracks/05 At the Farmers Market (Loopable Version).mp3" - } - }, - "playlist": { - "title": "theme", - "credits": "theme", - "outdoors": { - "day": "theme", - "night": "theme", - "winter": "theme" - }, - "indoors": { - "default": "theme", - "maps": {} - } - } - }, - "achievements": { - "list": [ - { - "id": "arrived", - "name": "Arrived", - "how": "Walked into the workshop." - }, - { - "id": "passport", - "name": "Stamped", - "how": "Got the workshop's stamp." - } - ] - }, - "unlocks": {}, - "credits": { - "title": "Kai Sandbox", - "byline": "The smallest kai game", - "sections": [ - { - "heading": "Art", - "lines": [ - "LimeZu (limezu.itch.io)", - "or placeholders" - ] - }, - { - "heading": "Code", - "lines": [ - "GPL-3.0 on GitHub", - "datagutt/datagutt.no" - ] - } - ], - "thanks": "Now make your own." - }, - "triggers": { - "list": [ - { - "on": "enterMap", - "map": "workshop", - "delay": 300, - "grant": "arrived", - "announce": true - }, - { - "on": "passportFull", - "grant": "passport", - "announce": true - } - ] - }, - "strings": { - "passport.title": "Workshop pass" - }, - "places": { - "list": [ - { - "id": "workshop", - "name": "Workshop", - "stamp": true, - "entrance": { - "map": "workshop", - "spawn": "start" - } - } - ] - } -} diff --git a/apps/sandbox/.kai/schema/achievements.json b/apps/sandbox/.kai/schema/achievements.json deleted file mode 100644 index 37550ca..0000000 --- a/apps/sandbox/.kai/schema/achievements.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "list": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string", - "minLength": 1 - }, - "how": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "id", - "name", - "how" - ] - } - } - }, - "required": [ - "list" - ] -} diff --git a/apps/sandbox/.kai/schema/characters.json b/apps/sandbox/.kai/schema/characters.json deleted file mode 100644 index 1b78a6d..0000000 --- a/apps/sandbox/.kai/schema/characters.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "additionalProperties": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "layers": { - "minItems": 1, - "type": "array", - "items": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "object", - "properties": { - "file": { - "type": "string", - "minLength": 1 - }, - "portrait": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "file", - "portrait" - ] - } - ] - } - }, - "recolor": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[0-9a-f]{6}([0-9a-f]{2})?$" - }, - "additionalProperties": { - "type": "string", - "pattern": "^[0-9a-f]{6}([0-9a-f]{2})?$" - } - }, - "placeholder": { - "type": "string", - "pattern": "^[0-9a-f]{6}([0-9a-f]{2})?$" - }, - "portrait": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - { - "type": "boolean", - "const": false - } - ] - } - }, - "required": [ - "layers", - "placeholder" - ] - }, - "properties": { - "$schema": { - "type": "string" - } - } -} diff --git a/apps/sandbox/.kai/schema/credits.json b/apps/sandbox/.kai/schema/credits.json deleted file mode 100644 index 842c17e..0000000 --- a/apps/sandbox/.kai/schema/credits.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "title": { - "type": "string", - "minLength": 1 - }, - "byline": { - "type": "string", - "minLength": 1 - }, - "sections": { - "type": "array", - "items": { - "type": "object", - "properties": { - "heading": { - "type": "string", - "minLength": 1 - }, - "lines": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "heading", - "lines" - ] - } - }, - "thanks": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "title", - "byline", - "sections", - "thanks" - ] -} diff --git a/apps/sandbox/.kai/schema/music.json b/apps/sandbox/.kai/schema/music.json deleted file mode 100644 index 7421aed..0000000 --- a/apps/sandbox/.kai/schema/music.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "tracks": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "additionalProperties": { - "type": "object", - "properties": { - "file": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "file" - ] - } - }, - "playlist": { - "type": "object", - "properties": { - "title": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "credits": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "outdoors": { - "type": "object", - "properties": { - "day": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "night": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "winter": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - } - }, - "required": [ - "day", - "night", - "winter" - ] - }, - "indoors": { - "type": "object", - "properties": { - "default": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "maps": { - "default": {}, - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "additionalProperties": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - } - } - }, - "required": [ - "default" - ] - } - }, - "required": [ - "title", - "credits", - "outdoors", - "indoors" - ] - } - }, - "required": [ - "tracks", - "playlist" - ] -} diff --git a/apps/sandbox/.kai/schema/npcs.json b/apps/sandbox/.kai/schema/npcs.json deleted file mode 100644 index 253d1c0..0000000 --- a/apps/sandbox/.kai/schema/npcs.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "additionalProperties": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "place": { - "type": "string", - "minLength": 1 - }, - "personality": { - "type": "string" - }, - "presents": { - "type": "string" - }, - "voice": { - "type": "object", - "properties": { - "wave": { - "type": "string", - "enum": [ - "sine", - "square", - "sawtooth", - "triangle" - ] - }, - "pitch": { - "type": "number", - "exclusiveMinimum": 0 - }, - "variance": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "volume": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "every": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "wave", - "pitch", - "variance", - "volume", - "every" - ] - } - }, - "required": [ - "name", - "voice" - ] - }, - "properties": { - "$schema": { - "type": "string" - } - } -} diff --git a/apps/sandbox/.kai/schema/places.json b/apps/sandbox/.kai/schema/places.json deleted file mode 100644 index ac5b5c5..0000000 --- a/apps/sandbox/.kai/schema/places.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "list": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "name": { - "type": "string", - "minLength": 1 - }, - "stamp": { - "type": "boolean" - }, - "entrance": { - "type": "object", - "properties": { - "map": { - "type": "string", - "minLength": 1 - }, - "spawn": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "map", - "spawn" - ] - } - }, - "required": [ - "id", - "name", - "stamp" - ] - } - } - }, - "required": [ - "list" - ] -} diff --git a/apps/sandbox/.kai/schema/strings.json b/apps/sandbox/.kai/schema/strings.json deleted file mode 100644 index 52e8815..0000000 --- a/apps/sandbox/.kai/schema/strings.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "default": {}, - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "menu.title": { - "type": "string", - "minLength": 1 - }, - "menu.button": { - "type": "string", - "minLength": 1 - }, - "menu.passport": { - "type": "string", - "minLength": 1 - }, - "menu.status": { - "type": "string", - "minLength": 1 - }, - "menu.journal": { - "type": "string", - "minLength": 1 - }, - "menu.settings": { - "type": "string", - "minLength": 1 - }, - "menu.credits": { - "type": "string", - "minLength": 1 - }, - "menu.close": { - "type": "string", - "minLength": 1 - }, - "menu.back": { - "type": "string", - "minLength": 1 - }, - "menu.backHint": { - "type": "string", - "minLength": 1 - }, - "status.title": { - "type": "string", - "minLength": 1 - }, - "credits.title": { - "type": "string", - "minLength": 1 - }, - "settings.title": { - "type": "string", - "minLength": 1 - }, - "settings.sound": { - "type": "string", - "minLength": 1 - }, - "settings.music": { - "type": "string", - "minLength": 1 - }, - "settings.visitors": { - "type": "string", - "minLength": 1 - }, - "settings.effects": { - "type": "string", - "minLength": 1 - }, - "settings.reducedMotion": { - "type": "string", - "minLength": 1 - }, - "value.on": { - "type": "string", - "minLength": 1 - }, - "value.off": { - "type": "string", - "minLength": 1 - }, - "value.auto": { - "type": "string", - "minLength": 1 - }, - "value.high": { - "type": "string", - "minLength": 1 - }, - "value.low": { - "type": "string", - "minLength": 1 - }, - "passport.title": { - "type": "string", - "minLength": 1 - }, - "passport.achievements": { - "type": "string", - "minLength": 1 - }, - "passport.pageHint": { - "type": "string", - "minLength": 1 - }, - "passport.stampsHint": { - "type": "string", - "minLength": 1 - }, - "passport.fullHint": { - "type": "string", - "minLength": 1 - }, - "passport.achievementsHint": { - "type": "string", - "minLength": 1 - }, - "passport.unknown": { - "type": "string", - "minLength": 1 - }, - "toast.stamped": { - "type": "string", - "minLength": 1 - }, - "toast.achievement": { - "type": "string", - "minLength": 1 - }, - "prompt.talk": { - "type": "string", - "minLength": 1 - }, - "prompt.read": { - "type": "string", - "minLength": 1 - }, - "prompt.enter": { - "type": "string", - "minLength": 1 - }, - "prompt.wake": { - "type": "string", - "minLength": 1 - }, - "prompt.play": { - "type": "string", - "minLength": 1 - }, - "prompt.look": { - "type": "string", - "minLength": 1 - }, - "prompt.use": { - "type": "string", - "minLength": 1 - }, - "prompt.pet": { - "type": "string", - "minLength": 1 - }, - "link.open": { - "type": "string", - "minLength": 1 - }, - "link.notNow": { - "type": "string", - "minLength": 1 - }, - "arcade.leave": { - "type": "string", - "minLength": 1 - }, - "arcade.leaveTouch": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false -} diff --git a/apps/sandbox/.kai/schema/triggers.json b/apps/sandbox/.kai/schema/triggers.json deleted file mode 100644 index ae8142e..0000000 --- a/apps/sandbox/.kai/schema/triggers.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "default": { - "list": [] - }, - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "list": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "on": { - "type": "string", - "const": "enterMap" - }, - "map": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "delay": { - "default": 0, - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "knot": { - "type": "string", - "minLength": 1 - }, - "grant": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "announce": { - "default": true, - "type": "boolean" - }, - "flag": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "on", - "map" - ] - }, - { - "type": "object", - "properties": { - "on": { - "type": "string", - "const": "bumpEdge" - }, - "cooldown": { - "default": 0, - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "knot": { - "type": "string", - "minLength": 1 - }, - "grant": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "announce": { - "default": true, - "type": "boolean" - }, - "flag": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "on" - ] - }, - { - "type": "object", - "properties": { - "on": { - "type": "string", - "const": "passportFull" - }, - "knot": { - "type": "string", - "minLength": 1 - }, - "grant": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "announce": { - "default": true, - "type": "boolean" - }, - "flag": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "on" - ] - } - ] - } - } - }, - "required": [ - "list" - ] -} diff --git a/apps/sandbox/.kai/schema/unlocks.json b/apps/sandbox/.kai/schema/unlocks.json deleted file mode 100644 index 3d6d68c..0000000 --- a/apps/sandbox/.kai/schema/unlocks.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "default": {}, - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - }, - "additionalProperties": { - "type": "object", - "properties": { - "stamps": { - "type": "string", - "const": "all" - } - }, - "required": [ - "stamps" - ] - }, - "properties": { - "$schema": { - "type": "string" - } - } -} From dde7b9b8c4f58f865946fcd7ebad620bce8b18df Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:36:36 +0200 Subject: [PATCH 141/153] docs(kai): write the architecture, plugin, content and new game guides Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- docs/kai/ARCHITECTURE.md | 124 +++++++++++++++++++++++++++++++++++ docs/kai/CONTENT.md | 91 ++++++++++++++++++++++++++ docs/kai/DESIGN.md | 7 +- docs/kai/NEW-GAME.md | 59 +++++++++++++++++ docs/kai/PLAN.md | 2 +- docs/kai/PLUGINS.md | 138 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 docs/kai/ARCHITECTURE.md create mode 100644 docs/kai/CONTENT.md create mode 100644 docs/kai/NEW-GAME.md create mode 100644 docs/kai/PLUGINS.md diff --git a/docs/kai/ARCHITECTURE.md b/docs/kai/ARCHITECTURE.md new file mode 100644 index 0000000..685d4a5 --- /dev/null +++ b/docs/kai/ARCHITECTURE.md @@ -0,0 +1,124 @@ +# kai: architecture + +kai is the engine behind Fjord Town: a top-down pixel-art walking game with NPCs, Ink +dialogue, doors, a passport, day and night, weather and other visitors as ghosts. It +exists as packages so that a second game can reuse all of it and bring only its own +content, maps, dialogue and plugins. The decisions behind this layout are in +[DESIGN.md](./DESIGN.md). + +Other pages: [PLUGINS.md](./PLUGINS.md) (the plugin API and triggers), +[CONTENT.md](./CONTENT.md) (`kai.json` and the content collections), +[NEW-GAME.md](./NEW-GAME.md) (start a new game from the sandbox). + +## Runtime and build time + +Every package is on one side of a line, and the line is what keeps the browser bundle +small and the engine free of any one game. + +- **Runtime** code runs in the visitor's browser (or, for live data, on the server + that renders the page). It is `@datagutt/kai`, `kai-net`, `kai-live`, `kai-arcade`, + and the host glue `kai-next`. +- **Build time** code runs in `kai` CLI commands and never ships: `kai-worldgen`, + `kai-limezu` and `kai-assets`. +- The schemas sit on the line. `@datagutt/kai/schema` (and `kai-live`'s + `presence/config.ts`) is Zod, which only the build runs. Runtime code imports only + their types, so it reads already validated JSON without carrying Zod. + +ESLint enforces this (`tooling/eslint-config/boundaries.js`): packages never import an +app, runtime code never imports build time packages or schema values, and the runtime +never imports Next or React. + +## Packages + +- `@datagutt/kai`: the runtime. `createGame()`, the Phaser scenes, entities, input, UI, + effects, synthesised audio, the Ink dialogue runner, saves, progress and the passport, + the world runtime (grid, pathfinding, map objects, seasons, day and night), the + character sheet layout, `GameData`, the plugin API and the triggers plugin. Phaser is + a peer dependency. +- `@datagutt/kai-net`: other visitors. The ghost room protocol, the browser client with + reconnects, the room logic and a Node attachment for the socket server. +- `@datagutt/kai-live`: live data. Lanyard (Discord presence), MET Norway weather, the + GitHub fetchers, the WorldState payload a page embeds, and two plugins: + `presenceNpc` (an NPC who follows someone's Discord presence) and `githubObjects` + (map objects drawn from GitHub data). +- `@datagutt/kai-arcade`: small cabinet games and the arcade screen that runs them. +- `@datagutt/kai-next`: running a kai game inside a Next page. The `useKaiGame` and + `useMenuKeys` hooks, the live data script, cached wrappers for the live fetchers and + the world socket route handler. +- `@datagutt/kai-worldgen`: the map toolkit with no art of its own. The map canvas, + autotiling, layout helpers, the tile registry, the `.tmj` writer, validation, + rendering and the atlas packer. Art reaches it through a `SheetSource`. +- `@datagutt/kai-limezu`: the art adapter for LimeZu's Modern Exteriors, Interiors and + Office packs. Terrain blocks, sheet and single references, the catalogue, generic + prefabs, furniture, lighting, seasonal rules, interiors, landscape features, cut + checks and character composition. +- `@datagutt/kai-assets`: the asset pipeline and the `kai` command. + +Dependencies point one way. `kai-net` depends on nothing of kai. `kai` depends on +`kai-net`. `kai-live`, `kai-arcade`, `kai-worldgen` and `kai-limezu` depend on `kai`. +`kai-next` depends on `kai`, `kai-live` and `kai-net`. `kai-assets` depends on `kai`, +`kai-net` and `kai-worldgen`, and reaches the art adapter only through the module that +`kai.json` names. + +`tooling/` holds the shared TypeScript, ESLint and Vitest presets. + +## How a game is put together + +1. `kai.json` is the game's static configuration: its id, save key, time zone, start + place, asset source and the modules the build loads. +2. `kai content` checks `content/` and `kai.json` against their schemas and writes + `.kai/content.json`, `.kai/config.json` and JSON Schemas under `.kai/schema/` (the + content files point at them with `$schema` for editor completion). +3. `kai world gen` runs the game's map builders (TypeScript in the app) and writes + `world/maps/*.tmj` plus the tile registry. The `.tmj` files are committed, and + `kai world check` fails CI when they are stale. +4. `kai assets` fetches the licensed art, then builds `public/`: the tile + atlas, character sheets and portraits, the bitmap font, UI sheets, music and the + compiled Ink. +5. At runtime the game calls `createGame(parent, options)` with its config, content, + live data, plugins, Ink externals and link resolver. Content reaches scenes and UI through `services.data` (a + `GameData`), never through module imports, so the engine holds no game's content. + +## The kai command + +Run it from an app's folder (the one with `kai.json`). Each app's `package.json` wraps +the commands as scripts, and Turborepo runs `content` and `assets` before anything that +needs them. + +- `kai content`: check `content/` and `kai.json`, write `.kai/`. +- `kai assets`: fetch the art, then build `public/`. +- `kai world gen`: write the maps. `kai world check` fails if they are stale or + invalid. `kai world render` draws them to `world/out/*.png` (`--collision`, `--grid`, + `--objects`, `--only=`). +- `kai characters`: a contact sheet of every character, for review. +- `kai art `: a tool of the art adapter, such as `catalog`. +- `kai dev`: the standalone game harness with live reload, serving the world socket + locally. It bundles the module named by `paths.harness`. + +## Licensed art + +The art lives in the private repository `datagutt/datagutt-assets`, never in this one. +`kai assets` uses a local checkout at `assets.localPath` when there is one, clones the +repository with the token in the environment variable `assets.tokenEnv` when there is +not, and draws placeholder art when neither works. Shared art sits at the root of that +repository; one game's own art sits under `games//`. + +## Turborepo + +`content`, `assets`, `build`, `dev`, `game:dev` and `test:e2e` are never cached. +The asset build reads art from outside the repository, which Turborepo cannot hash, and +the content bundle also depends on schemas in other packages. `typecheck`, `test` and +`world:check` depend on `content`, since they import the bundle. + +## Gotchas + +- Packages are TypeScript source with no build step. Node and Bun load them directly, + so relative imports need their `.ts` extension, and JSON imports that Node reaches + need `with { type: "json" }`. Next compiles them through `transpilePackages`. +- Code the build loads (a dialogue host and what it imports) must not import + `@datagutt/kai`'s index: that pulls in Phaser, which needs a browser. Import by path, + such as `@datagutt/kai/data`. +- `Phaser.Scene` already has `plugins` and `data`, so the world scene calls its own + `kaiPlugins` and `gameData`. +- Map object types are one closed union in `packages/kai/src/world/objects.ts`, parsed + there too. A plugin can place any of them, but a new type is an engine change. diff --git a/docs/kai/CONTENT.md b/docs/kai/CONTENT.md new file mode 100644 index 0000000..bdd1a52 --- /dev/null +++ b/docs/kai/CONTENT.md @@ -0,0 +1,91 @@ +# kai: configuration and content + +A game's data is files, not code. `kai.json` configures the game; `content/` holds +everything the game says and shows. `kai content` checks both against Zod schemas and +writes the checked result to `.kai/`, which the game and its site import. Ink dialogue +and map builders are the two exceptions: Ink stays Ink, and maps are TypeScript +(see [ARCHITECTURE.md](./ARCHITECTURE.md)). + +## kai.json + +One per app, next to its `package.json`. The schema with a comment on every field is +`packages/kai/src/schema/config.ts`; the fields fall into these groups: + +- The game: `id`, `title`, `timezone` (its clock and seasons), `startPlace`. +- Saves: `saveKey` and `visitorsOffKey`, the `localStorage` keys. Changing `saveKey` + loses every visitor's save. +- Where things are: `basePath` (the URL of the built assets) and `paths`, the modules + the build loads (`maps`, `ink`, `dialogueHost`, `harness`). +- Art: `assets` (the art repository, its local checkout, the token's environment + variable and the art adapter module), `ui` (the dialogue frame and emote sheet), + `sprites` (animated strips copied for plugins) and `font`. +- Live data: `live.weather` (the fallback place and MET Norway's required User-Agent) + and `live.github`. + +`kai.json` names secrets only by the environment variable that holds them. Behaviour +(plugins, Ink external functions) is code and stays in the game's `createGame()` call. + +## Collections + +`content/` holds collections. A collection is either one JSON file, +`content/.json`, or a folder of Markdown files, `content//*.md`. + +- A JSON file may start with `"$schema": "../.kai/schema/.json"` for editor + completion; the build drops it before validation. +- In a Markdown collection each file is one entry. The YAML frontmatter holds the + fields and the text below it is the field named as its body. A number in front of + the file name only orders the files: `10-portfolio.md` is the entry `portfolio`. + +The engine owns the collections it reads itself (`ENGINE_COLLECTIONS` in +`packages/kai/src/schema/engine.ts`). Every game has them: + +- `characters`: character looks by id, as layers of the art's character generator, with + optional recolours and a placeholder colour. +- `npcs`: the cast by id, with a name, a place and a typing voice. An NPC's dialogue is + the Ink knot with the same id. +- `music`: the tracks and which plays where (title, credits, outdoors by time and + season, indoors by map). +- `achievements`: in passport order. +- `unlocks`: conditions that open gates and locked doors. +- `credits`: the credits page and roll. +- `triggers`: rules without code (see [PLUGINS.md](./PLUGINS.md)). +- `strings`: overrides of the engine's English UI copy, by key. An unknown key fails the + build. + +A game adds its own collections in `content/schema.ts`. `places` is always one of them, +because what a place means differs per game; it extends the engine's `placeInfo` (an +id, a name, whether it gives a stamp, an entrance for `?at=`). Fjord Town adds its +profile, projects, experience, skills, socials and the presence NPC's configuration. + +```ts +import { + ENGINE_COLLECTIONS, + json, + markdown, + z, + type ContentOf, +} from "@datagutt/kai/schema"; +import { placeInfo } from "@datagutt/kai/schema/engine"; + +export const collections = { + places: json(z.object({ list: z.array(placeInfo) })), + quests: markdown( + z.object({ id: z.string(), title: z.string(), text: z.string() }), + { body: "text" }, + ), +}; + +export type Content = ContentOf; +``` + +The app reads the bundle as that type (`apps/sandbox/content/index.ts`). There is no +generated type file: `ContentOf` derives the type from the schemas, imported type only, +so Zod stays out of the game's bundle. + +## Checks beyond the schemas + +- The dialogue host (`paths.dialogueHost`) declares every Ink external function, so a + call to an unknown one fails the Ink compile. It also lists the ids an external + accepts, and every `# link:` tag must resolve to a link. +- The music playlist may only name tracks it lists. +- The generated maps must match their builders and pass validation (`kai world check`). diff --git a/docs/kai/DESIGN.md b/docs/kai/DESIGN.md index 170b494..40222c2 100644 --- a/docs/kai/DESIGN.md +++ b/docs/kai/DESIGN.md @@ -5,7 +5,8 @@ records the decisions from the grilling session on 2026-09-24. Later sessions mu re-litigate them. If a decision changes, edit it here and add a line to the changelog. Related files: [PLAN.md](./PLAN.md) (tasks and progress), [HANDOFF.md](./HANDOFF.md) -(session state). +(session state). How the engine works: [ARCHITECTURE.md](./ARCHITECTURE.md), +[PLUGINS.md](./PLUGINS.md), [CONTENT.md](./CONTENT.md), [NEW-GAME.md](./NEW-GAME.md). ## 1. Purpose @@ -149,3 +150,7 @@ Visitors must not notice the refactor. adapter (`assets.adapter`, exporting an `ArtAdapter`), the map builders (`paths.maps`), the dialogue host (`paths.dialogueHost`) and the harness entry (`paths.harness`). This keeps `kai-assets` free of any art family and any one game. +- 2026-09-24: the GitHub map objects are one plugin, `githubObjects`, instead of + `githubField` and `repoShelf`: both read the same live data and place one object type + each. A game's own plugins live where its game code lives (`game/plugins/` in + datagutt), not necessarily `src/plugins/`. diff --git a/docs/kai/NEW-GAME.md b/docs/kai/NEW-GAME.md new file mode 100644 index 0000000..60a1456 --- /dev/null +++ b/docs/kai/NEW-GAME.md @@ -0,0 +1,59 @@ +# Start a new game from the sandbox + +`apps/sandbox` is the smallest complete kai game: one map, one NPC, one Ink knot, two +triggers. It proves the engine needs nothing from Fjord Town, and it is the template for +the next game. These steps make `apps/` from it. + +## Copy it + +1. Copy the folder: `cp -r apps/sandbox apps/`. Leave out `node_modules`, + `.kai`, `public/game`, `.assets-cache` and `world/out` if they exist. +2. In `apps//package.json`, set `name` to ``. +3. In `kai.json`, set `id`, `title`, `timezone`, `saveKey` and `visitorsOffKey`. The keys + must differ from every other game on the same domain, or the games share saves. +4. Set `assets.tokenEnv` to the environment variable that CI and the host will use for + the art repository token. +5. In `playwright.config.ts`, pick a port no other app uses. +6. Run `bun install` at the repository root, so Bun links the new workspace. + +## Make it yours + +1. Places: edit `content/places.json`. `startPlace` in `kai.json` must name one with an + entrance. +2. Maps: write builders in `world/maps.ts` (or a folder of them) and list them in + `GENERATED_MAPS`. Run `bun run world:gen`, then commit `world/maps/*.tmj` and the tile + registry beside them. +3. Cast: add looks to `content/characters.json` and people to `content/npcs.json`. + Place each NPC on a map with an `npc` object. Run `bunx kai characters` to review + the looks. +4. Dialogue: write Ink in `dialogue/`, starting at `main.ink`. Each NPC talks from the + knot with their id. If the dialogue calls external functions, declare them in + `dialogue/host.ts` and bind them in `createGame`'s `externals`. +5. Rules: add achievements to `content/achievements.json` and triggers to + `content/triggers.json`. Reach for a plugin only when a trigger cannot say it (see + [PLUGINS.md](./PLUGINS.md)). +6. Words: override engine UI copy in `content/strings.json`, and fill in + `content/music.json` and `content/credits.json`. + +## Run it + +- `bunx turbo run dev --filter=` builds the content and assets, then starts the + harness from `src/main.ts`. Add `?debug` to the URL for the debug overlay and + `window.__kai`. +- `bunx turbo run lint typecheck world:check test:e2e --filter=` is what CI + runs for it. + +## Host it + +The sandbox boots straight into the world from a plain page. To put a game behind a Next +site, as Fjord Town does, depend on `@datagutt/kai-next`. `useKaiGame` loads the game +behind the page's own title screen, `LiveDataScript` embeds live data, and +`worldSocketHandler()` serves other visitors from a route. `apps/datagutt` is the +example: `components/game/GameShell.tsx` and `app/api/world/ws/route.ts`. + +## Art of its own + +Art used only by this game belongs under `games//` in the art repository; art shared +by more than one game stays at the repository's root. The build does not read +`games//` yet: Fjord Town's season overrides still sit at `seasons/` until task K3.5 +moves them. diff --git a/docs/kai/PLAN.md b/docs/kai/PLAN.md index 6536759..d285f15 100644 --- a/docs/kai/PLAN.md +++ b/docs/kai/PLAN.md @@ -208,7 +208,7 @@ Goal: a second app proves the engine needs nothing from datagutt. - [x] **K7.1** Move `docs/game/` to `apps/datagutt/docs/` and update the `.claude` hooks. *Done when* the SessionStart hook prints the right handoff on both branches. -- [ ] **K7.2** `docs/kai/`: architecture and package map, plugin API, content schemas, +- [x] **K7.2** `docs/kai/`: architecture and package map, plugin API, content schemas, the CLI, "start a new game from the sandbox". *Done when* written. - [ ] **K7.3** A README per package (what it is, what it must not import). A root `CLAUDE.md` for the monorepo, `apps/datagutt/CLAUDE.md` for Fjord Town. *Done when* diff --git a/docs/kai/PLUGINS.md b/docs/kai/PLUGINS.md new file mode 100644 index 0000000..e097d8a --- /dev/null +++ b/docs/kai/PLUGINS.md @@ -0,0 +1,138 @@ +# kai: plugins and triggers + +The engine runs the parts every game shares: walking, doors, signs, NPCs, dialogue, the +passport, saves. Anything one game does that another would not is a plugin, or, when it +is a simple rule, a trigger in content. The engine never names a game's plugin. + +The types are in `packages/kai/src/plugins/api.ts`. Plugins that more than one game can +use live in packages (`kai-live`, `kai-arcade`); the rest live in the app (Fjord Town's +are in `apps/datagutt/game/plugins/`). + +## Triggers first + +A rule that only reacts to the world and grants something needs no code. Put it in +`content/triggers.json`: + +```json +{ + "list": [ + { "on": "enterMap", "map": "mountain", "delay": 600, "grant": "summit" }, + { + "on": "bumpEdge", + "cooldown": 20000, + "knot": "edge_of_world", + "grant": "edge" + }, + { "on": "passportFull", "grant": "passport", "announce": false } + ] +} +``` + +- `enterMap` fires as the player arrives on `map`, after `delay` ms. +- `bumpEdge` fires when the player walks into the map's edge, at most once per + `cooldown` ms, and never while a conversation is open. +- `passportFull` fires on every map load while every stamp is in, and when the last + stamp is earned. Keep it to granting: a knot on it would play again on every map. + +Each trigger plays `knot` (narrated) if given, then sets `flag` and grants the +achievement `grant`. `announce: false` grants it without the banner. The engine runs the +triggers as a plugin ahead of the game's own. + +## Writing a plugin + +A plugin is an object with a `name` and any of the hooks below, usually made by a +function that takes its configuration: + +```ts +import { perWorld, type KaiPlugin } from "@datagutt/kai"; + +export function bellPlugin(): KaiPlugin { + const state = perWorld(() => ({ rung: 0 })); + return { + name: "bell", + usableAt(world, tile) { + if (world.map !== "church" || tile.x !== 5 || tile.y !== 2) return null; + return { + prompt: "Use", + use: () => + state(world).rung++ === 2 + ? world.achieve("bellringer") + : world.save(), + }; + }, + debug: (world) => ({ bellRung: state(world).rung }), + }; +} +``` + +Pass it to `createGame(parent, { ..., plugins: [bellPlugin()] })`. Plugins run in the +order given. That order is also the order of their start menu items, and the first +plugin that answers a question (a usable tile, a prompt, a talk) wins. + +## The hooks + +- `boot(services, params)`: once, before anything loads. It may change where the game + starts (`services.start`). Returning `start: true` skips the title; `stop` runs when + the game is destroyed. +- `externals(ctx)`: Ink external functions this plugin implements. They are bound beside + the game's own `externals`, and the game's dialogue host must declare them for the + Ink compile check. +- `objects`: map object types this plugin places, by type. The world calls the function + for each object of that type as the map loads. The engine places spawns, doors, signs, + spots, areas, gates, lights and NPCs itself; any other type needs a plugin, or the + world warns that nothing places it. +- `mapCreated(world)`: after the map, its objects and the player are in place. +- `usableAt(world, tile)`: what the player can use on a tile. It is asked before signs + and doors, so a plugin can take over a tile the map also marks. +- `promptFor(world, npc)`: the prompt over an NPC instead of "Talk". +- `talk(world, npc)`: return true to play the conversation yourself. +- `talked(world, npc, knot, reachedOwnKnot)`: a conversation ended. + `reachedOwnKnot` says whether it reached the NPC's own knot, which is what earns a + stamp. +- `stamped(world, place, complete)`: a stamp was earned; `complete` when it was the + last. +- `bumpedEdge(world)`: the player walked into the edge of the map. +- `update(world, dt, time)`: every frame the world runs freely (no takeover, menu or + dialogue). +- `menuItems(world)`: start menu items after the Passport, either an action or a page of + lines. +- `debug(world)`: fields merged into `window.__kai` under `?debug`, for e2e tests. + +## The World + +A `World` lives for one visit to one map. The player leaving the map ends it and the next +map brings a new one. Keep a plugin's state for one visit in `perWorld(make)`, which +forgets it with the World. Keep state that outlives maps in the closure of the plugin +factory, or in `world.progress.flags` if it must survive a reload (call `world.save()` +after changing it). + +What a plugin can do through the World: + +- Read the map: `map`, `outdoors`, `grid`, `layers`, `spawns`, `spots`, `areas`, + `doors`, `arrival`, `daylight`. +- Add NPCs of its own with `addNpc(def, actor, { managed })`. A managed NPC is moved by + its plugin; the world only makes it talkable. `removeNpc` and `npc(id)` go with it. +- Play dialogue with `playKnot(knot, npc, onEnd)`, with `npc` null for narration. +- Change progress with `achieve(id)` and `save()`. +- Move the player to another map or spot with `goTo(target)`. +- Take the frame with `takeOver(takeover)` for a cutscene, a cabinet or the credits. + The world stops walking and talking until the takeover's `update` returns false. + `syncNpcs` keeps map NPCs animating meanwhile; `music: "credits"` plays the credits + track. +- Clean up with `onShutdown(fn)` when the player leaves the map. + +`world.services` holds what lasts the whole game: `data` (the content as `GameData`), +`config`, `live`, `music`, `ghosts`, the clock (`hours`, `month`), `season`, `night` (a +story night: clear sky, 23:00, the aurora out on every map until a plugin ends it) and +`firstVisit`. + +## Examples in the repository + +- `kai-live/src/presence/plugin.ts`: an NPC who follows a Discord presence between maps, + configured by `content/presence.json`. It uses `boot`, `externals`, `mapCreated`, + `update`, `promptFor`, `talked`, `menuItems` and `debug`. +- `kai-live/src/github/plugin.ts`: map objects (`crops`, `books`) drawn from live data. +- `apps/datagutt/game/plugins/ferryIntro.ts`: a first visit cutscene as a takeover. +- `apps/datagutt/game/plugins/finale.ts`: a story night that ends in the credits. +- `apps/datagutt/game/plugins/arcade.ts`: arcade cabinets as a usable tile and a + takeover. From 25337115f22b41305daaf39cf0e18e2a74c03fed Mon Sep 17 00:00:00 2001 From: datagutt Date: Thu, 24 Sep 2026 22:41:15 +0200 Subject: [PATCH 142/153] docs(kai): add package readmes and split claude.md into monorepo and fjord town also lint that the map toolkit and the asset build never import an art adapter. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- CLAUDE.md | 115 ++++++++----------------- apps/datagutt/CLAUDE.md | 71 +++++++++++++++ docs/kai/ARCHITECTURE.md | 5 +- docs/kai/PLAN.md | 2 +- packages/kai-arcade/README.md | 14 +++ packages/kai-assets/README.md | 22 +++++ packages/kai-assets/eslint.config.js | 4 +- packages/kai-limezu/README.md | 17 ++++ packages/kai-live/README.md | 14 +++ packages/kai-net/README.md | 14 +++ packages/kai-next/README.md | 16 ++++ packages/kai-worldgen/README.md | 16 ++++ packages/kai-worldgen/eslint.config.js | 4 +- packages/kai/README.md | 21 +++++ tooling/eslint-config/boundaries.js | 11 +++ 15 files changed, 258 insertions(+), 88 deletions(-) create mode 100644 apps/datagutt/CLAUDE.md create mode 100644 packages/kai-arcade/README.md create mode 100644 packages/kai-assets/README.md create mode 100644 packages/kai-limezu/README.md create mode 100644 packages/kai-live/README.md create mode 100644 packages/kai-net/README.md create mode 100644 packages/kai-next/README.md create mode 100644 packages/kai-worldgen/README.md create mode 100644 packages/kai/README.md diff --git a/CLAUDE.md b/CLAUDE.md index c5b7677..5985629 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,101 +2,54 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Fjord Town +## What this is -The site is a top-down pixel-art game, "Fjord Town", with a plain-text twin, the Journal. Before working on the game, read `apps/datagutt/docs/README.md` (workflow), `apps/datagutt/docs/HANDOFF.md` (where the last session stopped) and `apps/datagutt/docs/PLAN.md` (tasks). Decisions in `apps/datagutt/docs/DESIGN.md` are settled. +A Bun workspace driven by Turborepo. It holds **kai**, an engine for top-down pixel-art walking games (Phaser 4, Ink dialogue, generated maps, LimeZu art), and the games built on it: -The engine is being split out as **kai** (`packages/`), with the site as `apps/datagutt`. On the `kai` branch, read `docs/kai/HANDOFF.md`, `docs/kai/PLAN.md` and `docs/kai/DESIGN.md` instead. +- `apps/datagutt`: datagutt.no, the portfolio site as a game, "Fjord Town", with a plain-text twin, the Journal. Next.js 16.3. See `apps/datagutt/CLAUDE.md`. +- `apps/sandbox`: the smallest kai game. It proves the engine needs nothing from Fjord Town and is the template for new games. +- `packages/kai*`: the engine, as TypeScript source with no build step. +- `tooling/`: shared TypeScript, ESLint and Vitest presets. -## Project Overview +## Docs and session workflow -Personal portfolio site for datagutt, built with **Next.js 16.3**, **React 19.3**, **TypeScript** and **Phaser 4**. A Bun workspace driven by **Turborepo**: the site lives in `apps/datagutt`, and every path below is relative to it. Bun installs and runs scripts; Next itself runs on Node. +- Engine: `docs/kai/`. Start with `ARCHITECTURE.md`; then `PLUGINS.md`, `CONTENT.md` and `NEW-GAME.md` as needed. Decisions in `docs/kai/DESIGN.md` are settled. +- Fjord Town: `apps/datagutt/docs/` (`DESIGN.md`, `PLAN.md`, `ART.md`, `README.md` for the workflow). +- Each package has a README saying what it is and what it must not import. +- The SessionStart hook prints the handoff for the branch: `docs/kai/` on `kai` branches, `apps/datagutt/docs/` otherwise. The Stop hook asks for a HANDOFF.md update when there are newer code commits. ## Commands ```bash # From the repository root (Turborepo runs the task in every workspace) -bun install # Install dependencies -bun run dev # Asset build, then the dev server (Turbopack) -bun run build # Asset build, then the production build -bun run lint # ESLint -bun run typecheck # tsc --noEmit -bun run test # Vitest unit tests (*.test.ts / *.test.mjs) -bun run world:check # Fail if the generated maps are out of date or invalid -bun run test:e2e # Build, then Playwright (Chrome desktop and phone) against `next start` -bun run format # Format with Prettier - -# From apps/datagutt (the `kai` CLI from @datagutt/kai-assets, configured by kai.json) -bun run content # kai content: check content/ against its schemas, write the .kai/ bundle -bun run game:dev # kai dev: standalone game harness at http://localhost:3200/game/dev.html?debug (run content and assets first) -bun run assets # kai assets: fetch licensed art (or fall back to placeholders) and build public/game/, then the title strip -bun run world:gen # kai world gen: generate the maps (world/maps/*.tmj) from world/gen/maps -bun run world:render # kai world render: render the maps to world/out/*.png (--collision, --grid, --objects, --only=) -bun run start # Start the production server -bun run test:e2e:all # The e2e tests plus Firefox, Safari and iPhone, two workers (WebKit needs `sudo bunx playwright install-deps webkit`) +bun install # Install dependencies (Bun 1.4; the lockfile is version 2) +bun run dev # Content and assets, then every app's dev server +bun run build # Content and assets, then the production builds +bun run lint # ESLint, including the import boundaries +bun run typecheck # tsc --noEmit +bun run test # Vitest unit tests +bun run world:check # Fail if generated maps are stale or invalid +bun run test:e2e # Build, then every app's Playwright tests +bun run format # Prettier +bunx turbo run --filter= # One workspace only ``` -## Architecture - -### Pages - -- `/` (`app/page.tsx`): the title screen, server-rendered (`components/game/TitleArt.tsx`, an SVG sky over a waterfront built from the game's tiles), and `components/game/GameShell.tsx`, which loads the game (`game/boot.ts`) behind it and runs the Press start menu. `?at=` deep links skip the title. -- `/journal`: every piece of content as a plain, server-rendered page (`components/journal/`), readable with JavaScript off. `/legacy` redirects here. -- `app/not-found.tsx`: the title backdrop with the ways back. -- Metadata, the link-preview image (`/game/og.png`, drawn by `scripts/assets/og.mjs`), `app/sitemap.ts` and `app/robots.ts` share `lib/site.ts`. - -### Game (`game/`) - -Phaser 4 scenes (`game/scenes/`), entities, input, UI, effects (`game/fx/`), audio (`game/audio/`), saves (`game/save/`), passport progress and live systems. Dialogue is Ink (`game/dialogue/ink/`), compiled by the asset build. - -### World (`world/`) - -Maps are generated, not drawn: `world/gen/maps/*.ts` build each map with `@datagutt/kai-worldgen` (the art-agnostic toolkit) from LimeZu sheet references (`@datagutt/kai-limezu`; the town's own buildings are in `world/gen/prefabs.ts`), and `bun run world:gen` writes `world/maps/*.tmj`. `kai.json` configures the art source, UI sheets, font and live settings. Pixels never enter this repository; the art lives in the private `datagutt/datagutt-assets` repo, fetched by `bun run assets` (locally from `../datagutt-assets` next to the repository, in CI with `ASSETS_REPO_TOKEN`). Without it the build uses placeholder art. - -### Data Fetching (lib/github.ts) - -Three server-side functions, cached with `'use cache'`: an hour on success, minutes after a failure (a failure is logged as `[github] … failed`). `lib/world-state.ts` combines them into the game's live payload, embedded in `/` as ` = { -readonly [K in keyof S as S[K] extends `${string}?` ? never : K]: ValueOf }; +type OptionalProps = { -readonly [K in keyof S as S[K] extends `${string}?` ? K : never]?: ValueOf }; +type RectOf = Z extends "rect" ? { w: number; h: number } : Z extends "rect?" ? { w?: number; h?: number } : unknown; +type Flatten = { [K in keyof T]: T[K] } & {}; + +/** The object a descriptor with these properties and this size describes. */ +export type ObjectFrom = Flatten<{ type: T; x: number; y: number } & RectOf & RequiredProps & OptionalProps>; + +/** A map object type and what a plugin does with each object of it as the map loads. */ +export type ObjectPlacer = { readonly type: MapObjectType; place(world: World, obj: AnyMapObject): void }; + +export interface MapObjectType { + readonly type: O["type"]; + readonly placement: Placement; + readonly size?: ObjectSize; + /** Property kinds by name, where the writer needs them (`tiles`). */ + readonly props: PropSpec; + /** Reads a Tiled object of this type back, or explains what is wrong with it. */ + parse(obj: TiledObject, tileSize: number): O; + is(obj: AnyMapObject): obj is O; + /** An object of this type at (x, y), for map builders. */ + at(x: number, y: number, props: Omit): O; + /** For a plugin's `objects`: `fn` runs for each object of this type as the map loads. */ + place(fn: (world: World, obj: O) => void): ObjectPlacer; +} + +export type MapObjectOf = D extends MapObjectType ? O : never; + +/** Reads a Tiled object's properties by kind, for types that read their own. */ +export type PropReader = { + string(name: string): string; + optionalString(name: string): string | undefined; + number(name: string): number; + facing(name: string): Facing; + raw(name: string): string | number | boolean | undefined; +}; export type TiledProperty = { name: string; type: "string" | "int" | "bool"; value: string | number | boolean }; @@ -72,86 +99,188 @@ export type TiledObject = { const FACINGS: readonly Facing[] = ["right", "up", "left", "down"]; -/** Reads a Tiled object back into a MapObject, or explains what is wrong with it. */ -export function parseMapObject(obj: TiledObject, tileSize: number): MapObject { +function reader(obj: TiledObject): PropReader { const props = new Map((obj.properties ?? []).map((p) => [p.name, p.value])); - const str = (name: string): string => { + const string = (name: string): string => { const v = props.get(name); if (typeof v !== "string" || v === "") throw new Error(`${obj.type} object ${obj.id} needs a "${name}" string property`); return v; }; - const facing = (): Facing => { - const v = str("facing"); - if (!FACINGS.includes(v as Facing)) throw new Error(`${obj.type} object ${obj.id} has invalid facing "${v}"`); - return v as Facing; + return { + string, + optionalString: (name) => { + const v = props.get(name); + return typeof v === "string" && v !== "" ? v : undefined; + }, + number: (name) => { + const v = Number(props.get(name)); + if (!Number.isFinite(v)) throw new Error(`${obj.type} object ${obj.id} needs a numeric "${name}"`); + return v; + }, + facing: (name) => { + const v = string(name); + if (!FACINGS.includes(v as Facing)) throw new Error(`${obj.type} object ${obj.id} has invalid facing "${v}"`); + return v as Facing; + }, + raw: (name) => props.get(name), }; - const x = Math.floor(obj.x / tileSize); - const y = Math.floor(obj.y / tileSize); - - switch (obj.type) { - case "spawn": - return { type: "spawn", id: obj.name || str("id"), x, y, facing: facing() }; - case "door": { - const unlock = props.get("unlock"); - return { type: "door", x, y, toMap: str("toMap"), toSpawn: str("toSpawn"), ...(typeof unlock === "string" && unlock ? { unlock } : {}) }; - } - case "sign": { - const dialogue = props.get("dialogue"); +} + +function readProps(obj: TiledObject, spec: PropSpec, read: PropReader): Record { + const out: Record = {}; + for (const [name, kind] of Object.entries(spec)) { + if (name === "id") out.id = obj.name || read.string("id"); + else if (kind === "string" || kind === "tiles") out[name] = read.string(name); + else if (kind === "string?") { + const v = read.optionalString(name); + if (v !== undefined) out[name] = v; + } else if (kind === "number") out[name] = read.number(name); + else if (kind === "number?") { + if (read.raw(name) !== undefined) out[name] = read.number(name); + } else if (kind === "bool") out[name] = read.raw(name) === "true"; + else out[name] = read.facing(name); + } + return out; +} + +type Options = { readonly placement: Placement; readonly size?: ObjectSize }; + +/** A map object type whose properties follow `props`. */ +export function defineMapObject, const Z extends ObjectSize | undefined = undefined>( + type: T, + options: Options & { readonly props?: S; readonly size?: Z }, +): MapObjectType>; +/** A map object type that reads its own properties, for shapes a property list can't say (a light's). */ +export function defineMapObject( + type: O["type"], + options: Options & { readonly props?: PropSpec; read(props: PropReader, obj: TiledObject): Omit }, +): MapObjectType; +export function defineMapObject( + type: string, + options: Options & { readonly props?: PropSpec; read?(props: PropReader, obj: TiledObject): Record }, +): MapObjectType { + const { placement, size } = options; + const spec = options.props ?? {}; + const descriptor: MapObjectType = { + type, + placement, + size, + props: spec, + parse(obj, tileSize) { + const read = reader(obj); const w = Math.round(obj.width / tileSize); const h = Math.round(obj.height / tileSize); - return { - type: "sign", - x, - y, - ...(w > 1 || h > 1 ? { w, h } : {}), - text: str("text"), - ...(typeof dialogue === "string" && dialogue ? { dialogue } : {}), - }; - } - case "cat": - return { type: "cat", x, y }; - case "arcade": - return { type: "arcade", x, y, game: str("game") }; - case "gate": - return { type: "gate", id: obj.name || str("id"), x, y, w: Math.round(obj.width / tileSize), h: Math.round(obj.height / tileSize), unlock: str("unlock"), text: str("text") }; - case "spot": - return { type: "spot", id: obj.name || str("id"), x, y, facing: facing() }; - case "npc": - return { type: "npc", id: obj.name || str("id"), character: str("character"), x, y, facing: facing(), name: str("name"), dialogue: str("dialogue") }; - case "crops": - return { type: "crops", x, y, w: Math.round(obj.width / tileSize), h: Math.round(obj.height / tileSize), stages: str("stages") }; - case "books": - return { type: "books", x, y, w: Math.round(obj.width / tileSize), h: Math.round(obj.height / tileSize) }; - case "area": - return { type: "area", id: obj.name || str("id"), x, y, w: Math.round(obj.width / tileSize), h: Math.round(obj.height / tileSize) }; - case "light": { - const num = (name: string) => { - const v = Number(props.get(name)); - if (!Number.isFinite(v)) throw new Error(`light object ${obj.id} needs a numeric "${name}"`); - return v; - }; - const shape = str("shape"); - const when = props.get("when"); - if (when !== undefined && when !== "day" && when !== "night") throw new Error(`light object ${obj.id} has invalid when "${when}"`); - const timed = when ? { when: when as LightTime } : {}; - if (shape === "glow") return { type: "light", shape, x, y, radius: num("radius"), color: str("color"), intensity: num("intensity"), flicker: props.get("flicker") === "true", ...timed }; - if (shape === "beam") return { type: "light", shape, x, y, w: num("w"), h: num("h"), color: str("color"), intensity: num("intensity"), ...timed }; - throw new Error(`light object ${obj.id} has unknown shape "${shape}"`); - } - default: - throw new Error(`Unknown map object type "${obj.type}" (object ${obj.id})`); + const rect = size === "rect" || (size === "rect?" && (w > 1 || h > 1)) ? { w, h } : {}; + const props = options.read ? options.read(read, obj) : readProps(obj, spec, read); + return { type, x: Math.floor(obj.x / tileSize), y: Math.floor(obj.y / tileSize), ...rect, ...props }; + }, + is: (obj): obj is AnyMapObject => obj.type === type, + at: (x, y, props) => ({ type, x, y, ...props }), + place: (fn) => ({ type: descriptor, place: fn }), + }; + return descriptor; +} + +export const spawnObject = defineMapObject("spawn", { props: { id: "string", facing: "facing" }, placement: "marker" }); + +/** A warp to another map. With `unlock`, it stays shut (and solid) until that holds. */ +export const doorObject = defineMapObject("door", { props: { toMap: "string", toSpawn: "string", unlock: "string?" }, placement: "marker" }); + +/** + * A readable thing: fixed `text`, or an Ink knot (`dialogue`) for live content. The map + * build grows it over the object it describes (kai-worldgen signs.ts): then (x, y) is the + * corner of a w×h rectangle, read from any blocked tile inside it. + */ +export const signObject = defineMapObject("sign", { props: { text: "string", dialogue: "string?" }, placement: "fixture", size: "rect?" }); + +export const npcObject = defineMapObject("npc", { + props: { id: "string", character: "string", facing: "facing", name: "string", dialogue: "string" }, + placement: "standing", +}); + +/** A way that stays shut until `unlock` holds: the w×h rectangle's barriers block it and read `text`; once open, they are cleared away. */ +export const gateObject = defineMapObject("gate", { props: { id: "string", unlock: "string", text: "string" }, placement: "overlay", size: "rect" }); + +/** A named place an NPC who moves between maps can be, facing a way: a live NPC goes to the spot its presence picks. */ +export const spotObject = defineMapObject("spot", { props: { id: "string", facing: "facing" }, placement: "marker" }); + +/** A named rectangle of the map for scripted scenes, such as the ferry for an intro. */ +export const areaObject = defineMapObject("area", { props: { id: "string" }, placement: "overlay", size: "rect" }); + +export type LightTime = "day" | "night"; + +/** + * A light, drawn additively over the map and characters. A glow is centred on its tile + * with a radius in tiles; a beam covers w×h tiles from its tile. `color` is rrggbb, + * `intensity` 0..1. `when` ties it to the visitor's clock: "day" for daylight through a + * window, "night" for street lamps and porch lights; always on without. + */ +export type LightObject = + | { type: "light"; shape: "glow"; x: number; y: number; radius: number; color: string; intensity: number; flicker: boolean; when?: LightTime } + | { type: "light"; shape: "beam"; x: number; y: number; w: number; h: number; color: string; intensity: number; when?: LightTime }; + +export const lightObject = defineMapObject("light", { + placement: "overlay", + read(props, obj) { + const shape = props.string("shape"); + const when = props.raw("when"); + if (when !== undefined && when !== "day" && when !== "night") throw new Error(`light object ${obj.id} has invalid when "${when}"`); + const timed = when ? { when: when as LightTime } : {}; + const color = props.string("color"); + const intensity = props.number("intensity"); + if (shape === "glow") return { shape, radius: props.number("radius"), color, intensity, flicker: props.raw("flicker") === "true", ...timed }; + if (shape === "beam") return { shape, w: props.number("w"), h: props.number("h"), color, intensity, ...timed }; + throw new Error(`light object ${obj.id} has unknown shape "${shape}"`); + }, +}); + +export type SpawnObject = MapObjectOf; +export type DoorObject = MapObjectOf; +export type SignObject = MapObjectOf; +export type NpcObject = MapObjectOf; +export type GateObject = MapObjectOf; +export type SpotObject = MapObjectOf; +export type AreaObject = MapObjectOf; + +/** The types the engine handles itself. */ +export const CORE_OBJECTS: readonly MapObjectType[] = [spawnObject, doorObject, signObject, npcObject, gateObject, spotObject, areaObject, lightObject]; + +/** One of the engine's own map objects. */ +export type MapObject = SpawnObject | DoorObject | SignObject | NpcObject | GateObject | SpotObject | AreaObject | LightObject; + +export type MapObjectTypes = ReadonlyMap; + +/** The engine's types plus a game's own, by type. */ +export function mapObjectTypes(extra: readonly MapObjectType[] = []): MapObjectTypes { + const types = new Map(); + for (const descriptor of [...CORE_OBJECTS, ...extra]) { + const known = types.get(descriptor.type); + if (known && known !== descriptor) throw new Error(`Two map object types are called "${descriptor.type}"`); + types.set(descriptor.type, descriptor); } + return types; +} + +const CORE_TYPES = mapObjectTypes(); + +function descriptorFor(type: string, types: MapObjectTypes, where: string): MapObjectType { + const descriptor = types.get(type); + if (!descriptor) throw new Error(`Unknown map object type "${type}" (${where}). Define it with defineMapObject and list it in the maps module's MAP_OBJECTS.`); + return descriptor; +} + +/** Reads a Tiled object back by its type's descriptor, or explains what is wrong with it. */ +export function parseMapObject(obj: TiledObject, tileSize: number, types: MapObjectTypes = CORE_TYPES): AnyMapObject { + return descriptorFor(obj.type, types, `object ${obj.id}`).parse(obj, tileSize); } /** The reverse of parseMapObject, used by the map build. */ -export function toTiledObject(obj: MapObject, id: number, tileSize: number): TiledObject { - const { type, x, y, ...rest } = obj; - const objectName = "id" in rest ? rest.id : ""; - // Areas (crops, books, grown signs) keep their size as the Tiled object's width and height. - const area = - type === "crops" || type === "books" || type === "area" || type === "gate" || (type === "sign" && "w" in rest && rest.w !== undefined) - ? (rest as { w: number; h: number }) - : null; +export function toTiledObject(obj: AnyMapObject, id: number, tileSize: number, types: MapObjectTypes = CORE_TYPES): TiledObject { + const { size } = descriptorFor(obj.type, types, `at (${obj.x}, ${obj.y})`); + const { type, x, y, ...rest } = obj as AnyMapObject & Record; + const objectName = typeof rest.id === "string" ? rest.id : ""; + // Rectangles keep their size as the Tiled object's width and height. + const area = size === "rect" || (size === "rect?" && rest.w !== undefined) ? { w: rest.w as number, h: rest.h as number } : null; const properties: TiledProperty[] = Object.entries(rest) .filter(([key]) => key !== "id" && !(area && (key === "w" || key === "h"))) .map(([key, value]) => ({ name: key, type: "string", value: String(value) })); @@ -168,3 +297,12 @@ export function toTiledObject(obj: MapObject, id: number, tileSize: number): Til ...(properties.length ? { properties } : {}), }; } + +/** The tiles an object stands on: its fixed footprint, or its own tile. */ +export function footprintOf(obj: AnyMapObject, descriptor: MapObjectType): [number, number][] { + const size = descriptor.size; + if (typeof size !== "object") return [[obj.x, obj.y]]; + const cells: [number, number][] = []; + for (let y = obj.y; y < obj.y + size.h; y++) for (let x = obj.x; x < obj.x + size.w; x++) cells.push([x, y]); + return cells; +} From 6788445dc9aadb481f6f791fb62c6cfcde02899f Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 25 Sep 2026 00:41:38 +0200 Subject: [PATCH 149/153] docs(kai): hand off after merging into game Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/docs/HANDOFF.md | 5 +++++ docs/kai/HANDOFF.md | 9 ++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/datagutt/docs/HANDOFF.md b/apps/datagutt/docs/HANDOFF.md index 21c76cd..3d9aa96 100644 --- a/apps/datagutt/docs/HANDOFF.md +++ b/apps/datagutt/docs/HANDOFF.md @@ -4,6 +4,11 @@ Last updated: 2026-09-23 (session 1: design through M5; the Nettbureau office on ## Current state +- **The engine split is merged into `game` (2026-09-25).** The site now lives in + `apps/datagutt` on the kai engine (`packages/`), built with Bun and Turborepo. Paths + and commands in the notes below predate it (`pnpm`, `game/scenes/`, `world/art/`): + see the root `CLAUDE.md`, `apps/datagutt/CLAUDE.md` and `docs/kai/` for where things + are now. The finale now resumes on the next map if it was missed. - **The plan is closed (user, 2026-09-24)** apart from C1 (the Terraria-style mine) and C3 (multi-instance ghosts), both deferred. M6.2, M6.6 and M6.7 were closed by the user; nobody here has merged `game` into `master` or deployed to production. diff --git a/docs/kai/HANDOFF.md b/docs/kai/HANDOFF.md index ccb73e0..6e1ba30 100644 --- a/docs/kai/HANDOFF.md +++ b/docs/kai/HANDOFF.md @@ -13,14 +13,12 @@ Last updated: 2026-09-25 (kai session 1, end) monorepo; `apps/datagutt/CLAUDE.md` covers the site and carries the Next agent rules block that `next dev` maintains. - Import boundaries are lint rules (`tooling/eslint-config/boundaries.js`). -- `game` still sits at the K1.6 commit, behind `kai`. Production (Vercel) +- `game` was fast-forwarded to `kai` on 2026-09-25. Production (Vercel) builds from `apps/datagutt` already. ## Waiting on the user -Merging `kai` into `game` (a fast-forward). Until then, builds of `game` look for season -overrides at the art repository's old `seasons/` path, which K3.5 moved to -`games/datagutt/seasons/`: they build fine but lose the hand-drawn snow roofs in winter. +Nothing. ## Next step @@ -31,7 +29,8 @@ overrides at the art repository's old `seasons/` path, which K3.5 moved to the user's own save reaches the pier with it. 2. Done: map object types are descriptors (docs/kai/PLUGINS.md, "Map object types"). The generated maps stayed byte-identical. -3. Merge `kai` into `game` when the user says so. +3. Done: `kai` merged into `game`. Game work continues from + `apps/datagutt/docs/HANDOFF.md` on `game`. ## Gotchas From da7d6696169649105b5f67c5174d7747e99a2570 Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 25 Sep 2026 01:18:09 +0200 Subject: [PATCH 150/153] feat(kai): animated sprite map objects a sprite object plays a kai.json strip at a tile plus a pixel offset, drawn among the characters by its bottom edge or on the ground or roof layer, and limited to seasons or daylight when asked. MapCanvas.reserve keeps the collision of the art a sprite replaces, renders draw each sprite's first frame, and the build rejects unknown sprite names, layers, seasons and times of day. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- docs/kai/DESIGN.md | 5 +++ docs/kai/PLUGINS.md | 4 +- packages/kai-assets/src/build/assets.ts | 3 ++ packages/kai-assets/src/world/gen.ts | 24 ++++++++++- packages/kai-assets/src/world/sprites.test.ts | 24 +++++++++++ packages/kai-assets/src/world/sprites.ts | 21 ++++++++++ packages/kai-worldgen/src/canvas.ts | 16 ++++++- packages/kai-worldgen/src/render.ts | 36 +++++++++++++++- packages/kai/src/plugins/api.ts | 4 +- packages/kai/src/scenes/WorldScene.ts | 42 ++++++++++++++++++- packages/kai/src/world/objects.test.ts | 2 + packages/kai/src/world/objects.ts | 23 +++++++++- 12 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 packages/kai-assets/src/world/sprites.test.ts create mode 100644 packages/kai-assets/src/world/sprites.ts diff --git a/docs/kai/DESIGN.md b/docs/kai/DESIGN.md index d619c43..1dec8d8 100644 --- a/docs/kai/DESIGN.md +++ b/docs/kai/DESIGN.md @@ -160,3 +160,8 @@ Visitors must not notice the refactor. `books` in kai-live, `cat` in datagutt). The map checks follow each type's placement. The build learns a game's types from `MAP_OBJECTS` in its maps module, the runtime from the plugins that place them. +- 2026-09-25: animated map objects are an engine type, `sprite`: a kai.json strip looping + at a tile plus a pixel offset, drawn among the characters or on the ground or roof + layer, limited to seasons or daylight if asked. The build checks the names; + `MapCanvas.reserve` keeps the collision of the art a sprite replaces. + diff --git a/docs/kai/PLUGINS.md b/docs/kai/PLUGINS.md index 111d84b..82a523a 100644 --- a/docs/kai/PLUGINS.md +++ b/docs/kai/PLUGINS.md @@ -80,7 +80,9 @@ plugin that answers a question (a usable tile, a prompt, a talk) wins. - `objects`: the map object types this plugin places, as `[descriptor.place(fn)]`. The world calls `fn` for each object of that type as the map loads (see "Map object types" below). The engine places spawns, doors, signs, spots, areas, gates, lights and - NPCs itself. An object of a type no plugin places is skipped with a warning. + NPCs itself, and animated `sprite`s (a kai.json strip looping at a tile, for a season + or a time of day if asked). An object of a type no plugin places is skipped with a + warning. - `mapCreated(world)`: after the map, its objects and the player are in place. - `usableAt(world, tile)`: what the player can use on a tile. It is asked before signs and doors, so a plugin can take over a tile the map also marks. diff --git a/packages/kai-assets/src/build/assets.ts b/packages/kai-assets/src/build/assets.ts index 01c349a..3d9696a 100644 --- a/packages/kai-assets/src/build/assets.ts +++ b/packages/kai-assets/src/build/assets.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import sharp from "sharp"; +import { spriteProblems } from "../world/sprites.ts"; import { doorObject, gateObject, npcObject, parseMapObject, signObject, spawnObject, type AnyMapObject, type TiledObject } from "@datagutt/kai/world/objects"; import { EMOTE_COLUMNS, EMOTE_FRAME, EMOTE_TAIL, EMOTES } from "@datagutt/kai/ui/emotes"; import type { CharacterRecipe } from "@datagutt/kai/schema/engine"; @@ -102,6 +103,8 @@ export async function buildAssets(app: KaiApp): Promise { const dialogue = compileDialogue(path.join(app.dir, config.paths.ink), await app.dialogueHost()); write("dialogue/main.json", dialogue.json); checkMaps(maps, dialogue.knots, Object.keys(content.unlocks)); + const spriteIssues = maps.flatMap(({ id, objects }) => spriteProblems(id, objects, config.sprites)); + if (spriteIssues.length) throw new Error(spriteIssues.join("\n")); // Placeholder builds have no music, and the game stays quiet. const music = art diff --git a/packages/kai-assets/src/world/gen.ts b/packages/kai-assets/src/world/gen.ts index 0f58789..8fc9910 100644 --- a/packages/kai-assets/src/world/gen.ts +++ b/packages/kai-assets/src/world/gen.ts @@ -20,6 +20,8 @@ import { canvasToTmj, formatTmj, isManual, type Tmj } from "@datagutt/kai-worldg import { validateMap } from "@datagutt/kai-worldgen/validate"; import type { KaiApp } from "../app.ts"; import { localArtDir, seasonOverridesDir } from "../art/source.ts"; +import { spriteProblems } from "./sprites.ts"; +import sharp from "sharp"; export async function worldGen(app: KaiApp, mode: "gen" | "check" | "render", args: string[]): Promise { const flag = (name: string) => args.includes(`--${name}`); @@ -53,7 +55,7 @@ export async function worldGen(app: KaiApp, mode: "gen" | "check" | "render", ar ...(map.outdoor ? { seasonal: adapter.seasonalTile } : {}), types, }); - problems.push(...validateMap(map.id, tmj, types), ...adapter.checkCuts(map.id, canvas.stamped)); + problems.push(...validateMap(map.id, tmj, types), ...spriteProblems(map.id, canvas.objects, app.config.sprites), ...adapter.checkCuts(map.id, canvas.stamped)); outputs.set(file, formatTmj(tmj)); } if (problems.length) throw new Error(`[world] ${problems.length} problem(s):\n ${problems.join("\n ")}`); @@ -82,6 +84,24 @@ export async function worldGen(app: KaiApp, mode: "gen" | "check" | "render", ar const season = opt("season") ?? "summer"; if (!isSeason(season)) throw new Error(`[world] Unknown season "${season}"`); if (mode === "render") { + // Renders draw each sprite at its first frame, cut from its strip in the art. + const frames = new Map>(); + const firstFrame = (name: string) => { + const sprite = app.config.sprites[name]; + if (!sprite) return Promise.resolve(null); + if (!frames.has(name)) { + frames.set( + name, + sharp(path.join(artDir, sprite.file)) + .extract({ left: 0, top: 0, width: sprite.frameWidth, height: sprite.frameHeight }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }) + .then(({ data, info }) => ({ data, width: info.width, height: info.height })), + ); + } + return frames.get(name)!; + }; fs.mkdirSync(files.out, { recursive: true }); for (const [file, text] of outputs) { if (!file.endsWith(".tmj")) continue; @@ -91,6 +111,8 @@ export async function worldGen(app: KaiApp, mode: "gen" | "check" | "render", ar grid: flag("grid"), scale: Number(opt("scale") ?? 1), types, + sprite: firstFrame, + season, }); const target = path.join(files.out, path.basename(file, ".tmj") + (season === "summer" ? "" : `@${season}`) + ".png"); fs.writeFileSync(target, png); diff --git a/packages/kai-assets/src/world/sprites.test.ts b/packages/kai-assets/src/world/sprites.test.ts new file mode 100644 index 0000000..a67c5dc --- /dev/null +++ b/packages/kai-assets/src/world/sprites.test.ts @@ -0,0 +1,24 @@ +import { spriteObject } from "@datagutt/kai/world/objects"; +import { describe, expect, it } from "vitest"; +import { spriteProblems } from "./sprites.ts"; + +const sprites = { gull: { file: "gull.png", frameWidth: 32, frameHeight: 32, frames: 6, frameRate: 5 } }; + +describe("spriteProblems", () => { + it("accepts sprites kai.json has, in a known layer, season and time of day", () => { + const ok = spriteObject.at(1, 2, { sprite: "gull", layer: "above", seasons: "spring,summer", when: "day" }); + expect(spriteProblems("town", [ok], sprites)).toEqual([]); + }); + + it("names what the game would silently leave out", () => { + const problems = spriteProblems( + "town", + [spriteObject.at(1, 2, { sprite: "crab" }), spriteObject.at(3, 4, { sprite: "gull", layer: "roof", seasons: "summer,monsoon", when: "dusk" })], + sprites, + ).join("\n"); + expect(problems).toMatch(/sprite "crab" at \(1, 2\) is not in kai.json sprites/); + expect(problems).toMatch(/layer "roof"/); + expect(problems).toMatch(/season "monsoon"/); + expect(problems).toMatch(/when "dusk"/); + }); +}); diff --git a/packages/kai-assets/src/world/sprites.ts b/packages/kai-assets/src/world/sprites.ts new file mode 100644 index 0000000..db13e39 --- /dev/null +++ b/packages/kai-assets/src/world/sprites.ts @@ -0,0 +1,21 @@ +// `sprite` map objects name strips from kai.json `sprites`: a name that isn't there, or a +// layer, season or time of day the game doesn't know, would only show up as a missing +// animation in the game. +import type { KaiConfig } from "@datagutt/kai/schema"; +import { SPRITE_LAYERS, spriteObject, type AnyMapObject } from "@datagutt/kai/world/objects"; +import { SEASONS } from "@datagutt/kai/world/season"; + +export function spriteProblems(mapId: string, objects: readonly AnyMapObject[], sprites: KaiConfig["sprites"]): string[] { + const problems: string[] = []; + for (const obj of objects) { + if (!spriteObject.is(obj)) continue; + const at = `${mapId}: sprite "${obj.sprite}" at (${obj.x}, ${obj.y})`; + if (!(obj.sprite in sprites)) problems.push(`${at} is not in kai.json sprites`); + for (const season of obj.seasons?.split(",") ?? []) { + if (!(SEASONS as readonly string[]).includes(season)) problems.push(`${at} has season "${season}"; use ${SEASONS.join(", ")}`); + } + if (obj.when !== undefined && obj.when !== "day" && obj.when !== "night") problems.push(`${at} has when "${obj.when}"; use day or night`); + if (obj.layer !== undefined && !(SPRITE_LAYERS as readonly string[]).includes(obj.layer)) problems.push(`${at} has layer "${obj.layer}"; use ${SPRITE_LAYERS.join(" or ")}, or none`); + } + return problems; +} diff --git a/packages/kai-worldgen/src/canvas.ts b/packages/kai-worldgen/src/canvas.ts index 04f2c46..41416f2 100644 --- a/packages/kai-worldgen/src/canvas.ts +++ b/packages/kai-worldgen/src/canvas.ts @@ -158,14 +158,26 @@ export class MapCanvas { * Its tiles stack over what is already on the layer (see `stack`). */ stamp(prefab: Prefab, x: number, y: number, transform?: Transform): this { + return this.place(prefab, x, y, transform, true); + } + + /** + * Place a prefab's collision and footprint but none of its tiles, for a thing an + * animated sprite draws instead (a `sprite` map object over the same spot). + */ + reserve(prefab: Prefab, x: number, y: number, transform?: Transform): this { + return this.place(prefab, x, y, transform, false); + } + + private place(prefab: Prefab, x: number, y: number, transform: Transform | undefined, draw: boolean): this { if (prefab.parts) { - for (const part of prefab.parts) this.stamp(part.prefab, x + part.dx, y + part.dy, transform); + for (const part of prefab.parts) this.place(part.prefab, x + part.dx, y + part.dy, transform, draw); return this; } this.stamped.push(prefab); const t = transform ? TRANSFORMS[transform] : null; const place = (dx: number, dy: number): [number, number] => (t ? t.at(dx, dy, prefab.w, prefab.h) : [dx, dy]); - for (let dy = 0; dy < prefab.h; dy++) { + for (let dy = 0; draw && dy < prefab.h; dy++) { const layer: LayerName = prefab.rowLayers?.[dy] ?? (dy < prefab.aboveRows ? "above" : prefab.flat ? "ground2" : "below"); for (let dx = 0; dx < prefab.w; dx++) { const [tx, ty] = place(dx, dy); diff --git a/packages/kai-worldgen/src/render.ts b/packages/kai-worldgen/src/render.ts index 3056082..bc60159 100644 --- a/packages/kai-worldgen/src/render.ts +++ b/packages/kai-worldgen/src/render.ts @@ -2,7 +2,8 @@ // art (through a SheetSource), for reviewing maps. Needs the private art. import sharp from "sharp"; import { beamAlpha, glowAlpha, hexRgb } from "@datagutt/kai/fx/lightShapes"; -import { lightObject, mapObjectTypes, parseMapObject, type MapObjectTypes, type TiledObject } from "@datagutt/kai/world/objects"; +import { lightObject, mapObjectTypes, parseMapObject, spriteObject, type MapObjectTypes, type TiledObject } from "@datagutt/kai/world/objects"; +import type { Raw } from "./atlas.ts"; import { drawKey, type SheetSource } from "./atlas.ts"; import { CLEAR_ID, COLLISION_ID } from "./registry.ts"; import { decodeGid, type Tmj } from "./tmj.ts"; @@ -29,7 +30,17 @@ export async function renderTmj( tmj: Tmj, tiles: string[], sheets: SheetSource, - options: { collision?: boolean; scale?: number; objects?: boolean; grid?: boolean; types?: MapObjectTypes } = {}, + options: { + collision?: boolean; + scale?: number; + objects?: boolean; + grid?: boolean; + types?: MapObjectTypes; + /** The first frame of a kai.json sprite by name, to draw `sprite` objects; left out, they are not drawn. */ + sprite?: (name: string) => Promise; + /** The season rendered, for sprites limited to some seasons (summer when left out). */ + season?: string; + } = {}, ): Promise { const W = tmj.width * T; const H = tmj.height * T; @@ -52,6 +63,27 @@ export async function renderTmj( const objects = tmj.layers .filter((l) => l.type === "objectgroup") .flatMap((l) => (l.objects as TiledObject[]).map((o) => parseMapObject(o, T, types))); + // Animated sprites at their first frame, over the tiles. + for (const obj of objects) { + if (!spriteObject.is(obj) || !options.sprite) continue; + if (obj.seasons && !obj.seasons.split(",").includes(options.season ?? "summer")) continue; + const frame = await options.sprite(obj.sprite); + if (!frame) continue; + const x0 = obj.x * T + (obj.dx ?? 0); + const y0 = obj.y * T + (obj.dy ?? 0); + for (let y = 0; y < frame.height; y++) { + for (let x = 0; x < frame.width; x++) { + const si = (y * frame.width + x) * 4; + const a = frame.data[si + 3] / 255; + const px = x0 + x; + const py = y0 + y; + if (!a || px < 0 || py < 0 || px >= W || py >= H) continue; + const di = (py * W + px) * 4; + for (let c = 0; c < 3; c++) out.data[di + c] = Math.round(frame.data[si + c] * a + out.data[di + c] * (1 - a)); + out.data[di + 3] = 255; + } + } + } for (const light of objects) { // Renders show the map by day: lamps and porch lights stay off. if (!lightObject.is(light) || light.when === "night") continue; diff --git a/packages/kai/src/plugins/api.ts b/packages/kai/src/plugins/api.ts index f3e744f..1908b7f 100644 --- a/packages/kai/src/plugins/api.ts +++ b/packages/kai/src/plugins/api.ts @@ -3,7 +3,7 @@ // something, a conversation ending), places its own map object types, adds start menu // items, and can take over the frame for a while (a cutscene, a cabinet, the credits). import type Phaser from "phaser"; -import type { AreaObject, DoorObject, MapObject, NpcObject, ObjectPlacer, SpawnObject, SpotObject } from "../world/objects.ts"; +import type { AreaObject, DoorObject, MapObject, NpcObject, ObjectPlacer, SpawnObject, SpotObject, SpriteObject } from "../world/objects.ts"; import type { Externals, ExternalsContext, GameServices, WorldTarget } from "../boot.ts"; import type { Actor } from "../entities/Actor.ts"; import type { FrameInput } from "../input/InputController.ts"; @@ -33,6 +33,8 @@ export interface World { readonly spawns: ReadonlyMap; readonly spots: ReadonlyMap; readonly areas: ReadonlyMap; + /** The map's animated sprites, as placed (a cutscene may move them). */ + readonly sprites: ReadonlyArray<{ readonly def: SpriteObject; readonly sprite: Phaser.GameObjects.Sprite }>; /** Doors by tile ("x,y"). */ readonly doors: ReadonlyMap; /** Where the player arrived on this map. */ diff --git a/packages/kai/src/scenes/WorldScene.ts b/packages/kai/src/scenes/WorldScene.ts index c450999..166bf37 100644 --- a/packages/kai/src/scenes/WorldScene.ts +++ b/packages/kai/src/scenes/WorldScene.ts @@ -31,11 +31,13 @@ import { signObject, spawnObject, spotObject, + spriteObject, type AnyMapObject, type Facing, type GateObject, type LightObject, type ObjectPlacer, + type SpriteObject, type TiledObject, } from "../world/objects.ts"; import { addLights } from "../fx/Lights.ts"; @@ -49,6 +51,7 @@ import { distanceField, FAR } from "../world/distance.ts"; import { Feel } from "../fx/Feel.ts"; import { FrameWatch, qualityFor, type Quality } from "../fx/quality.ts"; import { findPath, findPathAdjacent } from "../world/pathfind.ts"; +import { lightFactor } from "../world/dayNight.ts"; import { applySeason } from "../world/season.ts"; import { resolveWeather, weatherSound } from "../world/weather.ts"; import { calmWeather, type WeatherNow } from "../world/weather-kinds.ts"; @@ -57,6 +60,8 @@ type Door = ObjectOf<"door">; type Sign = ObjectOf<"sign">; const PLAYER_ID = "player"; +/** The roof layers' depth: over every character. */ +const ABOVE_DEPTH = 50_000; const tileKey = (p: Point) => `${p.x},${p.y}`; /** Registry key of the game-wide Ambience. */ const AMBIENCE_KEY = "ambience"; @@ -87,6 +92,7 @@ export class WorldScene extends Phaser.Scene { private spawns = new Map>(); private spots = new Map>(); private areas = new Map>(); + private sprites: { def: SpriteObject; sprite: Phaser.GameObjects.Sprite }[] = []; private layers = new Map(); private outdoors = false; private start: Point = { x: 0, y: 0 }; @@ -136,6 +142,7 @@ export class WorldScene extends Phaser.Scene { this.spawns = new Map(); this.spots = new Map(); this.areas = new Map(); + this.sprites = []; this.layers = new Map(); this.takeover = null; this.promptNpc = null; @@ -176,7 +183,7 @@ export class WorldScene extends Phaser.Scene { continue; } layer.setVisible(!collisionOnly); - layer.setDepth(layerData.name.includes("above") ? 50_000 : -1); + layer.setDepth(layerData.name.includes("above") ? ABOVE_DEPTH : -1); // Blended layers (the `shade` layer multiplies) say so in a layer property. const blend = (layerData.properties as { name: string; value: unknown }[] | undefined)?.find((p) => p.name === "blend")?.value; if (blend === "multiply") layer.setBlendMode(Phaser.BlendModes.MULTIPLY); @@ -209,6 +216,7 @@ export class WorldScene extends Phaser.Scene { if (spawnObject.is(obj)) this.spawns.set(obj.id, obj); else if (spotObject.is(obj)) this.spots.set(obj.id, obj); else if (areaObject.is(obj)) this.areas.set(obj.id, obj); + else if (spriteObject.is(obj)) this.placeSprite(obj); else if (doorObject.is(obj)) { // A locked warp is solid ground until it opens, whatever the map around it // allows: nobody slips round a gate onto it. @@ -251,6 +259,7 @@ export class WorldScene extends Phaser.Scene { // A story night stays at night, under a clear sky, whatever the clock and the weather. const hours = this.services.night ? () => 23 : this.services.hours; this.dayNight = new DayNight(this, lights.map((light, i) => ({ light, image: images[i] })), outdoors, hours, this.services.month); + this.showSpritesByDaylight(); const low = this.quality === "low"; const weather = this.services.live.weather; this.weatherNow = this.services.night ? calmWeather(weather.place) : resolveWeather(window.location.search, weather); @@ -371,6 +380,7 @@ export class WorldScene extends Phaser.Scene { const input = this.input2.poll(); this.dialogue.update(dt, time); this.dayNight.update(time); + this.showSpritesByDaylight(); if (this.progress.settings.effects === "auto" && this.frameWatch.sample(delta)) { // Struggling: the shaders go now, the weather thins on the next map. this.services.autoLow = true; @@ -580,6 +590,8 @@ export class WorldScene extends Phaser.Scene { daylight: this.dayNight.current, quality: this.quality, ambience: this.ambience.levels, + /** Animated sprites on screen now, by name: seasonal and day-only ones come and go. */ + sprites: this.sprites.filter(({ sprite }) => sprite.visible).map(({ def }) => def.sprite), music: this.services.music.current, ghosts: this.ghosts.count, emoteWheel: this.wheel.open, @@ -766,6 +778,33 @@ export class WorldScene extends Phaser.Scene { this.save(); } + /** A looping strip from kai.json `sprites`; with reduced motion it holds its first frame. */ + private placeSprite(def: SpriteObject) { + const key = `sprite:${def.sprite}`; + if (def.seasons && !def.seasons.split(",").includes(this.services.season)) return; + if (!this.textures.exists(key)) { + console.warn(`[world] ${this.target.map}: no sprite "${def.sprite}" in kai.json sprites`); + return; + } + const sprite = this.add.sprite(def.x * TILE + (def.dx ?? 0), def.y * TILE + (def.dy ?? 0), key).setOrigin(0); + // Just over its tile layer (the ground, or the roofs); otherwise sorted with the characters by its bottom edge. + sprite.setDepth(def.layer === "below" ? -0.9 : def.layer === "above" ? ABOVE_DEPTH + 0.1 : sprite.y + sprite.height); + // Neighbours start at different frames, so a flock doesn't move in step. + const frames = sprite.texture.frameTotal - 1; + const start = (def.x * 7 + def.y * 13) % Math.max(1, frames); + if (this.progress.reducedMotion) sprite.setFrame(0); + else sprite.play({ key, startFrame: start }); + this.sprites.push({ def, sprite }); + } + + /** Day-only and night-only sprites come and go with the light, as their lights do. */ + private showSpritesByDaylight() { + const { dark } = this.dayNight.current; + for (const { def, sprite } of this.sprites) { + if (def.when === "day" || def.when === "night") sprite.setVisible(lightFactor(def.when, dark) >= 0.5); + } + } + /** What a plugin lets the player use on `p`, if anything. */ private usableAt(p: Point): Usable | null { for (const plugin of this.kaiPlugins) { @@ -898,6 +937,7 @@ export class WorldScene extends Phaser.Scene { spawns: scene.spawns, spots: scene.spots, areas: scene.areas, + sprites: scene.sprites, doors: scene.doors, get arrival() { return { target: scene.target, tile: scene.start }; diff --git a/packages/kai/src/world/objects.test.ts b/packages/kai/src/world/objects.test.ts index 3bd1014..553d8eb 100644 --- a/packages/kai/src/world/objects.test.ts +++ b/packages/kai/src/world/objects.test.ts @@ -12,6 +12,8 @@ describe("map objects", () => { { type: "sign", x: 5, y: 6, text: "Hello" }, { type: "spot", id: "datagutt-desk", x: 6, y: 5, facing: "up" }, { type: "area", id: "ferry", x: 52, y: 67, w: 6, h: 4 }, + { type: "sprite", x: 23, y: 15, sprite: "windmill-blades", dy: 21, layer: "above" }, + { type: "sprite", x: 46, y: 57, sprite: "butterfly", seasons: "spring,summer", when: "day" }, { type: "light", shape: "glow", x: 7, y: 8, radius: 2.5, color: "ffae62", intensity: 0.5, flicker: true }, { type: "light", shape: "beam", x: 1, y: 1, w: 2, h: 3, color: "fff0d2", intensity: 0.4 }, { type: "light", shape: "beam", x: 1, y: 1, w: 2, h: 3, color: "fff0d2", intensity: 0.4, when: "day" }, diff --git a/packages/kai/src/world/objects.ts b/packages/kai/src/world/objects.ts index deb10ba..0e9c007 100644 --- a/packages/kai/src/world/objects.ts +++ b/packages/kai/src/world/objects.ts @@ -207,6 +207,24 @@ export const spotObject = defineMapObject("spot", { props: { id: "string", facin /** A named rectangle of the map for scripted scenes, such as the ferry for an intro. */ export const areaObject = defineMapObject("area", { props: { id: "string" }, placement: "overlay", size: "rect" }); +/** + * An animated strip from kai.json `sprites`, looping at its tile plus a pixel offset (`dx`, + * `dy`). It draws among the characters by its bottom edge; with `layer: "below"` under all + * of them (what they walk or stand on: an escalator, a boat's deck), and with `layer: + * "above"` over all of them, like roofs (a windmill's blades over its tower). `seasons` + * (comma-separated) limits it to those seasons, and `when` to "day" or "night", as for + * lights: butterflies in summer, a campfire after dark. It blocks + * nothing: stamp the thing it animates without its tiles (MapCanvas.reserve), or block + * its cells. + */ +export const spriteObject = defineMapObject("sprite", { + props: { sprite: "string", dx: "number?", dy: "number?", layer: "string?", seasons: "string?", when: "string?" }, + placement: "overlay", +}); + +/** The values a sprite's `layer` may take. */ +export const SPRITE_LAYERS = ["below", "above"] as const; + export type LightTime = "day" | "night"; /** @@ -241,12 +259,13 @@ export type NpcObject = MapObjectOf; export type GateObject = MapObjectOf; export type SpotObject = MapObjectOf; export type AreaObject = MapObjectOf; +export type SpriteObject = MapObjectOf; /** The types the engine handles itself. */ -export const CORE_OBJECTS: readonly MapObjectType[] = [spawnObject, doorObject, signObject, npcObject, gateObject, spotObject, areaObject, lightObject]; +export const CORE_OBJECTS: readonly MapObjectType[] = [spawnObject, doorObject, signObject, npcObject, gateObject, spotObject, areaObject, spriteObject, lightObject]; /** One of the engine's own map objects. */ -export type MapObject = SpawnObject | DoorObject | SignObject | NpcObject | GateObject | SpotObject | AreaObject | LightObject; +export type MapObject = SpawnObject | DoorObject | SignObject | NpcObject | GateObject | SpotObject | AreaObject | SpriteObject | LightObject; export type MapObjectTypes = ReadonlyMap; From 115e53ba30cec8bb022986792e1c2e2dca90d1dc Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 25 Sep 2026 01:18:10 +0200 Subject: [PATCH 151/153] feat(datagutt): animate the town the windmill's blades turn, the boats bob, the fountain runs, and the fireplace, treadmill and escalator move, from limezu's own strips. pigeons, gulls, buoys, butterflies in the warm half of the year and a campfire at the lookout fill places the maps already have, keeping to the daylight where it suits them. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/docs/ART.md | 25 + apps/datagutt/docs/HANDOFF.md | 6 +- apps/datagutt/docs/PLAN.md | 11 + apps/datagutt/e2e/game.spec.ts | 20 +- apps/datagutt/game/plugins/Intro.ts | 17 +- apps/datagutt/game/plugins/ferryIntro.ts | 2 + apps/datagutt/kai.json | 15 +- apps/datagutt/world/gen/maps/gym.ts | 4 +- apps/datagutt/world/gen/maps/house.ts | 3 +- apps/datagutt/world/gen/maps/mountain.ts | 5 + apps/datagutt/world/gen/maps/overworld.ts | 31 +- apps/datagutt/world/gen/maps/townHall.ts | 3 +- apps/datagutt/world/gen/prefabs.ts | 11 +- apps/datagutt/world/maps/gym.tmj | 51 +- apps/datagutt/world/maps/house.tmj | 44 +- apps/datagutt/world/maps/mountain.tmj | 105 +++- .../world/maps/town-hall-basement.tmj | 67 ++- apps/datagutt/world/maps/town.tmj | 503 ++++++++++++++---- 18 files changed, 746 insertions(+), 177 deletions(-) diff --git a/apps/datagutt/docs/ART.md b/apps/datagutt/docs/ART.md index 37b6ce5..93d342f 100644 --- a/apps/datagutt/docs/ART.md +++ b/apps/datagutt/docs/ART.md @@ -79,6 +79,31 @@ never overwrites a file that exists, since it may be hand-edited; `--force` redr commit them in datagutt-assets, then run `bun run world:gen` to rebuild the atlas. +# Animations + +LimeZu's animated strips live under `limezu/exteriors/Animated_16x16/` and +`limezu/interiors/animated/`. Use the PNGs: the GIFs lose their pale transparent colours. +Each strip is one row of frames. + +To animate something a map already draws: + +1. Find its strip and the frame size (the static art's size, usually). Check that frame + 0 lies over the static art: most do at offset (0, 0), some sit a few pixels off (the + treadmill 2 px higher, the windmill's blades 21 px down). +2. Add the strip to `kai.json` `sprites` (file, frame size, frame count, frame rate). +3. In the map builder, `c.reserve(prefab, x, y)` keeps its collision without its tiles, + and `spriteObject.at(x, y, { sprite, dx, dy })` plays the strip there. + +Sprites draw among the characters by their bottom edge. `layer: "below"` puts them +under everyone, for what the player stands on (the ferry's deck, the escalator), and +`layer: "above"` over everyone, like roofs (the windmill's blades over its tower). + +Sprites don't change with the seasons the way tiles do: a strip has no snow. Keep the +parts winter touches as tiles and animate the rest, as the windmill does (a tower of +tiles with snow on its roof, sprite blades). `seasons` and `when` limit a sprite to +some seasons or to day or night: butterflies in spring and summer, birds by day. Renders +(`bun run world:render`) draw each sprite's first frame, for the season rendered. + # Characters NPC walk sheets and portraits are stacked from the LimeZu character and portrait diff --git a/apps/datagutt/docs/HANDOFF.md b/apps/datagutt/docs/HANDOFF.md index 3d9aa96..534fd3d 100644 --- a/apps/datagutt/docs/HANDOFF.md +++ b/apps/datagutt/docs/HANDOFF.md @@ -1,9 +1,13 @@ # Handoff -Last updated: 2026-09-23 (session 1: design through M5; the Nettbureau office on Modern Office) +Last updated: 2026-09-25 (engine split merged; C4 animation) ## Current state +- **C4 animation (2026-09-25):** the windmill, boats, fountain, fireplace, treadmill and + escalator play LimeZu's strips; pigeons, gulls, buoys, butterflies (spring and + summer) and a campfire at the lookout by season and daylight. How to add more: ART.md, + "Animations". Next: C5, doors that open as the player goes through. - **The engine split is merged into `game` (2026-09-25).** The site now lives in `apps/datagutt` on the kai engine (`packages/`), built with Bun and Turborepo. Paths and commands in the notes below predate it (`pnpm`, `game/scenes/`, `world/art/`): diff --git a/apps/datagutt/docs/PLAN.md b/apps/datagutt/docs/PLAN.md index 2d7efe5..e492b32 100644 --- a/apps/datagutt/docs/PLAN.md +++ b/apps/datagutt/docs/PLAN.md @@ -431,3 +431,14 @@ Goal: ship v1 to production. faintly indoors. `?debug&weather=`.) (Since 2026-09-24: the visitor's own weather, by Vercel's IP location, Oslo as the fallback.) - [ ] **C3** Multi-instance ghosts: Upstash Redis pub/sub or Durable Objects. +- [x] **C4** Animation: the town moves. LimeZu ships animated strips for much of what + the maps use. *Done when* the looping ones play in place of their static art and + some ambient life fills places the maps already have, by season and daylight. + (Built on the engine's `sprite` map object and kai.json `sprites`: the windmill's + blades, the ferry and the rowboat bobbing, the fountain running, the fireplace, + the treadmill and the escalator; pigeons, gulls, buoys, butterflies in spring and + summer, and a campfire at the lookout. See ART.md, "Animations".) +- [ ] **C5** One-shot animations: doors that open as the player goes through (post + office, farmhouse, gym cabin, hytte, kiosk match LimeZu's door strips exactly), the + kiosk fridge and the farm oven. Needs a trigger on the sprite, not a loop. + diff --git a/apps/datagutt/e2e/game.spec.ts b/apps/datagutt/e2e/game.spec.ts index 3dafbb1..d9c41f4 100644 --- a/apps/datagutt/e2e/game.spec.ts +++ b/apps/datagutt/e2e/game.spec.ts @@ -20,6 +20,8 @@ type KaiState = { intro: boolean; credits: boolean; finale: boolean; + /** Animated sprites on screen, by name. */ + sprites: string[]; }; const state = (page: Page) => page.evaluate(() => (window as unknown as { __kai?: KaiState }).__kai ?? null); @@ -43,13 +45,13 @@ async function holdKey(page: Page, key: string, ms: number) { * `presence` fixes Thomas's Discord presence (content/presence.json mocks), so * where he stands doesn't depend on the real Lanyard feed. */ -async function continueAt(page: Page, at: { map: string; x: number; y: number; facing: string }, presence = "coding", stamps: string[] = []) { - await page.goto(`/?debug&presence=${presence}`); +async function continueAt(page: Page, at: { map: string; x: number; y: number; facing: string }, presence = "coding", stamps: string[] = [], query = "") { + await page.goto(`/?debug&presence=${presence}${query}`); await page.evaluate( ([save, stamps]) => localStorage.setItem("fjordtown.save", JSON.stringify({ version: 1, ...save, stamps, flags: {}, dialogue: {}, settings: {} })), [at, stamps] as const, ); - await page.goto(`/?debug&presence=${presence}`); + await page.goto(`/?debug&presence=${presence}${query}`); await choose(page, /continue/i); await expect.poll(() => state(page), { timeout: 30_000 }).toMatchObject({ map: at.map, tile: { x: at.x, y: at.y } }); } @@ -261,6 +263,18 @@ test.describe("world", () => { expect(flags.finale).toBe(true); }); + test("the town's animations follow the season and the daylight", async ({ page }) => { + const square = { map: "town", x: 47, y: 38, facing: "down" }; + await continueAt(page, square, "coding", [], "&season=summer&time=day"); + const summerDay = (await state(page))?.sprites ?? []; + expect(summerDay).toEqual(expect.arrayContaining(["windmill-blades", "fountain", "ferry", "rowboat", "pigeon", "seagull-left", "buoy", "butterfly"])); + + await continueAt(page, square, "coding", [], "&season=winter&time=night"); + const winterNight = (await state(page))?.sprites ?? []; + expect(winterNight).toEqual(expect.arrayContaining(["windmill-blades", "fountain", "ferry", "buoy"])); + for (const daytime of ["pigeon", "seagull-left", "butterfly"]) expect(winterNight).not.toContain(daytime); + }); + test("a full passport whose finale never finished brings it back on the next map", async ({ page }) => { test.setTimeout(120_000); const all = ["home", "boathouse", "radio-tower", "kiosk", "office", "town-hall", "gym", "library", "farm", "post-office"]; diff --git a/apps/datagutt/game/plugins/Intro.ts b/apps/datagutt/game/plugins/Intro.ts index aa778b6..da26eca 100644 --- a/apps/datagutt/game/plugins/Intro.ts +++ b/apps/datagutt/game/plugins/Intro.ts @@ -2,7 +2,7 @@ // the pier with the player aboard, the player hops ashore, and Arne says hello (with the // passport, the controls and the Journal). Any key, button or tap skips the sailing. // The ferry is part of the map; for the voyage its tiles are lifted into a moving group -// of images, and put back once it has docked. +// of images, and put back once it has docked. Its sprites (the bobbing hull) sail along. import Phaser from "phaser"; import { TILE } from "@datagutt/kai"; import type { Actor } from "@datagutt/kai/entities/Actor"; @@ -20,6 +20,8 @@ type Lifted = { layer: Phaser.Tilemaps.TilemapLayer | Phaser.Tilemaps.TilemapGPU export class Intro { private readonly boat: Phaser.GameObjects.Container; private readonly lifted: Lifted[] = []; + /** The ferry's sprites and where they are moored. */ + private readonly sailing: { sprite: Phaser.GameObjects.Sprite; x: number }[]; private tween: Phaser.Tweens.Tween | null = null; private stage: "sailing" | "ashore" | "done" = "sailing"; @@ -28,6 +30,7 @@ export class Intro { private readonly player: Actor, ferry: Area, layers: (Phaser.Tilemaps.TilemapLayer | Phaser.Tilemaps.TilemapGPULayer)[], + sprites: Phaser.GameObjects.Sprite[], private readonly spawn: Point, reducedMotion: boolean, /** Arne's welcome, then `done`. */ @@ -35,6 +38,8 @@ export class Intro { private readonly finished: () => void, ) { this.boat = scene.add.container(START_TILES * TILE, 0); + this.sailing = sprites.map((sprite) => ({ sprite, x: sprite.x })); + this.moveSprites(); const atlas = scene.textures.get("tiles:world"); const columns = Math.floor((atlas.getSourceImage() as { width: number }).width / TILE); for (const layer of layers) { @@ -79,7 +84,10 @@ export class Intro { this.goAshore(); } // The player rides the ferry: its deck, not the tile they stand on, moves. - if (this.stage === "sailing") this.player.offset.x = Math.round(this.boat.x); + if (this.stage === "sailing") { + this.player.offset.x = Math.round(this.boat.x); + this.moveSprites(); + } this.boat.setDepth(this.player.sprite.depth - 1); this.player.sync(); } @@ -112,8 +120,13 @@ export class Intro { }); } + private moveSprites(): void { + for (const { sprite, x } of this.sailing) sprite.x = x + Math.round(this.boat.x); + } + /** Back into the map: the ferry is moored where the generator put it. */ private dock(): void { + for (const { sprite, x } of this.sailing) sprite.x = x; for (const t of this.lifted) { const tile = t.layer.putTileAt(t.index, t.x, t.y); tile.flipX = t.flipX; diff --git a/apps/datagutt/game/plugins/ferryIntro.ts b/apps/datagutt/game/plugins/ferryIntro.ts index 8d6c62e..d638c25 100644 --- a/apps/datagutt/game/plugins/ferryIntro.ts +++ b/apps/datagutt/game/plugins/ferryIntro.ts @@ -13,11 +13,13 @@ export function ferryIntroPlugin(): KaiPlugin { const forced = new URLSearchParams(window.location.search).has("intro"); if (!ferry || !arne || !world.services.firstVisit || world.arrival.target.spawn !== "ferry" || (world.progress.flags.intro && !forced)) return; const layers = ["below", "above"].flatMap((name) => world.layers.get(name) ?? []); + const aboard = world.sprites.filter(({ def }) => def.x >= ferry.x && def.y >= ferry.y && def.x < ferry.x + ferry.w && def.y < ferry.y + ferry.h); const intro = new Intro( world.scene, world.player, ferry, layers, + aboard.map(({ sprite }) => sprite), world.arrival.tile, world.progress.reducedMotion, (done) => { diff --git a/apps/datagutt/kai.json b/apps/datagutt/kai.json index 70cbda3..81e15a8 100644 --- a/apps/datagutt/kai.json +++ b/apps/datagutt/kai.json @@ -24,7 +24,20 @@ "emotes": "limezu/interiors/ui_elements/UI_thinking_emotes_animation_16x16.png" }, "sprites": { - "cat": { "file": "limezu/interiors/animated/animated_cat.png", "frameWidth": 48, "frameHeight": 16, "frames": 12 } + "cat": { "file": "limezu/interiors/animated/animated_cat.png", "frameWidth": 48, "frameHeight": 16, "frames": 12 }, + "windmill-blades": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Additional_Houses_Wind_Mill_Blades_16x16.png", "frameWidth": 64, "frameHeight": 64, "frames": 4, "frameRate": 6 }, + "ferry": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Fishing_Boat_2_right_16x16.png", "frameWidth": 96, "frameHeight": 64, "frames": 8, "frameRate": 5 }, + "rowboat": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Boat_1_right_1_16x16.png", "frameWidth": 64, "frameHeight": 32, "frames": 8, "frameRate": 5 }, + "fountain": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Garden_Fountain_6_16x16.png", "frameWidth": 64, "frameHeight": 80, "frames": 6, "frameRate": 8 }, + "fireplace": { "file": "limezu/interiors/animated/animated_chimney_red.png", "frameWidth": 32, "frameHeight": 48, "frames": 3, "frameRate": 8 }, + "treadmill": { "file": "limezu/interiors/animated/animated_treadmill.png", "frameWidth": 48, "frameHeight": 64, "frames": 3, "frameRate": 10 }, + "escalator": { "file": "limezu/interiors/animated/animated_escalator_up.png", "frameWidth": 48, "frameHeight": 80, "frames": 6, "frameRate": 8 }, + "seagull-left": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Beach_Seagull_Idle_Left_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 5 }, + "seagull-right": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Beach_Seagull_Idle_Right_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 5 }, + "buoy": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Beach_Water_Buoy_1_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 8, "frameRate": 5 }, + "pigeon": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Pigeon_16x16.png", "frameWidth": 16, "frameHeight": 16, "frames": 6, "frameRate": 6 }, + "butterfly": { "file": "limezu/interiors/animated/animated_butterfly_1_idle.png", "frameWidth": 16, "frameHeight": 16, "frames": 4, "frameRate": 8 }, + "campfire": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Campfire_16x16.png", "frameWidth": 16, "frameHeight": 32, "frames": 6, "frameRate": 10 } }, "font": { "module": "geist/font/pixel", "file": "fonts/geist-pixel/GeistPixel-Square.woff2", "unitsPerPixel": 76 }, "live": { diff --git a/apps/datagutt/world/gen/maps/gym.ts b/apps/datagutt/world/gen/maps/gym.ts index d04bb96..92d55d0 100644 --- a/apps/datagutt/world/gen/maps/gym.ts +++ b/apps/datagutt/world/gen/maps/gym.ts @@ -4,6 +4,7 @@ // everyday tools (warm-up weights), the heavy rack (video and streaming) and the front // desk (payments, memberships). Everything comes from the one gym sheet so it matches. // Laid out with two-tile aisles so there is room to move between the kit. +import { spriteObject } from "@datagutt/kai/world/objects"; import { NPCS } from "../../../game/npcs.ts"; import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; @@ -27,7 +28,8 @@ export function gym(): MapCanvas { // Free weights on the mat, the heavy rack, the treadmill. c.stamp(F.gymMat, 2, 5).stamp(F.warmupRack, 3, 6); // everyday tools c.stamp(F.benchPress, 11, 6).stamp(F.weightPlates, 13, 9); // the heavy rack - c.stamp(F.treadmill, 15, 5); // cloud and DevOps + // The belt runs as a sprite; its art sits 2 px higher than the tiles cut from the sheet. + c.reserve(F.treadmill, 15, 5).add(spriteObject.at(15, 5, { sprite: "treadmill", dy: -2 })); // cloud and DevOps shadowUnder(c, F.benchPress, 11, 6); // Front desk by the door (payments), with a light over it. diff --git a/apps/datagutt/world/gen/maps/house.ts b/apps/datagutt/world/gen/maps/house.ts index 95d7862..8091eb5 100644 --- a/apps/datagutt/world/gen/maps/house.ts +++ b/apps/datagutt/world/gen/maps/house.ts @@ -6,6 +6,7 @@ // Upstairs, cooler and more personal: Thomas at one long desk with three computers, the // mini-fridge of energy drinks within reach, his bed, a dresser and the stairwell down. import { arcadeObject } from "@datagutt/kai-arcade/object"; +import { spriteObject } from "@datagutt/kai/world/objects"; import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder, windowLight } from "@datagutt/kai-limezu/lighting"; import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; @@ -22,7 +23,7 @@ export function house(): MapCanvas { c.stamp(F.stove, 2, 2).stamp(F.counter, 4, 2); c.stamp(F.window, 7, 1); c.stamp(F.tvCabinet, 9, 2).stamp(F.tv, 9, 1); - c.stamp(F.fireplace, 12, 1); + c.reserve(F.fireplace, 12, 1).add(spriteObject.at(12, 1, { sprite: "fireplace" })); c.stamp(F.stairsUp, 15, 1); // Dining: the chairs face the table from either side. diff --git a/apps/datagutt/world/gen/maps/mountain.ts b/apps/datagutt/world/gen/maps/mountain.ts index 089717d..41b6a04 100644 --- a/apps/datagutt/world/gen/maps/mountain.ts +++ b/apps/datagutt/world/gen/maps/mountain.ts @@ -8,6 +8,8 @@ import { MapCanvas } from "@datagutt/kai-worldgen/canvas"; import { building, forest, meadow, sign } from "@datagutt/kai-limezu/features"; import { Region } from "@datagutt/kai-worldgen/layout"; import { mapText } from "../text.ts"; +import { spriteObject } from "@datagutt/kai/world/objects"; +import { glow, GLOWS } from "@datagutt/kai-limezu/lighting"; const W = 40; const H = 30; @@ -34,6 +36,9 @@ export function mountain(): MapCanvas { // The lookout: a bench facing out over the fjord, far below. c.stamp(PREFABS.bench, 32, 18); + // A campfire by the bench, lit day and night; it throws its light after dark. + c.block(30, 17).add(spriteObject.at(30, 16, { sprite: "campfire" })).add({ ...glow(30, 17, GLOWS.fire), when: "night" }); + c.add(spriteObject.at(22, 21, { sprite: "butterfly", seasons: "spring,summer", when: "day" })); sign(c, 34, 19, say("lookout"), "signpost"); // Rocks from the old rockfall, pushed off the path. diff --git a/apps/datagutt/world/gen/maps/overworld.ts b/apps/datagutt/world/gen/maps/overworld.ts index 6bec4d8..af420ce 100644 --- a/apps/datagutt/world/gen/maps/overworld.ts +++ b/apps/datagutt/world/gen/maps/overworld.ts @@ -4,7 +4,7 @@ // x: 0-4 forest | 5-34 farm, datagutt's street, boathouse | 35-62 library, town hall, // square, post office, harbour | 63-90 radio hill, office, gym | 91-95 forest import { NPCS } from "../../../game/npcs.ts"; -import type { Facing, MapObject } from "@datagutt/kai/world/objects"; +import { spriteObject, type Facing, type MapObject } from "@datagutt/kai/world/objects"; import { arcadeObject } from "@datagutt/kai-arcade/object"; import { cropsObject } from "@datagutt/kai-live/github/objects"; import { catObject } from "../../../game/plugins/cat.object.ts"; @@ -82,7 +82,7 @@ export function overworld(): MapCanvas { // --- Buildings ------------------------------------------------------------------------- const farmDoor = building(c, PREFABS.farmhouse, 5, 14, { link: { toMap: "farmhouse", toSpawn: "entrance" } }); - c.stamp(PREFABS.windmill, 23, 15); + c.stamp(PREFABS.windmillTower, 23, 15).add(spriteObject.at(23, 15, { sprite: "windmill-blades", dy: 21, layer: "above" })); const libraryDoor = building(c, PREFABS.library, 29, 5, { addDoor: true, link: { toMap: "library", toSpawn: "entrance" } }); const hallDoor = building(c, PREFABS.townHall, 42, 4, { addDoor: true, link: { toMap: "town-hall", toSpawn: "entrance" } }); c.stamp(PREFABS.radioTower, 79, 5); @@ -122,14 +122,17 @@ export function overworld(): MapCanvas { const pierTop = shore[HARBOUR_X] - 1; const pierEnd = pierTop + 8; pier(c, HARBOUR_X, pierTop, pierEnd); - c.stamp(PREFABS.ferry, HARBOUR_X + 3, pierEnd - 4); - // The intro sails it in (game/scenes/Intro.ts); it needs to know which tiles it is. + // The boats bob as sprites (kai.json), drawn under whoever stands on their decks. + c.reserve(PREFABS.ferry, HARBOUR_X + 3, pierEnd - 4).add(spriteObject.at(HARBOUR_X + 3, pierEnd - 4, { sprite: "ferry", layer: "below" })); + // The intro sails it in (game/plugins/Intro.ts); it needs to know which tiles and sprites it is. c.add({ type: "area", id: "ferry", x: HARBOUR_X + 3, y: pierEnd - 4, w: PREFABS.ferry.w, h: PREFABS.ferry.h }); // Moored with its rope end (right) at the boathouse pier. - c.stamp(PREFABS.rowboat, 18, Math.max(...shore.slice(18, 22))); + const rowboatY = Math.max(...shore.slice(18, 22)); + c.reserve(PREFABS.rowboat, 18, rowboatY).add(spriteObject.at(18, rowboatY, { sprite: "rowboat", layer: "below" })); // --- Town furniture -------------------------------------------------------------------- - c.stamp(PREFABS.bigFountain, 48, 32); + // Running: the sprite's water plays over the fountain's footprint. + c.reserve(PREFABS.bigFountain, 48, 32).add(spriteObject.at(48, 32, { sprite: "fountain" })); c.stamp(PREFABS.benchLong, 43, 33).stamp(PREFABS.benchLong, 54, 33); // Lamps round the square and along the south verge of the main road. They light up // after dark from their heads (the prefab's top tile). @@ -211,5 +214,21 @@ export function overworld(): MapCanvas { sign(c, 78, 13, say("radio-tower")); c.add(npc("ferryman", HARBOUR_X + 2, pierEnd - 1, "left")); c.add(npc("farmer", 16, 12, "down")); + + // --- Life ---------------------------------------------------------------------------------- + // Idle loops that ask nothing of the player. Birds keep to the daylight; butterflies + // only come out in the warm half of the year. Gulls stay through the Norwegian winter. + const life: [string, number, number, { seasons?: string; when?: string }][] = [ + ["pigeon", 51, 31, { when: "day" }], + ["pigeon", 46, 37, { when: "day" }], + ["pigeon", 53, 38, { when: "day" }], + ["seagull-left", 40, 62, { when: "day" }], + ["seagull-right", 60, 62, { when: "day" }], + ["buoy", 41, 70, {}], + ["buoy", 62, 73, {}], + ["butterfly", 46, 57, { seasons: "spring,summer", when: "day" }], + ["butterfly", 39, 58, { seasons: "spring,summer", when: "day" }], + ]; + for (const [sprite, x, y, options] of life) c.add(spriteObject.at(x, y, { sprite, ...options })); return c; } diff --git a/apps/datagutt/world/gen/maps/townHall.ts b/apps/datagutt/world/gen/maps/townHall.ts index 4069e1e..a179097 100644 --- a/apps/datagutt/world/gen/maps/townHall.ts +++ b/apps/datagutt/world/gen/maps/townHall.ts @@ -3,6 +3,7 @@ // past mayors, public benches either side of the red carpet. The door by the stage goes // down to the basement: the municipal IT department, where Bjørn still keeps the servers // and, mostly, the printers running. +import { spriteObject } from "@datagutt/kai/world/objects"; import { NPCS } from "../../../game/npcs.ts"; import { FURNITURE as F } from "@datagutt/kai-limezu/furniture"; import { glow, GLOWS, shadowUnder } from "@datagutt/kai-limezu/lighting"; @@ -55,7 +56,7 @@ export function townHallBasement(): MapCanvas { floorPatch(c, { x: 14, y: 3, w: 6, h: 3 }, FLOORS.carpetGrey); // The escalator in the back-left corner; its second step is the door back up. - c.stamp(F.escalatorUp, 2, 1); + c.reserve(F.escalatorUp, 2, 1).add(spriteObject.at(2, 1, { sprite: "escalator", layer: "below" })); c.block(3, 2, false).add({ type: "door", x: 3, y: 2, toMap: "town-hall", toSpawn: "basement" }); c.add({ type: "spawn", id: "stairs", x: 3, y: 6, facing: "down" }); diff --git a/apps/datagutt/world/gen/prefabs.ts b/apps/datagutt/world/gen/prefabs.ts index bf5aa9c..8b55cc2 100644 --- a/apps/datagutt/world/gen/prefabs.ts +++ b/apps/datagutt/world/gen/prefabs.ts @@ -4,6 +4,9 @@ import type { Prefab } from "@datagutt/kai-worldgen/canvas"; import { PREFABS as LIMEZU_PREFABS, footprint, villa } from "@datagutt/kai-limezu/prefabs"; import { single } from "@datagutt/kai-limezu/singles"; +/** LimeZu calls it a windmill without its propeller: a lattice tower. */ +const latticeTower = single("houses", "Post_Apocalyptic_House_Wind_Mill_No_Propeller", { aboveRows: 5, collision: [".##."] }); + const TOWN_PREFABS = { /** datagutt's house: the red villa in falu red with a slate roof. */ homeVilla: villa("Villa_5", "villaRed"), @@ -12,8 +15,12 @@ const TOWN_PREFABS = { boathouse: footprint({ sheet: "houses", col: 16, row: 250, w: 15, h: 14, aboveRows: 3, door: [5, 13] }), /** Farmhouse: white board-and-batten house whose garage reads as barn doors. */ farmhouse: footprint({ sheet: "houses", col: 0, row: 250, w: 16, h: 14, aboveRows: 3, door: [9, 13] }), - /** LimeZu calls it a windmill without its propeller: a lattice tower. */ - radioTower: single("houses", "Post_Apocalyptic_House_Wind_Mill_No_Propeller", { aboveRows: 5, collision: [".##."] }), + radioTower: latticeTower, + /** + * The windmill without its blades, which turn over it as a sprite (kai.json + * `windmill-blades`). The tower stays tiles, so it keeps its snow in winter. + */ + windmillTower: latticeTower, /** The sheet stacks a second storey under the cottage; take only the top one. */ kiosk: footprint({ sheet: "villas", col: 23, row: 14, w: 4, h: 5, aboveRows: 2, door: [2, 4] }), office: footprint({ sheet: "houses", col: 0, row: 83, w: 10, h: 16, aboveRows: 3, door: [2, 14] }), diff --git a/apps/datagutt/world/maps/gym.tmj b/apps/datagutt/world/maps/gym.tmj index d17a0ee..a7d8217 100644 --- a/apps/datagutt/world/maps/gym.tmj +++ b/apps/datagutt/world/maps/gym.tmj @@ -10,7 +10,7 @@ "tilewidth": 16, "tileheight": 16, "nextlayerid": 9, - "nextobjectid": 11, + "nextobjectid": 12, "properties": [ { "name": "name", @@ -146,10 +146,10 @@ 0,0,2842,2843,2844,0,0,0,0,0,0,0,2845,2846,0,0,2847,0,0,0, 0,0,2848,2849,2850,0,0,0,0,0,0,0,2851,2852,0,0,2853,0,0,0, 0,0,0,0,0,0,2854,2855,2856,2857,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,2858,2859,2860,2861,0,0,0,0,0,2862,2863,2864,0,0, - 0,0,0,2865,2866,0,0,0,0,0,0,2867,2868,0,0,2869,2870,2871,0,0, - 0,0,0,2872,2873,0,0,0,0,0,0,2874,2875,0,0,2876,2877,2878,0,0, - 0,0,0,0,0,0,0,0,0,0,0,2879,2880,0,0,2881,2882,2883,0,0, + 0,0,0,0,0,0,2858,2859,2860,2861,0,0,0,0,0,0,0,0,0,0, + 0,0,0,2865,2866,0,0,0,0,0,0,2867,2868,0,0,0,0,0,0,0, + 0,0,0,2872,2873,0,0,0,0,0,0,2874,2875,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,2879,2880,0,0,0,0,0,0,0, 0,0,0,2884,2885,2886,2887,2888,0,0,0,0,0,2889,2890,0,0,0,0,0, 0,0,0,2891,2892,2893,2894,2895,0,0,0,0,0,2896,2897,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, @@ -221,6 +221,29 @@ { "id": 1, "name": "", + "type": "sprite", + "x": 240, + "y": 80, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "treadmill" + }, + { + "name": "dy", + "type": "string", + "value": "-2" + } + ] + }, + { + "id": 2, + "name": "", "type": "light", "x": 80, "y": 144, @@ -257,7 +280,7 @@ ] }, { - "id": 2, + "id": 3, "name": "", "type": "door", "x": 160, @@ -280,7 +303,7 @@ ] }, { - "id": 3, + "id": 4, "name": "entrance", "type": "spawn", "x": 160, @@ -298,7 +321,7 @@ ] }, { - "id": 4, + "id": 5, "name": "trainer", "type": "npc", "x": 144, @@ -331,7 +354,7 @@ ] }, { - "id": 5, + "id": 6, "name": "", "type": "sign", "x": 32, @@ -349,7 +372,7 @@ ] }, { - "id": 6, + "id": 7, "name": "", "type": "sign", "x": 96, @@ -367,7 +390,7 @@ ] }, { - "id": 7, + "id": 8, "name": "", "type": "sign", "x": 256, @@ -385,7 +408,7 @@ ] }, { - "id": 8, + "id": 9, "name": "", "type": "sign", "x": 48, @@ -403,7 +426,7 @@ ] }, { - "id": 9, + "id": 10, "name": "", "type": "sign", "x": 176, @@ -421,7 +444,7 @@ ] }, { - "id": 10, + "id": 11, "name": "", "type": "sign", "x": 64, diff --git a/apps/datagutt/world/maps/house.tmj b/apps/datagutt/world/maps/house.tmj index 983d982..21304bf 100644 --- a/apps/datagutt/world/maps/house.tmj +++ b/apps/datagutt/world/maps/house.tmj @@ -10,7 +10,7 @@ "tilewidth": 16, "tileheight": 16, "nextlayerid": 9, - "nextobjectid": 11, + "nextobjectid": 12, "properties": [ { "name": "name", @@ -146,9 +146,9 @@ "visible": true, "data": [ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,2432,2433,0,2434,2435,0,2436,2437,2438,0,0, - 0,0,2439,2440,2441,2442,2443,0,0,2444,2445,0,2446,2447,0,2448,2449,2450,0,0, - 0,0,2451,2452,2453,2454,2455,0,0,2456,2457,0,2458,2459,0,2460,2461,2462,0,0, + 0,0,0,0,0,0,0,0,0,2432,2433,0,0,0,0,2436,2437,2438,0,0, + 0,0,2439,2440,2441,2442,2443,0,0,2444,2445,0,0,0,0,2448,2449,2450,0,0, + 0,0,2451,2452,2453,2454,2455,0,0,2456,2457,0,0,0,0,2460,2461,2462,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2463,2464,2465,0,0, 0,0,0,0,0,0,0,0,2466,2467,0,0,2468,2469,0,0,0,0,0,0, 0,0,0,3071,2471,2472,3072,0,2474,2475,0,0,2476,2477,0,0,0,0,0,0, @@ -228,6 +228,24 @@ { "id": 1, "name": "", + "type": "sprite", + "x": 192, + "y": 16, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "fireplace" + } + ] + }, + { + "id": 2, + "name": "", "type": "light", "x": 112, "y": 48, @@ -269,7 +287,7 @@ ] }, { - "id": 2, + "id": 3, "name": "", "type": "light", "x": 192, @@ -307,7 +325,7 @@ ] }, { - "id": 3, + "id": 4, "name": "", "type": "light", "x": 144, @@ -345,7 +363,7 @@ ] }, { - "id": 4, + "id": 5, "name": "", "type": "door", "x": 256, @@ -368,7 +386,7 @@ ] }, { - "id": 5, + "id": 6, "name": "stairs", "type": "spawn", "x": 256, @@ -386,7 +404,7 @@ ] }, { - "id": 6, + "id": 7, "name": "", "type": "door", "x": 160, @@ -409,7 +427,7 @@ ] }, { - "id": 7, + "id": 8, "name": "entrance", "type": "spawn", "x": 160, @@ -427,7 +445,7 @@ ] }, { - "id": 8, + "id": 9, "name": "", "type": "sign", "x": 240, @@ -445,7 +463,7 @@ ] }, { - "id": 9, + "id": 10, "name": "", "type": "sign", "x": 32, @@ -463,7 +481,7 @@ ] }, { - "id": 10, + "id": 11, "name": "", "type": "sign", "x": 144, diff --git a/apps/datagutt/world/maps/mountain.tmj b/apps/datagutt/world/maps/mountain.tmj index ea690cc..9e657a3 100644 --- a/apps/datagutt/world/maps/mountain.tmj +++ b/apps/datagutt/world/maps/mountain.tmj @@ -10,7 +10,7 @@ "tilewidth": 16, "tileheight": 16, "nextlayerid": 10, - "nextobjectid": 6, + "nextobjectid": 9, "properties": [ { "name": "name", @@ -154,10 +154,10 @@ 0,0,0,0,0,0,0,0,0,0,0,50,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,61,61,0,0,0,0, 0,0,0,0,0,0,0,0,60,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,60,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,63,50,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,52,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,65,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,51,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,49,51,51,0,51,0,62,0,65,0,54,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,63,50,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,54,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,65,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,51,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,49,0,49,51,51,0,51,0,62,0,65,0,54,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,63,0,0,0,0,0,49,0,51,0,49,0,0,0,0,62,0,0,0,0,0,0,0,0,0,0,0,0, 0,70,0,0,0,0,0,0,0,0,63,0,0,0,0,0,0,0,0,0,0,0,49,51,0,51,0,0,0,0,0,0,0,0,0,61,0,0,0,61, 0,0,0,0,0,0,0,0,0,0,50,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,54,0,0,63,0,0,0,0,0, @@ -332,7 +332,7 @@ 1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0, 0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1, 1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,1,1,0,0,0, 0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1, @@ -464,6 +464,95 @@ { "id": 3, "name": "", + "type": "sprite", + "x": 480, + "y": 256, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "campfire" + } + ] + }, + { + "id": 4, + "name": "", + "type": "light", + "x": 480, + "y": 272, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "shape", + "type": "string", + "value": "glow" + }, + { + "name": "radius", + "type": "string", + "value": "2.5" + }, + { + "name": "color", + "type": "string", + "value": "ffae62" + }, + { + "name": "intensity", + "type": "string", + "value": "0.5" + }, + { + "name": "flicker", + "type": "string", + "value": "true" + }, + { + "name": "when", + "type": "string", + "value": "night" + } + ] + }, + { + "id": 5, + "name": "", + "type": "sprite", + "x": 352, + "y": 336, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "butterfly" + }, + { + "name": "seasons", + "type": "string", + "value": "spring,summer" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] + }, + { + "id": 6, + "name": "", "type": "sign", "x": 544, "y": 304, @@ -480,7 +569,7 @@ ] }, { - "id": 4, + "id": 7, "name": "", "type": "door", "x": 320, @@ -503,7 +592,7 @@ ] }, { - "id": 5, + "id": 8, "name": "trailhead", "type": "spawn", "x": 320, diff --git a/apps/datagutt/world/maps/town-hall-basement.tmj b/apps/datagutt/world/maps/town-hall-basement.tmj index 7315173..e0ebddd 100644 --- a/apps/datagutt/world/maps/town-hall-basement.tmj +++ b/apps/datagutt/world/maps/town-hall-basement.tmj @@ -10,7 +10,7 @@ "tilewidth": 16, "tileheight": 16, "nextlayerid": 9, - "nextobjectid": 18, + "nextobjectid": 19, "properties": [ { "name": "name", @@ -146,11 +146,11 @@ "visible": true, "data": [ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5882,5883,0,0,0,0,0,0, - 0,0,6193,6194,6195,0,6033,6034,5949,5950,5949,5950,6033,6034,6043,6044,6045,6046,6043,6044,5886,5887,5890,6035,5949,5950,0,0, - 0,0,6196,6197,6198,0,6036,6037,5951,5952,5951,5952,6036,6037,6047,6048,6049,6050,6047,6048,5893,5894,5897,6038,5951,5952,0,0, - 0,0,6199,6200,6201,0,6039,6040,5953,6041,5953,6041,6039,6040,6051,6052,6053,6054,6051,6052,0,0,5900,6042,5953,6041,0,0, - 0,0,6202,6203,6204,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,6205,6206,6207,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,6033,6034,5949,5950,5949,5950,6033,6034,6043,6044,6045,6046,6043,6044,5886,5887,5890,6035,5949,5950,0,0, + 0,0,0,0,0,0,6036,6037,5951,5952,5951,5952,6036,6037,6047,6048,6049,6050,6047,6048,5893,5894,5897,6038,5951,5952,0,0, + 0,0,0,0,0,0,6039,6040,5953,6041,5953,6041,6039,6040,6051,6052,6053,6054,6051,6052,0,0,5900,6042,5953,6041,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,6111,6112,6113,6144,6145,6146,0,0,0,0,0,0,0,0,6064,6065,6066,0,0,0,0,0,0, 0,0,0,0,0,6119,6209,6121,6149,6210,6151,6114,6115,6122,6123,6124,0,0,0,6076,6211,6078,6111,6112,6113,0,0,0, @@ -228,6 +228,29 @@ { "id": 1, "name": "", + "type": "sprite", + "x": 32, + "y": 16, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "escalator" + }, + { + "name": "layer", + "type": "string", + "value": "below" + } + ] + }, + { + "id": 2, + "name": "", "type": "door", "x": 48, "y": 32, @@ -249,7 +272,7 @@ ] }, { - "id": 2, + "id": 3, "name": "stairs", "type": "spawn", "x": 48, @@ -267,7 +290,7 @@ ] }, { - "id": 3, + "id": 4, "name": "", "type": "light", "x": 112, @@ -305,7 +328,7 @@ ] }, { - "id": 4, + "id": 5, "name": "", "type": "light", "x": 144, @@ -343,7 +366,7 @@ ] }, { - "id": 5, + "id": 6, "name": "", "type": "light", "x": 176, @@ -381,7 +404,7 @@ ] }, { - "id": 6, + "id": 7, "name": "", "type": "light", "x": 208, @@ -419,7 +442,7 @@ ] }, { - "id": 7, + "id": 8, "name": "", "type": "light", "x": 400, @@ -457,7 +480,7 @@ ] }, { - "id": 8, + "id": 9, "name": "", "type": "light", "x": 96, @@ -495,7 +518,7 @@ ] }, { - "id": 9, + "id": 10, "name": "", "type": "light", "x": 144, @@ -533,7 +556,7 @@ ] }, { - "id": 10, + "id": 11, "name": "", "type": "light", "x": 224, @@ -571,7 +594,7 @@ ] }, { - "id": 11, + "id": 12, "name": "", "type": "light", "x": 320, @@ -609,7 +632,7 @@ ] }, { - "id": 12, + "id": 13, "name": "", "type": "light", "x": 368, @@ -647,7 +670,7 @@ ] }, { - "id": 13, + "id": 14, "name": "sysadmin", "type": "npc", "x": 256, @@ -680,7 +703,7 @@ ] }, { - "id": 14, + "id": 15, "name": "", "type": "sign", "x": 32, @@ -698,7 +721,7 @@ ] }, { - "id": 15, + "id": 16, "name": "", "type": "sign", "x": 256, @@ -716,7 +739,7 @@ ] }, { - "id": 16, + "id": 17, "name": "", "type": "sign", "x": 320, @@ -734,7 +757,7 @@ ] }, { - "id": 17, + "id": 18, "name": "", "type": "sign", "x": 384, diff --git a/apps/datagutt/world/maps/town.tmj b/apps/datagutt/world/maps/town.tmj index 30a85e9..dd685e0 100644 --- a/apps/datagutt/world/maps/town.tmj +++ b/apps/datagutt/world/maps/town.tmj @@ -10,7 +10,7 @@ "tilewidth": 16, "tileheight": 16, "nextlayerid": 11, - "nextobjectid": 80, + "nextobjectid": 93, "properties": [ { "name": "name", @@ -25,17 +25,17 @@ { "name": "season:spring", "type": "string", - "value": "3:3119,4:3120,5:3121,6:3122,7:3123,8:3124,9:3125,10:3126,11:3127,12:3128,13:3129,14:3130,15:3131,16:3132,17:3133,18:3134,19:3135,20:3136,21:3137,22:3138,23:3139,24:3140,25:3141,26:3142,27:3143,28:3144,29:3145,30:3146,31:3147,32:3148,33:3149,34:3150,35:3151,36:3152,37:3153,38:3154,39:3155,40:3156,41:3157,42:3158,43:3159,44:3160,45:3161,46:3162,47:3163,48:3164,49:3165,50:3166,51:3167,52:3168,53:3168,54:3169,55:3170,56:3171,57:3172,58:3173,59:3166,60:3166,61:3174,62:3174,63:3167,64:3175,65:3176,66:3177,67:3178,68:3179,69:3180,70:3169,81:3181,82:3182,83:3183,84:3184,85:3185,86:3186,87:3187,88:3188,89:3189,90:3190,91:3191,234:3192,235:3193,236:3194,271:3195,272:3196,273:3197,278:3198,279:3199,280:3200,311:3201,640:3202,641:3203,642:3204,643:3205,644:3206,645:3207,646:3208,647:3209,872:3210,873:3211,874:3212,901:3213,902:3214,903:3215,914:3216,915:3217,916:3218,1037:3219,1092:3220,1093:3221,1094:3222,1095:3223,1096:3224,1097:3225,1098:3226,1153:3227,1154:3228,1155:3229,1156:3230,1157:3231,1158:3232,1159:3233,1214:3234,1215:3235,1216:3236,1217:3237,1268:3238,1269:3239,1270:3240,1271:3241,1322:3242,1338:3243,1443:3244,1904:3245,1905:3246,1906:3247,1907:3248,1908:3249,1909:3250,1910:3251,1915:3252,1916:3253,1917:3254,1922:3255,1923:3256,1924:3257,1925:3258,1926:3259,1951:3260,1952:3261,1953:3262,1954:3263,1955:3264,1956:3265,1957:3266,1958:3267,1959:3268,1960:3269,1961:3270,1962:3271,1963:3272,1964:3273,1965:3274,1966:3275,2073:3276,2074:3277,2075:3278,2080:3279,2081:3280,2082:3281,2083:3282,2148:3283,2149:3284,2150:3285,2151:3286,2152:3287,2153:3288,2154:3289,2155:3290,2160:3291,2161:3292,2162:3293,2163:3294,2164:3295,2165:3296,2166:3297,2167:3298,2168:3299,2169:3300,2170:3301,2171:3302,2172:3303,2173:3304,2174:3305,2175:3306,2246:3307,2287:3308,2292:3309,2297:3310,2298:3311,2299:3312,2300:3313,6268:6349,6269:6350,6270:6351,6271:6352,6272:6353,6273:6354,6274:6355,6275:6356,6276:6357,6277:6358,6278:6359,6279:6360,6280:6361,6281:6362,6282:6363,6283:6364,6284:6365,6285:6366,6286:6367,6287:6368,6288:6369,6289:6370,6290:6371,6291:6372,6292:6373,6293:6374,6294:6375,6295:6376,6296:6377,6297:6378,6298:6379,6299:6380,6300:6381,6301:6382,6302:6383,6303:6384,6304:6385,6305:6386,6306:6387,6307:6388,6308:6389,6309:6390,6310:6391,6311:6392,6312:6393,6313:6394,6314:6395,6315:6396,6316:6397,6317:6398,6318:6399,6319:6400,6320:6401,6321:6402,6322:6403,6323:6404,6325:6406,6326:6407,6327:6408,6329:6410,6330:6411,6331:6412,6332:6413,6333:6414,6334:6415,6335:6416,6336:6417,6337:6418,6338:6419,6339:6420,6340:6421,6341:6422,6342:6423,6343:6424,6344:6425,6345:6426,6346:6427,6347:6428,6348:6429,6592:6593,6596:6597,6704:6734,6705:6735,6706:6736,6707:6737" + "value": "3:3119,4:3120,5:3121,6:3122,7:3123,8:3124,9:3125,10:3126,11:3127,12:3128,13:3129,14:3130,15:3131,16:3132,17:3133,18:3134,19:3135,20:3136,21:3137,22:3138,23:3139,24:3140,25:3141,26:3142,27:3143,28:3144,29:3145,30:3146,31:3147,32:3148,33:3149,34:3150,35:3151,36:3152,37:3153,38:3154,39:3155,40:3156,41:3157,42:3158,43:3159,44:3160,45:3161,46:3162,47:3163,48:3164,49:3165,50:3166,51:3167,52:3168,53:3168,54:3169,55:3170,56:3171,57:3172,58:3173,59:3166,60:3166,61:3174,62:3174,63:3167,64:3175,65:3176,66:3177,67:3178,68:3179,69:3180,70:3169,81:3181,82:3182,83:3183,84:3184,85:3185,86:3186,87:3187,88:3188,89:3189,90:3190,91:3191,234:3192,235:3193,236:3194,271:3195,272:3196,273:3197,278:3198,279:3199,280:3200,311:3201,640:3202,641:3203,642:3204,643:3205,644:3206,645:3207,646:3208,647:3209,872:3210,873:3211,874:3212,901:3213,902:3214,903:3215,914:3216,915:3217,916:3218,1037:3219,1092:3220,1093:3221,1094:3222,1153:3227,1154:3228,1155:3229,1322:3242,1338:3243,1443:3244,1904:3245,1905:3246,1906:3247,1907:3248,1908:3249,1909:3250,1910:3251,1915:3252,1916:3253,1917:3254,1922:3255,1923:3256,1924:3257,1925:3258,1926:3259,1951:3260,1952:3261,1953:3262,1954:3263,1955:3264,1956:3265,1957:3266,1958:3267,1959:3268,1960:3269,1961:3270,1962:3271,1963:3272,1964:3273,1965:3274,1966:3275,2073:3276,2074:3277,2075:3278,2080:3279,2081:3280,2082:3281,2083:3282,2148:3283,2149:3284,2150:3285,2151:3286,2152:3287,2153:3288,2154:3289,2155:3290,2160:3291,2161:3292,2162:3293,2163:3294,2164:3295,2165:3296,2166:3297,2167:3298,2168:3299,2169:3300,2170:3301,2171:3302,2172:3303,2173:3304,2174:3305,2175:3306,2246:3307,2287:3308,2292:3309,6268:6349,6269:6350,6270:6351,6271:6352,6272:6353,6273:6354,6274:6355,6275:6356,6276:6357,6277:6358,6278:6359,6279:6360,6280:6361,6281:6362,6282:6363,6283:6364,6284:6365,6285:6366,6286:6367,6287:6368,6288:6369,6289:6370,6290:6371,6291:6372,6292:6373,6293:6374,6294:6375,6295:6376,6296:6377,6297:6378,6298:6379,6299:6380,6300:6381,6301:6382,6302:6383,6303:6384,6304:6385,6305:6386,6306:6387,6307:6388,6308:6389,6309:6390,6310:6391,6311:6392,6312:6393,6313:6394,6314:6395,6315:6396,6316:6397,6317:6398,6318:6399,6319:6400,6320:6401,6321:6402,6322:6403,6323:6404,6325:6406,6326:6407,6327:6408,6329:6410,6330:6411,6331:6412,6332:6413,6333:6414,6334:6415,6335:6416,6336:6417,6337:6418,6338:6419,6339:6420,6340:6421,6341:6422,6342:6423,6343:6424,6344:6425,6345:6426,6346:6427,6347:6428,6348:6429,6592:6593,6596:6597,6704:6734,6705:6735,6706:6736,6707:6737" }, { "name": "season:autumn", "type": "string", - "value": "3:3314,4:3315,5:3316,6:3317,7:3318,8:3319,9:3320,10:3321,11:3322,12:3323,13:3324,14:3325,15:3326,16:3327,17:3328,18:3329,19:3330,20:3331,21:3332,22:3333,23:3334,24:3335,25:3336,26:3337,27:3338,28:3339,29:3340,30:3341,31:3342,32:3343,33:3344,34:3345,35:3346,36:3347,37:3348,38:3349,39:3350,40:3351,41:3352,42:3353,43:3354,44:3355,45:3356,46:3357,47:3358,48:3359,49:3360,50:3361,51:3362,52:3363,53:3364,54:3365,55:3366,56:3367,57:3368,58:3369,59:3370,60:3371,61:3372,62:3373,63:3374,64:3375,65:3376,66:3377,67:3378,68:3379,69:3380,70:3381,81:5858,82:5859,83:3384,84:3385,85:5860,86:5861,87:3388,88:3389,89:3390,90:3391,91:3392,234:3393,235:3394,236:3395,271:3396,272:3397,273:3398,278:3399,279:3400,280:3401,311:3402,640:3403,641:3404,642:3405,643:3406,644:3407,645:3408,646:3409,647:3410,872:3411,873:3412,874:3413,901:3414,902:3415,903:3416,914:3417,915:3418,916:3419,1037:3420,1092:3421,1093:3422,1094:3423,1095:3424,1096:3425,1097:3426,1098:3427,1153:3428,1154:3429,1155:3430,1156:3431,1157:3432,1158:3433,1159:3434,1214:3435,1215:3436,1216:3437,1217:3438,1268:3439,1269:3440,1270:3441,1271:3442,1322:3443,1338:3444,1443:3445,1904:3446,1905:3447,1906:3448,1907:3449,1908:3450,1909:3451,1910:3452,1915:3453,1916:3454,1917:3455,1922:3456,1923:3457,1924:3458,1925:3459,1926:3460,1951:5862,1952:5863,1953:3463,1954:3464,1955:5864,1956:5865,1957:5866,1958:5867,1959:3469,1960:3470,1961:5868,1962:5869,1963:3473,1964:3474,1965:5870,1966:5871,2073:3477,2074:3478,2075:3479,2080:3480,2081:3481,2082:3482,2083:3483,2148:3484,2149:3485,2150:3486,2151:3487,2152:3488,2153:3489,2154:3490,2155:3491,2160:3492,2161:3493,2162:3494,2163:3495,2164:3496,2165:3497,2166:3498,2167:3499,2168:3500,2169:3501,2170:3502,2171:3503,2172:3504,2173:3505,2174:3506,2175:3507,2246:3508,2287:3509,2292:3510,2297:3511,2298:3512,2299:3513,2300:3514,6268:6430,6269:6431,6270:6432,6271:6433,6272:6434,6273:6435,6274:6436,6275:6437,6276:6438,6277:6439,6278:6440,6279:6441,6280:6442,6281:6443,6282:6444,6283:6445,6284:6446,6285:6447,6286:6448,6287:6449,6288:6450,6289:6451,6290:6452,6291:6453,6292:6454,6293:6455,6294:6456,6295:6457,6296:6458,6297:6459,6298:6460,6299:6461,6300:6462,6301:6463,6302:6464,6303:6465,6304:6466,6305:6467,6306:6468,6307:6469,6308:6470,6309:6471,6310:6472,6311:6473,6312:6474,6313:6475,6314:6476,6315:6477,6316:6478,6317:6479,6318:6480,6319:6481,6320:6482,6321:6483,6322:6484,6323:6485,6325:6487,6326:6488,6327:6489,6329:6491,6330:6492,6331:6493,6332:6494,6333:6495,6334:6496,6335:6497,6336:6498,6337:6499,6338:6500,6339:6501,6340:6502,6341:6503,6342:6504,6343:6505,6344:6506,6345:6507,6346:6508,6347:6509,6348:6510,6592:6594,6596:6598,6704:6738,6705:6739,6706:6740,6707:6741" + "value": "3:3314,4:3315,5:3316,6:3317,7:3318,8:3319,9:3320,10:3321,11:3322,12:3323,13:3324,14:3325,15:3326,16:3327,17:3328,18:3329,19:3330,20:3331,21:3332,22:3333,23:3334,24:3335,25:3336,26:3337,27:3338,28:3339,29:3340,30:3341,31:3342,32:3343,33:3344,34:3345,35:3346,36:3347,37:3348,38:3349,39:3350,40:3351,41:3352,42:3353,43:3354,44:3355,45:3356,46:3357,47:3358,48:3359,49:3360,50:3361,51:3362,52:3363,53:3364,54:3365,55:3366,56:3367,57:3368,58:3369,59:3370,60:3371,61:3372,62:3373,63:3374,64:3375,65:3376,66:3377,67:3378,68:3379,69:3380,70:3381,81:5858,82:5859,83:3384,84:3385,85:5860,86:5861,87:3388,88:3389,89:3390,90:3391,91:3392,234:3393,235:3394,236:3395,271:3396,272:3397,273:3398,278:3399,279:3400,280:3401,311:3402,640:3403,641:3404,642:3405,643:3406,644:3407,645:3408,646:3409,647:3410,872:3411,873:3412,874:3413,901:3414,902:3415,903:3416,914:3417,915:3418,916:3419,1037:3420,1092:3421,1093:3422,1094:3423,1153:3428,1154:3429,1155:3430,1322:3443,1338:3444,1443:3445,1904:3446,1905:3447,1906:3448,1907:3449,1908:3450,1909:3451,1910:3452,1915:3453,1916:3454,1917:3455,1922:3456,1923:3457,1924:3458,1925:3459,1926:3460,1951:5862,1952:5863,1953:3463,1954:3464,1955:5864,1956:5865,1957:5866,1958:5867,1959:3469,1960:3470,1961:5868,1962:5869,1963:3473,1964:3474,1965:5870,1966:5871,2073:3477,2074:3478,2075:3479,2080:3480,2081:3481,2082:3482,2083:3483,2148:3484,2149:3485,2150:3486,2151:3487,2152:3488,2153:3489,2154:3490,2155:3491,2160:3492,2161:3493,2162:3494,2163:3495,2164:3496,2165:3497,2166:3498,2167:3499,2168:3500,2169:3501,2170:3502,2171:3503,2172:3504,2173:3505,2174:3506,2175:3507,2246:3508,2287:3509,2292:3510,6268:6430,6269:6431,6270:6432,6271:6433,6272:6434,6273:6435,6274:6436,6275:6437,6276:6438,6277:6439,6278:6440,6279:6441,6280:6442,6281:6443,6282:6444,6283:6445,6284:6446,6285:6447,6286:6448,6287:6449,6288:6450,6289:6451,6290:6452,6291:6453,6292:6454,6293:6455,6294:6456,6295:6457,6296:6458,6297:6459,6298:6460,6299:6461,6300:6462,6301:6463,6302:6464,6303:6465,6304:6466,6305:6467,6306:6468,6307:6469,6308:6470,6309:6471,6310:6472,6311:6473,6312:6474,6313:6475,6314:6476,6315:6477,6316:6478,6317:6479,6318:6480,6319:6481,6320:6482,6321:6483,6322:6484,6323:6485,6325:6487,6326:6488,6327:6489,6329:6491,6330:6492,6331:6493,6332:6494,6333:6495,6334:6496,6335:6497,6336:6498,6337:6499,6338:6500,6339:6501,6340:6502,6341:6503,6342:6504,6343:6505,6344:6506,6345:6507,6346:6508,6347:6509,6348:6510,6592:6594,6596:6598,6704:6738,6705:6739,6706:6740,6707:6741" }, { "name": "season:winter", "type": "string", - "value": "3:3515,4:3516,5:3517,6:3518,7:3519,8:3520,9:3521,10:3522,11:3523,12:3524,13:3525,14:3526,15:3527,16:3528,17:3529,18:3530,19:3531,20:3532,21:3533,22:3534,23:3535,24:3536,25:3537,26:3538,27:3539,28:3540,29:3541,30:3542,31:3543,32:3544,33:3545,34:3546,35:3547,36:3548,37:3549,38:3550,39:3551,40:3552,41:3553,42:3554,43:3555,44:3556,45:3557,46:3558,47:3559,48:3560,49:0,50:3561,51:0,52:0,53:3562,54:3563,55:3564,56:3565,57:3566,58:3567,59:0,60:3568,61:3569,62:0,63:3570,64:0,65:0,66:3571,67:3572,68:3573,69:3574,70:0,81:3575,82:3576,83:3577,84:3578,85:3579,86:3580,87:3581,88:3582,89:3583,90:3584,91:3585,92:3586,93:3587,94:3588,95:3589,96:3590,97:3591,98:3592,99:3593,100:3594,101:3595,102:3596,103:3597,104:3598,105:3599,106:3600,107:3601,108:3602,109:3603,110:3604,111:3605,112:3606,113:3607,114:3608,115:3609,116:3610,117:3611,118:3612,119:3613,120:3614,121:3615,122:3616,123:3617,124:3618,125:3619,126:3620,127:3621,128:3622,129:3623,130:3624,131:3625,132:3626,133:3627,134:3628,135:3629,136:3630,137:3631,138:3632,139:3633,140:3634,141:3635,142:3636,143:3637,144:3638,145:3639,146:3640,147:3641,148:3642,149:3643,150:3644,151:3645,152:3646,153:3647,154:3648,155:3649,156:3650,157:3651,158:3652,159:3653,160:3654,161:3655,162:3656,163:3657,164:3658,165:3659,166:3660,167:3661,168:3662,169:3663,170:3664,171:3665,172:3666,173:3667,174:3668,175:3669,176:3670,177:3671,178:3672,179:3673,180:3674,181:3675,182:3676,183:3677,184:3678,185:3679,186:3680,187:3681,188:3682,189:3683,190:3684,191:3685,192:3686,193:3687,194:3688,195:3689,196:3690,197:3691,198:3692,199:3693,200:3694,201:3695,202:3696,203:3697,204:3698,205:3699,206:3700,207:3701,208:3702,209:3703,210:3704,211:3705,212:3706,213:3707,214:3708,215:3709,216:3710,217:3711,218:3712,219:3713,220:3714,221:3715,222:3716,223:3717,224:3718,225:3719,226:3720,227:3721,228:3722,229:3723,230:3724,231:3725,232:3726,233:3727,234:3728,235:3729,236:3730,237:3731,238:3732,239:3733,240:3734,241:3735,242:3736,243:3737,244:3738,245:3739,246:3740,247:3741,248:3742,249:3743,250:3744,251:3745,252:3746,253:3747,254:3748,255:3749,256:3750,257:3751,258:3752,259:3753,260:3754,261:3755,262:3756,263:3757,264:3758,265:3759,266:3760,267:3761,268:3762,269:3763,270:3764,271:3765,272:3766,273:3767,274:3768,275:3769,276:3770,277:3771,278:3772,279:3773,280:3774,281:3775,282:3776,283:3777,284:3778,285:3779,286:3780,287:3781,288:3782,289:3783,290:3784,291:3785,292:3786,293:3787,294:3788,295:3789,296:3790,297:3791,298:3792,299:3793,300:3794,301:3795,302:3796,303:3797,304:3798,305:3799,306:3800,307:3801,308:3802,309:3803,310:3804,311:3805,312:3806,313:3807,314:3808,315:3809,316:3810,317:3811,318:3812,319:3813,320:3814,321:3815,322:3816,323:3817,324:3818,325:3819,326:3820,327:3821,328:3822,329:3823,330:3824,331:3825,332:3826,333:3827,334:3828,335:3829,336:3830,337:3831,338:3832,339:3833,340:3834,341:3835,342:3836,343:3837,344:3838,345:3839,346:3840,347:3841,348:3842,349:3843,350:3844,351:3845,352:3846,353:3847,354:3848,355:3849,356:3850,357:3851,358:3852,359:3853,360:3854,361:3855,362:3856,363:3857,364:3858,365:3859,366:3860,367:3861,368:3862,369:3863,370:3864,371:3865,372:3866,373:3867,374:3868,375:3869,376:3870,377:3871,378:3872,379:3873,380:3874,381:3875,382:3876,383:3877,384:3878,385:3879,386:3880,387:3881,388:3882,389:3883,390:3884,391:3885,392:3886,393:3887,394:3888,395:3889,396:3890,397:3891,398:3892,399:3893,400:3894,401:3895,402:3896,403:3897,404:3898,405:3899,406:3900,407:3901,408:3902,409:3903,410:3904,411:3905,412:3906,413:3907,414:3908,415:3909,416:3910,417:3911,418:3912,419:3913,420:3914,421:3915,422:3916,423:3917,424:3918,425:3919,426:3920,427:3921,428:3922,429:3923,430:3924,431:3925,432:3926,433:3927,434:3928,435:3929,436:3930,437:3931,438:3932,439:3933,440:3934,441:3935,442:3936,443:3937,444:3938,445:3939,446:3940,447:3941,448:3942,449:3943,450:3944,451:3945,452:3946,453:3947,454:3948,455:3949,456:3950,457:3951,458:3952,459:3953,460:3954,461:3955,462:3956,463:3957,464:3958,465:3959,466:3960,467:3961,468:3962,469:3963,470:3964,471:3965,472:3966,473:3967,474:3968,475:3969,476:3970,477:3971,478:3972,479:3973,480:3974,481:3975,482:3976,483:3977,484:3978,485:3979,486:3980,487:3981,488:3982,489:3983,490:3984,491:3985,492:3986,493:3987,494:3988,495:3989,496:3990,497:3991,498:3992,499:3993,500:3994,501:3995,502:3996,503:3997,504:3998,505:3999,506:4000,507:4001,508:4002,509:4003,510:4004,511:4005,512:4006,513:4007,514:4008,515:4009,516:4010,517:4011,518:4012,519:4013,520:4014,521:4015,522:4016,523:4017,524:4018,525:4019,526:4020,527:4021,528:4022,529:4023,530:4024,531:4025,532:4026,533:4027,534:4028,535:4029,536:4030,537:4031,538:4032,539:4033,540:4034,541:4035,542:4036,543:4037,544:4038,545:4039,546:4040,547:4041,548:4042,549:4043,550:4044,551:4045,552:4046,553:4047,554:4048,555:4049,556:4050,557:4051,558:4052,559:4053,560:4054,561:4055,562:4056,563:4057,564:4058,565:4059,566:4060,567:4061,568:4062,569:4063,570:4064,571:4065,572:4066,573:4067,574:4068,575:4069,576:4070,577:4071,578:4072,579:4073,580:4074,581:4075,582:4076,583:4077,584:4078,585:4079,586:4080,587:4081,588:4082,589:4083,590:4084,591:4085,592:4086,593:4087,594:4088,595:4089,596:4090,597:4091,598:4092,599:4093,600:4094,601:4095,602:4096,603:4097,604:4098,605:4099,606:4100,607:4101,608:4102,609:4103,610:4104,611:4105,612:4106,613:4107,614:4108,615:4109,616:4110,617:4111,618:4112,619:4113,620:4114,621:4115,622:4116,623:4117,624:4118,625:4119,626:4120,627:4121,628:4122,629:4123,630:4124,631:4125,632:4126,633:4127,634:4128,635:4129,636:4130,637:4131,638:4132,639:4133,640:4134,641:4135,642:4136,643:4137,644:4138,645:4139,646:4140,647:4141,648:4142,649:4143,650:4144,651:4145,652:4146,653:4147,654:4148,655:4149,656:4150,657:4151,658:4152,659:4153,660:4154,661:4155,662:4156,663:4157,664:4158,665:4159,666:4160,667:4161,668:4162,669:4163,670:4164,671:4165,672:4166,673:4167,674:4168,675:4169,676:4170,677:4171,678:4172,679:4173,680:4174,681:4175,682:4176,683:4177,684:4178,685:4179,686:4180,687:4181,688:4182,689:4183,690:4184,691:4185,692:4186,693:4187,694:4188,695:4189,696:4190,697:4191,698:4192,699:4193,700:4194,701:4195,702:4196,703:4197,704:4198,705:4199,706:4200,707:4201,708:4202,709:4203,710:4204,711:4205,712:4206,713:4207,714:4208,715:4209,716:4210,717:4211,718:4212,719:4213,720:4214,721:4215,722:4216,723:4217,724:4218,725:4219,726:4220,727:4221,728:4222,729:4223,730:4224,731:4225,732:4226,733:4227,734:4228,735:4229,736:4230,737:4231,738:4232,739:4233,740:4234,741:4235,742:4236,743:4237,744:4238,745:4239,746:4240,747:4241,748:4242,749:4243,750:4244,751:4245,752:4246,753:4247,754:4248,755:4249,756:4250,757:4251,758:4252,759:4253,760:4254,761:4255,762:4256,763:4257,764:4258,765:4259,766:4260,767:4261,768:4262,769:4263,770:4264,771:4265,772:4266,773:4267,774:4268,776:4269,777:4270,778:4271,779:4272,780:4273,781:4274,782:4275,783:4276,784:4277,785:4278,786:4279,787:4280,788:4281,789:4282,790:4283,791:4284,792:4285,793:4286,794:4287,795:4288,796:4289,797:4290,798:4291,799:4292,800:4293,801:4294,802:4295,803:4296,804:4297,805:4298,806:4299,807:4300,808:4301,809:4302,810:4303,811:4304,812:4305,813:4306,814:4307,815:4308,816:4309,817:4310,818:4311,819:4312,821:4313,822:4314,823:4315,824:4316,825:4317,826:4318,827:4319,828:4320,829:4321,830:4322,831:4323,832:4324,833:4325,834:4326,835:4327,836:4328,837:4329,838:4330,839:4331,840:4332,841:4333,842:4334,843:4335,844:4336,845:4337,846:4338,847:4339,848:4340,849:4341,850:4342,851:4343,852:4344,853:4345,854:4346,855:4347,856:4348,857:4349,858:4350,859:4351,860:4352,861:4353,862:4354,863:4355,864:4356,865:4357,866:4358,867:4359,868:4360,869:4361,870:4362,871:4363,872:4364,873:4365,874:4366,875:4367,876:4368,877:4369,878:4370,879:4371,880:4372,881:4373,882:4374,883:4375,884:4376,885:4377,886:4378,887:4379,888:4380,889:4381,890:4382,891:4383,892:4384,893:4385,894:4386,895:4387,896:4388,897:4389,898:4390,899:4391,900:4392,901:4393,902:4394,903:4395,904:4396,905:4397,906:4398,907:4399,908:4400,909:4401,910:4402,911:4403,912:4404,913:4405,914:4406,915:4407,916:4408,917:4409,918:4410,919:4411,920:4412,921:4413,922:4414,923:4415,924:4416,925:4417,926:4418,927:4419,928:4420,929:4421,930:4422,931:4423,932:4424,933:4425,934:4426,935:4427,936:4428,937:4429,938:4430,939:4431,940:4432,941:4433,942:4434,943:4435,944:4436,945:4437,946:4438,947:4439,948:4440,949:4441,950:4442,951:4443,952:4444,953:4445,954:4446,955:4447,956:4448,957:4449,958:4450,959:4451,960:4452,961:4453,962:4454,963:4455,964:4456,965:4457,966:4458,967:4459,968:4460,969:4461,970:4462,971:4463,972:4464,973:4465,974:4466,975:4467,976:4468,977:4469,978:4470,979:4471,980:4472,981:4473,982:4474,983:4475,984:4476,985:4477,986:4478,987:4479,988:4480,989:4481,990:4482,991:4483,992:4484,993:4485,994:4486,995:4487,996:4488,997:4489,998:4490,999:4491,1000:4492,1001:4493,1002:4494,1003:4495,1004:4496,1005:4497,1006:4498,1007:4499,1008:4500,1009:4501,1010:4502,1011:4503,1012:4504,1013:4505,1014:4506,1015:4507,1016:4508,1017:4509,1018:4510,1019:4511,1020:4512,1021:4513,1022:4514,1023:4515,1024:4516,1025:4517,1026:4518,1027:4519,1028:4520,1029:4521,1030:4522,1031:4523,1032:4524,1033:4525,1034:4526,1035:4527,1036:4528,1037:4529,1038:4530,1039:4531,1040:4532,1041:4533,1042:4534,1043:4535,1044:4536,1045:4537,1046:4538,1047:4539,1048:4540,1049:4541,1050:4542,1051:4543,1052:4544,1053:4545,1054:4546,1055:4547,1056:4548,1057:4549,1058:4550,1059:4551,1060:4552,1061:4553,1062:4554,1063:4555,1064:4556,1065:4557,1066:4558,1067:4559,1068:4560,1069:4561,1070:4562,1071:4563,1072:4564,1073:4565,1074:4566,1075:4567,1076:4568,1077:4569,1078:4570,1079:4571,1080:4572,1081:4573,1082:4574,1083:4575,1084:4576,1085:4577,1086:4578,1087:4579,1088:4580,1089:4581,1090:4582,1091:4583,1092:4584,1093:4585,1094:4586,1095:4587,1096:4588,1097:4589,1098:4590,1099:4591,1100:4592,1101:4593,1102:4594,1103:4595,1104:4596,1105:4597,1106:4598,1107:4599,1108:4600,1109:4601,1110:4602,1111:4603,1112:4604,1113:4605,1114:4606,1115:4607,1116:4608,1117:4609,1118:4610,1119:4611,1120:4612,1121:4613,1122:4614,1123:4615,1124:4616,1125:4617,1126:4618,1127:4619,1128:4620,1129:4621,1130:4622,1131:4623,1132:4624,1133:4625,1134:4626,1135:4627,1136:4628,1137:4629,1138:4630,1139:4631,1140:4632,1141:4633,1142:4634,1143:4635,1144:4636,1145:4637,1146:4638,1147:4639,1148:4640,1149:4641,1150:4642,1151:4643,1152:4644,1153:4645,1154:4646,1155:4647,1156:4648,1157:4649,1158:4650,1159:4651,1160:4652,1161:4653,1162:4654,1163:4655,1164:4656,1165:4657,1166:4658,1167:4659,1168:4660,1169:4661,1170:4662,1171:4663,1172:4664,1173:4665,1174:4666,1175:4667,1176:4668,1177:4669,1178:4670,1179:4671,1180:4672,1181:4673,1182:4674,1183:4675,1184:4676,1185:4677,1186:4678,1187:4679,1188:4680,1189:4681,1190:4682,1191:4683,1192:4684,1193:4685,1194:4686,1195:4687,1196:4688,1197:4689,1198:4690,1199:4691,1200:4692,1201:4693,1202:4694,1203:4695,1204:4696,1205:4697,1206:4698,1207:4699,1208:4700,1209:4701,1210:4702,1211:4703,1212:4704,1213:4705,1214:4706,1215:4707,1216:4708,1217:4709,1218:4710,1219:4711,1220:4712,1221:4713,1222:4714,1223:4715,1224:4716,1225:4717,1226:4718,1227:4719,1228:4720,1229:4721,1230:4722,1231:4723,1232:4724,1233:4725,1234:4726,1235:4727,1236:4728,1237:4729,1238:4730,1239:4731,1240:4732,1241:4733,1242:4734,1243:4735,1244:4736,1245:4737,1246:4738,1247:4739,1248:4740,1249:4741,1250:4742,1251:4743,1252:4744,1253:4745,1254:4746,1255:4747,1256:4748,1257:4749,1258:4750,1259:4751,1260:4752,1261:4753,1262:4754,1263:4755,1264:4756,1265:4757,1266:4758,1267:4759,1268:4760,1269:4761,1270:4762,1271:4763,1272:4764,1273:4765,1274:4766,1275:4767,1276:4768,1277:4769,1278:4770,1279:4771,1280:4772,1281:4773,1282:4774,1283:4775,1284:4776,1285:4777,1286:4778,1287:4779,1288:4780,1289:4781,1290:4782,1291:4783,1292:4784,1293:4785,1294:4786,1295:4787,1296:4788,1297:4789,1298:4790,1299:4791,1300:4792,1301:4793,1302:4794,1303:4795,1304:4796,1305:4797,1306:4798,1307:4799,1308:4800,1309:4801,1310:4802,1311:4803,1312:4804,1313:4805,1314:4806,1315:4807,1316:4808,1317:4809,1318:4810,1319:4811,1320:4812,1321:4813,1322:4814,1323:4815,1324:4816,1325:4817,1326:4818,1327:4819,1328:4820,1329:4821,1330:4822,1331:4823,1332:4824,1333:4825,1334:4826,1335:4827,1336:4828,1337:4829,1338:4830,1339:4831,1340:4832,1341:4833,1342:4834,1343:4835,1344:4836,1345:4837,1346:4838,1347:4839,1348:4840,1349:4841,1350:4842,1351:4843,1352:4844,1353:4845,1354:4846,1355:4847,1356:4848,1357:4849,1358:4850,1359:4851,1360:4852,1361:4853,1362:4854,1363:4855,1364:4856,1365:4857,1366:4858,1367:4859,1368:4860,1369:4861,1370:4862,1371:4863,1372:4864,1373:4865,1374:4866,1375:4867,1376:4868,1377:4869,1378:4870,1379:4871,1380:4872,1381:4873,1382:4874,1383:4875,1384:4876,1385:4877,1386:4878,1387:4879,1388:4880,1389:4881,1390:4882,1391:4883,1392:4884,1393:4885,1394:4886,1395:4887,1396:4888,1397:4889,1398:4890,1399:4891,1400:4892,1401:4893,1402:4894,1403:4895,1404:4896,1405:4897,1406:4898,1407:4899,1408:4900,1409:4901,1410:4902,1411:4903,1412:4904,1413:4905,1414:4906,1415:4907,1416:4908,1417:4909,1418:4910,1419:4911,1420:4912,1421:4913,1422:4914,1423:4915,1424:4916,1425:4917,1426:4918,1427:4919,1428:4920,1429:4921,1430:4922,1431:4923,1432:4924,1433:4925,1434:4926,1435:4927,1436:4928,1437:4929,1438:4930,1439:4931,1440:4932,1441:4933,1442:4934,1443:4935,1444:4936,1445:4937,1446:4938,1447:4939,1448:4940,1449:4941,1450:4942,1451:4943,1452:4944,1453:4945,1454:4946,1455:4947,1456:4948,1457:4949,1458:4950,1459:4951,1460:4952,1461:4953,1462:4954,1463:4955,1464:4956,1465:4957,1466:4958,1467:4959,1468:4960,1469:4961,1470:4962,1471:4963,1472:4964,1473:4965,1474:4966,1475:4967,1476:4968,1477:4969,1478:4970,1479:4971,1480:4972,1481:4973,1482:4974,1483:4975,1484:4976,1485:4977,1486:4978,1487:4979,1488:4980,1489:4981,1490:4982,1491:4983,1492:4984,1493:4985,1494:4986,1495:4987,1496:4988,1497:4989,1498:4990,1499:4991,1500:4992,1501:4993,1502:4994,1503:4995,1504:4996,1505:4997,1506:4998,1507:4999,1508:5000,1509:5001,1510:5002,1511:5003,1512:5004,1513:5005,1514:5006,1515:5007,1516:5008,1517:5009,1518:5010,1519:5011,1520:5012,1521:5013,1522:5014,1523:5015,1524:5016,1525:5017,1526:5018,1527:5019,1528:5020,1529:5021,1530:5022,1531:5023,1532:5024,1533:5025,1534:5026,1535:5027,1536:5028,1537:5029,1538:5030,1539:5031,1540:5032,1541:5033,1542:5034,1543:5035,1544:5036,1545:5037,1546:5038,1547:5039,1548:5040,1549:5041,1550:5042,1551:5043,1552:5044,1553:5045,1554:5046,1555:5047,1556:5048,1557:5049,1558:5050,1559:5051,1560:5052,1561:5053,1562:5054,1563:5055,1564:5056,1565:5057,1566:5058,1567:5059,1568:5060,1569:5061,1570:5062,1571:5063,1572:5064,1573:5065,1574:5066,1575:5067,1576:5068,1577:5069,1578:5070,1579:5071,1580:5072,1581:5073,1582:5074,1583:5075,1584:5076,1585:5077,1586:5078,1587:5079,1588:5080,1589:5081,1590:5082,1591:5083,1592:5084,1593:5085,1594:5086,1595:5087,1596:5088,1597:5089,1598:5090,1599:5091,1600:5092,1601:5093,1602:5094,1603:5095,1604:5096,1605:5097,1606:5098,1607:5099,1608:5100,1609:5101,1610:5102,1611:5103,1612:5104,1613:5105,1614:5106,1615:5107,1616:5108,1617:5109,1618:5110,1619:5111,1620:5112,1621:5113,1622:5114,1623:5115,1624:5116,1625:5117,1626:5118,1627:5119,1628:5120,1629:5121,1630:5122,1631:5123,1632:5124,1633:5125,1634:5126,1635:5127,1636:5128,1637:5129,1638:5130,1639:5131,1640:5132,1641:5133,1642:5134,1643:5135,1644:5136,1645:5137,1646:5138,1647:5139,1648:5140,1649:5141,1650:5142,1651:5143,1652:5144,1653:5145,1654:5146,1655:5147,1656:5148,1657:5149,1658:5150,1659:5151,1660:5152,1661:5153,1662:5154,1663:5155,1664:5156,1665:5157,1666:5158,1667:5159,1668:5160,1669:5161,1670:5162,1671:5163,1672:5164,1673:5165,1674:5166,1675:5167,1676:5168,1677:5169,1678:5170,1679:5171,1680:5172,1681:5173,1682:5174,1683:5175,1684:5176,1685:5177,1686:5178,1687:5179,1688:5180,1689:5181,1690:5182,1691:5183,1692:5184,1693:5185,1694:5186,1695:5187,1696:5188,1697:5189,1698:5190,1699:5191,1700:5192,1701:5193,1702:5194,1703:5195,1704:5196,1705:5197,1706:5198,1707:5199,1708:5200,1709:5201,1710:5202,1711:5203,1712:5204,1713:5205,1714:5206,1715:5207,1716:5208,1717:5209,1718:5210,1719:5211,1720:5212,1721:5213,1722:5214,1723:5215,1724:5216,1725:5217,1726:5218,1727:5219,1728:5220,1729:5221,1730:5222,1731:5223,1732:5224,1733:5225,1734:5226,1735:5227,1736:5228,1737:5229,1738:5230,1739:5231,1740:5232,1741:5233,1742:5234,1743:5235,1744:5236,1745:5237,1746:5238,1747:5239,1748:5240,1749:5241,1750:5242,1751:5243,1752:5244,1753:5245,1754:5246,1755:5247,1756:5248,1757:5249,1758:5250,1759:5251,1760:5252,1761:5253,1762:5254,1763:5255,1764:5256,1765:5257,1766:5258,1767:5259,1768:5260,1769:5261,1770:5262,1771:5263,1772:5264,1773:5265,1774:5266,1775:5267,1776:5268,1777:5269,1778:5270,1779:5271,1780:5272,1781:5273,1782:5274,1783:5275,1784:5276,1785:5277,1786:5278,1787:5279,1788:5280,1789:5281,1790:5282,1791:5283,1792:5284,1793:5285,1794:5286,1795:5287,1796:5288,1797:5289,1798:5290,1799:5291,1800:5292,1801:5293,1802:5294,1803:5295,1804:5296,1805:5297,1806:5298,1807:5299,1808:5300,1809:5301,1810:5302,1811:5303,1812:5304,1813:5305,1814:5306,1815:5307,1816:5308,1817:5309,1818:5310,1819:5311,1820:5312,1821:5313,1822:5314,1823:5315,1824:5316,1825:5317,1826:5318,1827:5319,1828:5320,1829:5321,1830:5322,1831:5323,1832:5324,1833:5325,1834:5326,1835:5327,1836:5328,1837:5329,1838:5330,1839:5331,1840:5332,1841:5333,1842:5334,1843:5335,1844:5336,1845:5337,1846:5338,1847:5339,1848:5340,1849:5341,1850:5342,1851:5343,1852:5344,1853:5345,1854:5346,1855:5347,1856:5348,1857:5349,1858:5350,1859:5351,1860:5352,1861:5353,1862:5354,1863:5355,1864:5356,1865:5357,1866:5358,1867:5359,1868:5360,1869:5361,1870:5362,1871:5363,1872:5364,1873:5365,1874:5366,1875:5367,1876:5368,1877:5369,1878:5370,1879:5371,1880:5372,1881:5373,1882:5374,1883:5375,1884:5376,1885:5377,1886:5378,1887:5379,1888:5380,1889:5381,1890:5382,1891:5383,1892:5384,1893:5385,1894:5386,1895:5387,1896:5388,1897:5389,1898:5390,1899:5391,1900:5392,1901:5393,1902:5394,1903:5395,1904:5396,1905:5397,1906:5398,1907:5399,1908:5400,1909:5401,1910:5402,1915:5403,1916:5404,1917:5405,1922:5406,1923:5407,1924:5408,1925:5409,1926:5410,1951:5411,1952:5412,1953:5413,1954:5414,1955:5415,1956:5416,1957:5417,1958:5418,1959:5419,1960:5420,1961:5421,1962:5422,1963:5423,1964:5424,1965:5425,1966:5426,1967:5427,1968:5428,1969:5429,1970:5430,1971:5431,1972:5432,1973:5433,1974:5434,1975:5435,1976:5436,1977:5437,1978:5438,1979:5439,1980:5440,1981:5441,1982:5442,1983:5443,1984:5444,1985:5445,1986:5446,1987:5447,1988:5448,1989:5449,1990:5450,1991:5451,1992:5452,1993:5453,1994:5454,1995:5455,1996:5456,1997:5457,1998:5458,1999:5459,2000:5460,2001:5461,2002:5462,2003:5463,2004:5464,2005:5465,2006:5466,2007:5467,2008:5468,2009:5469,2010:5470,2011:5471,2012:5472,2013:5473,2014:5474,2015:5475,2016:5476,2017:5477,2018:5478,2019:5479,2020:5480,2021:5481,2022:5482,2023:5483,2024:5484,2025:5485,2026:5486,2027:5487,2028:5488,2029:5489,2030:5490,2031:5491,2032:5492,2033:5493,2034:5494,2035:5495,2036:5496,2037:5497,2038:5498,2039:5499,2040:5500,2041:5501,2042:5502,2043:5503,2044:5504,2045:5505,2046:5506,2047:5507,2048:5508,2049:5509,2050:5510,2051:5511,2052:5512,2053:5513,2054:5514,2055:5515,2056:5516,2057:5517,2058:5518,2059:5519,2060:5520,2061:5521,2062:5522,2063:5523,2064:5524,2065:5525,2066:5526,2067:5527,2068:5528,2069:5529,2070:5530,2071:5531,2072:5532,2073:5533,2074:5534,2075:5535,2076:5536,2077:5537,2078:5538,2079:5539,2080:5540,2081:5541,2082:5542,2083:5543,2084:5544,2085:5545,2086:5546,2087:5547,2088:5548,2089:5549,2090:5550,2091:5551,2092:5552,2093:5553,2094:5554,2095:5555,2096:5556,2097:5557,2098:5558,2099:5559,2100:5560,2101:5561,2102:5562,2103:5563,2104:5564,2105:5565,2106:5566,2107:5567,2108:5568,2109:5569,2110:5570,2111:5571,2112:5572,2113:5573,2114:5574,2115:5575,2116:5576,2117:5577,2118:5578,2119:5579,2120:5580,2121:5581,2122:5582,2123:5583,2124:5584,2125:5585,2126:5586,2127:5587,2128:5588,2129:5589,2130:5590,2131:5591,2132:5592,2133:5593,2134:5594,2135:5595,2136:5596,2137:5597,2138:5598,2139:5599,2140:5600,2141:5601,2142:5602,2143:5603,2144:5604,2145:5605,2146:5606,2147:5607,2148:5608,2149:5609,2150:5610,2151:5611,2152:5612,2153:5613,2154:5614,2155:5615,2156:5616,2157:5617,2158:5618,2159:5619,2160:5620,2161:5621,2162:5622,2163:5623,2164:5624,2165:5625,2166:5626,2167:5627,2168:5628,2169:5629,2170:5630,2171:5631,2172:5632,2173:5633,2174:5634,2175:5635,2176:5636,2177:5637,2178:5638,2179:5639,2180:5640,2181:5641,2182:5642,2183:5643,2184:5644,2185:5645,2186:5646,2187:5647,2188:5648,2189:5649,2190:5650,2191:5651,2192:5652,2193:5653,2194:5654,2195:5655,2196:5656,2197:5657,2198:5658,2199:5659,2200:5660,2201:5661,2202:5662,2203:5663,2204:5664,2205:5665,2206:5666,2207:5667,2208:5668,2209:5669,2210:5670,2211:5671,2212:5672,2213:5673,2214:5674,2215:5675,2216:5676,2217:5677,2218:5678,2219:5679,2220:5680,2221:5681,2222:5682,2223:5683,2224:5684,2225:5685,2226:5686,2227:5687,2228:5688,2229:5689,2230:5690,2231:5691,2232:5692,2233:5693,2234:5694,2235:5695,2236:5696,2237:5697,2238:5698,2239:5699,2240:5700,2241:5701,2242:5702,2243:5703,2244:5704,2245:5705,2246:5706,2247:5707,2248:5708,2249:5709,2250:5710,2251:5711,2252:5712,2253:5713,2254:5714,2255:5715,2256:5716,2257:5717,2258:5718,2259:5719,2260:5720,2261:5721,2262:5722,2263:5723,2264:5724,2265:5725,2266:5726,2267:5727,2268:5728,2269:5729,2270:5730,2271:5731,2272:5732,2273:5733,2274:5734,2275:5735,2276:5736,2277:5737,2278:5738,2279:5739,2280:5740,2281:5741,2282:5742,2283:5743,2284:5744,2285:5745,2286:5746,2287:5747,2288:5748,2289:5749,2290:5750,2291:5751,2292:5752,2293:5753,2294:5754,2295:5755,2296:5756,2297:5757,2298:5758,2299:5759,2300:5760,2303:5761,2304:5762,2305:5763,2306:5764,2307:5765,2308:5766,2309:5767,2310:5768,2311:5769,2312:5770,2313:5771,2314:5772,2315:5773,2316:5774,2317:5775,2318:5776,2319:5777,2320:5778,2321:5779,2322:5780,2323:5781,2324:5782,2325:5783,2326:5784,2327:5785,2328:5786,2329:5787,2330:5788,2331:5789,2332:5790,2333:5791,2334:5792,2335:5793,2336:5794,2337:5795,2338:5796,2339:5797,2340:5798,2341:5799,2342:5800,2343:5801,2344:5802,2345:5803,2346:5804,2347:5805,2348:5806,2349:5807,2350:5808,2351:5809,2352:5810,2353:5811,2354:5812,2355:5813,2356:5814,2357:5815,2358:5816,2359:5817,2360:5818,2361:5819,2362:5820,2363:5821,2364:5822,2365:5823,2366:5824,2367:5825,2368:5826,2369:5827,2370:5828,2371:5829,2372:5830,2373:5831,2374:5832,2375:5833,2376:5834,2377:5835,2378:5836,2379:5837,2380:5838,2381:5839,2382:5840,2383:5841,2384:5842,2385:5843,2386:5844,2387:5845,2388:5846,2389:5847,2390:5848,2391:5849,2392:5850,2393:5851,2394:5852,2395:5853,2396:5854,2397:5855,2398:5856,2399:5857,6268:6511,6269:6512,6270:6513,6271:6514,6272:6515,6273:6516,6274:6517,6275:6518,6276:6519,6277:6520,6278:6521,6279:6522,6280:6523,6281:6524,6282:6525,6283:6526,6284:6527,6285:6528,6286:6529,6287:6530,6288:6531,6289:6532,6290:6533,6291:6534,6292:6535,6293:6536,6294:6537,6295:6538,6296:6539,6297:6540,6298:6541,6299:6542,6300:6543,6301:6544,6302:6545,6303:6546,6304:6547,6305:6548,6306:6549,6307:6550,6308:6551,6309:6552,6310:6553,6311:6554,6312:6555,6313:6556,6314:6557,6315:6558,6316:6559,6317:6560,6318:6561,6319:6562,6320:6563,6321:6564,6322:6565,6323:6566,6325:6568,6326:6569,6327:6570,6329:6572,6330:6573,6331:6574,6332:6575,6333:6576,6334:6577,6335:6578,6336:6579,6337:6580,6338:6581,6339:6582,6340:6583,6341:6584,6342:6585,6343:6586,6344:6587,6345:6588,6346:6589,6347:6590,6348:6591,6592:6595,6596:6599,6704:6846,6705:6847,6706:6848,6707:6849" + "value": "3:3515,4:3516,5:3517,6:3518,7:3519,8:3520,9:3521,10:3522,11:3523,12:3524,13:3525,14:3526,15:3527,16:3528,17:3529,18:3530,19:3531,20:3532,21:3533,22:3534,23:3535,24:3536,25:3537,26:3538,27:3539,28:3540,29:3541,30:3542,31:3543,32:3544,33:3545,34:3546,35:3547,36:3548,37:3549,38:3550,39:3551,40:3552,41:3553,42:3554,43:3555,44:3556,45:3557,46:3558,47:3559,48:3560,49:0,50:3561,51:0,52:0,53:3562,54:3563,55:3564,56:3565,57:3566,58:3567,59:0,60:3568,61:3569,62:0,63:3570,64:0,65:0,66:3571,67:3572,68:3573,69:3574,70:0,81:3575,82:3576,83:3577,84:3578,85:3579,86:3580,87:3581,88:3582,89:3583,90:3584,91:3585,92:3586,93:3587,94:3588,95:3589,96:3590,97:3591,98:3592,99:3593,100:3594,101:3595,102:3596,103:3597,104:3598,105:3599,106:3600,107:3601,108:3602,109:3603,110:3604,111:3605,112:3606,113:3607,114:3608,115:3609,116:3610,117:3611,118:3612,119:3613,120:3614,121:3615,122:3616,123:3617,124:3618,125:3619,126:3620,127:3621,128:3622,129:3623,130:3624,131:3625,132:3626,133:3627,134:3628,135:3629,136:3630,137:3631,138:3632,139:3633,140:3634,141:3635,142:3636,143:3637,144:3638,145:3639,146:3640,147:3641,148:3642,149:3643,150:3644,151:3645,152:3646,153:3647,154:3648,155:3649,156:3650,157:3651,158:3652,159:3653,160:3654,161:3655,162:3656,163:3657,164:3658,165:3659,166:3660,167:3661,168:3662,169:3663,170:3664,171:3665,172:3666,173:3667,174:3668,175:3669,176:3670,177:3671,178:3672,179:3673,180:3674,181:3675,182:3676,183:3677,184:3678,185:3679,186:3680,187:3681,188:3682,189:3683,190:3684,191:3685,192:3686,193:3687,194:3688,195:3689,196:3690,197:3691,198:3692,199:3693,200:3694,201:3695,202:3696,203:3697,204:3698,205:3699,206:3700,207:3701,208:3702,209:3703,210:3704,211:3705,212:3706,213:3707,214:3708,215:3709,216:3710,217:3711,218:3712,219:3713,220:3714,221:3715,222:3716,223:3717,224:3718,225:3719,226:3720,227:3721,228:3722,229:3723,230:3724,231:3725,232:3726,233:3727,234:3728,235:3729,236:3730,237:3731,238:3732,239:3733,240:3734,241:3735,242:3736,243:3737,244:3738,245:3739,246:3740,247:3741,248:3742,249:3743,250:3744,251:3745,252:3746,253:3747,254:3748,255:3749,256:3750,257:3751,258:3752,259:3753,260:3754,261:3755,262:3756,263:3757,264:3758,265:3759,266:3760,267:3761,268:3762,269:3763,270:3764,271:3765,272:3766,273:3767,274:3768,275:3769,276:3770,277:3771,278:3772,279:3773,280:3774,281:3775,282:3776,283:3777,284:3778,285:3779,286:3780,287:3781,288:3782,289:3783,290:3784,291:3785,292:3786,293:3787,294:3788,295:3789,296:3790,297:3791,298:3792,299:3793,300:3794,301:3795,302:3796,303:3797,304:3798,305:3799,306:3800,307:3801,308:3802,309:3803,310:3804,311:3805,312:3806,313:3807,314:3808,315:3809,316:3810,317:3811,318:3812,319:3813,320:3814,321:3815,322:3816,323:3817,324:3818,325:3819,326:3820,327:3821,328:3822,329:3823,330:3824,331:3825,332:3826,333:3827,334:3828,335:3829,336:3830,337:3831,338:3832,339:3833,340:3834,341:3835,342:3836,343:3837,344:3838,345:3839,346:3840,347:3841,348:3842,349:3843,350:3844,351:3845,352:3846,353:3847,354:3848,355:3849,356:3850,357:3851,358:3852,359:3853,360:3854,361:3855,362:3856,363:3857,364:3858,365:3859,366:3860,367:3861,368:3862,369:3863,370:3864,371:3865,372:3866,373:3867,374:3868,375:3869,376:3870,377:3871,378:3872,379:3873,380:3874,381:3875,382:3876,383:3877,384:3878,385:3879,386:3880,387:3881,388:3882,389:3883,390:3884,391:3885,392:3886,393:3887,394:3888,395:3889,396:3890,397:3891,398:3892,399:3893,400:3894,401:3895,402:3896,403:3897,404:3898,405:3899,406:3900,407:3901,408:3902,409:3903,410:3904,411:3905,412:3906,413:3907,414:3908,415:3909,416:3910,417:3911,418:3912,419:3913,420:3914,421:3915,422:3916,423:3917,424:3918,425:3919,426:3920,427:3921,428:3922,429:3923,430:3924,431:3925,432:3926,433:3927,434:3928,435:3929,436:3930,437:3931,438:3932,439:3933,440:3934,441:3935,442:3936,443:3937,444:3938,445:3939,446:3940,447:3941,448:3942,449:3943,450:3944,451:3945,452:3946,453:3947,454:3948,455:3949,456:3950,457:3951,458:3952,459:3953,460:3954,461:3955,462:3956,463:3957,464:3958,465:3959,466:3960,467:3961,468:3962,469:3963,470:3964,471:3965,472:3966,473:3967,474:3968,475:3969,476:3970,477:3971,478:3972,479:3973,480:3974,481:3975,482:3976,483:3977,484:3978,485:3979,486:3980,487:3981,488:3982,489:3983,490:3984,491:3985,492:3986,493:3987,494:3988,495:3989,496:3990,497:3991,498:3992,499:3993,500:3994,501:3995,502:3996,503:3997,504:3998,505:3999,506:4000,507:4001,508:4002,509:4003,510:4004,511:4005,512:4006,513:4007,514:4008,515:4009,516:4010,517:4011,518:4012,519:4013,520:4014,521:4015,522:4016,523:4017,524:4018,525:4019,526:4020,527:4021,528:4022,529:4023,530:4024,531:4025,532:4026,533:4027,534:4028,535:4029,536:4030,537:4031,538:4032,539:4033,540:4034,541:4035,542:4036,543:4037,544:4038,545:4039,546:4040,547:4041,548:4042,549:4043,550:4044,551:4045,552:4046,553:4047,554:4048,555:4049,560:4054,561:4055,562:4056,563:4057,564:4058,565:4059,566:4060,567:4061,568:4062,569:4063,570:4064,571:4065,572:4066,573:4067,574:4068,575:4069,576:4070,577:4071,578:4072,579:4073,580:4074,581:4075,582:4076,583:4077,584:4078,585:4079,586:4080,587:4081,588:4082,589:4083,590:4084,591:4085,592:4086,593:4087,594:4088,595:4089,596:4090,597:4091,598:4092,599:4093,600:4094,601:4095,602:4096,603:4097,604:4098,605:4099,610:4104,611:4105,612:4106,613:4107,614:4108,615:4109,616:4110,617:4111,618:4112,619:4113,620:4114,621:4115,622:4116,623:4117,624:4118,625:4119,626:4120,627:4121,628:4122,629:4123,630:4124,631:4125,632:4126,633:4127,634:4128,635:4129,636:4130,637:4131,638:4132,639:4133,640:4134,641:4135,642:4136,643:4137,644:4138,645:4139,646:4140,647:4141,648:4142,649:4143,650:4144,651:4145,652:4146,653:4147,654:4148,655:4149,656:4150,657:4151,658:4152,659:4153,660:4154,661:4155,662:4156,663:4157,668:4162,669:4163,670:4164,671:4165,672:4166,673:4167,674:4168,675:4169,676:4170,677:4171,678:4172,679:4173,680:4174,681:4175,682:4176,683:4177,684:4178,685:4179,686:4180,687:4181,688:4182,689:4183,690:4184,691:4185,692:4186,693:4187,694:4188,695:4189,696:4190,697:4191,698:4192,699:4193,700:4194,701:4195,702:4196,703:4197,704:4198,705:4199,706:4200,707:4201,708:4202,709:4203,710:4204,711:4205,712:4206,713:4207,714:4208,715:4209,716:4210,717:4211,718:4212,719:4213,720:4214,721:4215,722:4216,723:4217,724:4218,725:4219,726:4220,727:4221,728:4222,729:4223,730:4224,731:4225,732:4226,733:4227,734:4228,735:4229,736:4230,737:4231,738:4232,739:4233,740:4234,741:4235,742:4236,743:4237,744:4238,745:4239,746:4240,747:4241,748:4242,749:4243,750:4244,751:4245,752:4246,753:4247,754:4248,755:4249,756:4250,757:4251,758:4252,759:4253,760:4254,761:4255,762:4256,763:4257,764:4258,765:4259,766:4260,767:4261,768:4262,769:4263,770:4264,771:4265,772:4266,773:4267,774:4268,776:4269,777:4270,778:4271,779:4272,780:4273,781:4274,782:4275,783:4276,784:4277,785:4278,786:4279,787:4280,788:4281,789:4282,790:4283,791:4284,792:4285,793:4286,794:4287,795:4288,796:4289,797:4290,798:4291,799:4292,800:4293,801:4294,802:4295,803:4296,804:4297,805:4298,806:4299,807:4300,808:4301,809:4302,810:4303,811:4304,812:4305,813:4306,814:4307,815:4308,816:4309,817:4310,818:4311,819:4312,821:4313,822:4314,823:4315,824:4316,825:4317,826:4318,827:4319,828:4320,829:4321,830:4322,831:4323,832:4324,833:4325,834:4326,835:4327,836:4328,837:4329,838:4330,839:4331,840:4332,841:4333,842:4334,843:4335,844:4336,845:4337,846:4338,847:4339,848:4340,849:4341,850:4342,851:4343,852:4344,853:4345,854:4346,855:4347,856:4348,857:4349,858:4350,859:4351,860:4352,861:4353,862:4354,863:4355,864:4356,865:4357,866:4358,867:4359,868:4360,869:4361,870:4362,871:4363,872:4364,873:4365,874:4366,875:4367,876:4368,877:4369,878:4370,879:4371,880:4372,881:4373,882:4374,883:4375,884:4376,885:4377,886:4378,887:4379,888:4380,889:4381,890:4382,891:4383,892:4384,893:4385,894:4386,895:4387,896:4388,897:4389,898:4390,899:4391,900:4392,901:4393,902:4394,903:4395,904:4396,905:4397,906:4398,907:4399,908:4400,909:4401,910:4402,911:4403,912:4404,913:4405,914:4406,915:4407,916:4408,917:4409,918:4410,919:4411,920:4412,921:4413,922:4414,923:4415,924:4416,925:4417,926:4418,927:4419,928:4420,929:4421,930:4422,931:4423,932:4424,933:4425,934:4426,935:4427,936:4428,937:4429,938:4430,939:4431,940:4432,941:4433,942:4434,943:4435,944:4436,945:4437,946:4438,947:4439,948:4440,949:4441,950:4442,951:4443,952:4444,953:4445,954:4446,955:4447,956:4448,957:4449,958:4450,959:4451,960:4452,961:4453,962:4454,963:4455,964:4456,965:4457,966:4458,967:4459,968:4460,969:4461,970:4462,971:4463,972:4464,973:4465,974:4466,975:4467,976:4468,977:4469,978:4470,979:4471,980:4472,981:4473,982:4474,983:4475,984:4476,985:4477,986:4478,987:4479,988:4480,989:4481,990:4482,991:4483,992:4484,993:4485,994:4486,995:4487,996:4488,997:4489,998:4490,999:4491,1000:4492,1001:4493,1002:4494,1003:4495,1004:4496,1005:4497,1006:4498,1007:4499,1008:4500,1009:4501,1010:4502,1011:4503,1012:4504,1013:4505,1014:4506,1015:4507,1016:4508,1017:4509,1018:4510,1019:4511,1020:4512,1021:4513,1022:4514,1023:4515,1024:4516,1025:4517,1026:4518,1027:4519,1028:4520,1029:4521,1030:4522,1031:4523,1032:4524,1033:4525,1034:4526,1035:4527,1036:4528,1037:4529,1038:4530,1039:4531,1040:4532,1041:4533,1042:4534,1043:4535,1044:4536,1045:4537,1046:4538,1047:4539,1048:4540,1049:4541,1050:4542,1051:4543,1052:4544,1053:4545,1054:4546,1055:4547,1056:4548,1057:4549,1058:4550,1059:4551,1060:4552,1061:4553,1062:4554,1063:4555,1064:4556,1065:4557,1066:4558,1067:4559,1068:4560,1069:4561,1070:4562,1071:4563,1072:4564,1073:4565,1074:4566,1075:4567,1076:4568,1077:4569,1078:4570,1079:4571,1080:4572,1081:4573,1082:4574,1083:4575,1084:4576,1085:4577,1086:4578,1087:4579,1088:4580,1089:4581,1090:4582,1091:4583,1092:4584,1093:4585,1094:4586,1099:4591,1100:4592,1101:4593,1102:4594,1103:4595,1104:4596,1105:4597,1106:4598,1107:4599,1108:4600,1109:4601,1110:4602,1111:4603,1112:4604,1113:4605,1114:4606,1115:4607,1116:4608,1117:4609,1118:4610,1119:4611,1120:4612,1121:4613,1122:4614,1123:4615,1124:4616,1125:4617,1126:4618,1127:4619,1128:4620,1129:4621,1130:4622,1131:4623,1132:4624,1133:4625,1134:4626,1135:4627,1136:4628,1137:4629,1138:4630,1139:4631,1140:4632,1141:4633,1142:4634,1143:4635,1144:4636,1145:4637,1146:4638,1147:4639,1148:4640,1149:4641,1150:4642,1151:4643,1152:4644,1153:4645,1154:4646,1155:4647,1160:4652,1161:4653,1162:4654,1163:4655,1164:4656,1165:4657,1166:4658,1167:4659,1168:4660,1169:4661,1170:4662,1171:4663,1172:4664,1173:4665,1174:4666,1175:4667,1176:4668,1177:4669,1178:4670,1179:4671,1180:4672,1181:4673,1182:4674,1183:4675,1184:4676,1185:4677,1186:4678,1187:4679,1188:4680,1189:4681,1190:4682,1191:4683,1192:4684,1193:4685,1194:4686,1195:4687,1196:4688,1197:4689,1198:4690,1199:4691,1200:4692,1201:4693,1202:4694,1203:4695,1204:4696,1205:4697,1206:4698,1207:4699,1208:4700,1209:4701,1210:4702,1211:4703,1212:4704,1213:4705,1218:4710,1219:4711,1220:4712,1221:4713,1222:4714,1223:4715,1224:4716,1225:4717,1226:4718,1227:4719,1228:4720,1229:4721,1230:4722,1231:4723,1232:4724,1233:4725,1234:4726,1235:4727,1236:4728,1237:4729,1238:4730,1239:4731,1240:4732,1241:4733,1242:4734,1243:4735,1244:4736,1245:4737,1246:4738,1247:4739,1248:4740,1249:4741,1250:4742,1251:4743,1252:4744,1253:4745,1254:4746,1255:4747,1256:4748,1257:4749,1258:4750,1259:4751,1260:4752,1261:4753,1262:4754,1263:4755,1264:4756,1265:4757,1266:4758,1267:4759,1272:4764,1273:4765,1274:4766,1275:4767,1276:4768,1277:4769,1278:4770,1279:4771,1280:4772,1281:4773,1282:4774,1283:4775,1284:4776,1285:4777,1286:4778,1287:4779,1288:4780,1289:4781,1290:4782,1291:4783,1292:4784,1293:4785,1294:4786,1295:4787,1296:4788,1297:4789,1298:4790,1299:4791,1300:4792,1301:4793,1302:4794,1303:4795,1304:4796,1305:4797,1306:4798,1307:4799,1308:4800,1309:4801,1310:4802,1311:4803,1312:4804,1313:4805,1314:4806,1315:4807,1316:4808,1317:4809,1318:4810,1319:4811,1320:4812,1321:4813,1322:4814,1323:4815,1324:4816,1325:4817,1326:4818,1327:4819,1328:4820,1329:4821,1330:4822,1331:4823,1332:4824,1333:4825,1334:4826,1335:4827,1336:4828,1337:4829,1338:4830,1339:4831,1340:4832,1341:4833,1342:4834,1343:4835,1344:4836,1345:4837,1346:4838,1347:4839,1348:4840,1349:4841,1350:4842,1351:4843,1352:4844,1353:4845,1354:4846,1355:4847,1356:4848,1357:4849,1358:4850,1359:4851,1360:4852,1361:4853,1362:4854,1363:4855,1364:4856,1365:4857,1366:4858,1367:4859,1368:4860,1369:4861,1370:4862,1371:4863,1372:4864,1373:4865,1374:4866,1375:4867,1376:4868,1377:4869,1378:4870,1379:4871,1380:4872,1381:4873,1382:4874,1383:4875,1384:4876,1385:4877,1386:4878,1387:4879,1388:4880,1389:4881,1390:4882,1391:4883,1392:4884,1393:4885,1394:4886,1395:4887,1396:4888,1397:4889,1398:4890,1399:4891,1400:4892,1401:4893,1402:4894,1403:4895,1404:4896,1405:4897,1406:4898,1407:4899,1408:4900,1409:4901,1410:4902,1411:4903,1412:4904,1413:4905,1414:4906,1415:4907,1416:4908,1417:4909,1418:4910,1419:4911,1420:4912,1421:4913,1422:4914,1423:4915,1424:4916,1425:4917,1426:4918,1427:4919,1428:4920,1429:4921,1430:4922,1431:4923,1432:4924,1433:4925,1434:4926,1435:4927,1436:4928,1437:4929,1438:4930,1439:4931,1440:4932,1441:4933,1442:4934,1443:4935,1444:4936,1445:4937,1446:4938,1447:4939,1448:4940,1449:4941,1450:4942,1451:4943,1452:4944,1453:4945,1454:4946,1455:4947,1456:4948,1457:4949,1458:4950,1459:4951,1460:4952,1461:4953,1462:4954,1463:4955,1464:4956,1465:4957,1466:4958,1467:4959,1468:4960,1469:4961,1470:4962,1471:4963,1472:4964,1473:4965,1474:4966,1475:4967,1476:4968,1477:4969,1478:4970,1479:4971,1480:4972,1481:4973,1482:4974,1483:4975,1484:4976,1485:4977,1486:4978,1487:4979,1488:4980,1489:4981,1490:4982,1491:4983,1492:4984,1493:4985,1494:4986,1495:4987,1496:4988,1497:4989,1498:4990,1499:4991,1500:4992,1501:4993,1502:4994,1503:4995,1504:4996,1505:4997,1506:4998,1507:4999,1508:5000,1509:5001,1510:5002,1511:5003,1512:5004,1513:5005,1514:5006,1515:5007,1516:5008,1517:5009,1518:5010,1519:5011,1520:5012,1521:5013,1522:5014,1523:5015,1524:5016,1525:5017,1526:5018,1527:5019,1528:5020,1529:5021,1530:5022,1531:5023,1532:5024,1533:5025,1534:5026,1535:5027,1536:5028,1537:5029,1538:5030,1539:5031,1540:5032,1541:5033,1542:5034,1543:5035,1544:5036,1545:5037,1546:5038,1547:5039,1548:5040,1549:5041,1550:5042,1551:5043,1552:5044,1553:5045,1554:5046,1555:5047,1556:5048,1557:5049,1558:5050,1559:5051,1560:5052,1561:5053,1562:5054,1563:5055,1564:5056,1565:5057,1566:5058,1567:5059,1568:5060,1569:5061,1570:5062,1571:5063,1572:5064,1573:5065,1574:5066,1575:5067,1576:5068,1577:5069,1578:5070,1579:5071,1580:5072,1581:5073,1582:5074,1583:5075,1584:5076,1585:5077,1586:5078,1587:5079,1588:5080,1589:5081,1590:5082,1591:5083,1592:5084,1593:5085,1594:5086,1595:5087,1596:5088,1597:5089,1598:5090,1599:5091,1600:5092,1601:5093,1602:5094,1603:5095,1604:5096,1605:5097,1606:5098,1607:5099,1608:5100,1609:5101,1610:5102,1611:5103,1612:5104,1613:5105,1614:5106,1615:5107,1616:5108,1617:5109,1618:5110,1619:5111,1620:5112,1621:5113,1622:5114,1623:5115,1624:5116,1625:5117,1626:5118,1627:5119,1628:5120,1629:5121,1630:5122,1631:5123,1632:5124,1633:5125,1634:5126,1635:5127,1636:5128,1637:5129,1638:5130,1639:5131,1640:5132,1641:5133,1642:5134,1643:5135,1644:5136,1645:5137,1646:5138,1647:5139,1648:5140,1649:5141,1650:5142,1651:5143,1652:5144,1653:5145,1654:5146,1655:5147,1656:5148,1657:5149,1658:5150,1659:5151,1660:5152,1661:5153,1662:5154,1663:5155,1664:5156,1665:5157,1666:5158,1667:5159,1668:5160,1669:5161,1670:5162,1671:5163,1672:5164,1673:5165,1674:5166,1675:5167,1676:5168,1677:5169,1678:5170,1679:5171,1680:5172,1681:5173,1682:5174,1683:5175,1684:5176,1685:5177,1686:5178,1687:5179,1688:5180,1689:5181,1690:5182,1691:5183,1692:5184,1693:5185,1694:5186,1695:5187,1696:5188,1697:5189,1698:5190,1699:5191,1700:5192,1701:5193,1702:5194,1703:5195,1704:5196,1705:5197,1706:5198,1707:5199,1708:5200,1709:5201,1710:5202,1711:5203,1712:5204,1713:5205,1714:5206,1715:5207,1716:5208,1717:5209,1718:5210,1719:5211,1720:5212,1721:5213,1722:5214,1723:5215,1724:5216,1725:5217,1726:5218,1727:5219,1728:5220,1729:5221,1730:5222,1731:5223,1732:5224,1733:5225,1734:5226,1735:5227,1736:5228,1737:5229,1738:5230,1739:5231,1740:5232,1741:5233,1742:5234,1743:5235,1744:5236,1745:5237,1746:5238,1747:5239,1748:5240,1749:5241,1750:5242,1751:5243,1752:5244,1753:5245,1754:5246,1755:5247,1756:5248,1757:5249,1758:5250,1759:5251,1760:5252,1761:5253,1762:5254,1763:5255,1764:5256,1765:5257,1766:5258,1767:5259,1768:5260,1769:5261,1770:5262,1771:5263,1772:5264,1773:5265,1774:5266,1775:5267,1776:5268,1777:5269,1778:5270,1779:5271,1780:5272,1781:5273,1782:5274,1783:5275,1784:5276,1785:5277,1786:5278,1787:5279,1788:5280,1789:5281,1790:5282,1791:5283,1792:5284,1793:5285,1794:5286,1795:5287,1796:5288,1797:5289,1798:5290,1799:5291,1800:5292,1801:5293,1802:5294,1803:5295,1804:5296,1805:5297,1806:5298,1807:5299,1808:5300,1809:5301,1810:5302,1811:5303,1812:5304,1813:5305,1814:5306,1815:5307,1816:5308,1817:5309,1818:5310,1819:5311,1820:5312,1821:5313,1822:5314,1823:5315,1824:5316,1825:5317,1826:5318,1827:5319,1828:5320,1829:5321,1830:5322,1831:5323,1832:5324,1833:5325,1834:5326,1835:5327,1836:5328,1837:5329,1838:5330,1839:5331,1840:5332,1841:5333,1842:5334,1843:5335,1844:5336,1845:5337,1846:5338,1847:5339,1848:5340,1849:5341,1850:5342,1851:5343,1852:5344,1853:5345,1854:5346,1855:5347,1856:5348,1857:5349,1858:5350,1859:5351,1860:5352,1861:5353,1862:5354,1863:5355,1864:5356,1865:5357,1866:5358,1867:5359,1868:5360,1869:5361,1870:5362,1871:5363,1872:5364,1873:5365,1874:5366,1875:5367,1876:5368,1877:5369,1878:5370,1879:5371,1880:5372,1881:5373,1882:5374,1883:5375,1884:5376,1885:5377,1886:5378,1887:5379,1888:5380,1889:5381,1890:5382,1891:5383,1892:5384,1893:5385,1894:5386,1895:5387,1896:5388,1897:5389,1898:5390,1899:5391,1900:5392,1901:5393,1902:5394,1903:5395,1904:5396,1905:5397,1906:5398,1907:5399,1908:5400,1909:5401,1910:5402,1915:5403,1916:5404,1917:5405,1922:5406,1923:5407,1924:5408,1925:5409,1926:5410,1951:5411,1952:5412,1953:5413,1954:5414,1955:5415,1956:5416,1957:5417,1958:5418,1959:5419,1960:5420,1961:5421,1962:5422,1963:5423,1964:5424,1965:5425,1966:5426,1967:5427,1968:5428,1969:5429,1970:5430,1971:5431,1972:5432,1973:5433,1974:5434,1975:5435,1976:5436,1977:5437,1978:5438,1979:5439,1980:5440,1981:5441,1982:5442,1983:5443,1984:5444,1985:5445,1986:5446,1987:5447,1988:5448,1989:5449,1990:5450,1991:5451,1992:5452,1993:5453,1994:5454,1995:5455,1996:5456,1997:5457,1998:5458,1999:5459,2000:5460,2001:5461,2002:5462,2003:5463,2004:5464,2005:5465,2006:5466,2007:5467,2008:5468,2009:5469,2010:5470,2011:5471,2012:5472,2013:5473,2014:5474,2015:5475,2016:5476,2017:5477,2018:5478,2019:5479,2020:5480,2021:5481,2022:5482,2023:5483,2024:5484,2025:5485,2026:5486,2027:5487,2028:5488,2029:5489,2030:5490,2031:5491,2032:5492,2033:5493,2034:5494,2035:5495,2036:5496,2037:5497,2038:5498,2039:5499,2040:5500,2041:5501,2042:5502,2043:5503,2044:5504,2045:5505,2046:5506,2047:5507,2048:5508,2049:5509,2050:5510,2051:5511,2052:5512,2053:5513,2054:5514,2055:5515,2056:5516,2057:5517,2058:5518,2059:5519,2060:5520,2061:5521,2062:5522,2063:5523,2064:5524,2065:5525,2066:5526,2067:5527,2068:5528,2069:5529,2070:5530,2071:5531,2072:5532,2073:5533,2074:5534,2075:5535,2076:5536,2077:5537,2078:5538,2079:5539,2080:5540,2081:5541,2082:5542,2083:5543,2084:5544,2085:5545,2086:5546,2087:5547,2088:5548,2089:5549,2090:5550,2091:5551,2092:5552,2093:5553,2094:5554,2095:5555,2096:5556,2097:5557,2098:5558,2099:5559,2100:5560,2101:5561,2102:5562,2103:5563,2104:5564,2105:5565,2106:5566,2107:5567,2108:5568,2109:5569,2110:5570,2111:5571,2112:5572,2113:5573,2114:5574,2115:5575,2120:5580,2121:5581,2122:5582,2123:5583,2124:5584,2125:5585,2126:5586,2127:5587,2128:5588,2129:5589,2130:5590,2131:5591,2132:5592,2133:5593,2134:5594,2135:5595,2148:5608,2149:5609,2150:5610,2151:5611,2152:5612,2153:5613,2154:5614,2155:5615,2160:5620,2161:5621,2162:5622,2163:5623,2164:5624,2165:5625,2166:5626,2167:5627,2168:5628,2169:5629,2170:5630,2171:5631,2172:5632,2173:5633,2174:5634,2175:5635,2176:5636,2177:5637,2178:5638,2179:5639,2180:5640,2181:5641,2182:5642,2183:5643,2184:5644,2185:5645,2186:5646,2187:5647,2188:5648,2189:5649,2190:5650,2191:5651,2192:5652,2193:5653,2194:5654,2195:5655,2196:5656,2197:5657,2198:5658,2199:5659,2200:5660,2201:5661,2202:5662,2203:5663,2204:5664,2205:5665,2206:5666,2207:5667,2208:5668,2209:5669,2210:5670,2211:5671,2212:5672,2213:5673,2214:5674,2215:5675,2216:5676,2217:5677,2218:5678,2219:5679,2220:5680,2221:5681,2222:5682,2223:5683,2224:5684,2225:5685,2226:5686,2227:5687,2228:5688,2229:5689,2230:5690,2231:5691,2232:5692,2233:5693,2234:5694,2235:5695,2236:5696,2237:5697,2238:5698,2239:5699,2240:5700,2241:5701,2242:5702,2243:5703,2244:5704,2245:5705,2246:5706,2247:5707,2248:5708,2249:5709,2250:5710,2251:5711,2252:5712,2253:5713,2254:5714,2255:5715,2256:5716,2257:5717,2258:5718,2259:5719,2260:5720,2261:5721,2262:5722,2263:5723,2264:5724,2265:5725,2266:5726,2267:5727,2268:5728,2269:5729,2270:5730,2271:5731,2272:5732,2273:5733,2274:5734,2275:5735,2276:5736,2277:5737,2278:5738,2279:5739,2280:5740,2281:5741,2282:5742,2283:5743,2284:5744,2285:5745,2286:5746,2287:5747,2288:5748,2289:5749,2290:5750,2291:5751,2292:5752,2293:5753,2294:5754,2295:5755,2296:5756,2303:5761,2304:5762,2305:5763,2306:5764,2307:5765,2308:5766,2309:5767,2310:5768,2311:5769,2312:5770,2313:5771,2314:5772,2315:5773,2316:5774,2317:5775,2318:5776,2319:5777,2320:5778,2321:5779,2322:5780,2323:5781,2324:5782,2325:5783,2326:5784,2327:5785,2328:5786,2329:5787,2330:5788,2331:5789,2332:5790,2333:5791,2334:5792,2335:5793,2336:5794,2337:5795,2338:5796,2339:5797,2340:5798,2341:5799,2342:5800,2343:5801,2344:5802,2345:5803,2346:5804,2347:5805,2348:5806,2349:5807,2350:5808,2351:5809,2352:5810,2353:5811,2354:5812,2355:5813,2356:5814,2357:5815,2358:5816,2359:5817,2360:5818,2361:5819,2362:5820,2363:5821,2364:5822,2365:5823,2366:5824,2367:5825,2368:5826,2369:5827,2370:5828,2371:5829,2372:5830,2373:5831,2374:5832,2375:5833,2376:5834,2377:5835,2378:5836,2379:5837,2380:5838,2381:5839,2382:5840,2383:5841,2384:5842,2385:5843,2386:5844,2387:5845,2388:5846,2389:5847,2390:5848,2391:5849,2392:5850,2393:5851,2394:5852,2395:5853,2396:5854,2397:5855,2398:5856,2399:5857,6268:6511,6269:6512,6270:6513,6271:6514,6272:6515,6273:6516,6274:6517,6275:6518,6276:6519,6277:6520,6278:6521,6279:6522,6280:6523,6281:6524,6282:6525,6283:6526,6284:6527,6285:6528,6286:6529,6287:6530,6288:6531,6289:6532,6290:6533,6291:6534,6292:6535,6293:6536,6294:6537,6295:6538,6296:6539,6297:6540,6298:6541,6299:6542,6300:6543,6301:6544,6302:6545,6303:6546,6304:6547,6305:6548,6306:6549,6307:6550,6308:6551,6309:6552,6310:6553,6311:6554,6312:6555,6313:6556,6314:6557,6315:6558,6316:6559,6317:6560,6318:6561,6319:6562,6320:6563,6321:6564,6322:6565,6323:6566,6325:6568,6326:6569,6327:6570,6329:6572,6330:6573,6331:6574,6332:6575,6333:6576,6334:6577,6335:6578,6336:6579,6337:6580,6338:6581,6339:6582,6340:6583,6341:6584,6342:6585,6343:6586,6344:6587,6345:6588,6346:6589,6347:6590,6348:6591,6592:6595,6596:6599,6704:6846,6705:6847,6706:6848,6707:6849" } ], "layers": [ @@ -433,9 +433,9 @@ 85,86,0,0,0,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,0,0,0,0,0,0,0,0,418,419,420,421,422,423,424,425,426,427,428,429,0,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,0,0,0,0,0,0,0,0,464,465,466,467,468,469,470,471,472,473,474,475,0,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,85,86, 0,0,0,0,0,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,0,0,0,0,0,0,0,0,510,511,512,513,514,515,516,517,518,519,520,521,0,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,85,86,0,0,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,0,0,556,557,558,559,0,0,560,561,562,563,564,565,566,567,568,569,570,571,0,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,83,84,0,0, - 0,0,0,0,0,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,0,0,606,607,608,609,0,0,610,611,612,613,614,615,616,617,618,619,620,621,0,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,640,641,642,643,644,645,646,647,83,84,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,0,0,664,665,666,667,0,0,668,669,670,671,672,673,674,675,676,677,678,679,0,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,83,84, + 0,85,86,0,0,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,0,0,200,201,202,203,0,0,560,561,562,563,564,565,566,567,568,569,570,571,0,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,83,84,0,0, + 0,0,0,0,0,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,0,0,237,238,239,240,0,0,610,611,612,613,614,615,616,617,618,619,620,621,0,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,640,641,642,643,644,645,646,647,83,84,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,0,0,274,275,276,277,0,0,668,669,670,671,672,673,674,675,676,677,678,679,0,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,83,84, 0,0,0,0,0,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,0,0,0,0,0,0,0,0,714,715,716,717,718,719,720,721,722,723,724,725,0,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,85,86,0,0,0, 81,82,0,0,0,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,0,0,0,0,0,0,0,0,760,761,762,763,764,765,766,767,768,769,770,771,0,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,640,641,642,643,0,0,0,0,0, 0,0,0,0,0,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,0,0,0,0,0,0,0,0,806,807,808,809,810,811,775,812,813,814,815,816,0,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,0,0,0,0,0,0,0,0,0,835,836,837,838,839,840,841,842,843,844,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, @@ -446,10 +446,10 @@ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,0,0,0,0, 0,0,0,0,0,0,960,961,962,963,964,965,966,967,968,0,969,970,971,972,973,974,975,976,977,0,978,979,980,981,982,983,984,985,986,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,0,0,0,0, 83,84,0,0,0,0,1010,1011,1012,1013,1014,1015,1016,1017,1018,0,1019,1020,1021,1022,1023,1024,1025,1026,1027,0,1028,1029,1030,1031,1032,1033,1034,1035,1036,0,0,0,0,1037,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1038,1039,1040,1041,1037,0,0,0,0,0,0,0,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,85,86,85,86, - 0,0,0,0,0,0,1065,1066,1067,1068,1069,1070,1071,1072,1073,0,1074,1075,1076,1077,1078,1079,1080,1081,1082,0,1083,1084,1085,1086,1087,1088,1089,1090,1091,0,0,0,0,0,0,0,0,1092,1093,1094,0,0,1095,1096,1097,1098,0,0,1092,1093,1094,1099,1100,1101,1102,0,0,0,0,0,0,0,0,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,0,0,0,0, - 0,0,0,0,0,0,1126,1127,1128,1129,1130,1131,1132,1133,1134,0,1135,1136,1137,1138,1139,1140,1141,1142,1143,0,1144,1145,1146,1147,1148,1149,1150,1151,1152,0,0,0,0,0,0,0,0,1153,1154,1155,0,0,1156,1157,1158,1159,0,0,1153,1154,1155,1160,1161,1162,1163,0,0,0,0,0,0,0,0,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,0,0,0,0, - 0,0,0,0,0,0,1187,1188,1189,1190,1191,1192,1193,1194,1195,0,1196,1197,1198,1199,1200,1201,1202,1203,1204,0,1205,1206,1207,1208,1209,1210,1211,1212,1213,0,0,0,0,0,0,0,0,0,0,0,0,0,1214,1215,1216,1217,0,0,0,0,0,0,0,0,311,0,0,0,0,0,0,0,0,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,0,0,0,0, - 83,84,0,0,0,0,1241,1242,1243,1244,1245,1246,1247,1248,1249,0,1250,1251,1252,1253,1254,1255,1256,1257,1258,0,1259,1260,1261,1262,1263,1264,1265,1266,1267,0,0,0,0,0,0,0,0,0,0,0,0,0,1268,1269,1270,1271,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1272,1273,1274,1275,1276,1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,83,84,85,86, + 0,0,0,0,0,0,1065,1066,1067,1068,1069,1070,1071,1072,1073,0,1074,1075,1076,1077,1078,1079,1080,1081,1082,0,1083,1084,1085,1086,1087,1088,1089,1090,1091,0,0,0,0,0,0,0,0,1092,1093,1094,0,0,0,0,0,0,0,0,1092,1093,1094,1099,1100,1101,1102,0,0,0,0,0,0,0,0,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,0,0,0,0, + 0,0,0,0,0,0,1126,1127,1128,1129,1130,1131,1132,1133,1134,0,1135,1136,1137,1138,1139,1140,1141,1142,1143,0,1144,1145,1146,1147,1148,1149,1150,1151,1152,0,0,0,0,0,0,0,0,1153,1154,1155,0,0,0,0,0,0,0,0,1153,1154,1155,1160,1161,1162,1163,0,0,0,0,0,0,0,0,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,0,0,0,0, + 0,0,0,0,0,0,1187,1188,1189,1190,1191,1192,1193,1194,1195,0,1196,1197,1198,1199,1200,1201,1202,1203,1204,0,1205,1206,1207,1208,1209,1210,1211,1212,1213,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,311,0,0,0,0,0,0,0,0,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,0,0,0,0, + 83,84,0,0,0,0,1241,1242,1243,1244,1245,1246,1247,1248,1249,0,1250,1251,1252,1253,1254,1255,1256,1257,1258,0,1259,1260,1261,1262,1263,1264,1265,1266,1267,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1272,1273,1274,1275,1276,1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,83,84,85,86, 0,0,0,0,0,0,1295,1296,1297,1298,1299,1300,1301,1302,1303,0,1304,1305,1306,1307,1308,1309,1310,1311,1312,0,1313,1314,1315,1316,1317,1318,1319,1320,1321,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1322,1323,1324,1325,1326,1327,1328,1329,1330,1331,1332,1333,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,0,0,0,0, 0,0,0,0,0,0,1345,1346,1347,1348,1349,1350,1351,1352,1353,0,1354,1355,1356,1357,1358,1359,1360,1361,1362,0,1363,1364,1365,1366,1367,1368,1369,1370,1371,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,1372,1373,1374,1375,1376,1377,1378,1379,1380,0,1381,1382,1383,1384,1385,1386,1387,1388,1389,0,1390,1391,1392,1393,1394,1395,1396,1397,1398,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,85,86,0,0, @@ -477,14 +477,14 @@ 0,0,0,0,0,0,0,0,0,0,311,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1904,1905,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,311,1906,1907,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1908,1909,1910,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1911,1912,1913,1914,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1908,1909,1910,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1918,1919,1920,1921,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1908,1909,1910,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1922,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1923,1924,1925,1926,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,1927,1928,1929,1930,1931,1932,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,1933,1934,1935,1936,1937,1938,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,1939,1940,1941,1942,1943,1944,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1922,1915,1916,1917,1945,1946,1947,1948,1949,1950,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1922,1915,1916,1917,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1923,1924,1925,1926,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, @@ -517,11 +517,11 @@ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2083,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1957,1958, 0,0,0,0,0,2084,2085,2086,2087,2088,2089,2090,2091,2092,2093,2094,2095,2096,2097,2098,2099,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1957,1958,0,1955,1956, - 1957,1958,0,0,0,2100,2101,2102,2103,2104,2105,2106,2107,2108,2109,2110,2111,2112,2113,2114,2115,0,0,2116,2117,2118,2119,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1955,1956,0,0,0, - 1955,1956,0,0,0,2120,2121,2122,2123,2124,2125,2126,2127,2128,2129,2130,2131,2132,2133,2134,2135,0,0,2136,2137,2138,2139,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1957,1958, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2140,2141,2142,2143,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1959,1960,1955,1956, - 0,1957,1958,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2144,2145,2146,2147,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2148,2149,2150,2151,2152,2153,2154,2155,1959,1960,0,0,0,0,0,1963,1964,0,0, - 0,1955,1956,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2156,2157,2158,2159,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2160,2161,2162,2163,2164,2165,2166,2167,1963,1964,0,0,0,0,0,1953,1954,1959,1960, + 1957,1958,0,0,0,2100,2101,2102,2103,2104,2105,2106,2107,2108,2109,2110,2111,2112,2113,2114,2115,0,0,2015,2016,2017,2018,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1955,1956,0,0,0, + 1955,1956,0,0,0,2120,2121,2122,2123,2124,2125,2126,2127,2128,2129,2130,2131,2132,2133,2134,2135,0,0,2049,2050,2051,2052,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1957,1958, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2065,2066,2067,2068,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1959,1960,1955,1956, + 0,1957,1958,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2069,2070,2071,2072,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2148,2149,2150,2151,2152,2153,2154,2155,1959,1960,0,0,0,0,0,1963,1964,0,0, + 0,1955,1956,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2076,2077,2078,2079,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2160,2161,2162,2163,2164,2165,2166,2167,1963,1964,0,0,0,0,0,1953,1954,1959,1960, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2168,2169,2170,2171,2172,2173,2174,2175,1953,1954,0,0,0,0,0,0,0,1963,1964, 1961,1962,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2148,2149,2150,2151,1957,1958,0,1953,1954, 1965,1966,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2176,2177,2178,2179,2180,2181,2182,2183,2184,2185,0,0,0,0,0,0,0,0,2160,2161,2162,2163,1955,1956,0,0,0, @@ -534,7 +534,7 @@ 1959,1960,0,0,0,0,2219,2220,2221,2222,2223,2224,2225,2226,2227,0,2228,2229,2230,2231,2232,2233,2234,2235,2236,0,2237,2238,2239,2240,2241,2242,2243,2244,2245,0,0,0,0,2246,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2246,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2247,2248,2249,2250,2251,2252,2253,2254,2255,2256,2257,2258,2259,0,0,0,0, 1963,1964,0,0,0,0,2260,2261,2262,2263,2264,2265,2266,2267,2268,0,2269,2270,2271,2272,2273,2274,2275,2276,2277,0,2278,2279,2280,2281,2282,2283,2284,2285,2286,0,0,0,0,2287,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2288,2289,2290,2291,2287,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1957,1958,1957,1958, 1953,1954,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2292,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2293,2294,2295,2296,2292,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1955,1956,1955,1956, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2297,2298,2299,2300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 1959,1960,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1959,1960,0,0, 1963,1964,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2083,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1963,1964,1957,1958, 1953,1954,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1953,1954,1955,1956, @@ -744,14 +744,14 @@ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, @@ -1010,6 +1010,34 @@ { "id": 6, "name": "", + "type": "sprite", + "x": 368, + "y": 240, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "windmill-blades" + }, + { + "name": "dy", + "type": "string", + "value": "21" + }, + { + "name": "layer", + "type": "string", + "value": "above" + } + ] + }, + { + "id": 7, + "name": "", "type": "door", "x": 560, "y": 416, @@ -1031,7 +1059,7 @@ ] }, { - "id": 7, + "id": 8, "name": "", "type": "light", "x": 560, @@ -1074,7 +1102,7 @@ ] }, { - "id": 8, + "id": 9, "name": "", "type": "door", "x": 720, @@ -1097,7 +1125,7 @@ ] }, { - "id": 9, + "id": 10, "name": "", "type": "light", "x": 720, @@ -1140,7 +1168,7 @@ ] }, { - "id": 10, + "id": 11, "name": "trail", "type": "gate", "x": 960, @@ -1163,7 +1191,7 @@ ] }, { - "id": 11, + "id": 12, "name": "", "type": "door", "x": 976, @@ -1191,7 +1219,7 @@ ] }, { - "id": 12, + "id": 13, "name": "", "type": "arcade", "x": 1120, @@ -1209,7 +1237,7 @@ ] }, { - "id": 13, + "id": 14, "name": "", "type": "door", "x": 1136, @@ -1232,7 +1260,7 @@ ] }, { - "id": 14, + "id": 15, "name": "", "type": "light", "x": 1136, @@ -1275,7 +1303,7 @@ ] }, { - "id": 15, + "id": 16, "name": "", "type": "door", "x": 1392, @@ -1298,7 +1326,7 @@ ] }, { - "id": 16, + "id": 17, "name": "", "type": "light", "x": 1392, @@ -1341,7 +1369,7 @@ ] }, { - "id": 17, + "id": 18, "name": "", "type": "light", "x": 128, @@ -1384,7 +1412,7 @@ ] }, { - "id": 18, + "id": 19, "name": "", "type": "sign", "x": 128, @@ -1402,7 +1430,7 @@ ] }, { - "id": 19, + "id": 20, "name": "", "type": "door", "x": 288, @@ -1425,7 +1453,7 @@ ] }, { - "id": 20, + "id": 21, "name": "", "type": "light", "x": 288, @@ -1468,7 +1496,7 @@ ] }, { - "id": 21, + "id": 22, "name": "", "type": "light", "x": 448, @@ -1511,7 +1539,7 @@ ] }, { - "id": 22, + "id": 23, "name": "", "type": "sign", "x": 448, @@ -1529,7 +1557,7 @@ ] }, { - "id": 23, + "id": 24, "name": "", "type": "door", "x": 944, @@ -1552,7 +1580,7 @@ ] }, { - "id": 24, + "id": 25, "name": "", "type": "light", "x": 944, @@ -1595,7 +1623,7 @@ ] }, { - "id": 25, + "id": 26, "name": "", "type": "door", "x": 656, @@ -1618,7 +1646,7 @@ ] }, { - "id": 26, + "id": 27, "name": "", "type": "light", "x": 656, @@ -1661,7 +1689,7 @@ ] }, { - "id": 27, + "id": 28, "name": "", "type": "door", "x": 192, @@ -1684,7 +1712,7 @@ ] }, { - "id": 28, + "id": 29, "name": "", "type": "light", "x": 192, @@ -1727,7 +1755,7 @@ ] }, { - "id": 29, + "id": 30, "name": "", "type": "door", "x": 880, @@ -1750,7 +1778,7 @@ ] }, { - "id": 30, + "id": 31, "name": "", "type": "light", "x": 880, @@ -1793,7 +1821,7 @@ ] }, { - "id": 31, + "id": 32, "name": "", "type": "light", "x": 1056, @@ -1836,7 +1864,7 @@ ] }, { - "id": 32, + "id": 33, "name": "", "type": "sign", "x": 1056, @@ -1854,7 +1882,7 @@ ] }, { - "id": 33, + "id": 34, "name": "", "type": "light", "x": 1248, @@ -1897,7 +1925,7 @@ ] }, { - "id": 34, + "id": 35, "name": "", "type": "sign", "x": 1248, @@ -1915,7 +1943,30 @@ ] }, { - "id": 35, + "id": 36, + "name": "", + "type": "sprite", + "x": 832, + "y": 1088, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "ferry" + }, + { + "name": "layer", + "type": "string", + "value": "below" + } + ] + }, + { + "id": 37, "name": "ferry", "type": "area", "x": 832, @@ -1926,7 +1977,48 @@ "visible": true }, { - "id": 36, + "id": 38, + "name": "", + "type": "sprite", + "x": 288, + "y": 1024, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "rowboat" + }, + { + "name": "layer", + "type": "string", + "value": "below" + } + ] + }, + { + "id": 39, + "name": "", + "type": "sprite", + "x": 768, + "y": 512, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "fountain" + } + ] + }, + { + "id": 40, "name": "", "type": "light", "x": 624, @@ -1969,7 +2061,7 @@ ] }, { - "id": 37, + "id": 41, "name": "", "type": "light", "x": 976, @@ -2012,7 +2104,7 @@ ] }, { - "id": 38, + "id": 42, "name": "", "type": "light", "x": 624, @@ -2055,7 +2147,7 @@ ] }, { - "id": 39, + "id": 43, "name": "", "type": "light", "x": 976, @@ -2098,7 +2190,7 @@ ] }, { - "id": 40, + "id": 44, "name": "", "type": "light", "x": 192, @@ -2141,7 +2233,7 @@ ] }, { - "id": 41, + "id": 45, "name": "", "type": "light", "x": 384, @@ -2184,7 +2276,7 @@ ] }, { - "id": 42, + "id": 46, "name": "", "type": "light", "x": 512, @@ -2227,7 +2319,7 @@ ] }, { - "id": 43, + "id": 47, "name": "", "type": "light", "x": 720, @@ -2270,7 +2362,7 @@ ] }, { - "id": 44, + "id": 48, "name": "", "type": "light", "x": 912, @@ -2313,7 +2405,7 @@ ] }, { - "id": 45, + "id": 49, "name": "", "type": "light", "x": 1024, @@ -2356,7 +2448,7 @@ ] }, { - "id": 46, + "id": 50, "name": "", "type": "light", "x": 1248, @@ -2399,7 +2491,7 @@ ] }, { - "id": 47, + "id": 51, "name": "", "type": "light", "x": 1424, @@ -2442,7 +2534,7 @@ ] }, { - "id": 48, + "id": 52, "name": "datagutt-fjord", "type": "spot", "x": 864, @@ -2460,7 +2552,7 @@ ] }, { - "id": 49, + "id": 53, "name": "datagutt-square", "type": "spot", "x": 752, @@ -2478,7 +2570,7 @@ ] }, { - "id": 50, + "id": 54, "name": "ferry", "type": "spawn", "x": 800, @@ -2496,7 +2588,7 @@ ] }, { - "id": 51, + "id": 55, "name": "datagutt-pier", "type": "spot", "x": 784, @@ -2514,7 +2606,7 @@ ] }, { - "id": 52, + "id": 56, "name": "finale", "type": "spawn", "x": 784, @@ -2532,7 +2624,7 @@ ] }, { - "id": 53, + "id": 57, "name": "house_door", "type": "spawn", "x": 288, @@ -2550,7 +2642,7 @@ ] }, { - "id": 54, + "id": 58, "name": "office_door", "type": "spawn", "x": 1136, @@ -2568,7 +2660,7 @@ ] }, { - "id": 55, + "id": 59, "name": "boathouse_door", "type": "spawn", "x": 192, @@ -2586,7 +2678,7 @@ ] }, { - "id": 56, + "id": 60, "name": "library_door", "type": "spawn", "x": 560, @@ -2604,7 +2696,7 @@ ] }, { - "id": 57, + "id": 61, "name": "kiosk_door", "type": "spawn", "x": 944, @@ -2622,7 +2714,7 @@ ] }, { - "id": 58, + "id": 62, "name": "post_office_door", "type": "spawn", "x": 656, @@ -2640,7 +2732,7 @@ ] }, { - "id": 59, + "id": 63, "name": "gym_door", "type": "spawn", "x": 1392, @@ -2658,7 +2750,7 @@ ] }, { - "id": 60, + "id": 64, "name": "farmhouse_door", "type": "spawn", "x": 224, @@ -2676,7 +2768,7 @@ ] }, { - "id": 61, + "id": 65, "name": "radio_hut_door", "type": "spawn", "x": 1216, @@ -2694,7 +2786,7 @@ ] }, { - "id": 62, + "id": 66, "name": "town_hall_door", "type": "spawn", "x": 720, @@ -2712,7 +2804,7 @@ ] }, { - "id": 63, + "id": 67, "name": "youth_club_door", "type": "spawn", "x": 880, @@ -2730,7 +2822,7 @@ ] }, { - "id": 64, + "id": 68, "name": "trail", "type": "spawn", "x": 976, @@ -2748,7 +2840,7 @@ ] }, { - "id": 65, + "id": 69, "name": "", "type": "cat", "x": 1504, @@ -2759,7 +2851,7 @@ "visible": true }, { - "id": 66, + "id": 70, "name": "", "type": "sign", "x": 832, @@ -2777,7 +2869,7 @@ ] }, { - "id": 67, + "id": 71, "name": "", "type": "sign", "x": 384, @@ -2795,7 +2887,7 @@ ] }, { - "id": 68, + "id": 72, "name": "", "type": "sign", "x": 528, @@ -2813,7 +2905,7 @@ ] }, { - "id": 69, + "id": 73, "name": "", "type": "sign", "x": 944, @@ -2831,7 +2923,7 @@ ] }, { - "id": 70, + "id": 74, "name": "", "type": "sign", "x": 704, @@ -2849,7 +2941,7 @@ ] }, { - "id": 71, + "id": 75, "name": "", "type": "sign", "x": 192, @@ -2867,7 +2959,7 @@ ] }, { - "id": 72, + "id": 76, "name": "", "type": "sign", "x": 960, @@ -2885,7 +2977,7 @@ ] }, { - "id": 73, + "id": 77, "name": "", "type": "sign", "x": 640, @@ -2903,7 +2995,7 @@ ] }, { - "id": 74, + "id": 78, "name": "", "type": "sign", "x": 160, @@ -2921,7 +3013,7 @@ ] }, { - "id": 75, + "id": 79, "name": "", "type": "sign", "x": 1104, @@ -2939,7 +3031,7 @@ ] }, { - "id": 76, + "id": 80, "name": "", "type": "sign", "x": 1360, @@ -2957,7 +3049,7 @@ ] }, { - "id": 77, + "id": 81, "name": "", "type": "sign", "x": 1248, @@ -2975,7 +3067,7 @@ ] }, { - "id": 78, + "id": 82, "name": "ferryman", "type": "npc", "x": 816, @@ -3008,7 +3100,7 @@ ] }, { - "id": 79, + "id": 83, "name": "farmer", "type": "npc", "x": 256, @@ -3039,6 +3131,213 @@ "value": "farmer" } ] + }, + { + "id": 84, + "name": "", + "type": "sprite", + "x": 816, + "y": 496, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "pigeon" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] + }, + { + "id": 85, + "name": "", + "type": "sprite", + "x": 736, + "y": 592, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "pigeon" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] + }, + { + "id": 86, + "name": "", + "type": "sprite", + "x": 848, + "y": 608, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "pigeon" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] + }, + { + "id": 87, + "name": "", + "type": "sprite", + "x": 640, + "y": 992, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "seagull-left" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] + }, + { + "id": 88, + "name": "", + "type": "sprite", + "x": 960, + "y": 992, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "seagull-right" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] + }, + { + "id": 89, + "name": "", + "type": "sprite", + "x": 656, + "y": 1120, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "buoy" + } + ] + }, + { + "id": 90, + "name": "", + "type": "sprite", + "x": 992, + "y": 1168, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "buoy" + } + ] + }, + { + "id": 91, + "name": "", + "type": "sprite", + "x": 736, + "y": 912, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "butterfly" + }, + { + "name": "seasons", + "type": "string", + "value": "spring,summer" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] + }, + { + "id": 92, + "name": "", + "type": "sprite", + "x": 624, + "y": 928, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "sprite", + "type": "string", + "value": "butterfly" + }, + { + "name": "seasons", + "type": "string", + "value": "spring,summer" + }, + { + "name": "when", + "type": "string", + "value": "day" + } + ] } ] } From 1e3c759ca68d3380594407ed3636f4d90c9a72fe Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 25 Sep 2026 01:53:24 +0200 Subject: [PATCH 152/153] feat(kai): critters that get away from the player kai.json critters names which sprites are animals and how each species gets away when the player comes close: a bird flies off out of sight, a gull waddles a few walkable tiles, a butterfly drifts. a built-in plugin runs them and brings them back once the player has gone; with reduced motion they stay put. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- docs/kai/CONTENT.md | 3 +- docs/kai/DESIGN.md | 3 + docs/kai/PLUGINS.md | 3 +- packages/kai/src/boot.ts | 5 +- packages/kai/src/constants.ts | 3 + packages/kai/src/plugins/critters.test.ts | 19 ++ packages/kai/src/plugins/critters.ts | 230 ++++++++++++++++++++++ packages/kai/src/scenes/WorldScene.ts | 4 +- packages/kai/src/schema/config.ts | 29 +++ 9 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 packages/kai/src/plugins/critters.test.ts create mode 100644 packages/kai/src/plugins/critters.ts diff --git a/docs/kai/CONTENT.md b/docs/kai/CONTENT.md index bdd1a52..1e68229 100644 --- a/docs/kai/CONTENT.md +++ b/docs/kai/CONTENT.md @@ -18,7 +18,8 @@ One per app, next to its `package.json`. The schema with a comment on every fiel the build loads (`maps`, `ink`, `dialogueHost`, `harness`). - Art: `assets` (the art repository, its local checkout, the token's environment variable and the art adapter module), `ui` (the dialogue frame and emote sheet), - `sprites` (animated strips copied for plugins) and `font`. + `sprites` (animated strips, for `sprite` map objects and plugins), `critters` (which + of those strips are animals that get away from the player) and `font`. - Live data: `live.weather` (the fallback place and MET Norway's required User-Agent) and `live.github`. diff --git a/docs/kai/DESIGN.md b/docs/kai/DESIGN.md index 1dec8d8..259b269 100644 --- a/docs/kai/DESIGN.md +++ b/docs/kai/DESIGN.md @@ -164,4 +164,7 @@ Visitors must not notice the refactor. at a tile plus a pixel offset, drawn among the characters or on the ground or roof layer, limited to seasons or daylight if asked. The build checks the names; `MapCanvas.reserve` keeps the collision of the art a sprite replaces. +- 2026-09-25: animals are configuration, not a plugin a game writes: kai.json `critters` + names each species' strips and how it gets away (`fly`, `waddle`, `drift`), and a + built-in plugin runs them, like the triggers. diff --git a/docs/kai/PLUGINS.md b/docs/kai/PLUGINS.md index 82a523a..b8acee2 100644 --- a/docs/kai/PLUGINS.md +++ b/docs/kai/PLUGINS.md @@ -36,7 +36,8 @@ A rule that only reacts to the world and grants something needs no code. Put it Each trigger plays `knot` (narrated) if given, then sets `flag` and grants the achievement `grant`. `announce: false` grants it without the banner. The engine runs the -triggers as a plugin ahead of the game's own. +triggers as a plugin ahead of the game's own, and the `critters` of `kai.json` (animals +that get away from the player) the same way. ## Writing a plugin diff --git a/packages/kai/src/boot.ts b/packages/kai/src/boot.ts index e9cbeee..b549690 100644 --- a/packages/kai/src/boot.ts +++ b/packages/kai/src/boot.ts @@ -5,6 +5,7 @@ import { GhostClient, ghostsDisabled, worldSocketUrl } from "@datagutt/kai-net/c import { Music } from "./audio/Music.ts"; import { GameData, type GameContent } from "./data.ts"; import type { KaiPlugin } from "./plugins/api.ts"; +import { crittersPlugin } from "./plugins/critters.ts"; import { triggersPlugin } from "./plugins/triggers.ts"; import type { Progress } from "./progress/Progress.ts"; import { browserStorage, clearSave, loadSave } from "./save/save.ts"; @@ -150,8 +151,8 @@ export function createGame(parent: HTMLElement, options: BootOptions): GameHandl data, live: options.live, links: options.links ?? (() => null), - // The content's declarative triggers run first, then the game's own plugins. - plugins: [triggersPlugin(options.content.triggers.list), ...(options.plugins ?? [])], + // The content's declarative triggers and kai.json's critters run first, then the game's own plugins. + plugins: [triggersPlugin(options.content.triggers.list), crittersPlugin(config.critters), ...(options.plugins ?? [])], externals: options.externals ?? (() => ({})), onProgress: options.onProgress ?? (() => {}), onReady: options.onReady ?? (() => {}), diff --git a/packages/kai/src/constants.ts b/packages/kai/src/constants.ts index 7b330e4..e1df1f8 100644 --- a/packages/kai/src/constants.ts +++ b/packages/kai/src/constants.ts @@ -1,2 +1,5 @@ /** Size of one map tile in game pixels. All LimeZu art is on this grid. */ export const TILE = 16; + +/** The roof layers' depth: over every character. */ +export const ABOVE_DEPTH = 50_000; diff --git a/packages/kai/src/plugins/critters.test.ts b/packages/kai/src/plugins/critters.test.ts new file mode 100644 index 0000000..44bdfe1 --- /dev/null +++ b/packages/kai/src/plugins/critters.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { sideAway, waddleRoom } from "./critters.ts"; + +describe("critters", () => { + it("get away from the player's side", () => { + expect(sideAway(100, 60)).toBe("right"); + expect(sideAway(40, 60)).toBe("left"); + expect(sideAway(60, 60)).toBe("right"); + }); + + it("waddle only over walkable tiles, and no further than they need", () => { + const open = () => true; + expect(waddleRoom({ x: 5, y: 5 }, "right", open)).toBe(3); + const wallAt7 = (x: number) => x !== 7; + expect(waddleRoom({ x: 5, y: 5 }, "right", wallAt7)).toBe(1); + expect(waddleRoom({ x: 5, y: 5 }, "left", wallAt7)).toBe(3); + expect(waddleRoom({ x: 5, y: 5 }, "right", () => false)).toBe(0); + }); +}); diff --git a/packages/kai/src/plugins/critters.ts b/packages/kai/src/plugins/critters.ts new file mode 100644 index 0000000..64fe9c7 --- /dev/null +++ b/packages/kai/src/plugins/critters.ts @@ -0,0 +1,230 @@ +// Animals among the map's sprites (kai.json `critters`): they get away when the player +// comes close and come back once the player has gone. A crow flaps up and flies off out +// of sight, a gull waddles a few tiles along the ground, a butterfly drifts off in a +// wavy line. With reduced motion they stay where they are. +import type Phaser from "phaser"; +import { ABOVE_DEPTH, TILE } from "../constants.ts"; +import type { KaiConfig } from "../schema/config.ts"; +import type { SpriteObject } from "../world/objects.ts"; +import { perWorld, type KaiPlugin, type World } from "./api.ts"; + +export type Critter = KaiConfig["critters"][string]; +export type Side = "left" | "right"; + +/** How close the player may come, in tiles, before a critter goes. */ +export const SCARE_TILES = 2.5; +/** How far from its place the player must be, in tiles, before it comes back. */ +export const CALM_TILES = 7; +/** It keeps away at least this long, in ms. */ +export const AWAY_MS = 10_000; +/** A flight: how far across and up, in tiles, and how long it takes. */ +const FLIGHT = { across: 10, up: 6, ms: 2000 }; +const HOP_MS = 200; +const WADDLE_TILES = 3; +const DRIFT = { tiles: 3, ms: 1400 }; + +type Point = { x: number; y: number }; + +type State = { + readonly def: SpriteObject; + readonly sprite: Phaser.GameObjects.Sprite; + readonly critter: Critter; + /** Its place on the map: the sprite's top-left at rest, and where its feet are then. */ + readonly home: Point; + readonly rest: Point; + facing: Side; + mode: "home" | "moving" | "aside" | "away"; + /** Scene time the critter left its place. */ + leftAt: number; + /** The side it fled to, which a bird that flew off comes back from. */ + fledTo: Side; +}; + +/** Away from the player: a critter right of them (or level with them) goes right. */ +export const sideAway = (critterX: number, playerX: number): Side => (critterX >= playerX ? "right" : "left"); +const opposite = (side: Side): Side => (side === "left" ? "right" : "left"); +const sign = (side: Side) => (side === "right" ? 1 : -1); + +/** Where a critter stands: the middle of its frame's bottom edge. */ +const feet = (sprite: Phaser.GameObjects.Sprite): Point => ({ x: sprite.x + sprite.width / 2, y: sprite.y + sprite.height }); +const tilesBetween = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y) / TILE; + +/** How many tiles a waddling critter can go towards `side` from `tile`, up to `max`. */ +export function waddleRoom(tile: Point, side: Side, walkable: (x: number, y: number) => boolean, max = WADDLE_TILES): number { + let steps = 0; + while (steps < max && walkable(tile.x + sign(side) * (steps + 1), tile.y)) steps++; + return steps; +} + +export function crittersPlugin(critters: KaiConfig["critters"]): KaiPlugin { + // Each strip name to its species, so a map's sprite finds how it behaves. + const bySprite = new Map(); + for (const critter of Object.values(critters)) for (const name of [critter.idle.left, critter.idle.right]) bySprite.set(name, critter); + + const states = perWorld((world): State[] => + world.sprites.flatMap(({ def, sprite }) => { + const critter = bySprite.get(def.sprite); + if (!critter) return []; + const facing: Side = def.sprite === critter.idle.right && def.sprite !== critter.idle.left ? "right" : "left"; + return [{ def, sprite, critter, home: { x: sprite.x, y: sprite.y }, rest: feet(sprite), facing, mode: "home" as const, leftAt: 0, fledTo: facing }]; + }), + ); + + /** Play one of a critter's strips, its feet staying where they are. */ + function show(state: State, strip: string, once = false) { + const { sprite } = state; + const at = feet(sprite); + sprite.play({ key: `sprite:${strip}`, ...(once ? { repeat: 0 } : {}) }); + sprite.setPosition(Math.round(at.x - sprite.width / 2), Math.round(at.y - sprite.height)); + } + + function sortByFeet(state: State) { + if (!state.def.layer) state.sprite.setDepth(state.sprite.y + state.sprite.height); + } + + function settle(state: State, mode: "home" | "aside", scene: Phaser.Scene) { + state.mode = mode; + show(state, state.critter.idle[state.facing]); + if (mode === "home") state.sprite.setPosition(state.home.x, state.home.y); + sortByFeet(state); + if (mode === "aside") state.leftAt = scene.time.now; + } + + function flee(world: World, state: State, side: Side) { + const { scene } = world; + state.mode = "moving"; + state.facing = side; + state.fledTo = side; + state.leftAt = scene.time.now; + const { critter, sprite } = state; + if (critter.flee === "fly") { + const flyOff = () => { + show(state, critter.move?.[side] ?? critter.idle[side]); + sprite.setDepth(ABOVE_DEPTH + 1); + scene.tweens.add({ + targets: sprite, + x: sprite.x + sign(side) * FLIGHT.across * TILE, + y: sprite.y - FLIGHT.up * TILE, + alpha: 0, + duration: FLIGHT.ms, + ease: "Sine.easeIn", + onComplete: () => { + state.mode = "away"; + state.leftAt = scene.time.now; + }, + }); + }; + const takeOff = critter.takeOff?.[side]; + if (!takeOff) return flyOff(); + show(state, takeOff, true); + sprite.once("animationcomplete", flyOff); + return; + } + if (critter.flee === "waddle") { + const at = feet(sprite); + const tile = { x: Math.floor(at.x / TILE), y: Math.floor((at.y - 1) / TILE) }; + const walkable = (x: number, y: number) => world.grid.isWalkable(x, y); + // Cornered on one side, it goes the other way; cornered on both, it stays. + const [go, steps] = [side, opposite(side)].map((s) => [s, waddleRoom(tile, s, walkable)] as const).find(([, n]) => n > 0) ?? [side, 0]; + state.facing = state.fledTo = go; + show(state, critter.move?.[go] ?? critter.idle[go]); + if (!steps) return settle(state, "aside", scene); + const from = { x: sprite.x, y: sprite.y }; + const hop = { t: 0 }; + scene.tweens.add({ + targets: hop, + t: 1, + duration: steps * HOP_MS, + onUpdate: () => { + sprite.x = Math.round(from.x + sign(go) * steps * TILE * hop.t); + sprite.y = Math.round(from.y - Math.abs(Math.sin(hop.t * steps * Math.PI)) * 2); + sortByFeet(state); + }, + onComplete: () => settle(state, "aside", scene), + }); + return; + } + // Drifting: off towards `side`, bobbing up and down on the way. + const from = { x: sprite.x, y: sprite.y }; + const rise = (Math.random() - 0.5) * 2 * TILE; + const drift = { t: 0 }; + show(state, critter.move?.[side] ?? critter.idle[side]); + scene.tweens.add({ + targets: drift, + t: 1, + duration: DRIFT.ms, + onUpdate: () => { + sprite.x = Math.round(from.x + sign(side) * DRIFT.tiles * TILE * drift.t); + sprite.y = Math.round(from.y + rise * drift.t + Math.sin(drift.t * Math.PI * 4) * 4); + sortByFeet(state); + }, + onComplete: () => settle(state, "aside", scene), + }); + } + + function comeBack(world: World, state: State) { + const { scene } = world; + const { critter, sprite, home, rest } = state; + state.mode = "moving"; + // Back the way it went, facing home. + const toward = opposite(state.fledTo); + state.facing = toward; + if (critter.flee === "fly") { + show(state, critter.move?.[toward] ?? critter.idle[toward]); + // The flying frame may be taller than the sitting one: it lands with its feet on the spot. + const landing = { x: Math.round(rest.x - sprite.width / 2), y: Math.round(rest.y - sprite.height) }; + sprite.setPosition(landing.x + sign(state.fledTo) * FLIGHT.across * TILE, landing.y - FLIGHT.up * TILE).setAlpha(0); + sprite.setDepth(ABOVE_DEPTH + 1); + scene.tweens.add({ + targets: sprite, + x: landing.x, + y: landing.y, + alpha: 1, + duration: FLIGHT.ms, + ease: "Sine.easeOut", + onComplete: () => settle(state, "home", scene), + }); + return; + } + show(state, critter.move?.[toward] ?? critter.idle[toward]); + const from = { x: sprite.x, y: sprite.y }; + const back = { t: 0 }; + const tiles = Math.max(1, Math.round(Math.abs(home.x - from.x) / TILE)); + scene.tweens.add({ + targets: back, + t: 1, + duration: critter.flee === "waddle" ? tiles * HOP_MS * 1.5 : DRIFT.ms, + onUpdate: () => { + sprite.x = Math.round(from.x + (home.x - from.x) * back.t); + const bob = critter.flee === "waddle" ? -Math.abs(Math.sin(back.t * tiles * Math.PI)) * 2 : Math.sin(back.t * Math.PI * 4) * 4; + sprite.y = Math.round(from.y + (home.y - from.y) * back.t + bob); + sortByFeet(state); + }, + onComplete: () => settle(state, "home", scene), + }); + } + + return { + name: "critters", + update(world) { + if (world.progress.reducedMotion) return; + const player = world.player.mover.tile; + const playerAt = { x: player.x * TILE + TILE / 2, y: (player.y + 1) * TILE }; + const now = world.scene.time.now; + for (const state of states(world)) { + // Out of season or out of the light: nothing to scare. + if (!state.sprite.visible) continue; + if (state.mode === "home" || state.mode === "aside") { + if (tilesBetween(feet(state.sprite), playerAt) < SCARE_TILES) { + flee(world, state, sideAway(feet(state.sprite).x, playerAt.x)); + continue; + } + } + if ((state.mode === "aside" || state.mode === "away") && now - state.leftAt >= AWAY_MS && tilesBetween(state.rest, playerAt) >= CALM_TILES) { + comeBack(world, state); + } + } + }, + debug: (world) => ({ critters: states(world).map(({ def, mode }) => `${def.sprite}@${def.x},${def.y}:${mode}`) }), + }; +} diff --git a/packages/kai/src/scenes/WorldScene.ts b/packages/kai/src/scenes/WorldScene.ts index 166bf37..afe9eeb 100644 --- a/packages/kai/src/scenes/WorldScene.ts +++ b/packages/kai/src/scenes/WorldScene.ts @@ -1,6 +1,6 @@ import Phaser from "phaser"; import { SERVICES_KEY, type GameServices, type WorldTarget } from "../boot.ts"; -import { TILE } from "../constants.ts"; +import { ABOVE_DEPTH, TILE } from "../constants.ts"; import { Actor } from "../entities/Actor.ts"; import { GhostLayer } from "../entities/Ghosts.ts"; import { EmoteWheel } from "../ui/EmoteWheel.ts"; @@ -60,8 +60,6 @@ type Door = ObjectOf<"door">; type Sign = ObjectOf<"sign">; const PLAYER_ID = "player"; -/** The roof layers' depth: over every character. */ -const ABOVE_DEPTH = 50_000; const tileKey = (p: Point) => `${p.x},${p.y}`; /** Registry key of the game-wide Ambience. */ const AMBIENCE_KEY = "ambience"; diff --git a/packages/kai/src/schema/config.ts b/packages/kai/src/schema/config.ts index 3aa1fcf..a966b7c 100644 --- a/packages/kai/src/schema/config.ts +++ b/packages/kai/src/schema/config.ts @@ -7,6 +7,9 @@ const id = z.string().regex(/^[a-z0-9-]+$/, "use lowercase letters, digits and d /** A path inside the licensed art checkout, such as "limezu/ui/Modern_UI_Style_1.png". */ const artPath = z.string().min(1); +/** Sprite names for facing left and right (the same name twice when it doesn't turn). */ +const facing = z.object({ left: id, right: id }); + export const kaiConfigSchema = z.object({ $schema: z.string().optional(), /** The game's id. Its art overrides live under `games//` in the art repository. */ @@ -71,6 +74,23 @@ export const kaiConfigSchema = z.object({ }), ) .default({}), + /** + * Sprites that are animals, by species. They get away when the player comes close + * (`fly` off out of sight, `waddle` a few tiles along the ground, `drift` in a wavy + * line) and come back once the player has gone. Strips are names in `sprites`, facing + * left and right: sitting (`idle`), on the move, and taking off (played once first). + */ + critters: z + .record( + id, + z.object({ + flee: z.enum(["fly", "waddle", "drift"]), + idle: facing, + move: facing.optional(), + takeOff: facing.optional(), + }), + ) + .default({}), /** The in-game bitmap font, drawn from a web font in an npm package. */ font: z.object({ /** A module of the package, resolved from the app ("geist/font/pixel"). */ @@ -93,6 +113,15 @@ export const kaiConfigSchema = z.object({ github: z.object({ user: z.string().min(1) }).optional(), }) .default({}), +}).superRefine((config, ctx) => { + for (const [species, critter] of Object.entries(config.critters)) { + for (const part of ["idle", "move", "takeOff"] as const) { + for (const side of ["left", "right"] as const) { + const name = critter[part]?.[side]; + if (name && !(name in config.sprites)) ctx.addIssue({ code: "custom", path: ["critters", species, part, side], message: `no sprite "${name}" in sprites` }); + } + } + } }); export type KaiConfig = z.output; From 3ed501e8c6e9024929696ef71b6093050bb941df Mon Sep 17 00:00:00 2001 From: datagutt Date: Fri, 25 Sep 2026 01:53:25 +0200 Subject: [PATCH 153/153] feat(datagutt): crows that fly off, gulls that waddle away the square's pigeons are hooded crows now, since limezu's crows have take-off and flying frames. gulls and butterflies get away too, and the site reads the validated config from .kai/ like the game. Claude-Session: https://claude.ai/code/session_014PUagkkCzJQPfsvmUWTdU5 --- apps/datagutt/content/live.ts | 4 ++-- apps/datagutt/docs/ART.md | 7 +++++++ apps/datagutt/docs/HANDOFF.md | 5 +++-- apps/datagutt/docs/PLAN.md | 6 ++++-- apps/datagutt/e2e/game.spec.ts | 14 ++++++++++++-- apps/datagutt/kai.json | 17 ++++++++++++++++- apps/datagutt/lib/kai.ts | 20 +++++++++++++++----- apps/datagutt/lib/weather.ts | 4 ++-- apps/datagutt/lib/world-state.ts | 4 ++-- apps/datagutt/world/gen/maps/overworld.ts | 14 ++++++++------ apps/datagutt/world/maps/town.tmj | 21 ++++++++++++++++++--- 11 files changed, 89 insertions(+), 27 deletions(-) diff --git a/apps/datagutt/content/live.ts b/apps/datagutt/content/live.ts index 1f70bb6..200c50b 100644 --- a/apps/datagutt/content/live.ts +++ b/apps/datagutt/content/live.ts @@ -2,10 +2,10 @@ // user, and Oslo (kai.json live.weather.fallback) when the visitor's place is unknown, // the town being Oslo-ish. import { emptyWorldState, weatherPlace } from "@datagutt/kai-live"; -import { kaiConfig } from "../lib/kai"; +import { siteLive } from "../lib/kai"; import { profile } from "./profile"; -const fallback = kaiConfig.live.weather.fallback; +const fallback = siteLive.weather.fallback; export const FALLBACK_PLACE = weatherPlace(fallback.city, fallback.lat, fallback.lon); export const EMPTY_WORLD_STATE = emptyWorldState({ discordId: profile.discordId, place: FALLBACK_PLACE.city }); diff --git a/apps/datagutt/docs/ART.md b/apps/datagutt/docs/ART.md index 93d342f..03bed9a 100644 --- a/apps/datagutt/docs/ART.md +++ b/apps/datagutt/docs/ART.md @@ -104,6 +104,13 @@ tiles with snow on its roof, sprite blades). `seasons` and `when` limit a sprite some seasons or to day or night: butterflies in spring and summer, birds by day. Renders (`bun run world:render`) draw each sprite's first frame, for the season rendered. +Animals are `critters` in `kai.json`: a species names its strips facing left and right +(sitting, moving, taking off) and how it gets away when the player comes within two and +a half tiles. Crows `fly` off out of sight (LimeZu's gulls and pigeons have no flying +frames, which is why the square has crows), gulls `waddle` a few walkable tiles, and +butterflies `drift`. Each comes back to its spot once the player is seven tiles off and +ten seconds have passed. They block nothing, since they never stay to be walked into. + # Characters NPC walk sheets and portraits are stacked from the LimeZu character and portrait diff --git a/apps/datagutt/docs/HANDOFF.md b/apps/datagutt/docs/HANDOFF.md index 534fd3d..d8860ca 100644 --- a/apps/datagutt/docs/HANDOFF.md +++ b/apps/datagutt/docs/HANDOFF.md @@ -5,8 +5,9 @@ Last updated: 2026-09-25 (engine split merged; C4 animation) ## Current state - **C4 animation (2026-09-25):** the windmill, boats, fountain, fireplace, treadmill and - escalator play LimeZu's strips; pigeons, gulls, buoys, butterflies (spring and - summer) and a campfire at the lookout by season and daylight. How to add more: ART.md, + escalator play LimeZu's strips; crows, gulls, buoys, butterflies (spring and summer) + and a campfire at the lookout by season and daylight. The animals flee the player + (kai.json `critters`) and come back. How to add more: ART.md, "Animations". Next: C5, doors that open as the player goes through. - **The engine split is merged into `game` (2026-09-25).** The site now lives in `apps/datagutt` on the kai engine (`packages/`), built with Bun and Turborepo. Paths diff --git a/apps/datagutt/docs/PLAN.md b/apps/datagutt/docs/PLAN.md index e492b32..d5bb8d5 100644 --- a/apps/datagutt/docs/PLAN.md +++ b/apps/datagutt/docs/PLAN.md @@ -436,8 +436,10 @@ Goal: ship v1 to production. some ambient life fills places the maps already have, by season and daylight. (Built on the engine's `sprite` map object and kai.json `sprites`: the windmill's blades, the ferry and the rowboat bobbing, the fountain running, the fireplace, - the treadmill and the escalator; pigeons, gulls, buoys, butterflies in spring and - summer, and a campfire at the lookout. See ART.md, "Animations".) + the treadmill and the escalator; crows, gulls, buoys, butterflies in spring and + summer, and a campfire at the lookout. The animals get away when the player comes + close (crows fly off, gulls waddle, butterflies drift) and come back later. See + ART.md, "Animations".) - [ ] **C5** One-shot animations: doors that open as the player goes through (post office, farmhouse, gym cabin, hytte, kiosk match LimeZu's door strips exactly), the kiosk fridge and the farm oven. Needs a trigger on the sprite, not a loop. diff --git a/apps/datagutt/e2e/game.spec.ts b/apps/datagutt/e2e/game.spec.ts index d9c41f4..f33fd73 100644 --- a/apps/datagutt/e2e/game.spec.ts +++ b/apps/datagutt/e2e/game.spec.ts @@ -22,6 +22,8 @@ type KaiState = { finale: boolean; /** Animated sprites on screen, by name. */ sprites: string[]; + /** Each critter as `@,:`. */ + critters: string[]; }; const state = (page: Page) => page.evaluate(() => (window as unknown as { __kai?: KaiState }).__kai ?? null); @@ -267,12 +269,20 @@ test.describe("world", () => { const square = { map: "town", x: 47, y: 38, facing: "down" }; await continueAt(page, square, "coding", [], "&season=summer&time=day"); const summerDay = (await state(page))?.sprites ?? []; - expect(summerDay).toEqual(expect.arrayContaining(["windmill-blades", "fountain", "ferry", "rowboat", "pigeon", "seagull-left", "buoy", "butterfly"])); + expect(summerDay).toEqual(expect.arrayContaining(["windmill-blades", "fountain", "ferry", "rowboat", "crow-left", "seagull-left", "buoy", "butterfly"])); await continueAt(page, square, "coding", [], "&season=winter&time=night"); const winterNight = (await state(page))?.sprites ?? []; expect(winterNight).toEqual(expect.arrayContaining(["windmill-blades", "fountain", "ferry", "buoy"])); - for (const daytime of ["pigeon", "seagull-left", "butterfly"]) expect(winterNight).not.toContain(daytime); + for (const daytime of ["crow-left", "seagull-left", "butterfly"]) expect(winterNight).not.toContain(daytime); + }); + + test("a crow flies off when the player comes close", async ({ page }) => { + // Two tiles east of the crow on the square's south side: close enough to scare it. + await continueAt(page, { map: "town", x: 55, y: 38, facing: "left" }, "coding", [], "&season=summer&time=day"); + await expect.poll(async () => (await state(page))?.critters, { timeout: 10_000 }).toContain("crow-left@53,38:away"); + // Crows further off stay put. + expect((await state(page))?.critters).toContain("crow-left@51,31:home"); }); test("a full passport whose finale never finished brings it back on the next map", async ({ page }) => { diff --git a/apps/datagutt/kai.json b/apps/datagutt/kai.json index 81e15a8..73de335 100644 --- a/apps/datagutt/kai.json +++ b/apps/datagutt/kai.json @@ -35,10 +35,25 @@ "seagull-left": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Beach_Seagull_Idle_Left_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 5 }, "seagull-right": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Beach_Seagull_Idle_Right_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 5 }, "buoy": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Beach_Water_Buoy_1_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 8, "frameRate": 5 }, - "pigeon": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Pigeon_16x16.png", "frameWidth": 16, "frameHeight": 16, "frames": 6, "frameRate": 6 }, + "crow-left": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Crow_idle_Left_16x16.png", "frameWidth": 32, "frameHeight": 16, "frames": 6, "frameRate": 5 }, + "crow-right": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Crow_idle_Right_16x16.png", "frameWidth": 32, "frameHeight": 16, "frames": 6, "frameRate": 5 }, + "crow-take-off-left": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Crow_Flap_Left_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 12 }, + "crow-take-off-right": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Crow_Flap_Right_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 12 }, + "crow-fly-left": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Crow_Hover_Left_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 10 }, + "crow-fly-right": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Crow_Hover_Right_16x16.png", "frameWidth": 32, "frameHeight": 32, "frames": 6, "frameRate": 10 }, "butterfly": { "file": "limezu/interiors/animated/animated_butterfly_1_idle.png", "frameWidth": 16, "frameHeight": 16, "frames": 4, "frameRate": 8 }, "campfire": { "file": "limezu/exteriors/Animated_16x16/Animated_sheets_16x16/Campfire_16x16.png", "frameWidth": 16, "frameHeight": 32, "frames": 6, "frameRate": 10 } }, + "critters": { + "crow": { + "flee": "fly", + "idle": { "left": "crow-left", "right": "crow-right" }, + "takeOff": { "left": "crow-take-off-left", "right": "crow-take-off-right" }, + "move": { "left": "crow-fly-left", "right": "crow-fly-right" } + }, + "gull": { "flee": "waddle", "idle": { "left": "seagull-left", "right": "seagull-right" } }, + "butterfly": { "flee": "drift", "idle": { "left": "butterfly", "right": "butterfly" } } + }, "font": { "module": "geist/font/pixel", "file": "fonts/geist-pixel/GeistPixel-Square.woff2", "unitsPerPixel": 76 }, "live": { "weather": { diff --git a/apps/datagutt/lib/kai.ts b/apps/datagutt/lib/kai.ts index b29fc6e..241def8 100644 --- a/apps/datagutt/lib/kai.ts +++ b/apps/datagutt/lib/kai.ts @@ -1,6 +1,16 @@ -import type { KaiConfigInput } from "@datagutt/kai/schema"; -import raw from "../kai.json"; +import type { KaiConfig } from "@datagutt/kai/schema"; +import config from "../.kai/config.json" with { type: "json" }; -// kai.json for code that runs in Next. The asset build validates it fully (`bun run -// assets`); here TypeScript only checks its shape. -export const kaiConfig = raw satisfies KaiConfigInput; +// kai.json for code that runs in Next, as `kai content` validated it (defaults filled in). +export const kaiConfig = config as KaiConfig; + +function required(value: T | undefined, field: string): T { + if (value === undefined) throw new Error(`kai.json needs \`${field}\`: the site fetches live data with it`); + return value; +} + +/** The live settings the site can't do without, which kai.json leaves optional for other games. */ +export const siteLive = { + weather: required(kaiConfig.live.weather, "live.weather"), + github: required(kaiConfig.live.github, "live.github"), +}; diff --git a/apps/datagutt/lib/weather.ts b/apps/datagutt/lib/weather.ts index bffe166..f1e2cf2 100644 --- a/apps/datagutt/lib/weather.ts +++ b/apps/datagutt/lib/weather.ts @@ -1,7 +1,7 @@ import { placeFromHeaders, type WeatherNow, type WeatherPlace } from "@datagutt/kai-live"; import { getWeather as cachedWeather } from "@datagutt/kai-next/weather"; import { FALLBACK_PLACE } from "@/content/live"; -import { kaiConfig } from "@/lib/kai"; +import { siteLive } from "@/lib/kai"; // The game's sky follows the visitor's weather (docs/PLAN.md C2), placed by Vercel's // IP location headers. Without those (local runs, or a place Vercel can't tell) the sky @@ -9,4 +9,4 @@ import { kaiConfig } from "@/lib/kai"; export const visitorPlace = (headers: Pick): WeatherPlace => placeFromHeaders(headers, FALLBACK_PLACE); -export const getWeather = (place: WeatherPlace = FALLBACK_PLACE): Promise => cachedWeather(place, kaiConfig.live.weather.userAgent); +export const getWeather = (place: WeatherPlace = FALLBACK_PLACE): Promise => cachedWeather(place, siteLive.weather.userAgent); diff --git a/apps/datagutt/lib/world-state.ts b/apps/datagutt/lib/world-state.ts index 8245348..f0ce2f0 100644 --- a/apps/datagutt/lib/world-state.ts +++ b/apps/datagutt/lib/world-state.ts @@ -2,7 +2,7 @@ import { cacheLife, cacheTag } from "next/cache"; import type { WorldState } from "@datagutt/kai-live"; import { getContributions, getGitHubStats, getPinnedRepos } from "@datagutt/kai-next/github"; import { profile } from "@/content/profile"; -import { kaiConfig } from "@/lib/kai"; +import { siteLive } from "@/lib/kai"; import { getDiscordId } from "./lanyard"; /** @@ -14,7 +14,7 @@ import { getDiscordId } from "./lanyard"; export async function getWorldState(): Promise> { "use cache"; cacheTag("github"); - const user = kaiConfig.live.github.user; + const user = siteLive.github.user; const [repos, stats, contributions] = await Promise.all([getPinnedRepos(user), getGitHubStats(user, profile.codingSince), getContributions(user)]); const complete = repos.length > 0 && stats.public_repos > 0 && contributions.length > 0; if (complete) cacheLife("hours"); diff --git a/apps/datagutt/world/gen/maps/overworld.ts b/apps/datagutt/world/gen/maps/overworld.ts index af420ce..b156fea 100644 --- a/apps/datagutt/world/gen/maps/overworld.ts +++ b/apps/datagutt/world/gen/maps/overworld.ts @@ -216,12 +216,14 @@ export function overworld(): MapCanvas { c.add(npc("farmer", 16, 12, "down")); // --- Life ---------------------------------------------------------------------------------- - // Idle loops that ask nothing of the player. Birds keep to the daylight; butterflies - // only come out in the warm half of the year. Gulls stay through the Norwegian winter. - const life: [string, number, number, { seasons?: string; when?: string }][] = [ - ["pigeon", 51, 31, { when: "day" }], - ["pigeon", 46, 37, { when: "day" }], - ["pigeon", 53, 38, { when: "day" }], + // Birds keep to the daylight; butterflies only come out in the warm half of the year. + // Gulls stay through the Norwegian winter. The animals get away when the player comes + // close (kai.json `critters`), so they block nothing. + const life: [string, number, number, { seasons?: string; when?: string; dx?: number }][] = [ + // Hooded crows, the town bird: the strip is two tiles wide, the crow in its middle. + ["crow-left", 51, 31, { when: "day", dx: -8 }], + ["crow-right", 46, 37, { when: "day", dx: -8 }], + ["crow-left", 53, 38, { when: "day", dx: -8 }], ["seagull-left", 40, 62, { when: "day" }], ["seagull-right", 60, 62, { when: "day" }], ["buoy", 41, 70, {}], diff --git a/apps/datagutt/world/maps/town.tmj b/apps/datagutt/world/maps/town.tmj index dd685e0..6e178cf 100644 --- a/apps/datagutt/world/maps/town.tmj +++ b/apps/datagutt/world/maps/town.tmj @@ -3146,12 +3146,17 @@ { "name": "sprite", "type": "string", - "value": "pigeon" + "value": "crow-left" }, { "name": "when", "type": "string", "value": "day" + }, + { + "name": "dx", + "type": "string", + "value": "-8" } ] }, @@ -3169,12 +3174,17 @@ { "name": "sprite", "type": "string", - "value": "pigeon" + "value": "crow-right" }, { "name": "when", "type": "string", "value": "day" + }, + { + "name": "dx", + "type": "string", + "value": "-8" } ] }, @@ -3192,12 +3202,17 @@ { "name": "sprite", "type": "string", - "value": "pigeon" + "value": "crow-left" }, { "name": "when", "type": "string", "value": "day" + }, + { + "name": "dx", + "type": "string", + "value": "-8" } ] },