From 96b45561d0d72abe4011196e41ed47d7ddde4039 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 5 Jul 2026 09:18:33 +1000 Subject: [PATCH] Carve the engine into a headless core behind a command/event bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handover plan (docs/HANDOVER.md), P0-P6, in one pass. One insight powers it: CLI/HTTP/MCP/Python is one feature, not four — a headless core driven by Commands in, Events out, with every surface a thin adapter. - mrpgi-core: the whole sim as a library with zero macroquad (the workspace boundary is the guardrail). GameState::apply/tick, fixed 1/20s cycles, deterministic replays, headless RGBA/PNG rendering. - mrpgi-headless: JSONL stdio REPL, --script replay (AI lane off = byte-identical), --render-room PNG, --serve HTTP (state/command/ events cursor/frame.png), --mcp JSON-RPC stdio server whose render_frame returns a real PNG so a model sees its own moves. - Games are folders: game.json manifest (all fields default — legacy dirs still load), data-driven verb table feeding both the parser and the AI whitelist, flags with requires_flag/sets_flag gating on objects and dialogue choices, live UpsertRoom lore ingestion with validation. - mrpgi (GUI): now one consumer of the bus; editor commits through the same commands MCP uses; audio became core events with per-game sfx/ music/ file overrides beating the chiptune synth. - Tests: parser goldens + a full win-path playthrough asserting byte-identical transcripts across runs. Deleted: the old fused src/ (root is now a virtual workspace), the dead demo Scene/Assets path. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 40 +- Cargo.toml | 17 +- README.md | 66 +- docs/CONTROL.md | 162 +++ docs/HANDOVER.md | 7 +- mrpgi-core/Cargo.toml | 16 + {src => mrpgi-core/src}/ai.rs | 16 +- {src => mrpgi-core/src}/assets.rs | 180 +-- mrpgi-core/src/audio.rs | 116 ++ mrpgi-core/src/bin/mrpgi-headless/http.rs | 143 +++ mrpgi-core/src/bin/mrpgi-headless/main.rs | 158 +++ mrpgi-core/src/bin/mrpgi-headless/mcp.rs | 162 +++ {src => mrpgi-core/src}/framebuffer.rs | 0 mrpgi-core/src/kit.rs | 278 +++++ mrpgi-core/src/lib.rs | 25 + {src => mrpgi-core/src}/palette.rs | 13 +- mrpgi-core/src/parser.rs | 268 ++++ {src => mrpgi-core/src}/picture.rs | 0 mrpgi-core/src/render.rs | 61 + {src => mrpgi-core/src}/sprite.rs | 0 mrpgi-core/src/state.rs | 603 +++++++++ {src => mrpgi-core/src}/view.rs | 46 + mrpgi-core/src/world.rs | 387 ++++++ mrpgi-core/tests/engine.rs | 195 +++ mrpgi/Cargo.toml | 9 + mrpgi/src/editor.rs | 867 +++++++++++++ mrpgi/src/main.rs | 304 +++++ mrpgi/src/sound.rs | 100 ++ run.sh | 4 +- src/editor.rs | 1362 --------------------- src/main.rs | 728 ----------- src/room.rs | 72 -- src/sound.rs | 151 --- 33 files changed, 4048 insertions(+), 2508 deletions(-) create mode 100644 docs/CONTROL.md create mode 100644 mrpgi-core/Cargo.toml rename {src => mrpgi-core/src}/ai.rs (90%) rename {src => mrpgi-core/src}/assets.rs (72%) create mode 100644 mrpgi-core/src/audio.rs create mode 100644 mrpgi-core/src/bin/mrpgi-headless/http.rs create mode 100644 mrpgi-core/src/bin/mrpgi-headless/main.rs create mode 100644 mrpgi-core/src/bin/mrpgi-headless/mcp.rs rename {src => mrpgi-core/src}/framebuffer.rs (100%) create mode 100644 mrpgi-core/src/kit.rs create mode 100644 mrpgi-core/src/lib.rs rename {src => mrpgi-core/src}/palette.rs (69%) create mode 100644 mrpgi-core/src/parser.rs rename {src => mrpgi-core/src}/picture.rs (100%) create mode 100644 mrpgi-core/src/render.rs rename {src => mrpgi-core/src}/sprite.rs (100%) create mode 100644 mrpgi-core/src/state.rs rename {src => mrpgi-core/src}/view.rs (57%) create mode 100644 mrpgi-core/src/world.rs create mode 100644 mrpgi-core/tests/engine.rs create mode 100644 mrpgi/Cargo.toml create mode 100644 mrpgi/src/editor.rs create mode 100644 mrpgi/src/main.rs create mode 100644 mrpgi/src/sound.rs delete mode 100644 src/editor.rs delete mode 100644 src/main.rs delete mode 100644 src/room.rs delete mode 100644 src/sound.rs diff --git a/Cargo.lock b/Cargo.lock index 4ec0c32..028e19d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "autocfg" version = "1.5.1" @@ -60,6 +66,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "color_quant" version = "1.1.0" @@ -170,6 +182,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "icu_collections" version = "2.2.0" @@ -371,10 +389,18 @@ dependencies = [ name = "mrpgi" version = "0.1.0" dependencies = [ - "image", "macroquad", + "mrpgi-core", +] + +[[package]] +name = "mrpgi-core" +version = "0.1.0" +dependencies = [ + "image", "serde", "serde_json", + "tiny_http", "ureq", ] @@ -604,6 +630,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 9c7251e..b126957 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,9 @@ -[package] -name = "mrpgi" -version = "0.1.0" -edition = "2021" -description = "Monster Robot Party Game Interpreter — a new-school, Apple-Silicon-native reimagining of the Sierra AGI adventure engine." - -[dependencies] -macroquad = "0.4" -image = { version = "0.24", default-features = false, features = ["png"] } # PNG-only: lean builds -serde = { version = "1", features = ["derive"] } # room (.json) save/load -serde_json = "1" -ureq = { version = "2", features = ["json", "tls"] } # AI parser: local Ollama / OpenRouter HTTP +[workspace] +members = ["mrpgi-core", "mrpgi"] +resolver = "2" # macroquad renders much more smoothly when its dependencies are optimized, -# even during `cargo run` dev builds. Our own crate stays fast to recompile. +# even during `cargo run` dev builds. Our own crates stay fast to recompile. [profile.dev] opt-level = 1 diff --git a/README.md b/README.md index 22d6df2..7c5a307 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,29 @@ embedded scripting language, and an optional AI parser). ## Run it ```sh -cargo run # dev build (fast to recompile) -cargo run --release # smooth, optimized build +cargo run -p mrpgi # the GUI (paint + play) — or ./run.sh +cargo run -p mrpgi --release # smooth, optimized build ``` -A window opens with the demo room. **Walk the robot with the arrow keys or by -clicking** — and stroll *behind the column* to watch the priority system place -you correctly in depth. `N` toggles CRT scanlines. `Esc` quits. +A window opens in the editor. Paint a room (or launch with `--sample` for the +3-room demo game), then press **Tab** to walk it — arrows or click to move, +type verbs to play. `F1` toggles CRT scanlines, `F2` sound, `Esc` quits. + +## Drive it from anywhere (headless) + +The engine is a headless core behind a typed command/event bus; the GUI is +just one consumer. The same bus is exposed as JSONL stdio, HTTP, and MCP: + +```sh +cargo run -p mrpgi-core --bin mrpgi-headless -- --sample --game games/demo +echo '{"cmd":"parse","text":"look"}' | cargo run -p mrpgi-core --bin mrpgi-headless -- --game games/demo +mrpgi-headless --serve 127.0.0.1:7777 --game games/demo # HTTP + /frame.png +claude mcp add mrpgi -- mrpgi-headless --mcp --game games/demo # Claude plays & authors +``` + +A game is a folder of JSON + PNGs — an LLM expansion is just JSON that +deserializes. See **[docs/CONTROL.md](docs/CONTROL.md)** for the full guide +(commands, events, HTTP routes, MCP tools, authoring JSON reference). ## How it works (the AGI model, faithfully) @@ -36,15 +52,27 @@ pixel at a time, *only where its priority ≥ the scenery's* — that's the whol ## Source map +Two crates: **`mrpgi-core`** (the headless engine — zero windowing deps, a +compile error is the guardrail) and **`mrpgi`** (the macroquad GUI). + | File | Role | |------|------| -| `palette.rs` | the 16-color EGA palette (indices → RGBA) | -| `framebuffer.rs` | the visual + priority buffers and the `band(y)` depth table | -| `picture.rs` | vector room painting (`PicOp` rectangles / lines / pixels) | -| `view.rs` | sprites as loops + cels, authored from tiny ASCII grids | -| `sprite.rs` | the ego: movement, click-to-walk, collision, walk animation | -| `room.rs` | the hand-authored demo room | -| `main.rs` | window, game loop, fixed-step cycles, compositing, HUD | +| `mrpgi-core/src/state.rs` | **the spine**: `GameState`, `Command`/`Event` bus, fixed-step `tick` | +| `mrpgi-core/src/world.rs` | rooms/objects/dialogue data model (all serde), `World`, game folders | +| `mrpgi-core/src/parser.rs` | data-driven verb table, verb-noun parser, dialogue state | +| `mrpgi-core/src/render.rs` | headless compositing → RGBA / PNG (the priority-buffer trick) | +| `mrpgi-core/src/framebuffer.rs` | the visual + priority buffers and the `band(y)` depth table | +| `mrpgi-core/src/picture.rs` | vector room painting (rects / lines / fills / stamps) | +| `mrpgi-core/src/view.rs` | sprites as loops + cels, authored from tiny ASCII grids | +| `mrpgi-core/src/sprite.rs` | the ego: movement, click-to-walk, collision, walk animation | +| `mrpgi-core/src/assets.rs` | PNG → EGA quantize + dither; sprite folders; kit art | +| `mrpgi-core/src/audio.rs` | chiptune synth + audio cues (played by front-ends, or files) | +| `mrpgi-core/src/ai.rs` | AI parser lane (Ollama / OpenRouter / off) on a worker thread | +| `mrpgi-core/src/kit.rs` | sample game + fantasy starter kit, built from the real types | +| `mrpgi-core/src/bin/mrpgi-headless/` | JSONL stdio, `--script` replay, `--render-room`, HTTP, MCP | +| `mrpgi/src/main.rs` | window, play loop over the bus, HUD | +| `mrpgi/src/editor.rs` | the in-engine paint tool (edits go through the same bus) | +| `mrpgi/src/sound.rs` | audio out: game-folder files override the built-in synth | ## Asset pipeline (the new-school bit) @@ -70,9 +98,13 @@ Drop your own art into `assets/`, then press **R** in-game to hot-reload it. - [ ] **True-color "HD" layer mode**: skip quantization for painted backdrops; the priority buffer stays indexed - [ ] **Polygon shapes** for `stamp_cel_fit` (not just rectangles) - [ ] **Directional views** (4 loops + horizontal mirroring) for the robot -- [ ] **Text parser**: vocabulary with synonyms → `said("open","door")` matching, plus an on-screen input line +- [x] **Text parser**: data-driven verb table with synonyms, verb-noun matching, on-screen input line +- [x] **Room transitions**, inventory, flags (with `requires_flag`/`sets_flag` puzzle gating) +- [x] **AI parser adapter**: when the classic parse misses, an LLM maps free text onto an *already-allowed* command (deterministic core stays authoritative; works offline without it) +- [x] **Loadable game folders** (`game.json` + JSON rooms + PNGs) so games ship without recompiling the engine +- [x] **Headless core + control surfaces**: JSONL stdio, script replay, HTTP, MCP, headless PNG rendering — see [docs/CONTROL.md](docs/CONTROL.md) - [ ] **Rhai room logic**: rooms scripted in an AGI-flavored embedded language (`if said(...) { print(...) }`), hot-reloadable -- [ ] **Room transitions** (`new_room`), inventory, flags & vars -- [ ] **AI parser adapter**: when the classic parse misses, an LLM maps free text onto an *already-allowed* room command (deterministic core stays authoritative; works offline without it) -- [ ] **3-voice + noise chiptune synth** -- [ ] **Loadable game folders** (text/JSON resources) so games ship without recompiling the engine +- [ ] **3-voice + noise chiptune synth** (per-game audio files already override the square-wave synth) +- [ ] **Strudel sink**: browser sidecar over the HTTP surface, driven by the same audio events +- [ ] **Vars & conditions on dialogue/objects** beyond boolean flags; cross-room NPC state +- [ ] **Pathfinding** for click-to-walk (today: greedy steering) diff --git a/docs/CONTROL.md b/docs/CONTROL.md new file mode 100644 index 0000000..821b8ef --- /dev/null +++ b/docs/CONTROL.md @@ -0,0 +1,162 @@ +# Driving MRPGI programmatically + +The engine core (`mrpgi-core`) is fully headless: a `GameState` driven by +JSON `Command`s in and `Event`s out, over your choice of surface. The GUI is +just one more consumer of the same bus. + +``` +GUI (macroquad) ─┐ +CLI / stdio ─────┤ +HTTP ────────────┼──▶ Command ▶ GameState ▶ Event +MCP (Claude) ────┤ +Python ──────────┘ +``` + +Everything below uses one binary: + +```sh +cargo build -p mrpgi-core # builds target/debug/mrpgi-headless +mrpgi-headless --help +``` + +`--game DIR` points any mode at a game folder (default: the current dir). + +## Game = a folder + +``` +games/mygame/ + game.json # manifest: name, start_room, intro_text, verbs, flags + rooms/room0.json # RoomDoc: strokes, spawn, exits [N,E,S,W], objects, music + sprites/*.png # auto-quantized to EGA; filename = sprite name + sfx/door.ogg # optional: overrides the built-in chiptune per cue + music/calm.ogg # optional: overrides a music mood (by name or index 1-5) +``` + +Every manifest field defaults, so a bare `rooms/` folder is already a valid +game. Write starter content with `--sample` (3-room robot party) or `--kit` +(fantasy world + object archetype catalog). + +## 1. JSONL stdio (the substrate) + +```sh +mrpgi-headless --game games/mygame +``` + +One JSON command per line on stdin, events stream back on stdout: + +```jsonl +{"cmd":"parse","text":"take key"} +{"cmd":"walk_to","x":150,"y":90} +{"cmd":"tick","n":20} +{"cmd":"choose","n":1} +{"cmd":"query"} +{"cmd":"new_game"} +{"cmd":"goto_room","n":2} +{"cmd":"set_flag","name":"power_on","value":true} +{"cmd":"paint_stroke","stroke":{"Rect":{"x":0,"y":0,"w":160,"h":16,"color":0,"meaning":"Wall"}}} +{"cmd":"upsert_object","obj":{"name":"gem","sprite":"potion","x":90,"y":120,"look":"Shiny.","takeable":true}} +{"cmd":"upsert_room","n":7,"doc":{"strokes":[],"spawn":[80,140]},"persist":true} +{"cmd":"save_room"} +{"cmd":"load_game","dir":"games/other"} +``` + +Notes: +- The engine only advances when told: `tick` runs fixed 1/20s cycles + (movement, room transitions, AI results). `walk_to` ticks until arrival. +- `upsert_room` validates before committing (walkable spawn, exits must point + at rooms that exist) and rejects bad lore with an `error` event. +- Events look like `{"event":"transcript","line":"You take the key."}`, + `{"event":"room_changed","room":1,"music":4}`, `{"event":"won"}`, + `{"event":"audio","cue":"pickup"}`, `{"event":"state","state":{...}}`. + +### Script replay (deterministic) + +```sh +mrpgi-headless --script play.jsonl --game games/mygame > events.log +``` + +Replays a command log through the fixed-step engine. The AI lane defaults to +**off** under `--script`, so the same script always produces byte-identical +events — that's the golden-test workflow for CI. + +### Render a room to PNG (no window) + +```sh +mrpgi-headless --render-room games/mygame/rooms/room0.json out.png --game games/mygame +``` + +## 2. HTTP + +```sh +mrpgi-headless --serve 127.0.0.1:7777 --game games/mygame +``` + +| Route | Does | +|---|---| +| `POST /command` | body = one Command JSON → JSON array of resulting Events | +| `GET /state` | full snapshot: room, ego, inventory, flags, objects, exits | +| `GET /frame.png` | the current frame, headless-rendered | +| `GET /events?since=N` | event log from cursor N → `{"next":M,"events":[...]}` | +| `POST /rooms/{n}` | body = RoomDoc JSON (add `?persist` to write to disk) | + +Python needs no client library: + +```python +import requests, json +E = "http://127.0.0.1:7777" +requests.post(f"{E}/command", data=json.dumps({"cmd": "parse", "text": "look"})) +state = requests.get(f"{E}/state").json() +open("frame.png", "wb").write(requests.get(f"{E}/frame.png").content) +``` + +## 3. MCP (Claude authors & plays) + +```sh +claude mcp add mrpgi -- /path/to/target/debug/mrpgi-headless --mcp --game games/mygame +``` + +Tools: `mrpgi_parse`, `mrpgi_walk_to`, `mrpgi_choose`, `mrpgi_tick`, +`mrpgi_state`, `mrpgi_render_frame` (returns a real PNG image — the model +*sees* the frame it authored), `mrpgi_new_game`, `mrpgi_load_game`, +`mrpgi_upsert_room`, `mrpgi_upsert_object`, `mrpgi_paint_stroke`, +`mrpgi_save_room`, and `mrpgi_command` (raw bus escape hatch). + +The loop that makes a whole game from prompts: upsert rooms/objects as JSON → +`mrpgi_render_frame` to look at them → play the game with `mrpgi_parse` / +`mrpgi_walk_to` to test the puzzle chain → `mrpgi_save_room` to commit. + +## Authoring JSON reference + +**RoomDoc** — `{"strokes":[...], "spawn":[x,y], "exits":[N,E,S,W], "objects":[...], "music":0-5}` +(exit entries are room numbers or `null`; music: 0 off, 1 calm, 2 eerie, 3 tense, 4 jolly, 5 spooky) + +**Stroke** (paint ops; `meaning` is `"None" | "Floor" | "Wall" | "Water" | "Trigger"`, colors are EGA 0-15): + +```json +{"Rect": {"x":0,"y":0,"w":160,"h":16,"color":0,"meaning":"Wall"}} +{"Ellipse": {"x":10,"y":40,"w":30,"h":20,"color":2,"meaning":"None"}} +{"Fill": {"x":80,"y":100,"color":7,"meaning":"Floor"}} +{"Line": {"pts":[[0,80],[159,80]],"color":15}} +{"Brush": {"pts":[[50,60],[52,61]],"color":4,"meaning":"None","size":2}} +{"Image": {"name":"tree","x":60,"y":80,"meaning":"None"}} +``` + +**ObjDef** — `name, sprite, x, y` plus optional `look`, `takeable`, `synonyms` +(space-separated), `use_text`, `needs` (item that unlocks it), `wins`, +`requires_flag` / `sets_flag` (puzzle gating), `dialogue` (nodes of +`{"says":"...","choices":[{"text":"...","goto":1,"requires_flag":"","sets_flag":""}]}`, +`goto:-1` ends). + +**game.json** — `{"name":"...", "start_room":0, "intro_text":"...", "verbs":[{"name":"take","words":["yoink"]}], "flags":{"met_wizard":false}}` +(manifest verbs merge extra synonyms into the built-in table; the AI parser's +whitelist is generated from the same table so they can never drift.) + +## The AI parser lane + +Free text the deterministic parser doesn't understand goes to a small LLM +that may only translate it into an *already-legal* command, which is then +re-run through the real logic — the AI can never invent state. + +``` +MRPGI_AI=ollama|openrouter|off MRPGI_AI_MODEL=... MRPGI_AI_URL=... +``` diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 80e0d0b..5ceb187 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,6 +1,11 @@ # MRPGI — Handover: making the engine fully programmatically controllable -> **Status:** design / discussion complete. **No engine code has been changed yet.** +> **Status:** ✅ **BUILT** (2026-07-05) — P0–P6 all landed: `mrpgi-core` workspace carve, +> `GameState` + Command/Event bus, headless JSONL CLI + `--script` replay + +> `--render-room`, game folders (`game.json`), data-driven verbs, flags & +> dialogue gating, HTTP + MCP surfaces, audio events with per-game file +> overrides, headless editor commands. See [CONTROL.md](CONTROL.md) for the +> control-surface guide. This document is kept as the design rationale. > **Date:** 2026-07-05 > **Purpose:** capture the vision, the current-state analysis, the decisions locked in, and the phased plan so any future session (human or AI) can pick up exactly where we left off. diff --git a/mrpgi-core/Cargo.toml b/mrpgi-core/Cargo.toml new file mode 100644 index 0000000..f0caa61 --- /dev/null +++ b/mrpgi-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mrpgi-core" +version = "0.1.0" +edition = "2021" +description = "MRPGI headless engine core — the sim, parser, renderer and control surfaces. Zero windowing dependencies: a compile error is the guardrail." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +image = { version = "0.24", default-features = false, features = ["png"] } # PNG-only: lean builds +ureq = { version = "2", features = ["json", "tls"] } # AI parser lane: Ollama / OpenRouter HTTP +tiny_http = "0.12" # the --serve control surface; ponytail: swap for axum+WS if streaming push is ever needed + +[[bin]] +name = "mrpgi-headless" +path = "src/bin/mrpgi-headless/main.rs" diff --git a/src/ai.rs b/mrpgi-core/src/ai.rs similarity index 90% rename from src/ai.rs rename to mrpgi-core/src/ai.rs index 352601d..4d2c71d 100644 --- a/src/ai.rs +++ b/mrpgi-core/src/ai.rs @@ -4,7 +4,7 @@ //! LLM translates the player's free text into ONE *allowed* command — which is //! then re-run through the real game logic, so logic stays authoritative and //! the AI can never invent state. The call runs on a worker thread and reports -//! back over a channel, so the game loop never blocks. +//! back over a channel, so no caller ever blocks. //! //! Provider is chosen by env vars (default = local Ollama, free + offline): //! MRPGI_AI ollama (default) | openrouter | off @@ -51,6 +51,10 @@ impl AiConfig { AiConfig { provider, model, url, key } } + pub fn off() -> Self { + AiConfig { provider: Provider::Off, model: String::new(), url: String::new(), key: None } + } + pub fn enabled(&self) -> bool { self.provider != Provider::Off } @@ -65,10 +69,12 @@ impl AiConfig { } /// The instruction we send the model — tight, so even a 4B local model behaves. -pub fn build_prompt(text: &str, things: &str) -> String { +/// `verbs` comes from the live verb table, so the whitelist the AI sees can +/// never drift from what the parser actually accepts. +pub fn build_prompt(text: &str, things: &str, verbs: &str) -> String { format!( "You translate a player's sentence into ONE text-adventure command. -Valid verbs: look, examine, take, drop, use, open, inventory. +Valid verbs: {verbs}. Things you can see: {things}. Reply with ONLY one short command like `use gate` or `take key`, using a verb above and (if it helps) one of the things listed. If nothing fits, reply `look around`. No quotes, no explanation. Player: {text} @@ -76,8 +82,8 @@ Command:" ) } -/// Fire the translation on a worker thread; poll the returned receiver each -/// frame with `try_recv()`. The result is a sanitized command (or "" = no idea). +/// Fire the translation on a worker thread; poll the returned receiver with +/// `try_recv()`. The result is a sanitized command (or "" = no idea). pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver { let (tx, rx) = mpsc::channel(); thread::spawn(move || { diff --git a/src/assets.rs b/mrpgi-core/src/assets.rs similarity index 72% rename from src/assets.rs rename to mrpgi-core/src/assets.rs index ebed64a..be0d1bd 100644 --- a/src/assets.rs +++ b/mrpgi-core/src/assets.rs @@ -6,8 +6,8 @@ //! gradients) and becomes an indexed [`Cel`]. From that moment it depth-sorts, //! gets occluded by scenery, and walks-behind exactly like hand-authored art. //! -//! Drop your own PNGs into `assets/` and the engine will use them; the sample -//! art here is generated on first run so the project is playable out of the box. +//! Drop your own PNGs into a game's `sprites/` and the engine will use them; +//! sample art is generated on first run so the project is playable out of the box. use crate::palette::EGA; use crate::view::{Cel, TRANSPARENT}; @@ -80,147 +80,8 @@ pub fn load_cel(path: &str, dither: bool) -> Cel { } } -/// All the art a room might want. Add fields here as games grow; for now this -/// holds the demo's three pieces. -pub struct Assets { - pub poster: Cel, - pub crate_box: Cel, - pub floor: Cel, -} - -impl Assets { - pub fn load(dir: &str) -> Self { - Assets { - poster: load_cel(&format!("{dir}/poster.png"), true), - crate_box: load_cel(&format!("{dir}/crate.png"), false), - floor: load_cel(&format!("{dir}/floor.png"), true), - } - } -} - // --------------------------------------------------------------------------- -// Sample-art generation (so the project runs with zero external files). -// These write real PNGs you can open, edit, and hot-reload with `R`. -// --------------------------------------------------------------------------- - -pub fn ensure_sample_assets(dir: &str) { - std::fs::create_dir_all(dir).ok(); - write_if_missing(&format!("{dir}/poster.png"), gen_poster); - write_if_missing(&format!("{dir}/crate.png"), gen_crate); - write_if_missing(&format!("{dir}/floor.png"), gen_floor); -} - -fn write_if_missing(path: &str, f: fn() -> image::RgbaImage) { - if !Path::new(path).exists() { - f().save(path).ok(); - } -} - -fn put_rect(im: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, c: [u8; 4]) { - for yy in y..(y + h).min(im.height()) { - for xx in x..(x + w).min(im.width()) { - im.put_pixel(xx, yy, image::Rgba(c)); - } - } -} - -/// A neon "Monster Robot Party" poster — a smooth gradient with a robot face, -/// so dithering has something to chew on. -fn gen_poster() -> image::RgbaImage { - let (w, h) = (64u32, 48u32); - let mut im = image::RgbaImage::new(w, h); - for y in 0..h { - for x in 0..w { - let t = y as f32 / h as f32; - let r = (90.0 * (1.0 - t)) as u8; - let g = (20.0 + 110.0 * t) as u8; - let b = (130.0 * (1.0 - t) + 140.0 * t) as u8; - im.put_pixel(x, y, image::Rgba([r, g, b, 255])); - } - } - // Robot head. - let (cx, cy) = (32.0, 24.0); - for y in 0..h { - for x in 0..w { - let dx = x as f32 - cx; - let dy = (y as f32 - cy) * 1.1; - if dx * dx + dy * dy < 13.0 * 13.0 { - im.put_pixel(x, y, image::Rgba([200, 200, 210, 255])); - } - } - } - put_rect(&mut im, 26, 20, 4, 4, [40, 230, 230, 255]); // eye - put_rect(&mut im, 34, 20, 4, 4, [40, 230, 230, 255]); // eye - put_rect(&mut im, 27, 30, 10, 2, [230, 60, 180, 255]); // mouth - put_rect(&mut im, 31, 8, 2, 6, [220, 220, 220, 255]); // antenna - put_rect(&mut im, 30, 5, 4, 4, [255, 230, 60, 255]); // antenna tip - // Confetti. - let cols = [ - [255, 80, 80, 255], - [80, 255, 120, 255], - [255, 230, 60, 255], - [120, 160, 255, 255], - ]; - for i in 0..44u32 { - let x = (i * 37 + 5) % w; - let y = (i * 23 + 3) % h; - im.put_pixel(x, y, image::Rgba(cols[(i % 4) as usize])); - } - im -} - -/// A pixel-art wooden crate (an object you can walk behind). -fn gen_crate() -> image::RgbaImage { - let (w, h) = (28u32, 24u32); - let mut im = image::RgbaImage::new(w, h); - put_rect(&mut im, 0, 0, w, h, [150, 95, 45, 255]); // wood - let dark = [95, 60, 25, 255]; - put_rect(&mut im, 0, 0, w, 2, dark); - put_rect(&mut im, 0, h - 2, w, 2, dark); - put_rect(&mut im, 0, 0, 2, h, dark); - put_rect(&mut im, w - 2, 0, 2, h, dark); - let light = [190, 135, 75, 255]; - for i in 0..h { - let x = (i * (w - 1) / (h - 1)).min(w - 1); - im.put_pixel(x, i, image::Rgba(light)); - im.put_pixel(w - 1 - x, i, image::Rgba(light)); - } - im -} - -/// A 16x16 dance-floor tile (tiles seamlessly; 16 is a multiple of the dither -/// period so there are no seams). -fn gen_floor() -> image::RgbaImage { - let (w, h) = (16u32, 16u32); - let mut im = image::RgbaImage::new(w, h); - for y in 0..h { - for x in 0..w { - let base = if ((x / 8) + (y / 8)) % 2 == 0 { - [22, 62, 56, 255] - } else { - [16, 48, 46, 255] - }; - im.put_pixel(x, y, image::Rgba(base)); - } - } - for x in 0..w { - im.put_pixel(x, 0, image::Rgba([10, 30, 30, 255])); - } - for y in 0..h { - im.put_pixel(0, y, image::Rgba([10, 30, 30, 255])); - } - for (x, y, c) in [ - (4u32, 5u32, [80, 255, 160, 255]), - (11, 9, [255, 90, 200, 255]), - (7, 13, [120, 160, 255, 255]), - ] { - im.put_pixel(x, y, image::Rgba(c)); - } - im -} - -// --------------------------------------------------------------------------- -// Sprite pipeline — drop PNGs in a folder, the editor places them. +// Sprite pipeline — drop PNGs in a folder, the engine places them. // --------------------------------------------------------------------------- /// Load every PNG in `dir` as an EGA-quantized cel (no dither — keep pixel art @@ -270,6 +131,33 @@ pub fn ensure_sample_sprites(dir: &str) { gen_sign().save(format!("{dir}/sign.png")).ok(); } +fn put_rect(im: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, c: [u8; 4]) { + for yy in y..(y + h).min(im.height()) { + for xx in x..(x + w).min(im.width()) { + im.put_pixel(xx, yy, image::Rgba(c)); + } + } +} + +/// A pixel-art wooden crate (an object you can walk behind). +fn gen_crate() -> image::RgbaImage { + let (w, h) = (28u32, 24u32); + let mut im = image::RgbaImage::new(w, h); + put_rect(&mut im, 0, 0, w, h, [150, 95, 45, 255]); // wood + let dark = [95, 60, 25, 255]; + put_rect(&mut im, 0, 0, w, 2, dark); + put_rect(&mut im, 0, h - 2, w, 2, dark); + put_rect(&mut im, 0, 0, 2, h, dark); + put_rect(&mut im, w - 2, 0, 2, h, dark); + let light = [190, 135, 75, 255]; + for i in 0..h { + let x = (i * (w - 1) / (h - 1)).min(w - 1); + im.put_pixel(x, i, image::Rgba(light)); + im.put_pixel(w - 1 - x, i, image::Rgba(light)); + } + im +} + fn gen_barrel() -> image::RgbaImage { let (w, h) = (16u32, 22u32); let mut im = image::RgbaImage::new(w, h); @@ -278,12 +166,12 @@ fn gen_barrel() -> image::RgbaImage { put_rect(&mut im, 12, 1, 2, 20, [110, 68, 30, 255]); put_rect(&mut im, 2, 3, 12, 2, [95, 95, 95, 255]); put_rect(&mut im, 2, 17, 12, 2, [95, 95, 95, 255]); + let _ = (w, h); im } fn gen_sign() -> image::RgbaImage { - let (w, h) = (28u32, 18u32); - let mut im = image::RgbaImage::new(w, h); + let mut im = image::RgbaImage::new(28, 18); put_rect(&mut im, 12, 8, 4, 10, [120, 75, 35, 255]); put_rect(&mut im, 2, 1, 24, 9, [200, 170, 60, 255]); put_rect(&mut im, 2, 1, 24, 2, [150, 120, 40, 255]); @@ -292,7 +180,7 @@ fn gen_sign() -> image::RgbaImage { // --------------------------------------------------------------------------- // Fantasy starter-kit placeholder sprites (crude but distinct). Replace with -// real art any time — same filenames in sprites/, press R. See ART_MANIFESTO. +// real art any time — same filenames in sprites/, press R. // --------------------------------------------------------------------------- const GREEN: [u8; 4] = [0, 170, 0, 255]; @@ -342,7 +230,7 @@ pub fn write_kit_sprites(dir: &str) { ]; for (name, f) in gens { let p = format!("{dir}/{name}.png"); - if !std::path::Path::new(&p).exists() { + if !Path::new(&p).exists() { f().save(&p).ok(); } } diff --git a/mrpgi-core/src/audio.rs b/mrpgi-core/src/audio.rs new file mode 100644 index 0000000..4e920a6 --- /dev/null +++ b/mrpgi-core/src/audio.rs @@ -0,0 +1,116 @@ +//! Audio *content*, not audio *output*. The core never touches a sound +//! device — it emits [`AudioCue`]/music events on the bus and synthesizes +//! square-wave WAV bytes on request. Front-ends decide how (and whether) to +//! play them: the GUI feeds them to macroquad, a browser sidecar could feed +//! Strudel, headless runs stay silent. Options live at the edges. + +use serde::{Deserialize, Serialize}; + +const SR: u32 = 22050; + +#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AudioCue { + Blip, + Pickup, + Confirm, + Error, + Door, + Win, +} + +impl AudioCue { + pub const ALL: [AudioCue; 6] = [AudioCue::Blip, AudioCue::Pickup, AudioCue::Confirm, AudioCue::Error, AudioCue::Door, AudioCue::Win]; + + pub fn name(self) -> &'static str { + match self { + AudioCue::Blip => "blip", + AudioCue::Pickup => "pickup", + AudioCue::Confirm => "confirm", + AudioCue::Error => "error", + AudioCue::Door => "door", + AudioCue::Win => "win", + } + } + + /// The built-in chiptune rendering of this cue, as WAV bytes. + pub fn wav(self) -> Vec { + match self { + AudioCue::Blip => seq(&[(660.0, 18)], 0.18), + AudioCue::Pickup => seq(&[(660.0, 55), (988.0, 80)], 0.20), + AudioCue::Confirm => seq(&[(523.0, 55), (784.0, 85)], 0.20), + AudioCue::Error => seq(&[(196.0, 90), (147.0, 150)], 0.22), + AudioCue::Door => seq(&[(392.0, 60), (294.0, 60), (196.0, 120)], 0.20), + AudioCue::Win => seq(&[(523.0, 90), (659.0, 90), (784.0, 90), (1047.0, 240)], 0.22), + } + } +} + +/// Mood names matching the editor's picker: 0 = off, 1..=5 below. +pub const MOODS: [&str; 6] = ["off", "calm", "eerie", "tense", "jolly", "spooky"]; + +/// The built-in looping ambient track for a mood (None = silence / unknown). +pub fn music_wav(mood: u8) -> Option> { + let notes: &[(f32, u32)] = match mood { + 1 => &[(262.0, 300), (0.0, 150), (330.0, 300), (0.0, 150), (392.0, 420), (0.0, 280), (330.0, 280), (0.0, 160), (294.0, 320), (0.0, 700)], + 2 => &[(220.0, 420), (0.0, 320), (311.0, 420), (0.0, 520), (233.0, 420), (0.0, 900)], + 3 => &[(147.0, 170), (0.0, 110), (147.0, 170), (0.0, 110), (165.0, 170), (0.0, 110), (147.0, 170), (0.0, 520)], + 4 => &[(523.0, 150), (659.0, 150), (784.0, 150), (659.0, 150), (523.0, 150), (0.0, 280), (587.0, 150), (784.0, 150), (0.0, 460)], + 5 => &[(165.0, 520), (0.0, 700), (196.0, 420), (0.0, 520), (147.0, 620), (0.0, 1100)], + _ => return None, + }; + let vol = match mood { + 3 => 0.18, + 4 => 0.15, + _ => 0.16, + }; + Some(seq(notes, vol)) +} + +/// Render a sequence of (frequency Hz, duration ms) square-wave notes to a WAV. +/// Frequency 0 is a rest. +pub fn seq(notes: &[(f32, u32)], vol: f32) -> Vec { + let mut samples: Vec = Vec::new(); + for &(freq, ms) in notes { + let n = (SR as u64 * ms as u64 / 1000) as usize; + if freq <= 0.0 { + for _ in 0..n { + samples.push(0); + } + continue; + } + let period = (SR as f32 / freq).max(2.0); + for i in 0..n { + let phase = (i as f32 % period) / period; + let square = if phase < 0.5 { 1.0 } else { -1.0 }; + // quick attack, linear decay — keeps it from clicking + let t = i as f32 / n.max(1) as f32; + let env = (t * 10.0).min(1.0) * (1.0 - t); + let v = square * vol * env * 0.7; + samples.push((v * i16::MAX as f32) as i16); + } + } + wav_bytes(&samples) +} + +pub fn wav_bytes(samples: &[i16]) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut b = Vec::with_capacity(44 + data_len as usize); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data_len).to_le_bytes()); + b.extend_from_slice(b"WAVE"); + b.extend_from_slice(b"fmt "); + b.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size + b.extend_from_slice(&1u16.to_le_bytes()); // format = PCM + b.extend_from_slice(&1u16.to_le_bytes()); // channels = mono + b.extend_from_slice(&SR.to_le_bytes()); // sample rate + b.extend_from_slice(&(SR * 2).to_le_bytes()); // byte rate + b.extend_from_slice(&2u16.to_le_bytes()); // block align + b.extend_from_slice(&16u16.to_le_bytes()); // bits per sample + b.extend_from_slice(b"data"); + b.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + b.extend_from_slice(&s.to_le_bytes()); + } + b +} diff --git a/mrpgi-core/src/bin/mrpgi-headless/http.rs b/mrpgi-core/src/bin/mrpgi-headless/http.rs new file mode 100644 index 0000000..72091db --- /dev/null +++ b/mrpgi-core/src/bin/mrpgi-headless/http.rs @@ -0,0 +1,143 @@ +//! The HTTP control surface — a thin adapter over the bus for curl, Python, +//! browsers, anything. Single-threaded on purpose: one request at a time +//! keeps engine access lock-free and command ordering deterministic. +//! +//! POST /command body = one Command JSON → JSON array of Events +//! GET /state → StateSnapshot JSON +//! GET /frame.png → current frame, headless-rendered +//! GET /events?since=N → {"next":M,"events":[...]} (cursor polling) +//! POST /rooms/{n}[?persist] body = RoomDoc JSON → events (lore drop!) +//! +//! ponytail: cursor polling instead of SSE/WebSocket push — upgrade to +//! axum + WS when something actually needs sub-second push (e.g. Strudel). + +use mrpgi_core::state::{Command, Event}; +use mrpgi_core::GameState; +use tiny_http::{Header, Method, Response, Server}; + +/// Rolling event log for /events polling. +struct Log { + base: usize, // absolute index of events[0] + events: Vec, +} + +impl Log { + fn push_all(&mut self, evs: &[Event]) { + for e in evs { + if let Ok(v) = serde_json::to_value(e) { + self.events.push(v); + } + } + // ponytail: flat cap; a client that falls >10k events behind re-syncs via /state + if self.events.len() > 10_000 { + let drop = self.events.len() - 10_000; + self.events.drain(..drop); + self.base += drop; + } + } + + fn since(&self, n: usize) -> (usize, &[serde_json::Value]) { + let start = n.max(self.base) - self.base; + let start = start.min(self.events.len()); + (self.base + self.events.len(), &self.events[start..]) + } +} + +/// Read a request body, capped — a room full of strokes is well under this; +/// anything bigger is not a game, it's a problem. +fn read_body(req: &mut tiny_http::Request) -> String { + let mut body = String::new(); + let _ = std::io::Read::read_to_string(&mut std::io::Read::take(req.as_reader(), 8 * 1024 * 1024), &mut body); + body +} + +pub fn serve(mut gs: GameState, boot: Vec, addr: &str) { + let server = Server::http(addr).unwrap_or_else(|e| panic!("can't bind {}: {}", addr, e)); + let mut log = Log { base: 0, events: Vec::new() }; + log.push_all(&boot); + eprintln!("mrpgi http listening on http://{} (POST /command, GET /state /frame.png /events)", addr); + + for mut req in server.incoming_requests() { + let url = req.url().to_string(); + let path = url.split('?').next().unwrap_or("").to_string(); + let query = url.split('?').nth(1).unwrap_or("").to_string(); + let method = req.method().clone(); + + let respond = |req: tiny_http::Request, code: u16, body: Vec, ctype: &str| { + let mut resp = Response::from_data(body).with_status_code(code); + resp.add_header(Header::from_bytes("Content-Type", ctype).unwrap()); + resp.add_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()); + let _ = req.respond(resp); + }; + let json = |req: tiny_http::Request, code: u16, v: serde_json::Value| { + respond(req, code, v.to_string().into_bytes(), "application/json"); + }; + + match (method, path.as_str()) { + (Method::Options, _) => { + let mut resp = Response::empty(204); + resp.add_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()); + resp.add_header(Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap()); + resp.add_header(Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap()); + let _ = req.respond(resp); + } + (Method::Post, "/command") => { + let body = read_body(&mut req); + match serde_json::from_str::(&body) { + Ok(cmd) => { + let evs = gs.apply(cmd); + log.push_all(&evs); + json(req, 200, serde_json::to_value(&evs).unwrap_or_default()); + } + Err(e) => json(req, 400, serde_json::json!({"error": format!("bad command: {}", e)})), + } + } + (Method::Get, "/state") => { + json(req, 200, serde_json::to_value(gs.snapshot()).unwrap_or_default()); + } + (Method::Get, "/frame.png") => { + respond(req, 200, gs.render_png(true), "image/png"); + } + (Method::Get, "/events") => { + let since: usize = query + .split('&') + .find_map(|kv| kv.strip_prefix("since=")) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let (next, events) = log.since(since); + json(req, 200, serde_json::json!({ "next": next, "events": events })); + } + (Method::Post, p) if p.starts_with("/rooms/") => { + let n: Option = p.trim_start_matches("/rooms/").parse().ok(); + let body = read_body(&mut req); + match (n, serde_json::from_str::(&body)) { + (Some(n), Ok(doc)) => { + let persist = query.split('&').any(|kv| kv == "persist" || kv == "persist=1" || kv == "persist=true"); + let evs = gs.apply(Command::UpsertRoom { n, doc, persist }); + log.push_all(&evs); + json(req, 200, serde_json::to_value(&evs).unwrap_or_default()); + } + (None, _) => json(req, 400, serde_json::json!({"error": "room number must be an integer"})), + (_, Err(e)) => json(req, 400, serde_json::json!({"error": format!("bad RoomDoc: {}", e)})), + } + } + (Method::Get, "/") => { + respond(req, 200, INDEX.as_bytes().to_vec(), "text/plain; charset=utf-8"); + } + _ => json(req, 404, serde_json::json!({"error": "no such route"})), + } + } +} + +const INDEX: &str = "\ +MRPGI headless HTTP surface. + +POST /command one Command JSON, e.g. {\"cmd\":\"parse\",\"text\":\"take key\"} +GET /state full state snapshot +GET /frame.png current frame (rendered with no window) +GET /events?since=N event log from cursor N; response carries the next cursor +POST /rooms/{n} body = RoomDoc JSON (add ?persist to write to disk) + +The engine only advances when told: send {\"cmd\":\"tick\",\"n\":20} to run a second +of game time, or use walk_to which ticks until arrival. +"; diff --git a/mrpgi-core/src/bin/mrpgi-headless/main.rs b/mrpgi-core/src/bin/mrpgi-headless/main.rs new file mode 100644 index 0000000..66c8aca --- /dev/null +++ b/mrpgi-core/src/bin/mrpgi-headless/main.rs @@ -0,0 +1,158 @@ +//! mrpgi-headless — every non-GUI way to drive the engine, in one binary. +//! +//! mrpgi-headless [--game DIR] JSONL REPL (commands in, events out) +//! mrpgi-headless --script FILE [--game DIR] replay a command log, print events +//! mrpgi-headless --render-room IN.json OUT.png [--game DIR] +//! mrpgi-headless --serve [ADDR] [--game DIR] HTTP surface (default 127.0.0.1:7777) +//! mrpgi-headless --mcp [--game DIR] MCP server on stdio (for Claude etc.) +//! mrpgi-headless --sample | --kit [--game DIR] write starter content, then exit +//! +//! JSONL wire format, one JSON object per line: +//! in: {"cmd":"parse","text":"take key"} +//! out: {"event":"transcript","line":"You take the key."} + +mod http; +mod mcp; + +use mrpgi_core::ai::AiConfig; +use mrpgi_core::state::{Command, Event}; +use mrpgi_core::{GameState, World}; +use std::io::{BufRead, Write}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let get_opt = |flag: &str| -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() + }; + let game_dir = get_opt("--game").unwrap_or_else(|| ".".into()); + + if args.iter().any(|a| a == "--help" || a == "-h") { + print!("{}", HELP); + return; + } + if args.iter().any(|a| a == "--sample") { + mrpgi_core::kit::write_sample_world(&game_dir); + eprintln!("sample game written to {}", game_dir); + return; + } + if args.iter().any(|a| a == "--kit") { + mrpgi_core::kit::write_kit(&game_dir); + eprintln!("fantasy kit written to {}", game_dir); + return; + } + + if let Some(pos) = args.iter().position(|a| a == "--render-room") { + let input = args.get(pos + 1).expect("--render-room needs IN.json OUT.png"); + let output = args.get(pos + 2).expect("--render-room needs IN.json OUT.png"); + render_room(&game_dir, input, output); + return; + } + + // Scripted replays default the AI lane off so they're deterministic; + // export MRPGI_AI to opt back in. + let scripted = args.iter().any(|a| a == "--script"); + let ai = if scripted && std::env::var("MRPGI_AI").is_err() { AiConfig::off() } else { AiConfig::from_env() }; + let mut gs = GameState::new(World::load_game(&game_dir), ai); + let boot = gs.apply(Command::NewGame { room: None }); + + if args.iter().any(|a| a == "--mcp") { + mcp::serve(gs); + return; + } + if let Some(pos) = args.iter().position(|a| a == "--serve") { + let addr = args + .get(pos + 1) + .filter(|a| !a.starts_with("--")) + .cloned() + .unwrap_or_else(|| "127.0.0.1:7777".into()); + http::serve(gs, boot, &addr); + return; + } + + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + emit(&mut out, &boot); + + if let Some(path) = get_opt("--script") { + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("can't read {}: {}", path, e)); + for line in text.lines() { + run_line(&mut gs, line, &mut out); + } + return; + } + + // The JSONL REPL: read commands until EOF. + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + run_line(&mut gs, &line, &mut out); + } +} + +fn run_line(gs: &mut GameState, line: &str, out: &mut impl Write) { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return; + } + match serde_json::from_str::(line) { + Ok(cmd) => emit(out, &gs.apply(cmd)), + Err(e) => emit(out, &[Event::Error { message: format!("bad command: {}", e) }]), + } +} + +fn emit(out: &mut impl Write, events: &[Event]) { + for e in events { + if let Ok(j) = serde_json::to_string(e) { + let _ = writeln!(out, "{}", j); + } + } + let _ = out.flush(); +} + +/// Bake a single RoomDoc to a PNG — no window, no game state. This is how an +/// LLM previews a room it just authored. +fn render_room(game_dir: &str, input: &str, output: &str) { + let text = std::fs::read_to_string(input).unwrap_or_else(|e| panic!("can't read {}: {}", input, e)); + let doc: mrpgi_core::RoomDoc = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{} isn't a RoomDoc: {}", input, e)); + + let sprites = mrpgi_core::assets::load_sprite_dir( + &std::path::Path::new(game_dir).join("sprites").to_string_lossy(), + ); + let mut fb = mrpgi_core::framebuffer::FrameBuffer::new(); + for s in &doc.strokes { + mrpgi_core::world::apply_stroke(&mut fb, s, &sprites); + } + let props: Vec = doc + .objects + .iter() + .filter_map(|o| { + sprites.iter().find(|(n, _)| *n == o.sprite).map(|(_, cel)| mrpgi_core::sprite::Prop { + x: o.x, + y: o.y, + cel: cel.clone(), + fixed_priority: None, + name: o.name.clone(), + }) + }) + .collect(); + let rgba = mrpgi_core::render::compose(&fb, None, &props); + std::fs::write(output, mrpgi_core::render::encode_png(&rgba)).unwrap_or_else(|e| panic!("can't write {}: {}", output, e)); + eprintln!("rendered {} -> {}", input, output); +} + +const HELP: &str = "\ +mrpgi-headless — drive the MRPGI engine with no window. + + mrpgi-headless [--game DIR] JSONL REPL: commands on stdin, events on stdout + mrpgi-headless --script FILE [--game DIR] replay a JSONL command log (AI lane off by default) + mrpgi-headless --render-room IN.json OUT.png [--game DIR] + mrpgi-headless --serve [ADDR] [--game DIR] HTTP: POST /command, GET /state|/frame.png|/events?since=N + mrpgi-headless --mcp [--game DIR] MCP server on stdio (point Claude at this) + mrpgi-headless --sample|--kit [--game DIR] write starter game content + +Commands are JSON, one per line, e.g. + {\"cmd\":\"parse\",\"text\":\"take key\"} + {\"cmd\":\"walk_to\",\"x\":150,\"y\":90} + {\"cmd\":\"tick\",\"n\":20} + {\"cmd\":\"query\"} +"; diff --git a/mrpgi-core/src/bin/mrpgi-headless/mcp.rs b/mrpgi-core/src/bin/mrpgi-headless/mcp.rs new file mode 100644 index 0000000..3740232 --- /dev/null +++ b/mrpgi-core/src/bin/mrpgi-headless/mcp.rs @@ -0,0 +1,162 @@ +//! The MCP control surface — newline-delimited JSON-RPC 2.0 on stdio, the +//! Model Context Protocol's stdio transport. This is how Claude both AUTHORS +//! a game (upsert rooms/objects, paint) and PLAYS it (parse, walk, tick), +//! with `mrpgi_render_frame` returning a real PNG so the model can *see* +//! the frame it just produced. +//! +//! Register with e.g.: +//! claude mcp add mrpgi -- /path/to/mrpgi-headless --mcp --game games/sample + +use mrpgi_core::state::{Command, Event}; +use mrpgi_core::GameState; +use serde_json::{json, Value}; +use std::io::{BufRead, Write}; + +pub fn serve(mut gs: GameState) { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + // Notifications (no id) need no reply. + let Some(id) = id else { continue }; + + let result = match method { + "initialize" => Ok(json!({ + "protocolVersion": msg["params"]["protocolVersion"].as_str().unwrap_or("2024-11-05"), + "capabilities": { "tools": {} }, + "serverInfo": { "name": "mrpgi", "version": env!("CARGO_PKG_VERSION") } + })), + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({ "tools": tool_list() })), + "tools/call" => { + let name = msg["params"]["name"].as_str().unwrap_or(""); + let args = msg["params"]["arguments"].clone(); + call_tool(&mut gs, name, args) + } + _ => Err((-32601, format!("method not found: {}", method))), + }; + + let reply = match result { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }), + }; + let _ = writeln!(out, "{}", reply); + let _ = out.flush(); + } +} + +fn tool(name: &str, desc: &str, props: Value, required: &[&str]) -> Value { + json!({ + "name": name, + "description": desc, + "inputSchema": { "type": "object", "properties": props, "required": required } + }) +} + +fn tool_list() -> Vec { + vec![ + tool("mrpgi_parse", "Type a player command into the running game (e.g. 'look', 'take key', 'use door', 'talk villager'). Returns the game's response events.", + json!({ "text": { "type": "string", "description": "the player's line" } }), &["text"]), + tool("mrpgi_walk_to", "Walk the player toward a point (x 0-159, y 16-167) and run game cycles until arrival or blocked. Room transitions happen automatically at open edges.", + json!({ "x": { "type": "integer" }, "y": { "type": "integer" } }), &["x", "y"]), + tool("mrpgi_choose", "Pick a numbered dialogue choice while a conversation is open.", + json!({ "n": { "type": "integer", "description": "1-based choice number" } }), &["n"]), + tool("mrpgi_tick", "Advance n fixed game cycles (20 per second of game time).", + json!({ "n": { "type": "integer" } }), &["n"]), + tool("mrpgi_state", "Get the full game state snapshot: room, ego position, inventory, flags, visible objects, exits, transcript tail.", + json!({}), &[]), + tool("mrpgi_render_frame", "Render the current frame headlessly and return it as a PNG image — look at what the player (or your authoring) actually produced.", + json!({ "with_ego": { "type": "boolean", "description": "draw the player robot (default true)" } }), &[]), + tool("mrpgi_new_game", "Reset the session and start at the manifest's start room (or a specific room).", + json!({ "room": { "type": "integer" } }), &[]), + tool("mrpgi_load_game", "Load a game folder (game.json + rooms/ + sprites/) and start it.", + json!({ "dir": { "type": "string" } }), &["dir"]), + tool("mrpgi_upsert_room", "Create or replace a whole room from a RoomDoc JSON: strokes (paint), spawn, exits [N,E,S,W], objects, music. Validated before commit. Set persist to write it to disk.", + json!({ "room": { "type": "integer" }, "doc": { "type": "object" }, "persist": { "type": "boolean" } }), &["room", "doc"]), + tool("mrpgi_upsert_object", "Insert or replace one object (matched by name) in a room (default: current room). ObjDef fields: name, sprite, x, y, look, takeable, synonyms, use_text, needs, wins, requires_flag, sets_flag, dialogue.", + json!({ "room": { "type": "integer" }, "obj": { "type": "object" } }), &["obj"]), + tool("mrpgi_paint_stroke", "Paint into the current room. Stroke JSON is one of: {\"Rect\":{x,y,w,h,color,meaning}}, {\"Ellipse\":{...}}, {\"Fill\":{x,y,color,meaning}}, {\"Line\":{pts,color}}, {\"Brush\":{pts,color,meaning,size}}, {\"Image\":{name,x,y,meaning}}. meaning is one of None|Floor|Wall|Water|Trigger; color is an EGA index 0-15.", + json!({ "stroke": { "type": "object" } }), &["stroke"]), + tool("mrpgi_save_room", "Write a room (default: current) to disk as rooms/roomN.json.", + json!({ "room": { "type": "integer" } }), &[]), + tool("mrpgi_command", "Escape hatch: send any raw engine Command JSON (see the JSONL surface docs).", + json!({ "command": { "type": "object" } }), &["command"]), + ] +} + +fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result { + let cmd = match name { + "mrpgi_parse" => Command::Parse { text: args["text"].as_str().unwrap_or("").to_string() }, + "mrpgi_walk_to" => Command::WalkTo { x: args["x"].as_i64().unwrap_or(80) as i32, y: args["y"].as_i64().unwrap_or(150) as i32 }, + "mrpgi_choose" => Command::Choose { n: args["n"].as_u64().unwrap_or(1) as usize }, + "mrpgi_tick" => Command::Tick { n: args["n"].as_u64().unwrap_or(20) as u32 }, + "mrpgi_state" => Command::Query, + "mrpgi_new_game" => Command::NewGame { room: args["room"].as_u64().map(|n| n as u32) }, + "mrpgi_load_game" => Command::LoadGame { dir: args["dir"].as_str().unwrap_or(".").to_string() }, + "mrpgi_save_room" => Command::SaveRoom { n: args["room"].as_u64().map(|n| n as u32) }, + "mrpgi_render_frame" => { + let png = gs.render_png(args["with_ego"].as_bool().unwrap_or(true)); + return Ok(json!({ + "content": [{ "type": "image", "data": b64(&png), "mimeType": "image/png" }] + })); + } + "mrpgi_upsert_room" => { + let doc = serde_json::from_value(args["doc"].clone()).map_err(|e| (-32602i64, format!("bad RoomDoc: {}", e)))?; + Command::UpsertRoom { n: args["room"].as_u64().unwrap_or(0) as u32, doc, persist: args["persist"].as_bool().unwrap_or(false) } + } + "mrpgi_upsert_object" => { + let obj = serde_json::from_value(args["obj"].clone()).map_err(|e| (-32602i64, format!("bad ObjDef: {}", e)))?; + Command::UpsertObject { room: args["room"].as_u64().map(|n| n as u32), obj } + } + "mrpgi_paint_stroke" => { + let stroke = serde_json::from_value(args["stroke"].clone()).map_err(|e| (-32602i64, format!("bad Stroke: {}", e)))?; + Command::PaintStroke { stroke } + } + "mrpgi_command" => serde_json::from_value(args["command"].clone()).map_err(|e| (-32602i64, format!("bad Command: {}", e)))?, + _ => return Err((-32602, format!("unknown tool: {}", name))), + }; + + let events = gs.apply(cmd); + let is_error = events.iter().any(|e| matches!(e, Event::Error { .. })); + let text = serde_json::to_string_pretty(&events).unwrap_or_default(); + Ok(json!({ + "content": [{ "type": "text", "text": text }], + "isError": is_error + })) +} + +/// Standard base64 (RFC 4648 with padding). Hand-rolled to stay dependency-free. +fn b64(data: &[u8]) -> String { + const TBL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + out.push(TBL[(n >> 18) as usize & 63] as char); + out.push(TBL[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { TBL[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { TBL[n as usize & 63] as char } else { '=' }); + } + out +} + +#[cfg(test)] +mod tests { + #[test] + fn b64_matches_known_vectors() { + assert_eq!(super::b64(b""), ""); + assert_eq!(super::b64(b"f"), "Zg=="); + assert_eq!(super::b64(b"fo"), "Zm8="); + assert_eq!(super::b64(b"foo"), "Zm9v"); + assert_eq!(super::b64(b"foobar"), "Zm9vYmFy"); + } +} diff --git a/src/framebuffer.rs b/mrpgi-core/src/framebuffer.rs similarity index 100% rename from src/framebuffer.rs rename to mrpgi-core/src/framebuffer.rs diff --git a/mrpgi-core/src/kit.rs b/mrpgi-core/src/kit.rs new file mode 100644 index 0000000..a8e88d0 --- /dev/null +++ b/mrpgi-core/src/kit.rs @@ -0,0 +1,278 @@ +//! Starter content, built with the real types so the JSON is always valid: +//! a 3-room sample game, the fantasy kit (archetype catalog + prefab +//! locations + a connected world), and the archetype loader the editor uses. + +use crate::assets; +use crate::world::{GameManifest, Meaning, ObjDef, RoomDoc, Stroke}; +use std::path::Path; + +#[allow(clippy::too_many_arguments)] +fn obj(name: &str, sprite: &str, x: i32, y: i32, look: &str, takeable: bool, needs: &str, use_text: &str, wins: bool, syn: &str) -> ObjDef { + ObjDef { + name: name.into(), + sprite: sprite.into(), + x, + y, + look: look.into(), + takeable, + synonyms: syn.into(), + use_text: use_text.into(), + needs: needs.into(), + wins, + requires_flag: String::new(), + sets_flag: String::new(), + dialogue: Vec::new(), + } +} + +fn write_room(base: &Path, i: usize, room: &RoomDoc) { + let path = base.join(format!("rooms/room{}.json", i)); + if path.exists() { + std::fs::rename(&path, path.with_extension("json.bak")).ok(); + } + if let Ok(j) = serde_json::to_string_pretty(room) { + std::fs::write(&path, j).ok(); + } +} + +fn write_manifest(base: &Path, name: &str, intro: &str) { + let m = GameManifest { name: name.into(), intro_text: intro.into(), ..Default::default() }; + if let Ok(j) = serde_json::to_string_pretty(&m) { + std::fs::write(base.join("game.json"), j).ok(); + } +} + +/// Write a fun little 3-room sample game into `base` (rooms/ + game.json). +pub fn write_sample_world(base: impl AsRef) { + let base = base.as_ref(); + std::fs::create_dir_all(base.join("rooms")).ok(); + assets::ensure_sample_sprites(&base.join("sprites").to_string_lossy()); + + let wall = |x: i32, y: i32, w: i32, h: i32| Stroke::Rect { x, y, w, h, color: 0, meaning: Meaning::Wall }; + let paint = |x: i32, y: i32, w: i32, h: i32, c: u8| Stroke::Rect { x, y, w, h, color: c, meaning: Meaning::None }; + let floor = |x: i32, y: i32, w: i32, h: i32, c: u8| Stroke::Rect { x, y, w, h, color: c, meaning: Meaning::Floor }; + + // Room 0 — the Boot-Up Closet (exit EAST to room 1) + let room0 = RoomDoc { + strokes: vec![ + paint(0, 0, 160, 168, 8), + floor(6, 16, 148, 146, 7), + wall(0, 0, 160, 16), + wall(0, 160, 160, 8), + wall(0, 0, 6, 168), + wall(154, 0, 6, 70), + wall(154, 100, 6, 68), + paint(64, 36, 28, 12, 13), + ], + spawn: (24, 140), + exits: [None, Some(1), None, None], + objects: vec![ + obj("battery", "barrel", 40, 124, "A fat 9-volt, warm to the touch. Still a little zap left in it.", true, "", "", false, "cell power"), + obj("crate", "crate", 120, 134, "A crate stamped MONSTER ROBOT PARTY. It rattles when you nudge it.", false, "", "", false, "box"), + obj("sign", "sign", 78, 56, "A flickering sign: -> THIS WAY TO THE PARTY.", false, "", "", false, "arrow notice"), + ], + music: 4, + }; + + // Room 1 — the Party Hall (WEST to 0, NORTH to 2) + let room1 = RoomDoc { + strokes: vec![ + paint(0, 0, 160, 168, 1), + floor(6, 16, 148, 146, 8), + wall(0, 0, 65, 16), + wall(95, 0, 65, 16), + wall(0, 160, 160, 8), + wall(154, 0, 6, 168), + wall(0, 0, 6, 70), + wall(0, 100, 6, 68), + paint(20, 28, 26, 8, 13), + paint(112, 26, 28, 8, 11), + paint(66, 22, 28, 6, 14), + ], + spawn: (80, 140), + exits: [Some(2), None, None, Some(0)], + objects: vec![ + obj("robot", "crate", 60, 124, "Another little robot, vibing hard to a beat only it can hear. It will not be interrupted.", false, "", "", false, "bot dude guy"), + obj("speaker", "crate", 122, 118, "A speaker the size of a fridge. Your chassis rattles, pleasantly.", false, "", "", false, "amp boombox"), + obj("punch", "barrel", 30, 142, "A barrel of suspicious party punch. You have no mouth, but you respect the commitment.", false, "", "", false, "drink barrel"), + ], + music: 4, + }; + + // Room 2 — the Exit Hatch (SOUTH back to 1) — the win room + let room2 = RoomDoc { + strokes: vec![ + paint(0, 0, 160, 168, 0), + floor(6, 16, 148, 146, 8), + wall(0, 0, 160, 16), + wall(0, 0, 6, 168), + wall(154, 0, 6, 168), + wall(0, 160, 65, 8), + wall(95, 160, 65, 8), + paint(60, 30, 40, 10, 10), + ], + spawn: (80, 140), + exits: [None, None, Some(1), None], + objects: vec![obj( + "hatch", + "sign", + 80, + 98, + "A heavy exit hatch with a dead battery slot. It won't budge without power.", + false, + "battery", + "You slam the battery into the slot. The hatch grinds open and cold night air rushes in. You roll out into the dark. YOU ESCAPED THE PARTY. What a night.", + true, + "door gate exit", + )], + music: 2, + }; + + for (i, room) in [room0, room1, room2].iter().enumerate() { + write_room(base, i, room); + } + write_manifest(base, "Monster Robot Party", "You blink awake. Type 'help'."); +} + +/// Build a standard walled room: backdrop + walkable floor + border walls, +/// leaving a centered gap on each edge listed in `gaps` (0=N,1=E,2=S,3=W). +fn walled(bg: u8, fl: u8, wc: u8, gaps: &[u8]) -> Vec { + let w = |x: i32, y: i32, ww: i32, hh: i32| Stroke::Rect { x, y, w: ww, h: hh, color: wc, meaning: Meaning::Wall }; + let mut v = vec![ + Stroke::Rect { x: 0, y: 0, w: 160, h: 168, color: bg, meaning: Meaning::None }, + Stroke::Rect { x: 6, y: 16, w: 148, h: 146, color: fl, meaning: Meaning::Floor }, + ]; + if gaps.contains(&0) { v.push(w(0, 0, 65, 16)); v.push(w(95, 0, 65, 16)); } else { v.push(w(0, 0, 160, 16)); } + if gaps.contains(&2) { v.push(w(0, 160, 65, 8)); v.push(w(95, 160, 65, 8)); } else { v.push(w(0, 160, 160, 8)); } + if gaps.contains(&3) { v.push(w(0, 0, 6, 70)); v.push(w(0, 100, 6, 68)); } else { v.push(w(0, 0, 6, 168)); } + if gaps.contains(&1) { v.push(w(154, 0, 6, 70)); v.push(w(154, 100, 6, 68)); } else { v.push(w(154, 0, 6, 168)); } + v +} + +/// Write the fantasy starter kit into `base`: placeholder sprites, an +/// object-archetype catalog (kit/objects.json), prefab locations +/// (kit/locations/*.json), and a connected playable world installed to +/// rooms/ (existing rooms → .bak). +pub fn write_kit(base: impl AsRef) { + let base = base.as_ref(); + assets::write_kit_sprites(&base.join("sprites").to_string_lossy()); + std::fs::create_dir_all(base.join("kit/locations")).ok(); + std::fs::create_dir_all(base.join("rooms")).ok(); + + // Object archetype catalog — copy any of these into a room's `objects`. + let catalog: Vec = vec![ + obj("sword", "sword", 80, 130, "A notched iron sword. It has Opinions about orcs.", true, "", "", false, "blade weapon"), + obj("spellbook", "spellbook", 80, 130, "A spellbook bound in something best not identified. The runes won't hold still.", true, "", "", false, "book tome grimoire"), + obj("key", "key", 80, 130, "A heavy iron key, cold and important-looking.", true, "", "", false, "ironkey"), + obj("potion", "potion", 80, 130, "A vial of bubbling red liquid. Almost certainly fine.", true, "", "", false, "vial elixir flask"), + obj("torch", "torch", 80, 130, "A guttering torch. It pushes the dark back a little.", true, "", "", false, "light flame"), + obj("orc", "orc", 80, 120, "A green orc, picking its teeth with a dagger. It hasn't noticed you. Yet.", false, "", "You poke the orc. It looks up. In hindsight, a mistake.", false, "monster brute goblin"), + obj("villager", "villager", 80, 120, "A nervous villager. 'You're... not from the cave, are you?'", false, "", "'Sorry, nothing for you,' the villager mumbles.", false, "person man woman npc"), + obj("chest", "chest", 80, 135, "A banded wooden chest. Locked.", false, "key", "The key turns; the lid yawns open and old gold spills out.", false, "box trunk"), + obj("door", "door", 80, 120, "A stout wooden door, banded with iron.", false, "key", "The lock clunks and the door swings wide.", false, "gate"), + obj("altar", "altar", 80, 100, "A stone altar humming with cold light. Something is meant to rest here.", false, "spellbook", "You lay the spellbook on the altar. A doorway tears open in the air. THE END.", true, "shrine pedestal"), + obj("tree", "tree", 60, 80, "An ancient tree, bark like old leather.", false, "", "", false, "oak trunk wood"), + obj("rock", "rock", 120, 130, "A mossy boulder. Immovable, and a little smug about it.", false, "", "", false, "boulder stone"), + obj("sign", "sign", 80, 50, "A weathered signpost, paint long faded.", false, "", "", false, "notice post"), + ]; + if let Ok(j) = serde_json::to_string_pretty(&catalog) { + std::fs::write(base.join("kit/objects.json"), j).ok(); + } + + // Connected world: 0 forest, 1 cave, 2 dungeon, 3 inn. + let mut s = walled(2, 10, 6, &[1, 2]); + s.push(Stroke::Rect { x: 30, y: 120, w: 18, h: 8, color: 2, meaning: Meaning::None }); + s.push(Stroke::Rect { x: 100, y: 60, w: 22, h: 8, color: 2, meaning: Meaning::None }); + let forest = RoomDoc { + strokes: s, + spawn: (24, 130), + exits: [None, Some(1), Some(3), None], + objects: vec![ + obj("tree", "tree", 44, 70, "An ancient tree, bark like old leather.", false, "", "", false, "oak trunk wood"), + obj("sword", "sword", 80, 132, "A notched iron sword someone left in the grass. Finders keepers.", true, "", "", false, "blade weapon"), + obj("rock", "rock", 120, 134, "A mossy boulder, smug and immovable.", false, "", "", false, "boulder stone"), + obj("sign", "sign", 80, 44, "A signpost: CAVE east, INN south.", false, "", "", false, "notice post"), + ], + music: 1, + }; + + let mut s = walled(0, 8, 0, &[0, 3]); + s.push(Stroke::Rect { x: 40, y: 60, w: 10, h: 6, color: 7, meaning: Meaning::None }); + s.push(Stroke::Rect { x: 110, y: 80, w: 12, h: 6, color: 7, meaning: Meaning::None }); + let cave = RoomDoc { + strokes: s, + spawn: (80, 130), + exits: [Some(2), None, None, Some(0)], + objects: vec![ + obj("orc", "orc", 92, 118, "A green orc picking its teeth with a dagger. It hasn't noticed you. Yet.", false, "", "You poke the orc. This was a mistake.", false, "monster brute goblin"), + obj("spellbook", "spellbook", 122, 132, "A spellbook on a ledge, runes squirming. The orc is guarding it (badly).", true, "", "", false, "book tome grimoire"), + obj("torch", "torch", 36, 110, "A guttering wall-torch. Grab it — it's dark deeper in.", true, "", "", false, "light flame"), + obj("chest", "chest", 58, 140, "A banded chest, half-buried. Locked.", false, "key", "The key turns; old gold spills out across the cave floor.", false, "box trunk"), + ], + music: 2, + }; + + let mut s = walled(0, 8, 0, &[2]); + s.push(Stroke::Rect { x: 58, y: 30, w: 44, h: 10, color: 13, meaning: Meaning::None }); + let dungeon = RoomDoc { + strokes: s, + spawn: (80, 138), + exits: [None, None, Some(1), None], + objects: vec![ + obj("altar", "altar", 80, 98, "A stone altar humming with cold light. A book-shaped hollow waits in its surface.", false, "spellbook", "You lay the spellbook on the altar. Light pours up, a doorway tears open, and you step through. YOU ESCAPED — adventure complete. THE END.", true, "shrine pedestal"), + obj("torch", "torch", 30, 120, "A torch in a rusted bracket.", true, "", "", false, "light flame"), + ], + music: 3, + }; + + let mut s = walled(6, 7, 6, &[0]); + s.push(Stroke::Rect { x: 40, y: 122, w: 80, h: 28, color: 4, meaning: Meaning::None }); + s.push(Stroke::Rect { x: 20, y: 24, w: 8, h: 8, color: 14, meaning: Meaning::None }); + let inn = RoomDoc { + strokes: s, + spawn: (80, 138), + exits: [Some(0), None, None, None], + objects: vec![ + obj("villager", "villager", 60, 112, "A nervous barkeep. 'Heading to the cave? Take the key — and my prayers.'", false, "", "'Be careful in there,' the barkeep says, sliding you nothing useful.", false, "barkeep person npc"), + obj("key", "key", 44, 140, "An iron key hanging by the door. The barkeep nods at you to take it.", true, "", "", false, "ironkey"), + obj("potion", "potion", 112, 132, "A complimentary vial of bubbling red liquid. Probably fine.", true, "", "", false, "vial elixir flask"), + obj("barrel", "barrel", 122, 120, "A barrel of inn ale. Smells like courage and mistakes.", false, "", "", false, "ale keg"), + ], + music: 4, + }; + + let rooms: [(&str, &RoomDoc); 4] = [("forest", &forest), ("cave", &cave), ("dungeon", &dungeon), ("inn", &inn)]; + for (name, r) in rooms.iter() { + if let Ok(j) = serde_json::to_string_pretty(*r) { + std::fs::write(base.join(format!("kit/locations/{}.json", name)), j).ok(); + } + } + for (i, (_n, r)) in rooms.iter().enumerate() { + write_room(base, i, r); + } + write_manifest(base, "The Altar Quest", "You wake in a quiet forest. Type 'help'."); +} + +/// Load every object archetype the kit ships (fantasy `kit/objects.json` + +/// every `kit/themes/*/objects.json`) so the editor can stamp them by clicking. +pub fn load_archetypes(base: impl AsRef) -> Vec { + let base = base.as_ref(); + let mut files = vec![base.join("kit/objects.json")]; + if let Ok(rd) = std::fs::read_dir(base.join("kit/themes")) { + for e in rd.flatten() { + let p = e.path().join("objects.json"); + if p.exists() { + files.push(p); + } + } + } + let mut out = Vec::new(); + for f in files { + if let Ok(s) = std::fs::read_to_string(&f) { + if let Ok(v) = serde_json::from_str::>(&s) { + out.extend(v); + } + } + } + out +} diff --git a/mrpgi-core/src/lib.rs b/mrpgi-core/src/lib.rs new file mode 100644 index 0000000..24437aa --- /dev/null +++ b/mrpgi-core/src/lib.rs @@ -0,0 +1,25 @@ +//! mrpgi-core — the headless MRPGI engine. +//! +//! The whole sim lives here with zero windowing dependencies (that's the +//! guardrail: this crate must never depend on macroquad). One insight powers +//! every control surface: "CLI or endpoints or websockets or MCP or Python" +//! is one feature, not seven — a [`state::GameState`] behind a typed +//! command/event bus. Every surface is a thin adapter that pushes +//! [`Command`]s in and reads [`Event`]s out. + +pub mod ai; +pub mod assets; +pub mod audio; +pub mod framebuffer; +pub mod kit; +pub mod palette; +pub mod parser; +pub mod picture; +pub mod render; +pub mod sprite; +pub mod state; +pub mod view; +pub mod world; + +pub use state::{Command, Event, GameState, StateSnapshot, CYCLE_DT}; +pub use world::{DlgChoice, DlgNode, GameManifest, Meaning, ObjDef, RoomDoc, Stroke, World}; diff --git a/src/palette.rs b/mrpgi-core/src/palette.rs similarity index 69% rename from src/palette.rs rename to mrpgi-core/src/palette.rs index 3098e6c..b771f4d 100644 --- a/src/palette.rs +++ b/mrpgi-core/src/palette.rs @@ -1,9 +1,7 @@ //! The 16-color EGA palette MRPGI inherits from the original AGI. //! //! Everything in the engine works in palette *indices* (0..=15); we only -//! convert to real RGBA at the very last moment, when we hand pixels to the GPU. - -use macroquad::prelude::Color; +//! convert to real RGBA at the very last moment, when pixels leave the core. /// Standard IBM EGA / CGA 16-color palette, as (R, G, B). pub const EGA: [(u8, u8, u8); 16] = [ @@ -25,16 +23,9 @@ pub const EGA: [(u8, u8, u8); 16] = [ (0xFF, 0xFF, 0xFF), // 15 white ]; -/// Raw RGBA bytes for a palette index (for writing straight into an Image). +/// Raw RGBA bytes for a palette index. #[inline] pub fn rgba(idx: u8) -> [u8; 4] { let (r, g, b) = EGA[(idx & 0x0F) as usize]; [r, g, b, 255] } - -/// macroquad `Color` for a palette index (for `draw_*` calls). -#[inline] -pub fn color(idx: u8) -> Color { - let (r, g, b) = EGA[(idx & 0x0F) as usize]; - Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0) -} diff --git a/mrpgi-core/src/parser.rs b/mrpgi-core/src/parser.rs new file mode 100644 index 0000000..06e7b3b --- /dev/null +++ b/mrpgi-core/src/parser.rs @@ -0,0 +1,268 @@ +//! The deterministic verb-noun parser, its data-driven verb table, and live +//! dialogue state. The AI lane plugs in *behind* this: it may only translate +//! free text into a command this parser already accepts. + +use crate::world::{DlgNode, ObjDef}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +const IGNORE: &[&str] = &["the", "a", "an", "at", "to", "with", "on", "of", "my", "your", "some", "please", "go", "up"]; + +/// One canonical verb and every word that maps to it. The same table drives +/// the parser AND the AI prompt whitelist, so the two can never drift. +#[derive(Clone, Serialize, Deserialize)] +pub struct VerbDef { + pub name: String, + pub words: Vec, +} + +fn v(name: &str, words: &[&str]) -> VerbDef { + VerbDef { name: name.into(), words: words.iter().map(|s| s.to_string()).collect() } +} + +/// The built-in verb table (the seven AGI-spirit verbs). +pub fn default_verbs() -> Vec { + vec![ + v("look", &["look", "l", "examine", "x", "inspect", "check", "read", "view", "see", "study"]), + v("take", &["take", "get", "grab", "pick", "snatch", "steal", "pocket", "collect", "nab"]), + v("use", &["use", "open", "unlock", "activate", "push", "pull", "turn", "operate"]), + v("talk", &["talk", "speak", "ask", "chat", "greet", "say"]), + v("drop", &["drop", "discard", "leave", "put"]), + v("inventory", &["inventory", "inv", "i", "items", "bag"]), + v("help", &["help", "?", "commands", "verbs"]), + ] +} + +/// Built-ins + a game's extra synonyms (merged by canonical name; unknown +/// canonical names are appended so the words at least parse as that verb). +pub fn build_verbs(extra: &[VerbDef]) -> Vec { + let mut verbs = default_verbs(); + for e in extra { + if let Some(existing) = verbs.iter_mut().find(|d| d.name == e.name) { + for w in &e.words { + if !existing.words.contains(w) { + existing.words.push(w.clone()); + } + } + } else { + verbs.push(e.clone()); + } + } + verbs +} + +/// Map a typed word to its canonical verb ("" if unknown). +pub fn canon_verb<'a>(verbs: &'a [VerbDef], w: &str) -> &'a str { + verbs + .iter() + .find(|d| d.words.iter().any(|word| word == w)) + .map(|d| d.name.as_str()) + .unwrap_or("") +} + +/// Every word the AI is allowed to use, for the prompt whitelist. +pub fn verb_whitelist(verbs: &[VerbDef]) -> String { + let mut words: Vec<&str> = Vec::new(); + for d in verbs { + for w in &d.words { + if w.len() > 1 && words.len() < 24 && !words.contains(&w.as_str()) { + words.push(w); + } + } + } + words.join(", ") +} + +/// Does this object answer to `noun` (its name or any synonym, loosely)? +fn obj_matches(o: &ObjDef, noun: &str) -> bool { + if noun.is_empty() { + return false; + } + let mut names: Vec = vec![o.name.to_lowercase()]; + for s in o.synonyms.split_whitespace() { + names.push(s.to_lowercase()); + } + names.iter().any(|nm| nm.as_str() == noun || nm.contains(noun) || noun.contains(nm.as_str())) +} + +/// Index of a visible (not-taken) object that answers to `noun`. +fn match_obj(objects: &[ObjDef], room: u32, taken: &[(u32, String)], noun: &str) -> Option { + objects.iter().position(|o| { + let visible = !taken.iter().any(|(r, n)| *r == room && *n == o.name); + visible && obj_matches(o, noun) + }) +} + +fn flag_ok(flags: &HashMap, name: &str) -> bool { + name.is_empty() || flags.get(name).copied().unwrap_or(false) +} + +/// The parser: typed line → ignore-words stripped → canonical verb + noun → +/// response. Deterministic; the AI lane plugs into the catch-all branch. +/// Returns (lines to print, understood). +#[allow(clippy::too_many_arguments)] +pub fn run_command( + input: &str, + verbs: &[VerbDef], + objects: &[ObjDef], + room: u32, + inventory: &mut Vec, + taken: &mut Vec<(u32, String)>, + won: &mut bool, + flags: &mut HashMap, + talk_target: &mut Option, +) -> (Vec, bool) { + let lower = input.trim().to_lowercase(); + let words: Vec<&str> = lower.split_whitespace().filter(|w| !IGNORE.contains(w)).collect(); + if words.is_empty() { + return (vec![], true); + } + let verb = canon_verb(verbs, words[0]); + let noun = words[1..].join(" "); + let understood = !verb.is_empty(); + let lines = match verb { + "look" => { + if noun.is_empty() || noun == "around" || noun == "room" || noun == "here" { + let names: Vec = objects + .iter() + .filter(|o| !taken.iter().any(|(r, n)| *r == room && *n == o.name)) + .map(|o| o.name.clone()) + .collect(); + if names.is_empty() { + vec![format!("Room {}. Nothing in particular stands out.", room)] + } else { + vec![format!("Room {}. You see: {}.", room, names.join(", "))] + } + } else if let Some(i) = match_obj(objects, room, taken, &noun) { + vec![objects[i].look.clone()] + } else { + vec![format!("You don't see any '{}' here.", noun)] + } + } + "take" => { + if let Some(i) = match_obj(objects, room, taken, &noun) { + if objects[i].takeable { + inventory.push(objects[i].name.clone()); + taken.push((room, objects[i].name.clone())); + vec![format!("You take the {}.", objects[i].name)] + } else { + vec![format!("You can't take the {}.", objects[i].name)] + } + } else { + vec![format!("There's no '{}' to take.", noun)] + } + } + "drop" => { + if let Some(p) = inventory.iter().position(|n| !noun.is_empty() && n.to_lowercase().contains(noun.as_str())) { + let n = inventory.remove(p); + vec![format!("You drop the {}.", n)] + } else { + vec![format!("You aren't carrying a '{}'.", noun)] + } + } + "inventory" => { + if inventory.is_empty() { + vec!["You're carrying nothing.".to_string()] + } else { + vec![format!("Carrying: {}.", inventory.join(", "))] + } + } + "use" => { + if let Some(i) = match_obj(objects, room, taken, &noun) { + let needs = objects[i].needs.to_lowercase(); + if !needs.is_empty() && !inventory.iter().any(|it| it.to_lowercase() == needs) { + vec![format!("It's locked. You need: {}.", objects[i].needs)] + } else if !flag_ok(flags, &objects[i].requires_flag) { + vec!["Nothing happens. Something else must come first.".to_string()] + } else { + let mut out = vec![if objects[i].use_text.is_empty() { + format!("You use the {}. Nothing obvious happens.", objects[i].name) + } else { + objects[i].use_text.clone() + }]; + if !objects[i].sets_flag.is_empty() { + flags.insert(objects[i].sets_flag.clone(), true); + } + if objects[i].wins { + *won = true; + out.push("\u{2605} THE END — you win! \u{2605}".to_string()); + } + out + } + } else { + vec![format!("There's no '{}' to use.", noun)] + } + } + "talk" => { + if let Some(i) = match_obj(objects, room, taken, &noun) { + if objects[i].dialogue.is_empty() { + vec![format!("{} has nothing to say.", objects[i].name)] + } else { + *talk_target = Some(i); + vec![] + } + } else { + vec![format!("There's no '{}' to talk to.", noun)] + } + } + "help" => { + let names: Vec<&str> = verbs.iter().map(|d| d.name.as_str()).collect(); + vec![format!("Verbs: {}. Arrows walk.", names.join(" \u{00B7} "))] + } + _ => vec![format!("I don't understand \"{}\".", input.trim())], + }; + (lines, understood) +} + +/// A live, in-progress conversation (a clone of an NPC's dialogue tree). +/// Choices gated by `requires_flag` are hidden until the flag is set; the +/// numbers the player sees always match what `choose` accepts. +#[derive(Clone)] +pub struct Dlg { + pub nodes: Vec, + pub node: usize, +} + +impl Dlg { + fn visible(&self, flags: &HashMap) -> Vec { + self.nodes[self.node] + .choices + .iter() + .enumerate() + .filter(|(_, c)| flag_ok(flags, &c.requires_flag)) + .map(|(i, _)| i) + .collect() + } + + pub fn lines(&self, flags: &HashMap) -> Vec { + let n = &self.nodes[self.node]; + let mut out = vec![format!("\u{201C}{}\u{201D}", n.says)]; + for (shown, i) in self.visible(flags).iter().enumerate() { + out.push(format!(" {}) {}", shown + 1, n.choices[*i].text)); + } + out + } + + pub fn terminal(&self, flags: &HashMap) -> bool { + self.visible(flags).is_empty() + } + + /// Pick visible choice `k` (1-based). Returns (lines to print, ended). + pub fn choose(&mut self, k: usize, flags: &mut HashMap) -> (Vec, bool) { + let vis = self.visible(flags); + if k == 0 || k > vis.len() { + return (vec![], false); + } + let c = self.nodes[self.node].choices[vis[k - 1]].clone(); + if !c.sets_flag.is_empty() { + flags.insert(c.sets_flag.clone(), true); + } + let mut out = vec![format!("> {}", c.text)]; + if c.goto < 0 || c.goto as usize >= self.nodes.len() { + return (out, true); + } + self.node = c.goto as usize; + out.extend(self.lines(flags)); + (out, self.terminal(flags)) + } +} diff --git a/src/picture.rs b/mrpgi-core/src/picture.rs similarity index 100% rename from src/picture.rs rename to mrpgi-core/src/picture.rs diff --git a/mrpgi-core/src/render.rs b/mrpgi-core/src/render.rs new file mode 100644 index 0000000..d28052f --- /dev/null +++ b/mrpgi-core/src/render.rs @@ -0,0 +1,61 @@ +//! Headless compositing: room buffers + depth-sorted screen objects → RGBA +//! pixels → optionally a PNG. No window required — this is what lets an LLM +//! *see* the frame it just authored. + +use crate::framebuffer::{FrameBuffer, PIC_H, PIC_W}; +use crate::palette; +use crate::sprite::{Prop, ScreenObj}; +use crate::view; + +/// The real AGI trick: draw each object bottom-up by priority, but test each +/// pixel against the room's priority buffer, so a high-priority column drawn +/// into the *room* still occludes a sprite standing behind it. +pub fn compose(room: &FrameBuffer, ego: Option<&ScreenObj>, props: &[Prop]) -> Vec { + let mut vis = room.visual.clone(); + + let mut draws: Vec<(u8, &view::Cel, i32, i32)> = Vec::with_capacity(props.len() + 1); + if let Some(ego) = ego { + draws.push((ego.priority(), ego.cel(), ego.x, ego.y)); + } + for p in props { + draws.push((p.priority(), p.cel(), p.x, p.y)); + } + draws.sort_by_key(|d| d.0); + + for (pri, cel, bx, by) in draws { + let ox = bx - cel.w as i32 / 2; + let oy = by - cel.h as i32; + for cy in 0..cel.h { + for cx in 0..cel.w { + let c = cel.at(cx, cy); + if c == view::TRANSPARENT { + continue; + } + let sx = ox + cx as i32; + let sy = oy + cy as i32; + if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H { + continue; + } + let i = sy as usize * PIC_W + sx as usize; + if pri >= room.priority[i] { + vis[i] = c; + } + } + } + } + + let mut rgba = Vec::with_capacity(PIC_W * PIC_H * 4); + for &v in &vis { + rgba.extend_from_slice(&palette::rgba(v)); + } + rgba +} + +/// Encode raw RGBA (PIC_W x PIC_H) to PNG bytes. +pub fn encode_png(rgba: &[u8]) -> Vec { + let img = image::RgbaImage::from_raw(PIC_W as u32, PIC_H as u32, rgba.to_vec()) + .expect("buffer is always PIC_W*PIC_H*4"); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageOutputFormat::Png).expect("png encode of a valid image"); + out.into_inner() +} diff --git a/src/sprite.rs b/mrpgi-core/src/sprite.rs similarity index 100% rename from src/sprite.rs rename to mrpgi-core/src/sprite.rs diff --git a/mrpgi-core/src/state.rs b/mrpgi-core/src/state.rs new file mode 100644 index 0000000..d5a9a2d --- /dev/null +++ b/mrpgi-core/src/state.rs @@ -0,0 +1,603 @@ +//! The engine's spine: [`GameState`] owns the whole sim and is driven purely +//! by [`Command`]s (in) and [`Event`]s (out), with a fixed-step `tick`. +//! +//! Every control surface — GUI, JSONL stdio, HTTP, MCP — is a thin adapter +//! that pushes commands and reads events. Nothing in here knows or cares +//! which one is attached; that's what keeps "add another option later" a +//! 30-line adapter instead of a rewrite. + +use crate::ai; +use crate::audio::AudioCue; +use crate::framebuffer::{CTRL_BLOCK, PIC_H, PIC_W}; +use crate::parser::{self, Dlg, VerbDef}; +use crate::render; +use crate::sprite::{Prop, ScreenObj}; +use crate::view::default_robot_view; +use crate::world::{find_spawn, ObjDef, RoomDoc, Stroke, World}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The AGI heartbeat: 20 game cycles per second, always exactly this long, +/// so scripted replays are bit-identical no matter who is driving. +pub const CYCLE_DT: f32 = 1.0 / 20.0; + +/// How much scrollback the core keeps (front-ends show what they like). +const TRANSCRIPT_CAP: usize = 200; + +/// Everything the engine can be told to do, from any surface. +/// Wire form is newline-JSON like `{"cmd":"parse","text":"take key"}`. +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum Command { + // --- playing --------------------------------------------------------- + /// Set the ego's direction (AGI ordering: 0 stop, 1 N .. 8 NW). + Walk { dir: u8 }, + /// Click-to-walk: head toward a picture-space point. + MoveTo { x: i32, y: i32 }, + /// Walk toward a point and run cycles until arrival (or stuck). + WalkTo { x: i32, y: i32 }, + /// A typed player line — the parser, with the AI lane as fallback. + Parse { text: String }, + /// Pick a numbered dialogue choice (1-based). + Choose { n: usize }, + EndDialogue, + /// Advance `n` fixed game cycles (movement, transitions, AI results). + Tick { n: u32 }, + /// Reset the session and start at `room` (or the manifest's start room). + NewGame { room: Option }, + GotoRoom { n: u32 }, + /// Emit a full state snapshot. + Query, + SetFlag { name: String, value: bool }, + SetVar { name: String, value: i32 }, + // --- authoring (the same bus the editor uses) ------------------------- + PaintStroke { stroke: Stroke }, + Undo, + ClearStrokes, + SetSpawn { x: i32, y: i32 }, + /// dir: 0 N, 1 E, 2 S, 3 W. None clears the exit. + SetExit { dir: u8, target: Option }, + SetMusic { mood: u8 }, + /// Insert/replace an object (matched by name) in `room` (None = current). + UpsertObject { room: Option, obj: ObjDef }, + DeleteObject { room: Option, name: String }, + /// Drop a whole room in as JSON — the "lore ingestion" command. + UpsertRoom { n: u32, doc: RoomDoc, #[serde(default)] persist: bool }, + SaveRoom { n: Option }, + LoadGame { dir: String }, + ReloadSprites, +} + +/// Everything the engine reports back. Serialized as e.g. +/// `{"event":"transcript","line":"You take the key."}`. +#[derive(Clone, Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Transcript { line: String }, + RoomChanged { room: u32, music: u8 }, + InventoryChanged { items: Vec }, + DialogueOpen { lines: Vec }, + DialogueClosed, + Won, + Audio { cue: AudioCue }, + Music { mood: u8 }, + EgoMoved { x: i32, y: i32, arrived: bool }, + State { state: StateSnapshot }, + Edited { room: u32, strokes: usize }, + RoomUpserted { room: u32, persisted: bool }, + RoomSaved { room: u32, path: String }, + GameLoaded { name: String, room: u32 }, + Error { message: String }, +} + +#[derive(Clone, Serialize)] +pub struct ObjSummary { + pub name: String, + pub x: i32, + pub y: i32, + pub takeable: bool, + pub has_dialogue: bool, +} + +#[derive(Clone, Serialize)] +pub struct StateSnapshot { + pub game: String, + pub room: u32, + pub ego: (i32, i32, u8), + pub inventory: Vec, + pub won: bool, + pub flags: HashMap, + pub vars: HashMap, + pub exits: [Option; 4], + pub music: u8, + pub objects: Vec, + pub in_dialogue: bool, + pub dialogue: Option>, + pub transcript_tail: Vec, +} + +pub struct GameState { + pub world: World, + pub ego: ScreenObj, + pub inventory: Vec, + pub taken: Vec<(u32, String)>, + pub won: bool, + pub dlg: Option, + pub transcript: Vec, + pub flags: HashMap, + pub vars: HashMap, + pub verbs: Vec, + pub ai: ai::AiConfig, + pending: Option>, + acc: f32, +} + +impl GameState { + pub fn new(world: World, ai: ai::AiConfig) -> Self { + let mut ego = ScreenObj::new(default_robot_view(), 80, 150); + ego.min_x = 4; + ego.max_x = PIC_W as i32 - 4; + ego.min_y = 16; + ego.max_y = PIC_H as i32 - 4; + let verbs = parser::build_verbs(&world.manifest.verbs); + let flags = world.manifest.flags.clone(); + GameState { + world, + ego, + inventory: Vec::new(), + taken: Vec::new(), + won: false, + dlg: None, + transcript: Vec::new(), + flags, + vars: HashMap::new(), + verbs, + ai, + pending: None, + acc: 0.0, + } + } + + // --- the bus ----------------------------------------------------------- + + pub fn apply(&mut self, cmd: Command) -> Vec { + let mut ev = Vec::new(); + match cmd { + Command::Walk { dir } => { + if dir != 0 { + self.ego.move_target = None; + self.ego.dir = dir.min(8); + } else if self.ego.move_target.is_none() { + self.ego.dir = 0; + } + } + Command::MoveTo { x, y } => { + if self.dlg.is_none() { + self.ego.move_target = Some((x, y)); + } + } + Command::WalkTo { x, y } => { + self.ego.move_target = Some((x, y)); + for _ in 0..1000 { + ev.extend(self.step_cycle()); + if self.ego.move_target.is_none() { + break; + } + } + ev.push(Event::EgoMoved { x: self.ego.x, y: self.ego.y, arrived: self.ego.move_target.is_none() }); + } + Command::Parse { text } => { + let line = text.trim().to_string(); + if !line.is_empty() { + if self.dlg.is_some() { + // A bare number picks a dialogue choice; anything else is ignored while talking. + if let Ok(n) = line.parse::() { + return self.apply(Command::Choose { n }); + } + ev.push(self.line("(type a number to answer, or end the conversation.)")); + } else { + ev.extend(self.run_parse(&line, false)); + } + } + } + Command::Choose { n } => { + if let Some(mut d) = self.dlg.take() { + let (lines, ended) = d.choose(n, &mut self.flags); + for l in lines { + ev.push(self.line(&l)); + } + if ended { + ev.push(self.line("(the conversation ends.)")); + ev.push(Event::DialogueClosed); + ev.push(Event::Audio { cue: AudioCue::Confirm }); + } else { + self.dlg = Some(d); + ev.push(Event::Audio { cue: AudioCue::Blip }); + } + } + } + Command::EndDialogue => { + if self.dlg.take().is_some() { + ev.push(self.line("(you end the conversation.)")); + ev.push(Event::DialogueClosed); + } + } + Command::Tick { n } => { + for _ in 0..n.min(10_000) { + ev.extend(self.poll_ai()); + ev.extend(self.step_cycle()); + } + } + Command::NewGame { room } => { + ev.extend(self.new_game(room)); + } + Command::GotoRoom { n } => { + self.world.goto_room(n); + ev.extend(self.enter_room()); + } + Command::Query => { + ev.push(Event::State { state: self.snapshot() }); + } + Command::SetFlag { name, value } => { + self.flags.insert(name, value); + } + Command::SetVar { name, value } => { + self.vars.insert(name, value); + } + Command::PaintStroke { stroke } => { + crate::world::apply_stroke(&mut self.world.fb, &stroke, &self.world.sprites); + self.world.doc.strokes.push(stroke); + ev.push(self.edited()); + } + Command::Undo => { + if self.world.doc.strokes.pop().is_some() { + self.world.bake(); + } + ev.push(self.edited()); + } + Command::ClearStrokes => { + self.world.doc.strokes.clear(); + self.world.bake(); + ev.push(self.edited()); + } + Command::SetSpawn { x, y } => { + self.world.doc.spawn = (x, y); + ev.push(self.edited()); + } + Command::SetExit { dir, target } => { + if dir < 4 { + self.world.doc.exits[dir as usize] = target; + ev.push(self.edited()); + } else { + ev.push(Event::Error { message: "exit dir must be 0..=3 (N,E,S,W)".into() }); + } + } + Command::SetMusic { mood } => { + self.world.doc.music = mood; + ev.push(Event::Music { mood }); + ev.push(self.edited()); + } + Command::UpsertObject { room, obj } => { + let n = room.unwrap_or(self.world.current); + if n == self.world.current { + upsert_obj(&mut self.world.doc.objects, obj); + } else { + let mut doc = self.world.peek_room(n).unwrap_or_default(); + upsert_obj(&mut doc.objects, obj); + self.world.overlay.insert(n, doc); + } + ev.push(Event::Edited { room: n, strokes: 0 }); + } + Command::DeleteObject { room, name } => { + let n = room.unwrap_or(self.world.current); + if n == self.world.current { + self.world.doc.objects.retain(|o| o.name != name); + } else if let Some(mut doc) = self.world.peek_room(n) { + doc.objects.retain(|o| o.name != name); + self.world.overlay.insert(n, doc); + } + ev.push(Event::Edited { room: n, strokes: 0 }); + } + Command::UpsertRoom { n, doc, persist } => match self.world.validate_room(n, &doc) { + Ok(()) => { + if let Err(e) = self.world.upsert_room(n, doc, persist) { + ev.push(Event::Error { message: format!("room {} not written: {}", n, e) }); + } else { + ev.push(Event::RoomUpserted { room: n, persisted: persist }); + } + } + Err(msg) => ev.push(Event::Error { message: msg }), + }, + Command::SaveRoom { n } => { + let n = n.unwrap_or(self.world.current); + let res = if n == self.world.current { + self.world.save_current() + } else if let Some(doc) = self.world.peek_room(n) { + self.world.write_room(n, &doc) + } else { + Err(std::io::Error::other("no such room to save")) + }; + match res { + Ok(()) => ev.push(Event::RoomSaved { room: n, path: self.world.room_path(n).to_string_lossy().into_owned() }), + Err(e) => ev.push(Event::Error { message: format!("save failed: {}", e) }), + } + } + Command::LoadGame { dir } => { + self.world = World::load_game(dir); + self.verbs = parser::build_verbs(&self.world.manifest.verbs); + ev.push(Event::GameLoaded { name: self.world.manifest.name.clone(), room: self.world.current }); + ev.extend(self.new_game(None)); + } + Command::ReloadSprites => { + self.world.reload_sprites(); + ev.push(self.edited()); + } + } + ev + } + + /// Wall-clock driver for real-time front-ends: accumulates `dt` into + /// fixed cycles. Scripted surfaces should send `Command::Tick` instead. + pub fn tick(&mut self, dt: f32) -> Vec { + let mut ev = self.poll_ai(); + if self.dlg.is_none() { + self.acc += dt; + let mut guard = 0; + while self.acc >= CYCLE_DT && guard < 5 { + ev.extend(self.step_cycle()); + self.acc -= CYCLE_DT; + guard += 1; + } + } + ev + } + + // --- internals ----------------------------------------------------------- + + fn line(&mut self, s: &str) -> Event { + self.transcript.push(s.to_string()); + if self.transcript.len() > TRANSCRIPT_CAP { + self.transcript.remove(0); + } + Event::Transcript { line: s.to_string() } + } + + fn edited(&mut self) -> Event { + Event::Edited { room: self.world.current, strokes: self.world.doc.strokes.len() } + } + + fn new_game(&mut self, room: Option) -> Vec { + let target = room.unwrap_or(self.world.manifest.start_room); + if target == self.world.current { + // Playtest what's live (possibly unsaved) — keep the doc, remember it. + self.world.stash_current(); + } else { + self.world.goto_room(target); + } + self.inventory.clear(); + self.taken.clear(); + self.won = false; + self.dlg = None; + self.pending = None; + self.transcript.clear(); + self.flags = self.world.manifest.flags.clone(); + self.vars.clear(); + self.acc = 0.0; + let mut ev = self.enter_room(); + let intro = self.world.manifest.intro_text.clone(); + ev.push(self.line(&intro)); + ev + } + + /// Position the ego at the current room's spawn and announce the room. + fn enter_room(&mut self) -> Vec { + let (sx, sy) = find_spawn(&self.world.fb, self.world.doc.spawn); + self.ego.x = sx; + self.ego.y = sy; + self.ego.dir = 0; + self.ego.move_target = None; + self.acc = 0.0; + vec![Event::RoomChanged { room: self.world.current, music: self.world.doc.music }] + } + + /// One fixed game cycle: move the ego, then handle walking off an edge + /// with an exit. Paused entirely while a conversation is open. + fn step_cycle(&mut self) -> Vec { + if self.dlg.is_some() { + return Vec::new(); + } + let mut ev = Vec::new(); + self.ego.update(&self.world.fb); + + let exits = self.world.doc.exits; + let trig = 5; + let leave = if self.ego.x <= 4 + trig && exits[3].is_some() { + Some((exits[3].unwrap(), 3u8)) + } else if self.ego.x >= PIC_W as i32 - 4 - trig && exits[1].is_some() { + Some((exits[1].unwrap(), 1u8)) + } else if self.ego.y <= 16 + trig && exits[0].is_some() { + Some((exits[0].unwrap(), 0u8)) + } else if self.ego.y >= PIC_H as i32 - 4 - trig && exits[2].is_some() { + Some((exits[2].unwrap(), 2u8)) + } else { + None + }; + if let Some((target, from)) = leave { + self.world.goto_room(target); + let inset = 14; + match from { + 3 => self.ego.x = PIC_W as i32 - 4 - inset, + 1 => self.ego.x = 4 + inset, + 0 => self.ego.y = PIC_H as i32 - 4 - inset, + _ => self.ego.y = 16 + inset, + } + if self.world.fb.priority_at(self.ego.x, self.ego.y) == CTRL_BLOCK { + let (sx, sy) = find_spawn(&self.world.fb, self.world.doc.spawn); + self.ego.x = sx; + self.ego.y = sy; + } + self.ego.dir = 0; + self.ego.move_target = None; + self.acc = 0.0; + let msg = format!("You step into room {}.", self.world.current); + ev.push(self.line(&msg)); + ev.push(Event::Audio { cue: AudioCue::Door }); + ev.push(Event::RoomChanged { room: self.world.current, music: self.world.doc.music }); + } + ev + } + + /// One line through the parser — shared by direct input and the AI lane + /// (this used to exist twice in the GUI loop; now it exists once). + fn run_parse(&mut self, line: &str, from_ai: bool) -> Vec { + let mut ev = Vec::new(); + let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) }; + ev.push(self.line(&echo)); + + let won_before = self.won; + let inv_before = self.inventory.len(); + let mut talk_target: Option = None; + let (lines, understood) = parser::run_command( + line, + &self.verbs, + &self.world.doc.objects, + self.world.current, + &mut self.inventory, + &mut self.taken, + &mut self.won, + &mut self.flags, + &mut talk_target, + ); + + if let Some(i) = talk_target { + let nodes = self.world.doc.objects[i].dialogue.clone(); + if !nodes.is_empty() { + let d = Dlg { nodes, node: 0 }; + let dlines = d.lines(&self.flags); + for l in &dlines { + ev.push(self.line(l)); + } + if !d.terminal(&self.flags) { + self.dlg = Some(d); + } + ev.push(Event::DialogueOpen { lines: dlines }); + ev.push(Event::Audio { cue: AudioCue::Confirm }); + } + return ev; + } + + if !understood && !from_ai && self.ai.enabled() && self.pending.is_none() { + let room = self.world.current; + let things: Vec = self + .world + .doc + .objects + .iter() + .filter(|o| !self.taken.iter().any(|(r, n)| *r == room && *n == o.name)) + .map(|o| o.name.clone()) + .collect(); + ev.push(self.line("\u{2026}(thinking)\u{2026}")); + let prompt = ai::build_prompt(line, &things.join(", "), &parser::verb_whitelist(&self.verbs)); + self.pending = Some(ai::translate_async(self.ai.clone(), prompt)); + ev.push(Event::Audio { cue: AudioCue::Blip }); + return ev; + } + + for l in &lines { + ev.push(self.line(l)); + } + if self.won && !won_before { + ev.push(Event::Won); + ev.push(Event::Audio { cue: AudioCue::Win }); + } else if self.inventory.len() > inv_before { + ev.push(Event::InventoryChanged { items: self.inventory.clone() }); + ev.push(Event::Audio { cue: AudioCue::Pickup }); + } else if understood || from_ai { + ev.push(Event::Audio { cue: AudioCue::Confirm }); + } else { + ev.push(Event::Audio { cue: AudioCue::Error }); + } + ev + } + + /// Poll the AI worker; if a translation arrived, run it through the real + /// parser (the AI can only ever *suggest* an already-legal command). + fn poll_ai(&mut self) -> Vec { + let got = self.pending.as_ref().and_then(|rx| rx.try_recv().ok()); + let Some(cmd) = got else { return Vec::new() }; + self.pending = None; + if cmd.is_empty() { + let mut ev = vec![self.line("You can't do that.")]; + ev.push(Event::Audio { cue: AudioCue::Error }); + ev + } else { + self.run_parse(&cmd, true) + } + } + + // --- observation ----------------------------------------------------------- + + pub fn snapshot(&self) -> StateSnapshot { + let room = self.world.current; + StateSnapshot { + game: self.world.manifest.name.clone(), + room, + ego: (self.ego.x, self.ego.y, self.ego.dir), + inventory: self.inventory.clone(), + won: self.won, + flags: self.flags.clone(), + vars: self.vars.clone(), + exits: self.world.doc.exits, + music: self.world.doc.music, + objects: self + .world + .doc + .objects + .iter() + .filter(|o| !self.taken.iter().any(|(r, n)| *r == room && *n == o.name)) + .map(|o| ObjSummary { + name: o.name.clone(), + x: o.x, + y: o.y, + takeable: o.takeable, + has_dialogue: !o.dialogue.is_empty(), + }) + .collect(), + in_dialogue: self.dlg.is_some(), + dialogue: self.dlg.as_ref().map(|d| d.lines(&self.flags)), + transcript_tail: self.transcript.iter().rev().take(8).rev().cloned().collect(), + } + } + + /// The props visible right now (room objects minus anything taken). + pub fn props(&self) -> Vec { + let room = self.world.current; + let mut props = Vec::new(); + for o in &self.world.doc.objects { + if self.taken.iter().any(|(r, n)| *r == room && n == &o.name) { + continue; + } + if let Some(cel) = self.world.sprite_cel(&o.sprite) { + props.push(Prop { x: o.x, y: o.y, cel: cel.clone(), fixed_priority: None, name: o.name.clone() }); + } + } + props + } + + /// Composite the current frame to raw RGBA (no window anywhere). + pub fn render_visual(&self, with_ego: bool) -> Vec { + render::compose(&self.world.fb, with_ego.then_some(&self.ego), &self.props()) + } + + pub fn render_png(&self, with_ego: bool) -> Vec { + render::encode_png(&self.render_visual(with_ego)) + } +} + +fn upsert_obj(objects: &mut Vec, obj: ObjDef) { + if let Some(existing) = objects.iter_mut().find(|o| o.name == obj.name) { + *existing = obj; + } else { + objects.push(obj); + } +} diff --git a/src/view.rs b/mrpgi-core/src/view.rs similarity index 57% rename from src/view.rs rename to mrpgi-core/src/view.rs index 23b6fc9..a968da9 100644 --- a/src/view.rs +++ b/mrpgi-core/src/view.rs @@ -31,6 +31,52 @@ pub struct View { pub loops: Vec, } +/// The default ego: a little front-facing party robot. Two cels make a +/// 2-frame walk bob. +pub fn default_robot_view() -> View { + let cel0 = cel_from_ascii(&[ + ".....c.....", + ".....8.....", + "...77777...", + "..7777777..", + "..7b777b7..", + "..7777777..", + "...77777...", + "....888....", + "..7777777..", + ".777777777.", + ".7777e7777.", + ".777777777.", + "..7777777..", + "..8.....8..", + "..88...88..", + "..88...88..", + ".888...888.", + ".888...888.", + ]); + let cel1 = cel_from_ascii(&[ + ".....c.....", + ".....8.....", + "...77777...", + "..7777777..", + "..7b777b7..", + "..7777777..", + "...77777...", + "....888....", + "..7777777..", + ".777777777.", + ".7777e7777.", + ".777777777.", + "..7777777..", + "..8.....8..", + "..88...88..", + "..88...88..", + "..888.888..", + "..888.888..", + ]); + View { loops: vec![ViewLoop { cels: vec![cel0, cel1] }] } +} + /// Build a cel from ASCII rows. Each char: hex `0`-`9`/`a`-`f` = palette index, /// `.` or space = transparent. Rows shorter than the widest are right-padded /// with transparency. diff --git a/mrpgi-core/src/world.rs b/mrpgi-core/src/world.rs new file mode 100644 index 0000000..3388b21 --- /dev/null +++ b/mrpgi-core/src/world.rs @@ -0,0 +1,387 @@ +//! The world data model — rooms, objects, dialogue, strokes — and [`World`], +//! the single source of truth for "what game is loaded and which room are we +//! in". Everything here is serde: a game is a folder of JSON + PNGs, and an +//! LLM expansion is just JSON that deserializes. + +use crate::assets; +use crate::framebuffer::{FrameBuffer, CTRL_BLOCK, PIC_H, PIC_W}; +use crate::parser::VerbDef; +use crate::picture::{self, PriFill}; +use crate::view::{self, Cel}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Meaning { + None, + Floor, + Wall, + Water, + Trigger, +} + +impl Meaning { + pub fn pri(self) -> PriFill { + match self { + Meaning::None => PriFill::Keep, + Meaning::Floor => PriFill::Band, + Meaning::Wall => PriFill::Set(CTRL_BLOCK), + Meaning::Water => PriFill::Set(1), + Meaning::Trigger => PriFill::Set(2), + } + } + pub fn label(self) -> &'static str { + match self { + Meaning::None => "none", + Meaning::Floor => "floor", + Meaning::Wall => "wall", + Meaning::Water => "water", + Meaning::Trigger => "trigger", + } + } +} + +/// One editable paint operation. A room's look + walkability is the replayable +/// list of these — the modern echo of AGI's PICTURE opcode streams. +#[derive(Clone, Serialize, Deserialize)] +pub enum Stroke { + Brush { pts: Vec<(i32, i32)>, color: u8, meaning: Meaning, size: i32 }, + Line { pts: Vec<(i32, i32)>, color: u8 }, + Rect { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning }, + Ellipse { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning }, + Fill { x: i32, y: i32, color: u8, meaning: Meaning }, + Image { name: String, x: i32, y: i32, meaning: Meaning }, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct DlgChoice { + pub text: String, + pub goto: i32, // next node index, or -1 to end the conversation + /// Only offered while this flag is true (empty = always offered). + #[serde(default)] + pub requires_flag: String, + /// Picking this choice sets this flag true (empty = none). + #[serde(default)] + pub sets_flag: String, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct DlgNode { + pub says: String, + #[serde(default)] + pub choices: Vec, +} + +/// A live object: a sprite the player can look at / take / use / talk to. Its +/// base point (x, y) is its feet, like the ego, so it depth-sorts the same way. +#[derive(Clone, Serialize, Deserialize)] +pub struct ObjDef { + pub name: String, + pub sprite: String, + pub x: i32, + pub y: i32, + #[serde(default)] + pub look: String, + #[serde(default)] + pub takeable: bool, + #[serde(default)] + pub synonyms: String, // space-separated alternate names + #[serde(default)] + pub use_text: String, // shown on "use"/"open" + #[serde(default)] + pub needs: String, // item required to use it (empty = no lock) + #[serde(default)] + pub wins: bool, // using it (successfully) wins the game + /// Using it also requires this flag to be true (empty = no gate). + #[serde(default)] + pub requires_flag: String, + /// Using it (successfully) sets this flag true (empty = none). + #[serde(default)] + pub sets_flag: String, + #[serde(default)] + pub dialogue: Vec, // node 0 = entry; empty = no conversation +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RoomDoc { + pub strokes: Vec, + pub spawn: (i32, i32), + #[serde(default)] + pub exits: [Option; 4], // N, E, S, W → target room index + #[serde(default)] + pub objects: Vec, + #[serde(default)] + pub music: u8, // 0 = silence, 1.. = ambient mood +} + +impl Default for RoomDoc { + fn default() -> Self { + RoomDoc { strokes: Vec::new(), spawn: (80, 150), exits: [None; 4], objects: Vec::new(), music: 0 } + } +} + +/// `game.json` — what makes a folder a game. Every field defaults, so a bare +/// folder of rooms is already a valid game (that's how legacy projects load). +#[derive(Clone, Serialize, Deserialize)] +pub struct GameManifest { + #[serde(default = "default_name")] + pub name: String, + #[serde(default)] + pub start_room: u32, + #[serde(default = "default_intro")] + pub intro_text: String, + /// Extra verb synonyms, merged over the built-in table by canonical name. + #[serde(default)] + pub verbs: Vec, + /// Initial flag values for a new game. + #[serde(default)] + pub flags: HashMap, +} + +fn default_name() -> String { + "untitled".into() +} +fn default_intro() -> String { + "You blink awake. Type 'help'.".into() +} + +impl Default for GameManifest { + fn default() -> Self { + GameManifest { + name: default_name(), + start_room: 0, + intro_text: default_intro(), + verbs: Vec::new(), + flags: HashMap::new(), + } + } +} + +// --- stroke application ------------------------------------------------ + +/// Paint a filled disc (brush / eraser). +pub fn disc(fb: &mut FrameBuffer, cx: i32, cy: i32, size: i32, color: u8, pri: PriFill) { + for dy in -size..=size { + for dx in -size..=size { + if dx * dx + dy * dy <= size * size { + picture::px(fb, cx + dx, cy + dy, Some(color), pri); + } + } + } +} + +/// Stamp a cel centered on (cx, cy); transparent pixels are skipped. `pri` +/// writes walkability under the painted pixels. +pub fn stamp(fb: &mut FrameBuffer, cel: &Cel, cx: i32, cy: i32, pri: PriFill) { + let ox = cx - cel.w as i32 / 2; + let oy = cy - cel.h as i32 / 2; + for y in 0..cel.h { + for x in 0..cel.w { + let c = cel.at(x, y); + if c == view::TRANSPARENT { + continue; + } + picture::px(fb, ox + x as i32, oy + y as i32, Some(c), pri); + } + } +} + +pub fn apply_stroke(fb: &mut FrameBuffer, s: &Stroke, sprites: &[(String, Cel)]) { + match s { + Stroke::Brush { pts, color, meaning, size } => { + for &(x, y) in pts { + disc(fb, x, y, *size, *color, meaning.pri()); + } + } + Stroke::Line { pts, color } => { + for w in pts.windows(2) { + picture::line_into(fb, w[0], w[1], *color, PriFill::Keep); + } + } + Stroke::Rect { x, y, w, h, color, meaning } => { + picture::rect_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri()); + } + Stroke::Ellipse { x, y, w, h, color, meaning } => { + picture::ellipse_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri()); + } + Stroke::Fill { x, y, color, meaning } => { + picture::flood_fill(fb, *x, *y, Some(*color), meaning.pri()); + } + Stroke::Image { name, x, y, meaning } => { + if let Some((_, cel)) = sprites.iter().find(|(n, _)| n == name) { + stamp(fb, cel, *x, *y, meaning.pri()); + } + } + } +} + +/// Find a walkable spawn near `prefer`, scanning upward from the bottom. +pub fn find_spawn(fb: &FrameBuffer, prefer: (i32, i32)) -> (i32, i32) { + if fb.priority_at(prefer.0, prefer.1) != CTRL_BLOCK { + return prefer; + } + for y in (20..164).rev() { + for x in 8..152 { + if fb.priority_at(x, y) != CTRL_BLOCK { + return (x, y); + } + } + } + prefer +} + +/// The loaded game: manifest + sprites + the current room baked into a +/// framebuffer, plus an in-memory overlay of rooms that differ from disk +/// (unsaved editor work, or lore upserted live over the bus). +pub struct World { + pub base: PathBuf, + pub manifest: GameManifest, + pub current: u32, + pub doc: RoomDoc, + pub fb: FrameBuffer, + pub sprites: Vec<(String, Cel)>, + pub overlay: HashMap, +} + +impl World { + /// Load a game folder: `game.json` (all-defaults if absent) + `rooms/` + + /// `sprites/`. A legacy project (bare `rooms/*.json` in the cwd) is just a + /// game folder with no manifest — one code path serves both. + pub fn load_game(dir: impl Into) -> Self { + let base: PathBuf = dir.into(); + let manifest: GameManifest = std::fs::read_to_string(base.join("game.json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let sprites = assets::load_sprite_dir(&base.join("sprites").to_string_lossy()); + let mut w = World { + base, + current: manifest.start_room, + manifest, + doc: RoomDoc::default(), + fb: FrameBuffer::new(), + sprites, + overlay: HashMap::new(), + }; + let start = w.current; + // Legacy fallback: a single old-style `rooms/room.json` acts as room 0. + if !w.room_path(start).exists() && start == 0 { + let legacy = w.base.join("rooms/room.json"); + if let Ok(s) = std::fs::read_to_string(legacy) { + if let Ok(doc) = serde_json::from_str::(&s) { + w.overlay.insert(0, doc); + } + } + } + w.goto_room(start); + w + } + + pub fn room_path(&self, n: u32) -> PathBuf { + self.base.join(format!("rooms/room{}.json", n)) + } + + /// Read a room doc without switching to it: overlay first, then disk. + pub fn peek_room(&self, n: u32) -> Option { + if let Some(doc) = self.overlay.get(&n) { + return Some(doc.clone()); + } + std::fs::read_to_string(self.room_path(n)).ok().and_then(|s| serde_json::from_str(&s).ok()) + } + + /// Switch the working room WITHOUT saving. Overlay wins over disk; a + /// missing room is a fresh blank one. + pub fn goto_room(&mut self, n: u32) { + self.current = n; + self.doc = self.peek_room(n).unwrap_or_default(); + self.bake(); + } + + /// Replay the current room's strokes into the framebuffer. + pub fn bake(&mut self) { + self.fb.clear(); + for s in &self.doc.strokes { + apply_stroke(&mut self.fb, s, &self.sprites); + } + } + + /// Stash the current room into the overlay (so leaving and returning + /// keeps unsaved changes) — used before playtesting live edits. + pub fn stash_current(&mut self) { + self.overlay.insert(self.current, self.doc.clone()); + } + + /// Insert/replace a room. If it's the current room the framebuffer rebakes. + /// With `persist` it is also written to disk (and dropped from the overlay). + pub fn upsert_room(&mut self, n: u32, doc: RoomDoc, persist: bool) -> std::io::Result<()> { + if persist { + self.write_room(n, &doc)?; + self.overlay.remove(&n); + } else { + self.overlay.insert(n, doc.clone()); + } + if n == self.current { + self.doc = doc; + self.bake(); + } + Ok(()) + } + + pub fn write_room(&self, n: u32, doc: &RoomDoc) -> std::io::Result<()> { + let path = self.room_path(n); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let j = serde_json::to_string_pretty(doc).map_err(std::io::Error::other)?; + std::fs::write(path, j) + } + + /// Save the current room to disk. + pub fn save_current(&mut self) -> std::io::Result<()> { + self.write_room(self.current, &self.doc.clone())?; + self.overlay.remove(&self.current); + Ok(()) + } + + pub fn sprite_cel(&self, name: &str) -> Option<&Cel> { + self.sprites.iter().find(|(n, _)| n == name).map(|(_, c)| c) + } + + pub fn reload_sprites(&mut self) { + self.sprites = assets::load_sprite_dir(&self.base.join("sprites").to_string_lossy()); + self.bake(); + } + + /// Sanity-check a room before committing it (the lore-ingestion gate): + /// exits must point at rooms that exist (on disk, in the overlay, or being + /// upserted alongside), and the room must have somewhere to stand. + pub fn validate_room(&self, n: u32, doc: &RoomDoc) -> Result<(), String> { + let mut fb = FrameBuffer::new(); + for s in &doc.strokes { + apply_stroke(&mut fb, s, &self.sprites); + } + let (sx, sy) = find_spawn(&fb, doc.spawn); + if fb.priority_at(sx, sy) == CTRL_BLOCK { + return Err(format!("room {} has no walkable spawn — it's all wall", n)); + } + for (d, t) in doc.exits.iter().enumerate() { + if let Some(t) = t { + let known = *t == n || self.overlay.contains_key(t) || self.room_path(*t).exists(); + if !known { + return Err(format!( + "room {} exit {} points at room {}, which doesn't exist yet — upsert it first", + n, + ["N", "E", "S", "W"][d], + t + )); + } + } + } + Ok(()) + } +} + +// Keep constants nearby for consumers that reason about room geometry. +pub const ROOM_W: usize = PIC_W; +pub const ROOM_H: usize = PIC_H; diff --git a/mrpgi-core/tests/engine.rs b/mrpgi-core/tests/engine.rs new file mode 100644 index 0000000..a48873e --- /dev/null +++ b/mrpgi-core/tests/engine.rs @@ -0,0 +1,195 @@ +//! Golden tests for the parser and a full headless play-through of the +//! sample game — the safety net under every future refactor. + +use mrpgi_core::ai::AiConfig; +use mrpgi_core::parser::{build_verbs, default_verbs, run_command}; +use mrpgi_core::state::{Command, Event}; +use mrpgi_core::world::ObjDef; +use mrpgi_core::{GameState, World}; +use std::collections::HashMap; + +fn obj(name: &str, takeable: bool, needs: &str, use_text: &str, wins: bool) -> ObjDef { + ObjDef { + name: name.into(), + sprite: "sign".into(), + x: 80, + y: 100, + look: format!("It's a {}.", name), + takeable, + synonyms: String::new(), + use_text: use_text.into(), + needs: needs.into(), + wins, + requires_flag: String::new(), + sets_flag: String::new(), + dialogue: Vec::new(), + } +} + +/// Run one line against a tiny fixture room; return (first line, understood). +fn run(input: &str, objects: &[ObjDef], inventory: &mut Vec) -> (String, bool) { + let verbs = default_verbs(); + let mut taken = Vec::new(); + let mut won = false; + let mut flags = HashMap::new(); + let mut talk = None; + let (lines, understood) = run_command(input, &verbs, objects, 0, inventory, &mut taken, &mut won, &mut flags, &mut talk); + (lines.first().cloned().unwrap_or_default(), understood) +} + +#[test] +fn parser_goldens() { + let objects = vec![obj("battery", true, "", "", false), obj("hatch", false, "battery", "It opens!", true)]; + + let mut inv = Vec::new(); + assert_eq!(run("look", &objects, &mut inv), ("Room 0. You see: battery, hatch.".into(), true)); + assert_eq!(run("examine the battery", &objects, &mut inv), ("It's a battery.".into(), true)); + assert_eq!(run("grab battery", &objects, &mut inv), ("You take the battery.".into(), true)); + assert_eq!(inv, vec!["battery".to_string()]); + assert_eq!(run("take hatch", &objects, &mut inv), ("You can't take the hatch.".into(), true)); + assert_eq!(run("i", &objects, &mut inv), ("Carrying: battery.".into(), true)); + assert_eq!(run("drop battery", &objects, &mut inv), ("You drop the battery.".into(), true)); + assert_eq!(run("drop battery", &objects, &mut inv), ("You aren't carrying a 'battery'.".into(), true)); + assert_eq!(run("dance wildly", &objects, &mut inv).1, false); + assert_eq!(run("talk hatch", &objects, &mut inv), ("hatch has nothing to say.".into(), true)); +} + +#[test] +fn use_respects_needs_lock() { + let objects = vec![obj("battery", true, "", "", false), obj("hatch", false, "battery", "It opens!", true)]; + let verbs = default_verbs(); + let mut inv = Vec::new(); + let mut taken = Vec::new(); + let mut won = false; + let mut flags = HashMap::new(); + let mut talk = None; + + let (lines, _) = run_command("open hatch", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + assert_eq!(lines[0], "It's locked. You need: battery."); + assert!(!won); + + run_command("take battery", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + let (lines, _) = run_command("open hatch", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + assert_eq!(lines[0], "It opens!"); + assert!(won); +} + +#[test] +fn use_respects_flag_gate_and_sets_flags() { + let mut lever = obj("lever", false, "", "The lever clunks over.", false); + lever.sets_flag = "power_on".into(); + let mut door = obj("door", false, "", "The door slides open.", true); + door.requires_flag = "power_on".into(); + let objects = vec![lever, door]; + + let verbs = default_verbs(); + let (mut inv, mut taken, mut won, mut flags, mut talk) = (Vec::new(), Vec::new(), false, HashMap::new(), None); + + let (lines, _) = run_command("use door", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + assert_eq!(lines[0], "Nothing happens. Something else must come first."); + assert!(!won); + + run_command("use lever", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + assert_eq!(flags.get("power_on"), Some(&true)); + + let (lines, _) = run_command("use door", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + assert_eq!(lines[0], "The door slides open."); + assert!(won); +} + +#[test] +fn manifest_verbs_extend_the_table() { + let extra = vec![mrpgi_core::parser::VerbDef { name: "take".into(), words: vec!["yoink".into()] }]; + let verbs = build_verbs(&extra); + let objects = vec![obj("battery", true, "", "", false)]; + let (mut inv, mut taken, mut won, mut flags, mut talk) = (Vec::new(), Vec::new(), false, HashMap::new(), None); + let (lines, understood) = run_command("yoink battery", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + assert!(understood); + assert_eq!(lines[0], "You take the battery."); +} + +/// Play the shipped sample game start → win, entirely over the bus, twice — +/// and require the two transcripts to be byte-identical (replay determinism). +#[test] +fn sample_game_win_path_is_deterministic() { + let dir = std::env::temp_dir().join(format!("mrpgi-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + mrpgi_core::kit::write_sample_world(&dir); + + let play = || -> (Vec, bool) { + let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); + let script = [ + Command::NewGame { room: None }, + Command::Parse { text: "take battery".into() }, + Command::WalkTo { x: 156, y: 85 }, // through the east door of room 0 + Command::Tick { n: 10 }, + Command::WalkTo { x: 80, y: 18 }, // through the north door of room 1 + Command::Tick { n: 10 }, + Command::Parse { text: "use hatch".into() }, + Command::Query, + ]; + let mut won = false; + for cmd in script { + for e in gs.apply(cmd) { + if matches!(e, Event::Won) { + won = true; + } + } + } + (gs.transcript.clone(), won) + }; + + let (t1, won1) = play(); + let (t2, won2) = play(); + assert!(won1, "expected to win the sample game; transcript: {:?}", t1); + assert!(won2); + assert_eq!(t1, t2, "replay must be bit-identical"); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// Headless render smoke test: the frame is a real PNG with the right size. +#[test] +fn renders_a_png_with_no_window() { + let dir = std::env::temp_dir().join(format!("mrpgi-render-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + mrpgi_core::kit::write_sample_world(&dir); + + let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); + gs.apply(Command::NewGame { room: None }); + let png = gs.render_png(true); + assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]); + let img = image::load_from_memory(&png).unwrap(); + assert_eq!((img.width(), img.height()), (160, 168)); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// Lore ingestion: upsert a new room over the bus, walk into it. +#[test] +fn upsert_room_extends_a_running_game() { + let dir = std::env::temp_dir().join(format!("mrpgi-lore-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + mrpgi_core::kit::write_sample_world(&dir); + + let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); + gs.apply(Command::NewGame { room: None }); + + // A bad room (exit into the void) must be rejected... + let mut bad = mrpgi_core::RoomDoc::default(); + bad.exits[0] = Some(99); + let ev = gs.apply(Command::UpsertRoom { n: 7, doc: bad, persist: false }); + assert!(matches!(ev[0], Event::Error { .. })); + + // ...and a good one accepted and playable. + let mut doc = mrpgi_core::RoomDoc::default(); + doc.objects.push(obj("gift", true, "", "", false)); + let ev = gs.apply(Command::UpsertRoom { n: 7, doc, persist: false }); + assert!(matches!(ev[0], Event::RoomUpserted { room: 7, .. }), "unexpected: {}", serde_json::to_string(&ev).unwrap()); + + gs.apply(Command::GotoRoom { n: 7 }); + let ev = gs.apply(Command::Parse { text: "take gift".into() }); + assert!(ev.iter().any(|e| matches!(e, Event::InventoryChanged { .. }))); + + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/mrpgi/Cargo.toml b/mrpgi/Cargo.toml new file mode 100644 index 0000000..6dfb4c8 --- /dev/null +++ b/mrpgi/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "mrpgi" +version = "0.1.0" +edition = "2021" +description = "Monster Robot Party Game Interpreter — macroquad GUI front-end (play + paint) over mrpgi-core." + +[dependencies] +mrpgi-core = { path = "../mrpgi-core" } +macroquad = "0.4" diff --git a/mrpgi/src/editor.rs b/mrpgi/src/editor.rs new file mode 100644 index 0000000..b448c82 --- /dev/null +++ b/mrpgi/src/editor.rs @@ -0,0 +1,867 @@ +//! In-engine room editor — MRPGI's "paint app" (the GUI half). +//! +//! You paint on TWO layers at once: +//! * LOOK — the visual buffer (16 EGA colours, what the player sees) +//! * MEANING — the priority/control buffer (walkable / wall / water / depth) +//! Colour and meaning are set independently: pick a colour, pick a meaning tag, +//! then draw / fill / stamp a region. Drop PNGs in `sprites/` to place pixel +//! art. +//! +//! The world itself lives in mrpgi-core's `World`; committed edits go through +//! the same `Command` bus every other surface uses (PaintStroke, Undo, +//! SetExit, ...), so an LLM over MCP and a human with a mouse are literally +//! driving the same engine. + +use macroquad::prelude::*; +use mrpgi_core::framebuffer::{PIC_H, PIC_W}; +use mrpgi_core::state::Command; +use mrpgi_core::view::TRANSPARENT; +use mrpgi_core::world::{disc, Meaning, ObjDef, Stroke}; +use mrpgi_core::{kit, palette, GameState}; + +const PANEL_W: f32 = 200.0; + +const C_PANEL: Color = Color { r: 16.0 / 255.0, g: 19.0 / 255.0, b: 24.0 / 255.0, a: 1.0 }; +const C_BTN: Color = Color { r: 30.0 / 255.0, g: 35.0 / 255.0, b: 43.0 / 255.0, a: 1.0 }; +const C_BTN_HOV: Color = Color { r: 46.0 / 255.0, g: 54.0 / 255.0, b: 66.0 / 255.0, a: 1.0 }; +const C_BTN_ON: Color = Color { r: 34.0 / 255.0, g: 95.0 / 255.0, b: 82.0 / 255.0, a: 1.0 }; +const C_BORDER: Color = Color { r: 72.0 / 255.0, g: 82.0 / 255.0, b: 98.0 / 255.0, a: 1.0 }; +const C_TEXT: Color = Color { r: 205.0 / 255.0, g: 213.0 / 255.0, b: 225.0 / 255.0, a: 1.0 }; +const C_DIM: Color = Color { r: 120.0 / 255.0, g: 130.0 / 255.0, b: 145.0 / 255.0, a: 1.0 }; +const C_ACCENT: Color = Color { r: 95.0 / 255.0, g: 1.0, b: 215.0 / 255.0, a: 1.0 }; + +/// macroquad Color for an EGA palette index. +fn pcolor(idx: u8) -> Color { + let [r, g, b, _] = palette::rgba(idx); + Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0) +} + +#[derive(Clone, Copy, PartialEq)] +pub enum Tool { + Brush, + Line, + Rect, + Fill, + Pick, + Erase, + Image, + Ellipse, + Spawn, + Object, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum ViewMode { + Look, + Both, + Control, +} + +/// Which object text field is currently being typed into. +#[derive(Clone, Copy, PartialEq)] +enum Focus { + None, + Name, + Look, + Use, + Needs, + Syn, +} + +pub struct Editor { + pub tool: Tool, + pub color: u8, + pub meaning: Meaning, + pub view: ViewMode, + pub brush_size: i32, + base: String, + active_sprite: usize, + selected_obj: Option, + editing: Focus, + archetypes: Vec, + arch_idx: usize, + line_pts: Vec<(i32, i32)>, + rect_start: Option<(i32, i32)>, + brush_pts: Vec<(i32, i32)>, + painting: bool, + pub status: String, + img: Image, + tex: Texture2D, +} + +impl Editor { + pub fn new(base: &str) -> Self { + let img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK); + let tex = Texture2D::from_image(&img); + tex.set_filter(FilterMode::Nearest); + Editor { + tool: Tool::Brush, + color: 6, + meaning: Meaning::Floor, + view: ViewMode::Both, + brush_size: 2, + base: base.to_string(), + active_sprite: 0, + selected_obj: None, + editing: Focus::None, + archetypes: kit::load_archetypes(base), + arch_idx: 0, + line_pts: Vec::new(), + rect_start: None, + brush_pts: Vec::new(), + painting: false, + status: "draw a room — then press Tab to walk it".to_string(), + img, + tex, + } + } + + pub fn is_editing(&self) -> bool { + self.editing != Focus::None + } + + pub fn stop_editing(&mut self) { + self.editing = Focus::None; + } + + fn save_current(&mut self, gs: &mut GameState) { + let n = gs.world.current; + self.status = match gs.world.save_current() { + Ok(()) => format!("saved {}", gs.world.room_path(n).display()), + Err(_) => "save FAILED".to_string(), + }; + } + + /// Reload the current room from disk, discarding unsaved work. + fn load_current(&mut self, gs: &mut GameState) { + let n = gs.world.current; + gs.world.overlay.remove(&n); + gs.world.goto_room(n); + self.status = format!("loaded {}", gs.world.room_path(n).display()); + } + + /// Switch rooms via the nav buttons — saves the room you're leaving to + /// disk first (matching the original editor's data-safety behavior). + fn switch_room(&mut self, gs: &mut GameState, n: u32) { + if gs.world.save_current().is_err() { + self.status = "save FAILED".to_string(); + } + gs.world.goto_room(n); + self.line_pts.clear(); + self.rect_start = None; + self.brush_pts.clear(); + self.painting = false; + self.selected_obj = None; + self.editing = Focus::None; + self.status = format!("room {}", n); + } + + fn cycle_exit(&mut self, gs: &mut GameState, d: usize) { + let next = match gs.world.doc.exits[d] { + None => Some(0), + Some(k) if k < 9 => Some(k + 1), + Some(_) => None, + }; + gs.apply(Command::SetExit { dir: d as u8, target: next }); + self.status = "exit set — K saves the room".into(); + } + + fn reload_sprites(&mut self, gs: &mut GameState) { + gs.apply(Command::ReloadSprites); + self.archetypes = kit::load_archetypes(&self.base); + self.status = format!("reloaded {} sprites, {} kit archetypes", gs.world.sprites.len(), self.archetypes.len()); + } + + fn commit(&mut self, gs: &mut GameState, s: Stroke) { + gs.apply(Command::PaintStroke { stroke: s }); + } + + fn undo(&mut self, gs: &mut GameState) { + gs.apply(Command::Undo); + self.status = "undo".to_string(); + } + + fn clear(&mut self, gs: &mut GameState) { + gs.apply(Command::ClearStrokes); + self.status = "cleared".to_string(); + } + + fn cycle_sprite(&mut self, gs: &GameState, delta: i32) { + if gs.world.sprites.is_empty() { + return; + } + let n = gs.world.sprites.len() as i32; + self.active_sprite = (((self.active_sprite as i32 + delta) % n + n) % n) as usize; + } + + fn cycle_arch(&mut self, delta: i32) { + let n = (self.archetypes.len() + 1) as i32; // index 0 = blank + if n <= 1 { + return; + } + self.arch_idx = (((self.arch_idx as i32 + delta) % n + n) % n) as usize; + } + + // --- per-frame edit loop ---------------------------------------------- + pub fn frame(&mut self, gs: &mut GameState) { + let sw = screen_width(); + let sh = screen_height(); + let panel = Rect::new(0.0, 0.0, PANEL_W, sh); + + let cax = PANEL_W + 10.0; + let cay = 34.0; // leave room for the world strip + let avail_w = (sw - cax - 10.0).max(64.0); + let avail_h = (sh - cay - 10.0).max(64.0); + let scale = (avail_w / 320.0).min(avail_h / 168.0).max(0.5); + let cw = 320.0 * scale; + let ch = 168.0 * scale; + let canvas = Rect::new(cax + (avail_w - cw) * 0.5, cay + (avail_h - ch) * 0.5, cw, ch); + + let (mx, my) = mouse_position(); + let m = vec2(mx, my); + let clicked = is_mouse_button_pressed(MouseButton::Left); + let down = is_mouse_button_down(MouseButton::Left); + let released = is_mouse_button_released(MouseButton::Left); + let rclick = is_mouse_button_pressed(MouseButton::Right); + let in_panel = panel.contains(m); + + let cpx = ((mx - canvas.x) / (canvas.w / PIC_W as f32)).floor() as i32; + let cpy = ((my - canvas.y) / (canvas.h / PIC_H as f32)).floor() as i32; + let in_canvas = + canvas.contains(m) && cpx >= 0 && cpy >= 0 && cpx < PIC_W as i32 && cpy < PIC_H as i32; + + // --- text entry into the selected object's fields ---------------- + let typing = self.tool == Tool::Object && self.editing != Focus::None && self.selected_obj.is_some(); + if typing { + let sel = self.selected_obj.unwrap(); + if sel < gs.world.doc.objects.len() { + let objects = &mut gs.world.doc.objects; + if is_key_pressed(KeyCode::Backspace) { + match self.editing { + Focus::Name => { objects[sel].name.pop(); } + Focus::Look => { objects[sel].look.pop(); } + Focus::Use => { objects[sel].use_text.pop(); } + Focus::Needs => { objects[sel].needs.pop(); } + Focus::Syn => { objects[sel].synonyms.pop(); } + _ => {} + } + } + while let Some(c) = get_char_pressed() { + if !c.is_control() { + match self.editing { + Focus::Name if objects[sel].name.len() < 32 => objects[sel].name.push(c), + Focus::Look if objects[sel].look.len() < 160 => objects[sel].look.push(c), + Focus::Use if objects[sel].use_text.len() < 160 => objects[sel].use_text.push(c), + Focus::Needs if objects[sel].needs.len() < 32 => objects[sel].needs.push(c), + Focus::Syn if objects[sel].synonyms.len() < 80 => objects[sel].synonyms.push(c), + _ => {} + } + } + } + if is_key_pressed(KeyCode::Enter) { + self.editing = match self.editing { + Focus::Name => Focus::Look, + Focus::Look => Focus::Use, + Focus::Use => Focus::Needs, + Focus::Needs => Focus::Syn, + _ => Focus::None, + }; + } + } + } + + // --- keyboard shortcuts (suppressed while typing in a field) ------ + if !typing { + if is_key_pressed(KeyCode::Key1) { self.tool = Tool::Brush; } + if is_key_pressed(KeyCode::Key2) { self.tool = Tool::Line; } + if is_key_pressed(KeyCode::Key3) { self.tool = Tool::Rect; } + if is_key_pressed(KeyCode::Key4) { self.tool = Tool::Fill; } + if is_key_pressed(KeyCode::Key5) { self.tool = Tool::Pick; } + if is_key_pressed(KeyCode::Key6) { self.tool = Tool::Erase; } + if is_key_pressed(KeyCode::Key7) { self.tool = Tool::Image; } + if is_key_pressed(KeyCode::Key8) { self.tool = Tool::Ellipse; } + if is_key_pressed(KeyCode::Key9) { self.tool = Tool::Spawn; } + if is_key_pressed(KeyCode::Key0) { self.tool = Tool::Object; } + if is_key_pressed(KeyCode::Minus) { let c = gs.world.current; self.switch_room(gs, c.saturating_sub(1)); } + if is_key_pressed(KeyCode::Equal) { let c = gs.world.current; self.switch_room(gs, c + 1); } + if is_key_pressed(KeyCode::LeftBracket) { if self.tool == Tool::Object { self.cycle_arch(-1); } else { self.cycle_sprite(gs, -1); } } + if is_key_pressed(KeyCode::RightBracket) { if self.tool == Tool::Object { self.cycle_arch(1); } else { self.cycle_sprite(gs, 1); } } + if is_key_pressed(KeyCode::Comma) { self.brush_size = (self.brush_size - 1).max(1); } + if is_key_pressed(KeyCode::Period) { self.brush_size = (self.brush_size + 1).min(8); } + if is_key_pressed(KeyCode::L) { + self.view = match self.view { + ViewMode::Look => ViewMode::Both, + ViewMode::Both => ViewMode::Control, + ViewMode::Control => ViewMode::Look, + }; + } + if is_key_pressed(KeyCode::Z) || is_key_pressed(KeyCode::Backspace) { self.undo(gs); } + if is_key_pressed(KeyCode::K) { self.save_current(gs); } + if is_key_pressed(KeyCode::O) { self.load_current(gs); } + if is_key_pressed(KeyCode::C) { self.clear(gs); } + if is_key_pressed(KeyCode::R) { self.reload_sprites(gs); } + } + + // --- canvas painting (only when not over the panel) --------------- + if typing && clicked && in_canvas { + self.editing = Focus::None; + } + if in_canvas && !in_panel && !typing { + match self.tool { + Tool::Brush | Tool::Erase => { + if down { + let (c, mng, sz) = if self.tool == Tool::Erase { + (mrpgi_core::framebuffer::BG_VISUAL, Meaning::Floor, self.brush_size + 1) + } else { + (self.color, self.meaning, self.brush_size) + }; + // live preview straight into the buffer; committed as + // one Brush stroke (via the bus) on release + disc(&mut gs.world.fb, cpx, cpy, sz, c, mng.pri()); + self.brush_pts.push((cpx, cpy)); + self.painting = true; + } + } + Tool::Line => { + if clicked { + self.line_pts.push((cpx, cpy)); + } + } + Tool::Rect | Tool::Ellipse => { + if clicked { + self.rect_start = Some((cpx, cpy)); + } + } + Tool::Fill => { + if clicked { + // applied directly (re-running a fill isn't idempotent), + // recorded in the stroke list for save/undo/rebake + let n = mrpgi_core::picture::flood_fill(&mut gs.world.fb, cpx, cpy, Some(self.color), self.meaning.pri()); + gs.world.doc.strokes.push(Stroke::Fill { x: cpx, y: cpy, color: self.color, meaning: self.meaning }); + if n as f32 > 0.85 * (PIC_W * PIC_H) as f32 { + self.status = "LEAK? fill spread huge — region not sealed (Z=undo)".into(); + } else { + self.status = format!("filled {} px ({})", n, self.meaning.label()); + } + } + } + Tool::Pick => { + if clicked { + self.color = gs.world.fb.visual[cpy as usize * PIC_W + cpx as usize]; + self.status = format!("picked colour {}", self.color); + } + } + Tool::Image => { + if clicked && !gs.world.sprites.is_empty() { + let i = self.active_sprite.min(gs.world.sprites.len() - 1); + let name = gs.world.sprites[i].0.clone(); + self.commit(gs, Stroke::Image { name: name.clone(), x: cpx, y: cpy, meaning: self.meaning }); + self.status = format!("placed {}", name); + } + } + Tool::Spawn => { + if clicked { + gs.apply(Command::SetSpawn { x: cpx, y: cpy }); + self.status = "spawn point set".into(); + } + } + Tool::Object => { + if clicked { + let hit = gs.world.doc.objects.iter().position(|o| (cpx - o.x).abs() <= 16 && (cpy - o.y).abs() <= 26); + if let Some(idx) = hit { + self.selected_obj = Some(idx); + self.status = format!("selected '{}'", gs.world.doc.objects[idx].name); + } else if self.arch_idx > 0 && self.arch_idx <= self.archetypes.len() { + let mut a = self.archetypes[self.arch_idx - 1].clone(); + a.x = cpx; + a.y = cpy; + let nm = a.name.clone(); + gs.world.doc.objects.push(a); + self.selected_obj = Some(gs.world.doc.objects.len() - 1); + self.status = format!("placed '{}' from kit", nm); + } else if !gs.world.sprites.is_empty() { + let i = self.active_sprite.min(gs.world.sprites.len() - 1); + let sprite = gs.world.sprites[i].0.clone(); + let look = format!("It's a {}.", sprite); + gs.world.doc.objects.push(ObjDef { + name: sprite.clone(), + sprite, + x: cpx, + y: cpy, + look, + takeable: true, + synonyms: String::new(), + use_text: String::new(), + needs: String::new(), + wins: false, + requires_flag: String::new(), + sets_flag: String::new(), + dialogue: Vec::new(), + }); + self.selected_obj = Some(gs.world.doc.objects.len() - 1); + self.status = "placed object — Tab to play, then 'look '".into(); + } + } + } + } + } + + // commit on release (works even if the cursor left the canvas) + if released { + if self.painting { + let (c, mng, sz) = if self.tool == Tool::Erase { + (mrpgi_core::framebuffer::BG_VISUAL, Meaning::Floor, self.brush_size + 1) + } else { + (self.color, self.meaning, self.brush_size) + }; + let pts = std::mem::take(&mut self.brush_pts); + if !pts.is_empty() { + self.commit(gs, Stroke::Brush { pts, color: c, meaning: mng, size: sz }); + } + self.painting = false; + } + if let Some((sx, sy)) = self.rect_start.take() { + let ex = cpx.clamp(0, PIC_W as i32 - 1); + let ey = cpy.clamp(0, PIC_H as i32 - 1); + let x = sx.min(ex); + let y = sy.min(ey); + let w = (sx - ex).abs() + 1; + let h = (sy - ey).abs() + 1; + if self.tool == Tool::Ellipse { + self.commit(gs, Stroke::Ellipse { x, y, w, h, color: self.color, meaning: self.meaning }); + self.status = format!("ellipse {}x{}", w, h); + } else { + self.commit(gs, Stroke::Rect { x, y, w, h, color: self.color, meaning: self.meaning }); + self.status = format!("rect {}x{}", w, h); + } + } + } + + if self.tool == Tool::Line && (rclick || is_key_pressed(KeyCode::Enter)) { + if self.line_pts.len() >= 2 { + let pts = std::mem::take(&mut self.line_pts); + self.commit(gs, Stroke::Line { pts, color: self.color }); + self.status = "line committed".into(); + } else { + self.line_pts.clear(); + } + } + + // --- render & present -------------------------------------------- + self.render_image(gs); + clear_background(Color { r: 8.0 / 255.0, g: 9.0 / 255.0, b: 11.0 / 255.0, a: 1.0 }); + draw_texture_ex( + &self.tex, + canvas.x, + canvas.y, + WHITE, + DrawTextureParams { dest_size: Some(vec2(canvas.w, canvas.h)), ..Default::default() }, + ); + draw_rectangle_lines(canvas.x, canvas.y, canvas.w, canvas.h, 2.0, C_BORDER); + + // spawn marker + let (spx, spy) = gs.world.doc.spawn; + let s = self.pixel_to_screen(&canvas, spx, spy); + draw_circle_lines(s.x, s.y, 7.0, 2.0, C_ACCENT); + + // previews + let ppx = self.pixel_to_screen(&canvas, cpx, cpy); + if self.tool == Tool::Line && !self.line_pts.is_empty() { + for w in self.line_pts.windows(2) { + let a = self.pixel_to_screen(&canvas, w[0].0, w[0].1); + let b = self.pixel_to_screen(&canvas, w[1].0, w[1].1); + draw_line(a.x, a.y, b.x, b.y, 2.0, C_ACCENT); + } + let last = *self.line_pts.last().unwrap(); + let a = self.pixel_to_screen(&canvas, last.0, last.1); + draw_line(a.x, a.y, ppx.x, ppx.y, 1.0, C_DIM); + } + if (self.tool == Tool::Rect || self.tool == Tool::Ellipse) && self.rect_start.is_some() { + let (sx, sy) = self.rect_start.unwrap(); + let a = self.pixel_to_screen(&canvas, sx, sy); + draw_rectangle_lines(a.x.min(ppx.x), a.y.min(ppx.y), (a.x - ppx.x).abs(), (a.y - ppx.y).abs(), 2.0, C_ACCENT); + } + if in_canvas { + let pw = canvas.w / PIC_W as f32; + let ph = canvas.h / PIC_H as f32; + draw_rectangle_lines(canvas.x + cpx as f32 * pw, canvas.y + cpy as f32 * ph, pw, ph, 1.0, C_ACCENT); + } + + // object markers (name labels + selection box) + let pw = canvas.w / PIC_W as f32; + let ph = canvas.h / PIC_H as f32; + for (i, o) in gs.world.doc.objects.iter().enumerate() { + let sp = vec2( + canvas.x + (o.x as f32 + 0.5) * pw, + canvas.y + (o.y as f32 + 0.5) * ph, + ); + draw_text(&o.name, sp.x - 12.0, sp.y + 12.0, 13.0, C_ACCENT); + if Some(i) == self.selected_obj { + let (w, h) = gs + .world + .sprites + .iter() + .find(|(n, _)| *n == o.sprite) + .map(|(_, c)| (c.w as f32, c.h as f32)) + .unwrap_or((10.0, 16.0)); + draw_rectangle_lines(sp.x - w * 0.5 * pw, sp.y - h * ph, w * pw, h * ph, 2.0, C_ACCENT); + } + } + + // top strip: world (room nav + edge exits) + let strip_click = clicked && my < 30.0 && mx > PANEL_W; + let mut xc = PANEL_W + 12.0; + draw_text(&format!("room {}", gs.world.current), xc, 20.0, 18.0, C_ACCENT); + xc += 78.0; + if self.button(xc, 5.0, 24.0, 22.0, "<", false, mx, my) && strip_click { + let c = gs.world.current; + self.switch_room(gs, c.saturating_sub(1)); + } + xc += 28.0; + if self.button(xc, 5.0, 24.0, 22.0, ">", false, mx, my) && strip_click { + let c = gs.world.current; + self.switch_room(gs, c + 1); + } + xc += 36.0; + draw_text("exits", xc, 20.0, 15.0, C_DIM); + xc += 42.0; + for (d, dl) in [(0usize, "N"), (1, "E"), (2, "S"), (3, "W")] { + let label = match gs.world.doc.exits[d] { + Some(v) => format!("{}:{}", dl, v), + None => format!("{}:-", dl), + }; + if self.button(xc, 5.0, 42.0, 22.0, &label, gs.world.doc.exits[d].is_some(), mx, my) && strip_click { + self.cycle_exit(gs, d); + } + xc += 46.0; + } + let moods = mrpgi_core::audio::MOODS; + let mlabel = format!("\u{266A} {}", moods.get(gs.world.doc.music as usize).copied().unwrap_or("?")); + if self.button(xc + 6.0, 5.0, 72.0, 22.0, &mlabel, gs.world.doc.music != 0, mx, my) && strip_click { + let mood = (gs.world.doc.music + 1) % 6; + gs.apply(Command::SetMusic { mood }); + } + + self.draw_panel(gs, sh, mx, my, clicked && in_panel); + } + + fn pixel_to_screen(&self, canvas: &Rect, px: i32, py: i32) -> Vec2 { + vec2( + canvas.x + (px as f32 + 0.5) * (canvas.w / PIC_W as f32), + canvas.y + (py as f32 + 0.5) * (canvas.h / PIC_H as f32), + ) + } + + fn render_image(&mut self, gs: &GameState) { + let fb = &gs.world.fb; + let data = self.img.get_image_data_mut(); + for i in 0..PIC_W * PIC_H { + let v = fb.visual[i]; + let p = fb.priority[i]; + let base = palette::rgba(v); + data[i] = match self.view { + ViewMode::Look => base, + ViewMode::Both => match p { + 0 => blend(base, [230, 70, 70], 0.45), + 1 => blend(base, [70, 120, 230], 0.45), + 2 => blend(base, [90, 220, 110], 0.45), + _ => base, + }, + ViewMode::Control => match p { + 0 => [150, 40, 40, 255], + 1 => [40, 70, 160, 255], + 2 => [50, 150, 70, 255], + band => { + let g = 60 + (band as u32 * 12).min(180) as u8; + [g, g, g, 255] + } + }, + }; + } + for o in &gs.world.doc.objects { + if let Some((_, cel)) = gs.world.sprites.iter().find(|(n, _)| *n == o.sprite) { + stamp_into_image(data, cel, o.x, o.y); + } + } + self.tex.update(&self.img); + } + + fn draw_panel(&mut self, gs: &mut GameState, sh: f32, mx: f32, my: f32, click: bool) { + draw_rectangle(0.0, 0.0, PANEL_W, sh, C_PANEL); + draw_line(PANEL_W, 0.0, PANEL_W, sh, 1.0, C_BORDER); + let p = 8.0; + let bw = (PANEL_W - 3.0 * p) / 2.0; + let mut y = 8.0; + + draw_text("MRPGI EDITOR", p, y + 12.0, 20.0, C_ACCENT); + y += 22.0; + draw_text("Tab: play / edit", p, y + 10.0, 15.0, C_DIM); + y += 18.0; + + let tools = [ + (Tool::Brush, "1 brush"), + (Tool::Line, "2 line"), + (Tool::Rect, "3 rect"), + (Tool::Fill, "4 fill"), + (Tool::Pick, "5 pick"), + (Tool::Erase, "6 erase"), + (Tool::Image, "7 image"), + (Tool::Ellipse, "8 oval"), + (Tool::Spawn, "9 spawn"), + (Tool::Object, "0 obj"), + ]; + for (i, &(t, label)) in tools.iter().enumerate() { + let x = p + (i % 2) as f32 * (bw + p); + let by = y + (i / 2) as f32 * 28.0; + if self.button(x, by, bw, 24.0, label, self.tool == t, mx, my) && click { + self.tool = t; + } + } + y += 5.0 * 28.0 + 4.0; + if self.tool != Tool::Object { + self.editing = Focus::None; + } + + draw_text("colour (look)", p, y + 10.0, 14.0, C_DIM); + y += 15.0; + let sw = (PANEL_W - 2.0 * p) / 8.0; + for idx in 0..16u8 { + let x = p + (idx % 8) as f32 * sw; + let sy = y + (idx / 8) as f32 * sw; + if self.swatch(x, sy, sw - 2.0, idx, self.color == idx, mx, my) && click { + self.color = idx; + } + } + y += 2.0 * sw + 6.0; + + draw_text("meaning (tag)", p, y + 10.0, 14.0, C_DIM); + y += 15.0; + let tags = [ + (Meaning::Floor, "floor"), + (Meaning::Wall, "wall"), + (Meaning::Water, "water"), + (Meaning::Trigger, "trigger"), + (Meaning::None, "none"), + ]; + for (i, &(mng, label)) in tags.iter().enumerate() { + let x = p + (i % 2) as f32 * (bw + p); + let by = y + (i / 2) as f32 * 28.0; + if self.button(x, by, bw, 24.0, label, self.meaning == mng, mx, my) && click { + self.meaning = mng; + } + } + y += 3.0 * 28.0 + 4.0; + + let vw = (PANEL_W - 4.0 * p) / 3.0; + let views = [(ViewMode::Look, "look"), (ViewMode::Both, "both"), (ViewMode::Control, "ctrl")]; + for (i, &(v, label)) in views.iter().enumerate() { + let x = p + i as f32 * (vw + p); + if self.button(x, y, vw, 22.0, label, self.view == v, mx, my) && click { + self.view = v; + } + } + y += 28.0; + + // contextual: sprite tray or brush size + if self.tool == Tool::Image && !gs.world.sprites.is_empty() { + let i = self.active_sprite.min(gs.world.sprites.len() - 1); + draw_text(&format!("sprite {}/{}: {}", i + 1, gs.world.sprites.len(), gs.world.sprites[i].0), p, y + 10.0, 14.0, C_TEXT); + y += 16.0; + self.draw_cel_preview(gs, p, y, 44.0); + draw_text("[ ] cycle R reload", p + 52.0, y + 16.0, 14.0, C_DIM); + y += 50.0; + } else if self.tool == Tool::Brush || self.tool == Tool::Erase { + draw_text(&format!("brush size: {} (, .)", self.brush_size), p, y + 10.0, 14.0, C_TEXT); + y += 18.0; + } else if self.tool == Tool::Object { + let kit_label = if self.arch_idx == 0 { + "(blank · uses current sprite)".to_string() + } else { + self.archetypes.get(self.arch_idx - 1).map(|a| a.name.clone()).unwrap_or_default() + }; + draw_text(&format!("kit [ ]: {}", kit_label), p, y + 8.0, 12.0, C_ACCENT); + y += 14.0; + match self.selected_obj { + Some(sel) if sel < gs.world.doc.objects.len() => { + let fw = PANEL_W - 2.0 * p; + draw_text("name", p, y + 8.0, 12.0, C_DIM); + y += 12.0; + if self.field(p, y, fw, 18.0, &gs.world.doc.objects[sel].name.clone(), self.editing == Focus::Name, mx, my) && click { + self.editing = Focus::Name; + } + y += 22.0; + draw_text("look text", p, y + 8.0, 12.0, C_DIM); + y += 12.0; + if self.field(p, y, fw, 18.0, &gs.world.doc.objects[sel].look.clone(), self.editing == Focus::Look, mx, my) && click { + self.editing = Focus::Look; + } + y += 22.0; + draw_text("use text (on use/open)", p, y + 8.0, 12.0, C_DIM); + y += 12.0; + if self.field(p, y, fw, 18.0, &gs.world.doc.objects[sel].use_text.clone(), self.editing == Focus::Use, mx, my) && click { + self.editing = Focus::Use; + } + y += 22.0; + draw_text("needs item (to use)", p, y + 8.0, 12.0, C_DIM); + y += 12.0; + if self.field(p, y, fw, 18.0, &gs.world.doc.objects[sel].needs.clone(), self.editing == Focus::Needs, mx, my) && click { + self.editing = Focus::Needs; + } + y += 22.0; + draw_text("also called (synonyms)", p, y + 8.0, 12.0, C_DIM); + y += 12.0; + if self.field(p, y, fw, 18.0, &gs.world.doc.objects[sel].synonyms.clone(), self.editing == Focus::Syn, mx, my) && click { + self.editing = Focus::Syn; + } + y += 24.0; + let take = gs.world.doc.objects[sel].takeable; + if self.button(p, y, bw, 20.0, if take { "takeable" } else { "fixed" }, take, mx, my) && click { + gs.world.doc.objects[sel].takeable = !take; + self.editing = Focus::None; + } + let wins = gs.world.doc.objects[sel].wins; + if self.button(p + bw + p, y, bw, 20.0, if wins { "wins: yes" } else { "wins: no" }, wins, mx, my) && click { + gs.world.doc.objects[sel].wins = !wins; + self.editing = Focus::None; + } + y += 24.0; + if self.button(p, y, fw, 20.0, "delete object", false, mx, my) && click { + gs.world.doc.objects.remove(sel); + self.selected_obj = None; + self.editing = Focus::None; + } + y += 22.0; + } + _ => { + self.selected_obj = None; + self.editing = Focus::None; + draw_text("click to place / select", p, y + 9.0, 12.0, C_DIM); + y += 14.0; + } + } + } + + let acts: [(&str, u8); 4] = [("K save", 0), ("O load", 1), ("C clear", 2), ("Z undo", 3)]; + for (i, &(label, id)) in acts.iter().enumerate() { + let x = p + (i % 2) as f32 * (bw + p); + let by = y + (i / 2) as f32 * 28.0; + if self.button(x, by, bw, 24.0, label, false, mx, my) && click { + match id { + 0 => self.save_current(gs), + 1 => self.load_current(gs), + 2 => self.clear(gs), + _ => self.undo(gs), + } + } + } + y += 2.0 * 28.0 + 4.0; + + draw_text("status:", p, y + 10.0, 13.0, C_DIM); + y += 14.0; + for line in wrap(&self.status, 27) { + draw_text(&line, p, y + 10.0, 14.0, C_TEXT); + y += 15.0; + } + } + + fn draw_cel_preview(&self, gs: &GameState, x: f32, y: f32, max: f32) { + if gs.world.sprites.is_empty() { + return; + } + let cel = &gs.world.sprites[self.active_sprite.min(gs.world.sprites.len() - 1)].1; + let s = (max / cel.w.max(cel.h) as f32).max(1.0).floor(); + for cy in 0..cel.h { + for cx in 0..cel.w { + let c = cel.at(cx, cy); + if c == TRANSPARENT { + continue; + } + draw_rectangle(x + cx as f32 * s, y + cy as f32 * s, s, s, pcolor(c)); + } + } + } + + fn button(&self, x: f32, y: f32, w: f32, h: f32, label: &str, active: bool, mx: f32, my: f32) -> bool { + let hov = mx >= x && mx <= x + w && my >= y && my <= y + h; + let bg = if active { C_BTN_ON } else if hov { C_BTN_HOV } else { C_BTN }; + draw_rectangle(x, y, w, h, bg); + draw_rectangle_lines(x, y, w, h, 1.0, C_BORDER); + draw_text(label, x + 6.0, y + h * 0.5 + 5.0, 15.0, if active { C_ACCENT } else { C_TEXT }); + hov + } + + fn swatch(&self, x: f32, y: f32, s: f32, idx: u8, active: bool, mx: f32, my: f32) -> bool { + draw_rectangle(x, y, s, s, pcolor(idx)); + if active { + draw_rectangle_lines(x - 1.0, y - 1.0, s + 2.0, s + 2.0, 2.0, C_ACCENT); + } else { + draw_rectangle_lines(x, y, s, s, 1.0, C_BORDER); + } + mx >= x && mx <= x + s && my >= y && my <= y + s + } + + fn field(&self, x: f32, y: f32, w: f32, h: f32, text: &str, focused: bool, mx: f32, my: f32) -> bool { + let hov = mx >= x && mx <= x + w && my >= y && my <= y + h; + let bg = if focused { C_BTN_ON } else if hov { C_BTN_HOV } else { C_BTN }; + draw_rectangle(x, y, w, h, bg); + draw_rectangle_lines(x, y, w, h, 1.0, if focused { C_ACCENT } else { C_BORDER }); + let maxc = ((w - 10.0) / 8.0) as usize; + let count = text.chars().count(); + let shown: String = if count > maxc { + text.chars().skip(count - maxc).collect() + } else { + text.to_string() + }; + let caret = if focused { "_" } else { "" }; + draw_text(&format!("{}{}", shown, caret), x + 5.0, y + h * 0.5 + 5.0, 16.0, C_TEXT); + hov + } +} + +/// Composite a cel into a raw RGBA image (bottom-anchored at cx,cy). Used to +/// preview live objects in the editor without baking them into the buffers. +fn stamp_into_image(data: &mut [[u8; 4]], cel: &mrpgi_core::view::Cel, cx: i32, cy: i32) { + let ox = cx - cel.w as i32 / 2; + let oy = cy - cel.h as i32; + for y in 0..cel.h { + for x in 0..cel.w { + let c = cel.at(x, y); + if c == TRANSPARENT { + continue; + } + let sx = ox + x as i32; + let sy = oy + y as i32; + if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H { + continue; + } + data[sy as usize * PIC_W + sx as usize] = palette::rgba(c); + } + } +} + +fn blend(base: [u8; 4], over: [u8; 3], a: f32) -> [u8; 4] { + [ + (base[0] as f32 * (1.0 - a) + over[0] as f32 * a) as u8, + (base[1] as f32 * (1.0 - a) + over[1] as f32 * a) as u8, + (base[2] as f32 * (1.0 - a) + over[2] as f32 * a) as u8, + 255, + ] +} + +fn wrap(s: &str, width: usize) -> Vec { + let mut out = Vec::new(); + let mut line = String::new(); + for word in s.split_whitespace() { + if line.len() + word.len() + 1 > width && !line.is_empty() { + out.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() { + out.push(line); + } + out +} diff --git a/mrpgi/src/main.rs b/mrpgi/src/main.rs new file mode 100644 index 0000000..77cbe7d --- /dev/null +++ b/mrpgi/src/main.rs @@ -0,0 +1,304 @@ +//! MRPGI — Monster Robot Party Game Interpreter (GUI front-end). +//! +//! Two modes: EDIT (paint a room — visual + walkability) and PLAY (walk it). +//! Press `Tab` to switch. All game logic lives in mrpgi-core; this window is +//! just one consumer of the engine's command/event bus — the same bus the +//! CLI, HTTP and MCP surfaces drive. + +mod editor; +mod sound; + +use editor::Editor; +use macroquad::prelude::*; +use mrpgi_core::ai::AiConfig; +use mrpgi_core::framebuffer::{PIC_H, PIC_W}; +use mrpgi_core::state::{Command, Event}; +use mrpgi_core::{GameState, World}; + +fn window_conf() -> Conf { + Conf { + window_title: "MRPGI — Monster Robot Party Game Interpreter".to_owned(), + window_width: 1024, + window_height: 720, + high_dpi: true, + window_resizable: true, + ..Default::default() + } +} + +const STATUS_H: f32 = 150.0; + +#[derive(PartialEq)] +enum Mode { + Edit, + Play, +} + +#[macroquad::main(window_conf)] +async fn main() { + let game_dir = std::env::args() + .skip_while(|a| a != "--game") + .nth(1) + .unwrap_or_else(|| ".".to_string()); + if std::env::args().any(|a| a == "--sample") { + mrpgi_core::kit::write_sample_world(&game_dir); + } + if std::env::args().any(|a| a == "--kit") { + mrpgi_core::kit::write_kit(&game_dir); + } + mrpgi_core::assets::ensure_sample_sprites( + &std::path::Path::new(&game_dir).join("sprites").to_string_lossy(), + ); + + let ai_cfg = AiConfig::from_env(); + let ai_label = ai_cfg.label(); + let mut gs = GameState::new(World::load_game(&game_dir), ai_cfg); + let mut ed = Editor::new(&game_dir); + + let mut mode = Mode::Edit; + let mut play_img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK); + let play_tex = Texture2D::from_image(&play_img); + play_tex.set_filter(FilterMode::Nearest); + + let mut crt = false; + let mut command = String::new(); + let mut audio = sound::AudioOut::load(&game_dir).await; + + loop { + if is_key_pressed(KeyCode::Escape) { + if mode == Mode::Edit && ed.is_editing() { + ed.stop_editing(); + } else if mode == Mode::Play && gs.dlg.is_some() { + for e in gs.apply(Command::EndDialogue) { + handle(&e, &audio); + } + } else { + break; + } + } + if is_key_pressed(KeyCode::Tab) { + mode = match mode { + Mode::Edit => { + // Entering Play: fresh run from the room on the canvas, + // exactly as painted (saved or not). + let room = gs.world.current; + command.clear(); + for e in gs.apply(Command::NewGame { room: Some(room) }) { + handle(&e, &audio); + } + Mode::Play + } + Mode::Play => { + audio.set_music(0); + Mode::Edit + } + }; + } + if is_key_pressed(KeyCode::F2) { + let m = !audio.muted; + audio.set_muted(m); + } + + match mode { + Mode::Edit => ed.frame(&mut gs), + + Mode::Play => { + if is_key_pressed(KeyCode::F1) { + crt = !crt; + } + audio.set_music(gs.world.doc.music); + let layout = Layout::compute(); + let in_dlg = gs.dlg.is_some(); + let mut events: Vec = Vec::new(); + + // --- movement (arrows / click; paused while talking) --------- + if !in_dlg { + events.extend(gs.apply(Command::Walk { dir: read_keyboard_dir() })); + if is_mouse_button_pressed(MouseButton::Left) { + let (mx, my) = mouse_position(); + if let Some((px, py)) = layout.to_picture(mx, my) { + events.extend(gs.apply(Command::MoveTo { x: px, y: py })); + } + } + } + + // --- dialogue choices, or the parser (paused while talking) -- + if in_dlg { + if let Some(k) = digit_pressed() { + events.extend(gs.apply(Command::Choose { n: k })); + } + } else { + if is_key_pressed(KeyCode::Backspace) { + command.pop(); + } + while let Some(c) = get_char_pressed() { + if !c.is_control() && command.chars().count() < 64 { + command.push(c); + } + } + if is_key_pressed(KeyCode::Enter) { + let line = command.trim().to_string(); + command.clear(); + if !line.is_empty() { + events.extend(gs.apply(Command::Parse { text: line })); + } + } + } + + // --- game cycles, transitions, AI results -------------------- + events.extend(gs.tick(get_frame_time())); + for e in &events { + handle(e, &audio); + } + + // --- render --------------------------------------------------- + let rgba = gs.render_visual(true); + play_img.bytes.copy_from_slice(&rgba); + play_tex.update(&play_img); + + clear_background(BLACK); + draw_texture_ex( + &play_tex, + layout.x, + layout.y, + WHITE, + DrawTextureParams { dest_size: Some(vec2(layout.w, layout.h)), ..Default::default() }, + ); + if crt { + draw_scanlines(&layout); + } + draw_play_panel(&gs, &command, crt, &ai_label, !audio.muted); + if gs.won { + let sw2 = screen_width(); + let by = screen_height() * 0.34; + draw_rectangle(0.0, by, sw2, 56.0, color_u8!(18, 40, 30, 235)); + let t = "\u{2605} YOU WIN \u{2605}"; + let dim = measure_text(t, None, 40, 1.0); + draw_text(t, (sw2 - dim.width) * 0.5, by + 40.0, 40.0, color_u8!(120, 255, 180, 255)); + } + } + } + + next_frame().await; + } +} + +/// The GUI's whole event handler: play the sounds. (Transcript, inventory and +/// win state are drawn straight from GameState each frame.) +fn handle(e: &Event, audio: &sound::AudioOut) { + if let Event::Audio { cue } = e { + audio.play(*cue); + } +} + +fn read_keyboard_dir() -> u8 { + let up = is_key_down(KeyCode::Up); + let down = is_key_down(KeyCode::Down); + let left = is_key_down(KeyCode::Left); + let right = is_key_down(KeyCode::Right); + match (up, down, left, right) { + (true, false, false, false) => 1, + (true, false, false, true) => 2, + (false, false, false, true) => 3, + (false, true, false, true) => 4, + (false, true, false, false) => 5, + (false, true, true, false) => 6, + (false, false, true, false) => 7, + (true, false, true, false) => 8, + _ => 0, + } +} + +struct Layout { + x: f32, + y: f32, + w: f32, + h: f32, +} + +impl Layout { + fn compute() -> Self { + let sw = screen_width(); + let sh = screen_height(); + let avail_h = (sh - STATUS_H).max(1.0); + let scale = (sw / 320.0).min(avail_h / PIC_H as f32); + let w = 320.0 * scale; + let h = PIC_H as f32 * scale; + Layout { x: (sw - w) * 0.5, y: 0.0, w, h } + } + + fn to_picture(&self, mx: f32, my: f32) -> Option<(i32, i32)> { + let px = (mx - self.x) / (self.w / PIC_W as f32); + let py = (my - self.y) / (self.h / PIC_H as f32); + if px >= 0.0 && py >= 0.0 && px < PIC_W as f32 && py < PIC_H as f32 { + Some((px as i32, py as i32)) + } else { + None + } + } +} + +fn draw_scanlines(l: &Layout) { + let mut y = l.y; + while y < l.y + l.h { + draw_line(l.x, y, l.x + l.w, y, 1.0, Color::new(0.0, 0.0, 0.0, 0.28)); + y += 3.0; + } +} + +fn draw_play_panel(gs: &GameState, command: &str, crt: bool, ai: &str, sound_on: bool) { + let sw = screen_width(); + let sh = screen_height(); + let top = sh - STATUS_H; + draw_rectangle(0.0, top, sw, STATUS_H, color_u8!(12, 14, 18, 255)); + draw_line(0.0, top, sw, top, 1.0, color_u8!(60, 70, 85, 255)); + + let mut yy = top + 20.0; + let start = gs.transcript.len().saturating_sub(5); + for line in &gs.transcript[start..] { + let col = if line.starts_with('>') { + color_u8!(255, 135, 215, 255) + } else { + color_u8!(205, 213, 225, 255) + }; + draw_text(line, 12.0, yy, 18.0, col); + yy += 18.0; + } + + if gs.dlg.is_some() { + draw_text("(type a number to choose · Esc to leave)", 12.0, sh - 30.0, 18.0, color_u8!(95, 255, 215, 255)); + } else { + draw_text(&format!("> {}_", command), 12.0, sh - 30.0, 20.0, color_u8!(95, 255, 215, 255)); + } + let inv = if gs.inventory.is_empty() { "—".to_string() } else { gs.inventory.join(", ") }; + draw_text( + &format!( + "room {} · inv: {} · ai: {} · Tab: edit · F1: CRT[{}] · F2: snd[{}] · Esc: quit", + gs.world.current, + inv, + ai, + if crt { "on" } else { "off" }, + if sound_on { "on" } else { "off" } + ), + 12.0, + sh - 8.0, + 14.0, + color_u8!(120, 130, 145, 255), + ); +} + +fn digit_pressed() -> Option { + for (k, code) in [ + (1usize, KeyCode::Key1), + (2, KeyCode::Key2), + (3, KeyCode::Key3), + (4, KeyCode::Key4), + (5, KeyCode::Key5), + (6, KeyCode::Key6), + ] { + if is_key_pressed(code) { + return Some(k); + } + } + None +} diff --git a/mrpgi/src/sound.rs b/mrpgi/src/sound.rs new file mode 100644 index 0000000..2a940b3 --- /dev/null +++ b/mrpgi/src/sound.rs @@ -0,0 +1,100 @@ +//! Audio output — the GUI's sink for the core's audio events. +//! +//! Two sources, per cue/track, checked in order ("always want options"): +//! 1. files in the game folder: `sfx/.{wav,ogg,mp3}`, `music/.{...}` +//! (mood by name — calm/eerie/tense/jolly/spooky — or by index 1-5) +//! 2. the core's built-in square-wave chiptune synth +//! If the audio backend can't start, everything degrades to silence. + +use macroquad::audio::{load_sound_from_bytes, play_sound, stop_sound, PlaySoundParams, Sound}; +use mrpgi_core::audio::{music_wav, AudioCue, MOODS}; +use std::path::Path; + +pub struct AudioOut { + sfx: Vec<(AudioCue, Option)>, + music: Vec>, // by mood index; [0] is silence + current: u8, + pub muted: bool, +} + +/// Read an override file for a base name, trying each supported extension. +fn file_bytes(dir: &Path, stem: &str) -> Option> { + for ext in ["wav", "ogg", "mp3"] { + if let Ok(b) = std::fs::read(dir.join(format!("{stem}.{ext}"))) { + return Some(b); + } + } + None +} + +async fn snd(bytes: &[u8]) -> Option { + load_sound_from_bytes(bytes).await.ok() +} + +impl AudioOut { + pub async fn load(game_dir: &str) -> Self { + let base = Path::new(game_dir); + let mut sfx = Vec::new(); + for cue in AudioCue::ALL { + let bytes = file_bytes(&base.join("sfx"), cue.name()).unwrap_or_else(|| cue.wav()); + sfx.push((cue, snd(&bytes).await)); + } + let mut music = vec![None]; // mood 0 = silence + for mood in 1u8..=5 { + let bytes = file_bytes(&base.join("music"), MOODS[mood as usize]) + .or_else(|| file_bytes(&base.join("music"), &mood.to_string())) + .or_else(|| music_wav(mood)); + music.push(match bytes { + Some(b) => snd(&b).await, + None => None, + }); + } + AudioOut { sfx, music, current: 255, muted: false } + } + + pub fn play(&self, cue: AudioCue) { + if self.muted { + return; + } + let vol = match cue { + AudioCue::Blip => 0.4, + AudioCue::Pickup => 0.7, + AudioCue::Confirm => 0.55, + AudioCue::Error => 0.6, + AudioCue::Door => 0.6, + AudioCue::Win => 0.85, + }; + if let Some((_, Some(s))) = self.sfx.iter().find(|(c, _)| *c == cue) { + play_sound(s, PlaySoundParams { looped: false, volume: vol }); + } + } + + /// Switch to a mood (no-op if already playing it). Loops at low volume. + pub fn set_music(&mut self, mood: u8) { + if mood == self.current { + return; + } + if let Some(Some(s)) = self.music.get(self.current as usize) { + stop_sound(s); + } + self.current = mood; + if !self.muted { + if let Some(Some(s)) = self.music.get(mood as usize) { + play_sound(s, PlaySoundParams { looped: true, volume: 0.13 }); + } + } + } + + pub fn set_muted(&mut self, m: bool) { + self.muted = m; + if m { + for t in self.music.iter().flatten() { + stop_sound(t); + } + } else { + let c = self.current; + self.current = 255; + self.set_music(c); // replay current mood + } + } +} diff --git a/run.sh b/run.sh index 682865e..c5da727 100755 --- a/run.sh +++ b/run.sh @@ -8,4 +8,6 @@ export MRPGI_AI_MODEL="${MRPGI_AI_MODEL:-hf.co/OBLITERATUS/gemma-4-E4B-it-OBLITE # Disable the AI lane entirely: # MRPGI_AI=off ./run.sh # -exec cargo run "$@" +# Headless surfaces live in the companion binary: +# cargo run -p mrpgi-core --bin mrpgi-headless -- --help +exec cargo run -p mrpgi "$@" diff --git a/src/editor.rs b/src/editor.rs deleted file mode 100644 index 78d4d29..0000000 --- a/src/editor.rs +++ /dev/null @@ -1,1362 +0,0 @@ -//! In-engine room editor — MRPGI's "paint app". -//! -//! You paint on TWO layers at once: -//! * LOOK — the visual buffer (16 EGA colours, what the player sees) -//! * MEANING — the priority/control buffer (walkable / wall / water / depth) -//! Colour and meaning are set independently: pick a colour, pick a meaning tag, -//! then draw / fill / stamp a region. Drop PNGs in `sprites/` to place pixel -//! art. A room is an editable list of strokes (`RoomDoc`) saved as JSON. - -use crate::assets; -use crate::framebuffer::{FrameBuffer, BG_VISUAL, CTRL_BLOCK, PIC_H, PIC_W}; -use crate::palette; -use crate::picture::{self, PriFill}; -use crate::view::{self, Cel}; -use macroquad::prelude::*; -use serde::{Deserialize, Serialize}; - -const PANEL_W: f32 = 200.0; - -const C_PANEL: Color = Color { r: 16.0 / 255.0, g: 19.0 / 255.0, b: 24.0 / 255.0, a: 1.0 }; -const C_BTN: Color = Color { r: 30.0 / 255.0, g: 35.0 / 255.0, b: 43.0 / 255.0, a: 1.0 }; -const C_BTN_HOV: Color = Color { r: 46.0 / 255.0, g: 54.0 / 255.0, b: 66.0 / 255.0, a: 1.0 }; -const C_BTN_ON: Color = Color { r: 34.0 / 255.0, g: 95.0 / 255.0, b: 82.0 / 255.0, a: 1.0 }; -const C_BORDER: Color = Color { r: 72.0 / 255.0, g: 82.0 / 255.0, b: 98.0 / 255.0, a: 1.0 }; -const C_TEXT: Color = Color { r: 205.0 / 255.0, g: 213.0 / 255.0, b: 225.0 / 255.0, a: 1.0 }; -const C_DIM: Color = Color { r: 120.0 / 255.0, g: 130.0 / 255.0, b: 145.0 / 255.0, a: 1.0 }; -const C_ACCENT: Color = Color { r: 95.0 / 255.0, g: 255.0 / 255.0, b: 215.0 / 255.0, a: 1.0 }; - -#[derive(Clone, Copy, PartialEq)] -pub enum Tool { - Brush, - Line, - Rect, - Fill, - Pick, - Erase, - Image, - Ellipse, - Spawn, - Object, -} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum Meaning { - None, - Floor, - Wall, - Water, - Trigger, -} - -impl Meaning { - fn pri(self) -> PriFill { - match self { - Meaning::None => PriFill::Keep, - Meaning::Floor => PriFill::Band, - Meaning::Wall => PriFill::Set(CTRL_BLOCK), - Meaning::Water => PriFill::Set(1), - Meaning::Trigger => PriFill::Set(2), - } - } - fn label(self) -> &'static str { - match self { - Meaning::None => "none", - Meaning::Floor => "floor", - Meaning::Wall => "wall", - Meaning::Water => "water", - Meaning::Trigger => "trigger", - } - } -} - -#[derive(Clone, Copy, PartialEq)] -pub enum ViewMode { - Look, - Both, - Control, -} - -/// Which object text field is currently being typed into. -#[derive(Clone, Copy, PartialEq)] -enum Focus { - None, - Name, - Look, - Use, - Needs, - Syn, -} - -#[derive(Clone, Serialize, Deserialize)] -enum Stroke { - Brush { pts: Vec<(i32, i32)>, color: u8, meaning: Meaning, size: i32 }, - Line { pts: Vec<(i32, i32)>, color: u8 }, - Rect { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning }, - Ellipse { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning }, - Fill { x: i32, y: i32, color: u8, meaning: Meaning }, - Image { name: String, x: i32, y: i32, meaning: Meaning }, -} - -/// A live object: a sprite the player can look at / take. Its base point -/// (x, y) is its feet, like the ego, so it depth-sorts the same way. -#[derive(Clone, Serialize, Deserialize)] -pub struct DlgChoice { - pub text: String, - pub goto: i32, // next node index, or -1 to end the conversation -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct DlgNode { - pub says: String, - #[serde(default)] - pub choices: Vec, -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct ObjDef { - pub name: String, - pub sprite: String, - pub x: i32, - pub y: i32, - #[serde(default)] - pub look: String, - #[serde(default)] - pub takeable: bool, - #[serde(default)] - pub synonyms: String, // space-separated alternate names - #[serde(default)] - pub use_text: String, // shown on "use"/"open" - #[serde(default)] - pub needs: String, // item required to use it (empty = no lock) - #[serde(default)] - pub wins: bool, // using it (successfully) wins the game - #[serde(default)] - pub dialogue: Vec, // node 0 = entry; empty = no conversation -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct RoomDoc { - strokes: Vec, - spawn: (i32, i32), - #[serde(default)] - exits: [Option; 4], // N, E, S, W → target room index - #[serde(default)] - objects: Vec, - #[serde(default)] - music: u8, // 0 = silence, 1.. = ambient mood -} - -impl Default for RoomDoc { - fn default() -> Self { - RoomDoc { strokes: Vec::new(), spawn: (80, 150), exits: [None; 4], objects: Vec::new(), music: 0 } - } -} - -pub struct Editor { - pub tool: Tool, - pub color: u8, - pub meaning: Meaning, - pub view: ViewMode, - pub brush_size: i32, - pub fb: FrameBuffer, - doc: RoomDoc, - current: u32, - sprites: Vec<(String, Cel)>, - active_sprite: usize, - selected_obj: Option, - editing: Focus, - archetypes: Vec, - arch_idx: usize, - line_pts: Vec<(i32, i32)>, - rect_start: Option<(i32, i32)>, - brush_pts: Vec<(i32, i32)>, - painting: bool, - pub status: String, - img: Image, - tex: Texture2D, -} - -impl Editor { - pub fn new() -> Self { - let img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK); - let tex = Texture2D::from_image(&img); - tex.set_filter(FilterMode::Nearest); - assets::ensure_sample_sprites("sprites"); - let sprites = assets::load_sprite_dir("sprites"); - Editor { - tool: Tool::Brush, - color: 6, - meaning: Meaning::Floor, - view: ViewMode::Both, - brush_size: 2, - fb: FrameBuffer::new(), - doc: RoomDoc::default(), - current: 0, - sprites, - active_sprite: 0, - selected_obj: None, - editing: Focus::None, - archetypes: load_archetypes(), - arch_idx: 0, - line_pts: Vec::new(), - rect_start: None, - brush_pts: Vec::new(), - painting: false, - status: "draw a room — then press E to walk it".to_string(), - img, - tex, - } - } - - pub fn spawn(&self) -> (i32, i32) { - self.doc.spawn - } - - pub fn current(&self) -> u32 { - self.current - } - - pub fn current_exits(&self) -> [Option; 4] { - self.doc.exits - } - - pub fn music(&self) -> u8 { - self.doc.music - } - - fn cycle_music(&mut self) { - self.doc.music = (self.doc.music + 1) % 6; // 0=silence, 1..5 moods - } - - pub fn objects(&self) -> &[ObjDef] { - &self.doc.objects - } - - pub fn sprite_cel(&self, name: &str) -> Option<&Cel> { - self.sprites.iter().find(|(n, _)| n == name).map(|(_, c)| c) - } - - pub fn is_editing(&self) -> bool { - self.editing != Focus::None - } - - pub fn stop_editing(&mut self) { - self.editing = Focus::None; - } - - fn room_path(n: u32) -> String { - format!("rooms/room{}.json", n) - } - - fn save_current(&mut self) { - let p = Self::room_path(self.current); - self.save(&p); - } - - fn load_current(&mut self) { - let p = Self::room_path(self.current); - self.load(&p); - } - - /// Load room 0 on startup (falling back to a legacy single `room.json`). - pub fn boot(&mut self) { - self.current = 0; - let p0 = Self::room_path(0); - if std::path::Path::new(&p0).exists() { - self.load(&p0); - } else if std::path::Path::new("rooms/room.json").exists() { - self.load("rooms/room.json"); - } - } - - /// Switch the working room WITHOUT saving (used by Play transitions). - pub fn goto_room(&mut self, n: u32) { - self.current = n; - self.line_pts.clear(); - self.rect_start = None; - self.brush_pts.clear(); - self.painting = false; - let p = Self::room_path(n); - if std::path::Path::new(&p).exists() { - self.load(&p); - } else { - self.doc = RoomDoc::default(); - self.bake(); - self.status = format!("room {} (new)", n); - } - } - - /// Save the current room, then switch (used by the editor's nav buttons). - fn switch_room(&mut self, n: u32) { - self.save_current(); - self.goto_room(n); - } - - fn cycle_exit(&mut self, d: usize) { - self.doc.exits[d] = match self.doc.exits[d] { - None => Some(0), - Some(k) if k < 9 => Some(k + 1), - Some(_) => None, - }; - self.status = "exit set — K saves the room".into(); - } - - fn reload_sprites(&mut self) { - self.sprites = assets::load_sprite_dir("sprites"); - if self.active_sprite >= self.sprites.len() { - self.active_sprite = 0; - } - self.archetypes = load_archetypes(); - self.status = format!("reloaded {} sprites, {} kit archetypes", self.sprites.len(), self.archetypes.len()); - } - - // --- stroke application ------------------------------------------------ - fn apply_stroke(fb: &mut FrameBuffer, s: &Stroke, sprites: &[(String, Cel)]) { - match s { - Stroke::Brush { pts, color, meaning, size } => { - for &(x, y) in pts { - disc(fb, x, y, *size, *color, meaning.pri()); - } - } - Stroke::Line { pts, color } => { - for w in pts.windows(2) { - picture::line_into(fb, w[0], w[1], *color, PriFill::Keep); - } - } - Stroke::Rect { x, y, w, h, color, meaning } => { - picture::rect_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri()); - } - Stroke::Ellipse { x, y, w, h, color, meaning } => { - picture::ellipse_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri()); - } - Stroke::Fill { x, y, color, meaning } => { - picture::flood_fill(fb, *x, *y, Some(*color), meaning.pri()); - } - Stroke::Image { name, x, y, meaning } => { - if let Some((_, cel)) = sprites.iter().find(|(n, _)| n == name) { - stamp(fb, cel, *x, *y, meaning.pri()); - } - } - } - } - - fn commit(&mut self, s: Stroke) { - Self::apply_stroke(&mut self.fb, &s, &self.sprites); - self.doc.strokes.push(s); - } - - fn bake(&mut self) { - self.fb.clear(); - let strokes = std::mem::take(&mut self.doc.strokes); - for s in &strokes { - Self::apply_stroke(&mut self.fb, s, &self.sprites); - } - self.doc.strokes = strokes; - } - - fn undo(&mut self) { - if self.doc.strokes.pop().is_some() { - self.bake(); - self.status = "undo".to_string(); - } - } - - fn clear(&mut self) { - self.doc.strokes.clear(); - self.bake(); - self.status = "cleared".to_string(); - } - - pub fn save(&mut self, path: &str) { - if let Some(dir) = std::path::Path::new(path).parent() { - std::fs::create_dir_all(dir).ok(); - } - match serde_json::to_string_pretty(&self.doc) { - Ok(j) => { - self.status = if std::fs::write(path, j).is_ok() { - format!("saved {}", path) - } else { - "save FAILED".to_string() - }; - } - Err(_) => self.status = "save FAILED".to_string(), - } - } - - pub fn load(&mut self, path: &str) { - if let Ok(s) = std::fs::read_to_string(path) { - if let Ok(doc) = serde_json::from_str::(&s) { - self.doc = doc; - self.bake(); - self.status = format!("loaded {}", path); - } - } - } - - fn cycle_sprite(&mut self, delta: i32) { - if self.sprites.is_empty() { - return; - } - let n = self.sprites.len() as i32; - self.active_sprite = (((self.active_sprite as i32 + delta) % n + n) % n) as usize; - } - - fn cycle_arch(&mut self, delta: i32) { - let n = (self.archetypes.len() + 1) as i32; // index 0 = blank - if n <= 1 { - return; - } - self.arch_idx = (((self.arch_idx as i32 + delta) % n + n) % n) as usize; - } - - // --- per-frame edit loop ---------------------------------------------- - pub fn frame(&mut self) { - let sw = screen_width(); - let sh = screen_height(); - let panel = Rect::new(0.0, 0.0, PANEL_W, sh); - - let cax = PANEL_W + 10.0; - let cay = 34.0; // leave room for the world strip - let avail_w = (sw - cax - 10.0).max(64.0); - let avail_h = (sh - cay - 10.0).max(64.0); - let scale = (avail_w / 320.0).min(avail_h / 168.0).max(0.5); - let cw = 320.0 * scale; - let ch = 168.0 * scale; - let canvas = Rect::new(cax + (avail_w - cw) * 0.5, cay + (avail_h - ch) * 0.5, cw, ch); - - let (mx, my) = mouse_position(); - let m = vec2(mx, my); - let clicked = is_mouse_button_pressed(MouseButton::Left); - let down = is_mouse_button_down(MouseButton::Left); - let released = is_mouse_button_released(MouseButton::Left); - let rclick = is_mouse_button_pressed(MouseButton::Right); - let in_panel = panel.contains(m); - - let cpx = ((mx - canvas.x) / (canvas.w / PIC_W as f32)).floor() as i32; - let cpy = ((my - canvas.y) / (canvas.h / PIC_H as f32)).floor() as i32; - let in_canvas = - canvas.contains(m) && cpx >= 0 && cpy >= 0 && cpx < PIC_W as i32 && cpy < PIC_H as i32; - - // --- text entry into the selected object's fields ---------------- - let typing = self.tool == Tool::Object && self.editing != Focus::None && self.selected_obj.is_some(); - if typing { - let sel = self.selected_obj.unwrap(); - if sel < self.doc.objects.len() { - if is_key_pressed(KeyCode::Backspace) { - match self.editing { - Focus::Name => { self.doc.objects[sel].name.pop(); } - Focus::Look => { self.doc.objects[sel].look.pop(); } - Focus::Use => { self.doc.objects[sel].use_text.pop(); } - Focus::Needs => { self.doc.objects[sel].needs.pop(); } - Focus::Syn => { self.doc.objects[sel].synonyms.pop(); } - _ => {} - } - } - while let Some(c) = get_char_pressed() { - if !c.is_control() { - match self.editing { - Focus::Name if self.doc.objects[sel].name.len() < 32 => self.doc.objects[sel].name.push(c), - Focus::Look if self.doc.objects[sel].look.len() < 160 => self.doc.objects[sel].look.push(c), - Focus::Use if self.doc.objects[sel].use_text.len() < 160 => self.doc.objects[sel].use_text.push(c), - Focus::Needs if self.doc.objects[sel].needs.len() < 32 => self.doc.objects[sel].needs.push(c), - Focus::Syn if self.doc.objects[sel].synonyms.len() < 80 => self.doc.objects[sel].synonyms.push(c), - _ => {} - } - } - } - if is_key_pressed(KeyCode::Enter) { - self.editing = match self.editing { - Focus::Name => Focus::Look, - Focus::Look => Focus::Use, - Focus::Use => Focus::Needs, - Focus::Needs => Focus::Syn, - _ => Focus::None, - }; - } - } - } - - // --- keyboard shortcuts (suppressed while typing in a field) ------ - if !typing { - if is_key_pressed(KeyCode::Key1) { self.tool = Tool::Brush; } - if is_key_pressed(KeyCode::Key2) { self.tool = Tool::Line; } - if is_key_pressed(KeyCode::Key3) { self.tool = Tool::Rect; } - if is_key_pressed(KeyCode::Key4) { self.tool = Tool::Fill; } - if is_key_pressed(KeyCode::Key5) { self.tool = Tool::Pick; } - if is_key_pressed(KeyCode::Key6) { self.tool = Tool::Erase; } - if is_key_pressed(KeyCode::Key7) { self.tool = Tool::Image; } - if is_key_pressed(KeyCode::Key8) { self.tool = Tool::Ellipse; } - if is_key_pressed(KeyCode::Key9) { self.tool = Tool::Spawn; } - if is_key_pressed(KeyCode::Key0) { self.tool = Tool::Object; } - if is_key_pressed(KeyCode::Minus) { let c = self.current; self.switch_room(c.saturating_sub(1)); } - if is_key_pressed(KeyCode::Equal) { let c = self.current; self.switch_room(c + 1); } - if is_key_pressed(KeyCode::LeftBracket) { if self.tool == Tool::Object { self.cycle_arch(-1); } else { self.cycle_sprite(-1); } } - if is_key_pressed(KeyCode::RightBracket) { if self.tool == Tool::Object { self.cycle_arch(1); } else { self.cycle_sprite(1); } } - if is_key_pressed(KeyCode::Comma) { self.brush_size = (self.brush_size - 1).max(1); } - if is_key_pressed(KeyCode::Period) { self.brush_size = (self.brush_size + 1).min(8); } - if is_key_pressed(KeyCode::L) { - self.view = match self.view { - ViewMode::Look => ViewMode::Both, - ViewMode::Both => ViewMode::Control, - ViewMode::Control => ViewMode::Look, - }; - } - if is_key_pressed(KeyCode::Z) || is_key_pressed(KeyCode::Backspace) { self.undo(); } - if is_key_pressed(KeyCode::K) { self.save_current(); } - if is_key_pressed(KeyCode::O) { self.load_current(); } - if is_key_pressed(KeyCode::C) { self.clear(); } - if is_key_pressed(KeyCode::R) { self.reload_sprites(); } - } - - // --- canvas painting (only when not over the panel) --------------- - if typing && clicked && in_canvas { - self.editing = Focus::None; - } - if in_canvas && !in_panel && !typing { - match self.tool { - Tool::Brush | Tool::Erase => { - if down { - let (c, mng, sz) = if self.tool == Tool::Erase { - (BG_VISUAL, Meaning::Floor, self.brush_size + 1) - } else { - (self.color, self.meaning, self.brush_size) - }; - disc(&mut self.fb, cpx, cpy, sz, c, mng.pri()); - self.brush_pts.push((cpx, cpy)); - self.painting = true; - } - } - Tool::Line => { - if clicked { - self.line_pts.push((cpx, cpy)); - } - } - Tool::Rect | Tool::Ellipse => { - if clicked { - self.rect_start = Some((cpx, cpy)); - } - } - Tool::Fill => { - if clicked { - let n = picture::flood_fill(&mut self.fb, cpx, cpy, Some(self.color), self.meaning.pri()); - self.doc.strokes.push(Stroke::Fill { x: cpx, y: cpy, color: self.color, meaning: self.meaning }); - if n as f32 > 0.85 * (PIC_W * PIC_H) as f32 { - self.status = "LEAK? fill spread huge — region not sealed (Z=undo)".into(); - } else { - self.status = format!("filled {} px ({})", n, self.meaning.label()); - } - } - } - Tool::Pick => { - if clicked { - self.color = self.fb.visual[cpy as usize * PIC_W + cpx as usize]; - self.status = format!("picked colour {}", self.color); - } - } - Tool::Image => { - if clicked && !self.sprites.is_empty() { - let i = self.active_sprite.min(self.sprites.len() - 1); - let name = self.sprites[i].0.clone(); - self.commit(Stroke::Image { name: name.clone(), x: cpx, y: cpy, meaning: self.meaning }); - self.status = format!("placed {}", name); - } - } - Tool::Spawn => { - if clicked { - self.doc.spawn = (cpx, cpy); - self.status = "spawn point set".into(); - } - } - Tool::Object => { - if clicked { - let hit = self.doc.objects.iter().position(|o| (cpx - o.x).abs() <= 16 && (cpy - o.y).abs() <= 26); - if let Some(idx) = hit { - self.selected_obj = Some(idx); - self.status = format!("selected '{}'", self.doc.objects[idx].name); - } else if self.arch_idx > 0 && self.arch_idx <= self.archetypes.len() { - let mut a = self.archetypes[self.arch_idx - 1].clone(); - a.x = cpx; - a.y = cpy; - let nm = a.name.clone(); - self.doc.objects.push(a); - self.selected_obj = Some(self.doc.objects.len() - 1); - self.status = format!("placed '{}' from kit", nm); - } else if !self.sprites.is_empty() { - let i = self.active_sprite.min(self.sprites.len() - 1); - let sprite = self.sprites[i].0.clone(); - let look = format!("It's a {}.", sprite); - self.doc.objects.push(ObjDef { name: sprite.clone(), sprite, x: cpx, y: cpy, look, takeable: true, synonyms: String::new(), use_text: String::new(), needs: String::new(), wins: false, dialogue: Vec::new() }); - self.selected_obj = Some(self.doc.objects.len() - 1); - self.status = "placed object — Tab to play, then 'look '".into(); - } - } - } - } - } - - // commit on release (works even if the cursor left the canvas) - if released { - if self.painting { - let (c, mng, sz) = if self.tool == Tool::Erase { - (BG_VISUAL, Meaning::Floor, self.brush_size + 1) - } else { - (self.color, self.meaning, self.brush_size) - }; - let pts = std::mem::take(&mut self.brush_pts); - if !pts.is_empty() { - self.doc.strokes.push(Stroke::Brush { pts, color: c, meaning: mng, size: sz }); - } - self.painting = false; - } - if let Some((sx, sy)) = self.rect_start.take() { - let ex = cpx.clamp(0, PIC_W as i32 - 1); - let ey = cpy.clamp(0, PIC_H as i32 - 1); - let x = sx.min(ex); - let y = sy.min(ey); - let w = (sx - ex).abs() + 1; - let h = (sy - ey).abs() + 1; - if self.tool == Tool::Ellipse { - self.commit(Stroke::Ellipse { x, y, w, h, color: self.color, meaning: self.meaning }); - self.status = format!("ellipse {}x{}", w, h); - } else { - self.commit(Stroke::Rect { x, y, w, h, color: self.color, meaning: self.meaning }); - self.status = format!("rect {}x{}", w, h); - } - } - } - - if self.tool == Tool::Line && (rclick || is_key_pressed(KeyCode::Enter)) { - if self.line_pts.len() >= 2 { - let pts = std::mem::take(&mut self.line_pts); - self.commit(Stroke::Line { pts, color: self.color }); - self.status = "line committed".into(); - } else { - self.line_pts.clear(); - } - } - - // --- render & present -------------------------------------------- - self.render_image(); - clear_background(Color { r: 8.0 / 255.0, g: 9.0 / 255.0, b: 11.0 / 255.0, a: 1.0 }); - draw_texture_ex( - &self.tex, - canvas.x, - canvas.y, - WHITE, - DrawTextureParams { dest_size: Some(vec2(canvas.w, canvas.h)), ..Default::default() }, - ); - draw_rectangle_lines(canvas.x, canvas.y, canvas.w, canvas.h, 2.0, C_BORDER); - - // spawn marker - let (spx, spy) = self.doc.spawn; - let s = self.pixel_to_screen(&canvas, spx, spy); - draw_circle_lines(s.x, s.y, 7.0, 2.0, C_ACCENT); - - // previews - let ppx = self.pixel_to_screen(&canvas, cpx, cpy); - if self.tool == Tool::Line && !self.line_pts.is_empty() { - for w in self.line_pts.windows(2) { - let a = self.pixel_to_screen(&canvas, w[0].0, w[0].1); - let b = self.pixel_to_screen(&canvas, w[1].0, w[1].1); - draw_line(a.x, a.y, b.x, b.y, 2.0, C_ACCENT); - } - let last = *self.line_pts.last().unwrap(); - let a = self.pixel_to_screen(&canvas, last.0, last.1); - draw_line(a.x, a.y, ppx.x, ppx.y, 1.0, C_DIM); - } - if (self.tool == Tool::Rect || self.tool == Tool::Ellipse) && self.rect_start.is_some() { - let (sx, sy) = self.rect_start.unwrap(); - let a = self.pixel_to_screen(&canvas, sx, sy); - draw_rectangle_lines(a.x.min(ppx.x), a.y.min(ppx.y), (a.x - ppx.x).abs(), (a.y - ppx.y).abs(), 2.0, C_ACCENT); - } - if in_canvas { - let pw = canvas.w / PIC_W as f32; - let ph = canvas.h / PIC_H as f32; - draw_rectangle_lines(canvas.x + cpx as f32 * pw, canvas.y + cpy as f32 * ph, pw, ph, 1.0, C_ACCENT); - } - - // object markers (name labels + selection box) - let pw = canvas.w / PIC_W as f32; - let ph = canvas.h / PIC_H as f32; - for (i, o) in self.doc.objects.iter().enumerate() { - let sp = self.pixel_to_screen(&canvas, o.x, o.y); - draw_text(&o.name, sp.x - 12.0, sp.y + 12.0, 13.0, C_ACCENT); - if Some(i) == self.selected_obj { - let (w, h) = self - .sprites - .iter() - .find(|(n, _)| *n == o.sprite) - .map(|(_, c)| (c.w as f32, c.h as f32)) - .unwrap_or((10.0, 16.0)); - draw_rectangle_lines(sp.x - w * 0.5 * pw, sp.y - h * ph, w * pw, h * ph, 2.0, C_ACCENT); - } - } - - // top strip: world (room nav + edge exits) - let strip_click = clicked && my < 30.0 && mx > PANEL_W; - let mut xc = PANEL_W + 12.0; - draw_text(&format!("room {}", self.current), xc, 20.0, 18.0, C_ACCENT); - xc += 78.0; - if self.button(xc, 5.0, 24.0, 22.0, "<", false, mx, my) && strip_click { - let c = self.current; - self.switch_room(c.saturating_sub(1)); - } - xc += 28.0; - if self.button(xc, 5.0, 24.0, 22.0, ">", false, mx, my) && strip_click { - let c = self.current; - self.switch_room(c + 1); - } - xc += 36.0; - draw_text("exits", xc, 20.0, 15.0, C_DIM); - xc += 42.0; - for (d, dl) in [(0usize, "N"), (1, "E"), (2, "S"), (3, "W")] { - let label = match self.doc.exits[d] { - Some(v) => format!("{}:{}", dl, v), - None => format!("{}:-", dl), - }; - if self.button(xc, 5.0, 42.0, 22.0, &label, self.doc.exits[d].is_some(), mx, my) && strip_click { - self.cycle_exit(d); - } - xc += 46.0; - } - let moods = ["off", "calm", "eerie", "tense", "jolly", "spooky"]; - let mlabel = format!("\u{266A} {}", moods.get(self.doc.music as usize).copied().unwrap_or("?")); - if self.button(xc + 6.0, 5.0, 72.0, 22.0, &mlabel, self.doc.music != 0, mx, my) && strip_click { - self.cycle_music(); - } - - self.draw_panel(sh, mx, my, clicked && in_panel); - } - - fn pixel_to_screen(&self, canvas: &Rect, px: i32, py: i32) -> Vec2 { - vec2( - canvas.x + (px as f32 + 0.5) * (canvas.w / PIC_W as f32), - canvas.y + (py as f32 + 0.5) * (canvas.h / PIC_H as f32), - ) - } - - fn render_image(&mut self) { - let data = self.img.get_image_data_mut(); - for i in 0..PIC_W * PIC_H { - let v = self.fb.visual[i]; - let p = self.fb.priority[i]; - let base = palette::rgba(v); - data[i] = match self.view { - ViewMode::Look => base, - ViewMode::Both => match p { - 0 => blend(base, [230, 70, 70], 0.45), - 1 => blend(base, [70, 120, 230], 0.45), - 2 => blend(base, [90, 220, 110], 0.45), - _ => base, - }, - ViewMode::Control => match p { - 0 => [150, 40, 40, 255], - 1 => [40, 70, 160, 255], - 2 => [50, 150, 70, 255], - band => { - let g = 60 + (band as u32 * 12).min(180) as u8; - [g, g, g, 255] - } - }, - }; - } - for o in &self.doc.objects { - if let Some((_, cel)) = self.sprites.iter().find(|(n, _)| *n == o.sprite) { - stamp_into_image(data, cel, o.x, o.y); - } - } - self.tex.update(&self.img); - } - - fn draw_panel(&mut self, sh: f32, mx: f32, my: f32, click: bool) { - draw_rectangle(0.0, 0.0, PANEL_W, sh, C_PANEL); - draw_line(PANEL_W, 0.0, PANEL_W, sh, 1.0, C_BORDER); - let p = 8.0; - let bw = (PANEL_W - 3.0 * p) / 2.0; - let mut y = 8.0; - - draw_text("MRPGI EDITOR", p, y + 12.0, 20.0, C_ACCENT); - y += 22.0; - draw_text("E: play / edit", p, y + 10.0, 15.0, C_DIM); - y += 18.0; - - let tools = [ - (Tool::Brush, "1 brush"), - (Tool::Line, "2 line"), - (Tool::Rect, "3 rect"), - (Tool::Fill, "4 fill"), - (Tool::Pick, "5 pick"), - (Tool::Erase, "6 erase"), - (Tool::Image, "7 image"), - (Tool::Ellipse, "8 oval"), - (Tool::Spawn, "9 spawn"), - (Tool::Object, "0 obj"), - ]; - for (i, &(t, label)) in tools.iter().enumerate() { - let x = p + (i % 2) as f32 * (bw + p); - let by = y + (i / 2) as f32 * 28.0; - if self.button(x, by, bw, 24.0, label, self.tool == t, mx, my) && click { - self.tool = t; - } - } - y += 5.0 * 28.0 + 4.0; - if self.tool != Tool::Object { - self.editing = Focus::None; - } - - draw_text("colour (look)", p, y + 10.0, 14.0, C_DIM); - y += 15.0; - let sw = (PANEL_W - 2.0 * p) / 8.0; - for idx in 0..16u8 { - let x = p + (idx % 8) as f32 * sw; - let sy = y + (idx / 8) as f32 * sw; - if self.swatch(x, sy, sw - 2.0, idx, self.color == idx, mx, my) && click { - self.color = idx; - } - } - y += 2.0 * sw + 6.0; - - draw_text("meaning (tag)", p, y + 10.0, 14.0, C_DIM); - y += 15.0; - let tags = [ - (Meaning::Floor, "floor"), - (Meaning::Wall, "wall"), - (Meaning::Water, "water"), - (Meaning::Trigger, "trigger"), - (Meaning::None, "none"), - ]; - for (i, &(mng, label)) in tags.iter().enumerate() { - let x = p + (i % 2) as f32 * (bw + p); - let by = y + (i / 2) as f32 * 28.0; - if self.button(x, by, bw, 24.0, label, self.meaning == mng, mx, my) && click { - self.meaning = mng; - } - } - y += 3.0 * 28.0 + 4.0; - - let vw = (PANEL_W - 4.0 * p) / 3.0; - let views = [(ViewMode::Look, "look"), (ViewMode::Both, "both"), (ViewMode::Control, "ctrl")]; - for (i, &(v, label)) in views.iter().enumerate() { - let x = p + i as f32 * (vw + p); - if self.button(x, y, vw, 22.0, label, self.view == v, mx, my) && click { - self.view = v; - } - } - y += 28.0; - - // contextual: sprite tray or brush size - if self.tool == Tool::Image && !self.sprites.is_empty() { - let i = self.active_sprite.min(self.sprites.len() - 1); - draw_text(&format!("sprite {}/{}: {}", i + 1, self.sprites.len(), self.sprites[i].0), p, y + 10.0, 14.0, C_TEXT); - y += 16.0; - self.draw_cel_preview(p, y, 44.0); - draw_text("[ ] cycle R reload", p + 52.0, y + 16.0, 14.0, C_DIM); - y += 50.0; - } else if self.tool == Tool::Brush || self.tool == Tool::Erase { - draw_text(&format!("brush size: {} (, .)", self.brush_size), p, y + 10.0, 14.0, C_TEXT); - y += 18.0; - } else if self.tool == Tool::Object { - let kit_label = if self.arch_idx == 0 { - "(blank · uses current sprite)".to_string() - } else { - self.archetypes.get(self.arch_idx - 1).map(|a| a.name.clone()).unwrap_or_default() - }; - draw_text(&format!("kit [ ]: {}", kit_label), p, y + 8.0, 12.0, C_ACCENT); - y += 14.0; - match self.selected_obj { - Some(sel) if sel < self.doc.objects.len() => { - let fw = PANEL_W - 2.0 * p; - draw_text("name", p, y + 8.0, 12.0, C_DIM); - y += 12.0; - if self.field(p, y, fw, 18.0, &self.doc.objects[sel].name.clone(), self.editing == Focus::Name, mx, my) && click { - self.editing = Focus::Name; - } - y += 22.0; - draw_text("look text", p, y + 8.0, 12.0, C_DIM); - y += 12.0; - if self.field(p, y, fw, 18.0, &self.doc.objects[sel].look.clone(), self.editing == Focus::Look, mx, my) && click { - self.editing = Focus::Look; - } - y += 22.0; - draw_text("use text (on use/open)", p, y + 8.0, 12.0, C_DIM); - y += 12.0; - if self.field(p, y, fw, 18.0, &self.doc.objects[sel].use_text.clone(), self.editing == Focus::Use, mx, my) && click { - self.editing = Focus::Use; - } - y += 22.0; - draw_text("needs item (to use)", p, y + 8.0, 12.0, C_DIM); - y += 12.0; - if self.field(p, y, fw, 18.0, &self.doc.objects[sel].needs.clone(), self.editing == Focus::Needs, mx, my) && click { - self.editing = Focus::Needs; - } - y += 22.0; - draw_text("also called (synonyms)", p, y + 8.0, 12.0, C_DIM); - y += 12.0; - if self.field(p, y, fw, 18.0, &self.doc.objects[sel].synonyms.clone(), self.editing == Focus::Syn, mx, my) && click { - self.editing = Focus::Syn; - } - y += 24.0; - let take = self.doc.objects[sel].takeable; - if self.button(p, y, bw, 20.0, if take { "takeable" } else { "fixed" }, take, mx, my) && click { - self.doc.objects[sel].takeable = !take; - self.editing = Focus::None; - } - let wins = self.doc.objects[sel].wins; - if self.button(p + bw + p, y, bw, 20.0, if wins { "wins: yes" } else { "wins: no" }, wins, mx, my) && click { - self.doc.objects[sel].wins = !wins; - self.editing = Focus::None; - } - y += 24.0; - if self.button(p, y, fw, 20.0, "delete object", false, mx, my) && click { - self.doc.objects.remove(sel); - self.selected_obj = None; - self.editing = Focus::None; - } - y += 22.0; - } - _ => { - self.selected_obj = None; - self.editing = Focus::None; - draw_text("click to place / select", p, y + 9.0, 12.0, C_DIM); - y += 14.0; - } - } - } - - let acts: [(&str, u8); 4] = [("K save", 0), ("O load", 1), ("C clear", 2), ("Z undo", 3)]; - for (i, &(label, id)) in acts.iter().enumerate() { - let x = p + (i % 2) as f32 * (bw + p); - let by = y + (i / 2) as f32 * 28.0; - if self.button(x, by, bw, 24.0, label, false, mx, my) && click { - match id { - 0 => self.save_current(), - 1 => self.load_current(), - 2 => self.clear(), - _ => self.undo(), - } - } - } - y += 2.0 * 28.0 + 4.0; - - draw_text("status:", p, y + 10.0, 13.0, C_DIM); - y += 14.0; - for line in wrap(&self.status, 27) { - draw_text(&line, p, y + 10.0, 14.0, C_TEXT); - y += 15.0; - } - } - - fn draw_cel_preview(&self, x: f32, y: f32, max: f32) { - if self.sprites.is_empty() { - return; - } - let cel = &self.sprites[self.active_sprite.min(self.sprites.len() - 1)].1; - let s = (max / cel.w.max(cel.h) as f32).max(1.0).floor(); - for cy in 0..cel.h { - for cx in 0..cel.w { - let c = cel.at(cx, cy); - if c == view::TRANSPARENT { - continue; - } - draw_rectangle(x + cx as f32 * s, y + cy as f32 * s, s, s, palette::color(c)); - } - } - } - - fn button(&self, x: f32, y: f32, w: f32, h: f32, label: &str, active: bool, mx: f32, my: f32) -> bool { - let hov = mx >= x && mx <= x + w && my >= y && my <= y + h; - let bg = if active { C_BTN_ON } else if hov { C_BTN_HOV } else { C_BTN }; - draw_rectangle(x, y, w, h, bg); - draw_rectangle_lines(x, y, w, h, 1.0, C_BORDER); - draw_text(label, x + 6.0, y + h * 0.5 + 5.0, 15.0, if active { C_ACCENT } else { C_TEXT }); - hov - } - - fn swatch(&self, x: f32, y: f32, s: f32, idx: u8, active: bool, mx: f32, my: f32) -> bool { - draw_rectangle(x, y, s, s, palette::color(idx)); - if active { - draw_rectangle_lines(x - 1.0, y - 1.0, s + 2.0, s + 2.0, 2.0, C_ACCENT); - } else { - draw_rectangle_lines(x, y, s, s, 1.0, C_BORDER); - } - mx >= x && mx <= x + s && my >= y && my <= y + s - } - - fn field(&self, x: f32, y: f32, w: f32, h: f32, text: &str, focused: bool, mx: f32, my: f32) -> bool { - let hov = mx >= x && mx <= x + w && my >= y && my <= y + h; - let bg = if focused { C_BTN_ON } else if hov { C_BTN_HOV } else { C_BTN }; - draw_rectangle(x, y, w, h, bg); - draw_rectangle_lines(x, y, w, h, 1.0, if focused { C_ACCENT } else { C_BORDER }); - let maxc = ((w - 10.0) / 8.0) as usize; - let count = text.chars().count(); - let shown: String = if count > maxc { - text.chars().skip(count - maxc).collect() - } else { - text.to_string() - }; - let caret = if focused { "_" } else { "" }; - draw_text(&format!("{}{}", shown, caret), x + 5.0, y + h * 0.5 + 5.0, 16.0, C_TEXT); - hov - } -} - -/// Composite a cel into a raw RGBA image (bottom-anchored at cx,cy). Used to -/// preview live objects in the editor without baking them into the buffers. -fn stamp_into_image(data: &mut [[u8; 4]], cel: &Cel, cx: i32, cy: i32) { - let ox = cx - cel.w as i32 / 2; - let oy = cy - cel.h as i32; - for y in 0..cel.h { - for x in 0..cel.w { - let c = cel.at(x, y); - if c == view::TRANSPARENT { - continue; - } - let sx = ox + x as i32; - let sy = oy + y as i32; - if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H { - continue; - } - data[sy as usize * PIC_W + sx as usize] = palette::rgba(c); - } - } -} - -/// Paint a filled disc (brush / eraser). -fn disc(fb: &mut FrameBuffer, cx: i32, cy: i32, size: i32, color: u8, pri: PriFill) { - for dy in -size..=size { - for dx in -size..=size { - if dx * dx + dy * dy <= size * size { - picture::px(fb, cx + dx, cy + dy, Some(color), pri); - } - } - } -} - -/// Stamp a cel centered on (cx, cy); transparent pixels are skipped. `pri` -/// writes walkability under the painted pixels. -fn stamp(fb: &mut FrameBuffer, cel: &Cel, cx: i32, cy: i32, pri: PriFill) { - let ox = cx - cel.w as i32 / 2; - let oy = cy - cel.h as i32 / 2; - for y in 0..cel.h { - for x in 0..cel.w { - let c = cel.at(x, y); - if c == view::TRANSPARENT { - continue; - } - picture::px(fb, ox + x as i32, oy + y as i32, Some(c), pri); - } - } -} - -fn blend(base: [u8; 4], over: [u8; 3], a: f32) -> [u8; 4] { - [ - (base[0] as f32 * (1.0 - a) + over[0] as f32 * a) as u8, - (base[1] as f32 * (1.0 - a) + over[1] as f32 * a) as u8, - (base[2] as f32 * (1.0 - a) + over[2] as f32 * a) as u8, - 255, - ] -} - -fn wrap(s: &str, width: usize) -> Vec { - let mut out = Vec::new(); - let mut line = String::new(); - for word in s.split_whitespace() { - if line.len() + word.len() + 1 > width && !line.is_empty() { - out.push(std::mem::take(&mut line)); - } - if !line.is_empty() { - line.push(' '); - } - line.push_str(word); - } - if !line.is_empty() { - out.push(line); - } - out -} - -/// Write a fun little 3-room sample game to `rooms/` (built with the real -/// types, so the JSON is always valid). Reuses the auto-generated placeholder -/// sprites — the names + descriptions carry the personality. -pub fn write_sample_world() { - std::fs::create_dir_all("rooms").ok(); - - let wall = |x: i32, y: i32, w: i32, h: i32| Stroke::Rect { x, y, w, h, color: 0, meaning: Meaning::Wall }; - let paint = |x: i32, y: i32, w: i32, h: i32, c: u8| Stroke::Rect { x, y, w, h, color: c, meaning: Meaning::None }; - let floor = |x: i32, y: i32, w: i32, h: i32, c: u8| Stroke::Rect { x, y, w, h, color: c, meaning: Meaning::Floor }; - let obj = |name: &str, sprite: &str, x: i32, y: i32, look: &str, takeable: bool, needs: &str, use_text: &str, wins: bool, syn: &str| ObjDef { - name: name.into(), - sprite: sprite.into(), - x, - y, - look: look.into(), - takeable, - synonyms: syn.into(), - use_text: use_text.into(), - needs: needs.into(), - wins, - dialogue: Vec::new(), - }; - - // Room 0 — the Boot-Up Closet (exit EAST to room 1) - let room0 = RoomDoc { - strokes: vec![ - paint(0, 0, 160, 168, 8), - floor(6, 16, 148, 146, 7), - wall(0, 0, 160, 16), - wall(0, 160, 160, 8), - wall(0, 0, 6, 168), - wall(154, 0, 6, 70), - wall(154, 100, 6, 68), - paint(64, 36, 28, 12, 13), - ], - spawn: (24, 140), - exits: [None, Some(1), None, None], - objects: vec![ - obj("battery", "barrel", 40, 124, "A fat 9-volt, warm to the touch. Still a little zap left in it.", true, "", "", false, "cell power"), - obj("crate", "crate", 120, 134, "A crate stamped MONSTER ROBOT PARTY. It rattles when you nudge it.", false, "", "", false, "box"), - obj("sign", "sign", 78, 56, "A flickering sign: -> THIS WAY TO THE PARTY.", false, "", "", false, "arrow notice"), - ], - music: 4, - }; - - // Room 1 — the Party Hall (WEST to 0, NORTH to 2) - let room1 = RoomDoc { - strokes: vec![ - paint(0, 0, 160, 168, 1), - floor(6, 16, 148, 146, 8), - wall(0, 0, 65, 16), - wall(95, 0, 65, 16), - wall(0, 160, 160, 8), - wall(154, 0, 6, 168), - wall(0, 0, 6, 70), - wall(0, 100, 6, 68), - paint(20, 28, 26, 8, 13), - paint(112, 26, 28, 8, 11), - paint(66, 22, 28, 6, 14), - ], - spawn: (80, 140), - exits: [Some(2), None, None, Some(0)], - objects: vec![ - obj("robot", "crate", 60, 124, "Another little robot, vibing hard to a beat only it can hear. It will not be interrupted.", false, "", "", false, "bot dude guy"), - obj("speaker", "crate", 122, 118, "A speaker the size of a fridge. Your chassis rattles, pleasantly.", false, "", "", false, "amp boombox"), - obj("punch", "barrel", 30, 142, "A barrel of suspicious party punch. You have no mouth, but you respect the commitment.", false, "", "", false, "drink barrel"), - ], - music: 4, - }; - - // Room 2 — the Exit Hatch (SOUTH back to 1) — the win room - let room2 = RoomDoc { - strokes: vec![ - paint(0, 0, 160, 168, 0), - floor(6, 16, 148, 146, 8), - wall(0, 0, 160, 16), - wall(0, 0, 6, 168), - wall(154, 0, 6, 168), - wall(0, 160, 65, 8), - wall(95, 160, 65, 8), - paint(60, 30, 40, 10, 10), - ], - spawn: (80, 140), - exits: [None, None, Some(1), None], - objects: vec![obj( - "hatch", - "sign", - 80, - 98, - "A heavy exit hatch with a dead battery slot. It won't budge without power.", - false, - "battery", - "You slam the battery into the slot. The hatch grinds open and cold night air rushes in. You roll out into the dark. YOU ESCAPED THE PARTY. What a night.", - true, - "door gate exit", - )], - music: 2, - }; - - for (i, room) in [room0, room1, room2].iter().enumerate() { - let path = format!("rooms/room{}.json", i); - if std::path::Path::new(&path).exists() { - std::fs::rename(&path, format!("{}.bak", path)).ok(); - } - if let Ok(j) = serde_json::to_string_pretty(room) { - std::fs::write(&path, j).ok(); - } - } -} - -/// Build a standard walled room: backdrop + walkable floor + border walls, -/// leaving a centered gap on each edge listed in `gaps` (0=N,1=E,2=S,3=W). -fn walled(bg: u8, fl: u8, wc: u8, gaps: &[u8]) -> Vec { - let w = |x: i32, y: i32, ww: i32, hh: i32| Stroke::Rect { x, y, w: ww, h: hh, color: wc, meaning: Meaning::Wall }; - let mut v = vec![ - Stroke::Rect { x: 0, y: 0, w: 160, h: 168, color: bg, meaning: Meaning::None }, - Stroke::Rect { x: 6, y: 16, w: 148, h: 146, color: fl, meaning: Meaning::Floor }, - ]; - if gaps.contains(&0) { v.push(w(0, 0, 65, 16)); v.push(w(95, 0, 65, 16)); } else { v.push(w(0, 0, 160, 16)); } - if gaps.contains(&2) { v.push(w(0, 160, 65, 8)); v.push(w(95, 160, 65, 8)); } else { v.push(w(0, 160, 160, 8)); } - if gaps.contains(&3) { v.push(w(0, 0, 6, 70)); v.push(w(0, 100, 6, 68)); } else { v.push(w(0, 0, 6, 168)); } - if gaps.contains(&1) { v.push(w(154, 0, 6, 70)); v.push(w(154, 100, 6, 68)); } else { v.push(w(154, 0, 6, 168)); } - v -} - -/// Write the fantasy starter kit: placeholder sprites, an object-archetype -/// catalog (kit/objects.json), prefab locations (kit/locations/*.json), and a -/// connected playable world installed to rooms/ (existing rooms → .bak). -pub fn write_kit() { - crate::assets::write_kit_sprites("sprites"); - std::fs::create_dir_all("kit/locations").ok(); - - let obj = |name: &str, sprite: &str, x: i32, y: i32, look: &str, takeable: bool, needs: &str, use_text: &str, wins: bool, syn: &str| ObjDef { - name: name.into(), - sprite: sprite.into(), - x, - y, - look: look.into(), - takeable, - synonyms: syn.into(), - use_text: use_text.into(), - needs: needs.into(), - wins, - dialogue: Vec::new(), - }; - - // Object archetype catalog — copy any of these into a room's `objects`. - let catalog: Vec = vec![ - obj("sword", "sword", 80, 130, "A notched iron sword. It has Opinions about orcs.", true, "", "", false, "blade weapon"), - obj("spellbook", "spellbook", 80, 130, "A spellbook bound in something best not identified. The runes won't hold still.", true, "", "", false, "book tome grimoire"), - obj("key", "key", 80, 130, "A heavy iron key, cold and important-looking.", true, "", "", false, "ironkey"), - obj("potion", "potion", 80, 130, "A vial of bubbling red liquid. Almost certainly fine.", true, "", "", false, "vial elixir flask"), - obj("torch", "torch", 80, 130, "A guttering torch. It pushes the dark back a little.", true, "", "", false, "light flame"), - obj("orc", "orc", 80, 120, "A green orc, picking its teeth with a dagger. It hasn't noticed you. Yet.", false, "", "You poke the orc. It looks up. In hindsight, a mistake.", false, "monster brute goblin"), - obj("villager", "villager", 80, 120, "A nervous villager. 'You're... not from the cave, are you?'", false, "", "'Sorry, nothing for you,' the villager mumbles.", false, "person man woman npc"), - obj("chest", "chest", 80, 135, "A banded wooden chest. Locked.", false, "key", "The key turns; the lid yawns open and old gold spills out.", false, "box trunk"), - obj("door", "door", 80, 120, "A stout wooden door, banded with iron.", false, "key", "The lock clunks and the door swings wide.", false, "gate"), - obj("altar", "altar", 80, 100, "A stone altar humming with cold light. Something is meant to rest here.", false, "spellbook", "You lay the spellbook on the altar. A doorway tears open in the air. THE END.", true, "shrine pedestal"), - obj("tree", "tree", 60, 80, "An ancient tree, bark like old leather.", false, "", "", false, "oak trunk wood"), - obj("rock", "rock", 120, 130, "A mossy boulder. Immovable, and a little smug about it.", false, "", "", false, "boulder stone"), - obj("sign", "sign", 80, 50, "A weathered signpost, paint long faded.", false, "", "", false, "notice post"), - ]; - if let Ok(j) = serde_json::to_string_pretty(&catalog) { - std::fs::write("kit/objects.json", j).ok(); - } - - // Connected world: 0 forest, 1 cave, 2 dungeon, 3 inn. - let mut s = walled(2, 10, 6, &[1, 2]); - s.push(Stroke::Rect { x: 30, y: 120, w: 18, h: 8, color: 2, meaning: Meaning::None }); - s.push(Stroke::Rect { x: 100, y: 60, w: 22, h: 8, color: 2, meaning: Meaning::None }); - let forest = RoomDoc { - strokes: s, - spawn: (24, 130), - exits: [None, Some(1), Some(3), None], - objects: vec![ - obj("tree", "tree", 44, 70, "An ancient tree, bark like old leather.", false, "", "", false, "oak trunk wood"), - obj("sword", "sword", 80, 132, "A notched iron sword someone left in the grass. Finders keepers.", true, "", "", false, "blade weapon"), - obj("rock", "rock", 120, 134, "A mossy boulder, smug and immovable.", false, "", "", false, "boulder stone"), - obj("sign", "sign", 80, 44, "A signpost: CAVE east, INN south.", false, "", "", false, "notice post"), - ], - music: 1, - }; - - let mut s = walled(0, 8, 0, &[0, 3]); - s.push(Stroke::Rect { x: 40, y: 60, w: 10, h: 6, color: 7, meaning: Meaning::None }); - s.push(Stroke::Rect { x: 110, y: 80, w: 12, h: 6, color: 7, meaning: Meaning::None }); - let cave = RoomDoc { - strokes: s, - spawn: (80, 130), - exits: [Some(2), None, None, Some(0)], - objects: vec![ - obj("orc", "orc", 92, 118, "A green orc picking its teeth with a dagger. It hasn't noticed you. Yet.", false, "", "You poke the orc. This was a mistake.", false, "monster brute goblin"), - obj("spellbook", "spellbook", 122, 132, "A spellbook on a ledge, runes squirming. The orc is guarding it (badly).", true, "", "", false, "book tome grimoire"), - obj("torch", "torch", 36, 110, "A guttering wall-torch. Grab it — it's dark deeper in.", true, "", "", false, "light flame"), - obj("chest", "chest", 58, 140, "A banded chest, half-buried. Locked.", false, "key", "The key turns; old gold spills out across the cave floor.", false, "box trunk"), - ], - music: 2, - }; - - let mut s = walled(0, 8, 0, &[2]); - s.push(Stroke::Rect { x: 58, y: 30, w: 44, h: 10, color: 13, meaning: Meaning::None }); - let dungeon = RoomDoc { - strokes: s, - spawn: (80, 138), - exits: [None, None, Some(1), None], - objects: vec![ - obj("altar", "altar", 80, 98, "A stone altar humming with cold light. A book-shaped hollow waits in its surface.", false, "spellbook", "You lay the spellbook on the altar. Light pours up, a doorway tears open, and you step through. YOU ESCAPED — adventure complete. THE END.", true, "shrine pedestal"), - obj("torch", "torch", 30, 120, "A torch in a rusted bracket.", true, "", "", false, "light flame"), - ], - music: 3, - }; - - let mut s = walled(6, 7, 6, &[0]); - s.push(Stroke::Rect { x: 40, y: 122, w: 80, h: 28, color: 4, meaning: Meaning::None }); - s.push(Stroke::Rect { x: 20, y: 24, w: 8, h: 8, color: 14, meaning: Meaning::None }); - let inn = RoomDoc { - strokes: s, - spawn: (80, 138), - exits: [Some(0), None, None, None], - objects: vec![ - obj("villager", "villager", 60, 112, "A nervous barkeep. 'Heading to the cave? Take the key — and my prayers.'", false, "", "'Be careful in there,' the barkeep says, sliding you nothing useful.", false, "barkeep person npc"), - obj("key", "key", 44, 140, "An iron key hanging by the door. The barkeep nods at you to take it.", true, "", "", false, "ironkey"), - obj("potion", "potion", 112, 132, "A complimentary vial of bubbling red liquid. Probably fine.", true, "", "", false, "vial elixir flask"), - obj("barrel", "barrel", 122, 120, "A barrel of inn ale. Smells like courage and mistakes.", false, "", "", false, "ale keg"), - ], - music: 4, - }; - - let rooms: [(&str, &RoomDoc); 4] = [("forest", &forest), ("cave", &cave), ("dungeon", &dungeon), ("inn", &inn)]; - for (name, r) in rooms.iter() { - if let Ok(j) = serde_json::to_string_pretty(*r) { - std::fs::write(format!("kit/locations/{}.json", name), j).ok(); - } - } - for (i, (_n, r)) in rooms.iter().enumerate() { - let path = format!("rooms/room{}.json", i); - if std::path::Path::new(&path).exists() { - std::fs::rename(&path, format!("{}.bak", path)).ok(); - } - if let Ok(j) = serde_json::to_string_pretty(*r) { - std::fs::write(&path, j).ok(); - } - } -} - -/// Load every object archetype the kit ships (fantasy `kit/objects.json` + -/// every `kit/themes/*/objects.json`) so the editor can stamp them by clicking. -fn load_archetypes() -> Vec { - let mut files = vec!["kit/objects.json".to_string()]; - if let Ok(rd) = std::fs::read_dir("kit/themes") { - for e in rd.flatten() { - let p = e.path().join("objects.json"); - if let Some(s) = p.to_str() { - if p.exists() { - files.push(s.to_string()); - } - } - } - } - let mut out = Vec::new(); - for f in files { - if let Ok(s) = std::fs::read_to_string(&f) { - if let Ok(v) = serde_json::from_str::>(&s) { - out.extend(v); - } - } - } - out -} - -/// Find a walkable spawn near `prefer`, scanning upward from the bottom. -pub fn find_spawn(fb: &FrameBuffer, prefer: (i32, i32)) -> (i32, i32) { - if fb.priority_at(prefer.0, prefer.1) != CTRL_BLOCK { - return prefer; - } - for y in (20..164).rev() { - for x in 8..152 { - if fb.priority_at(x, y) != CTRL_BLOCK { - return (x, y); - } - } - } - prefer -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index fe1615a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,728 +0,0 @@ -//! MRPGI — Monster Robot Party Game Interpreter -//! -//! A new-school, Apple-Silicon-native reimagining of Sierra's AGI engine. -//! Two modes: EDIT (paint a room — visual + walkability) and PLAY (walk it). -//! Press `E` to switch. - -#![allow(dead_code)] - -mod ai; -mod assets; -mod editor; -mod framebuffer; -mod palette; -mod picture; -mod room; -mod sound; -mod sprite; -mod view; - -use editor::{Editor, ObjDef}; -use framebuffer::{FrameBuffer, PIC_H, PIC_W}; -use macroquad::prelude::*; -use sprite::{Prop, ScreenObj}; -use view::{cel_from_ascii, View, ViewLoop}; - -fn window_conf() -> Conf { - Conf { - window_title: "MRPGI — Monster Robot Party Game Interpreter".to_owned(), - window_width: 1024, - window_height: 720, - high_dpi: true, - window_resizable: true, - ..Default::default() - } -} - -const STATUS_H: f32 = 150.0; -const CYCLE_DT: f32 = 1.0 / 20.0; - -#[derive(PartialEq)] -enum Mode { - Edit, - Play, -} - -#[macroquad::main(window_conf)] -async fn main() { - let mut editor = Editor::new(); - if std::env::args().any(|a| a == "--sample") { - editor::write_sample_world(); - } - if std::env::args().any(|a| a == "--kit") { - editor::write_kit(); - } - editor.boot(); // load room 0 (or a legacy room.json) if present - let ai_cfg = ai::AiConfig::from_env(); - let ai_label = ai_cfg.label(); - - let mut mode = Mode::Edit; - let mut ego = ScreenObj::new(robot_view(), 80, 150); - - let mut play_img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK); - let play_tex = Texture2D::from_image(&play_img); - play_tex.set_filter(FilterMode::Nearest); - - let mut acc = 0.0f32; - let mut crt = false; - let mut command = String::new(); - let mut transcript: Vec = Vec::new(); - let mut inventory: Vec = Vec::new(); - let mut taken: Vec<(u32, String)> = Vec::new(); - let mut won = false; - let mut pending: Option> = None; - let mut sfx = sound::Sfx::load().await; - let mut music = sound::Music::load().await; - let mut dlg: Option = None; - - loop { - if is_key_pressed(KeyCode::Escape) { - if mode == Mode::Edit && editor.is_editing() { - editor.stop_editing(); - } else if dlg.is_some() { - dlg = None; - transcript.push("(you end the conversation.)".to_string()); - } else { - break; - } - } - if is_key_pressed(KeyCode::Tab) { - mode = match mode { - Mode::Edit => { - // Entering Play: fresh run — spawn the ego, reset session. - let (sx, sy) = editor::find_spawn(&editor.fb, editor.spawn()); - ego.x = sx; - ego.y = sy; - ego.dir = 0; - ego.move_target = None; - ego.min_x = 4; - ego.max_x = PIC_W as i32 - 4; - ego.min_y = 16; - ego.max_y = PIC_H as i32 - 4; - acc = 0.0; - command.clear(); - inventory.clear(); - taken.clear(); - won = false; - pending = None; - dlg = None; - transcript.clear(); - transcript.push("You blink awake. Type 'help'. (Tab = back to editor)".to_string()); - Mode::Play - } - Mode::Play => { - music.set(0); - Mode::Edit - } - }; - } - - if is_key_pressed(KeyCode::F2) { - sfx.muted = !sfx.muted; - music.set_muted(sfx.muted); - } - - match mode { - Mode::Edit => editor.frame(), - - Mode::Play => { - if is_key_pressed(KeyCode::F1) { - crt = !crt; - } - music.set(editor.music()); - let layout = Layout::compute(); - let in_dlg = dlg.is_some(); - - // --- movement (arrows / click; paused while talking) --------- - let kdir = if in_dlg { 0 } else { read_keyboard_dir() }; - if kdir != 0 { - ego.move_target = None; - ego.dir = kdir; - } else if ego.move_target.is_none() { - ego.dir = 0; - } - if !in_dlg && is_mouse_button_pressed(MouseButton::Left) { - let (mx, my) = mouse_position(); - if let Some(p) = layout.to_picture(mx, my) { - ego.move_target = Some(p); - } - } - - // --- dialogue choices, or the parser (paused while talking) -- - if in_dlg { - if let Some(k) = digit_pressed() { - let res = dlg.as_mut().map(|d| d.choose(k)); - if let Some((lines, ended)) = res { - for l in lines { - transcript.push(l); - } - if ended { - dlg = None; - transcript.push("(the conversation ends.)".to_string()); - sfx.confirm(); - } else { - sfx.blip(); - } - while transcript.len() > 7 { - transcript.remove(0); - } - } - } - } else { - if is_key_pressed(KeyCode::Backspace) { - command.pop(); - } - while let Some(c) = get_char_pressed() { - if !c.is_control() && command.chars().count() < 64 { - command.push(c); - } - } - if is_key_pressed(KeyCode::Enter) { - let line = command.trim().to_string(); - command.clear(); - if !line.is_empty() { - transcript.push(format!("> {}", line)); - let won_b = won; - let inv_b = inventory.len(); - let mut talk_target: Option = None; - let (lines, understood) = run_command(&line, editor.objects(), editor.current(), &mut inventory, &mut taken, &mut won, &mut talk_target); - if let Some(i) = talk_target { - let nodes = editor.objects()[i].dialogue.clone(); - if !nodes.is_empty() { - let d = Dlg { nodes, node: 0 }; - for l in d.lines() { - transcript.push(l); - } - if !d.terminal() { - dlg = Some(d); - } - sfx.confirm(); - } - } else if !understood && ai_cfg.enabled() && pending.is_none() { - let room_now = editor.current(); - let things: Vec = editor - .objects() - .iter() - .filter(|o| !taken.iter().any(|(r, n)| *r == room_now && *n == o.name)) - .map(|o| o.name.clone()) - .collect(); - transcript.push("…(thinking)…".to_string()); - pending = Some(ai::translate_async(ai_cfg.clone(), ai::build_prompt(&line, &things.join(", ")))); - sfx.blip(); - } else { - for l in lines { - transcript.push(l); - } - if won && !won_b { - sfx.win(); - } else if inventory.len() > inv_b { - sfx.pickup(); - } else if understood { - sfx.confirm(); - } else { - sfx.error(); - } - } - while transcript.len() > 7 { - transcript.remove(0); - } - } - } - } - - // --- AI worker: poll for a translated command ---------------- - if pending.is_some() { - let got = pending.as_ref().and_then(|rx| rx.try_recv().ok()); - if let Some(cmd) = got { - pending = None; - if cmd.is_empty() { - transcript.push("You can't do that.".to_string()); - sfx.error(); - } else { - transcript.push(format!("(\u{2248} {})", cmd)); - let won_b = won; - let inv_b = inventory.len(); - let mut talk_target: Option = None; - let (lines2, _) = run_command(&cmd, editor.objects(), editor.current(), &mut inventory, &mut taken, &mut won, &mut talk_target); - if let Some(i) = talk_target { - let nodes = editor.objects()[i].dialogue.clone(); - if !nodes.is_empty() { - let d = Dlg { nodes, node: 0 }; - for l in d.lines() { - transcript.push(l); - } - if !d.terminal() { - dlg = Some(d); - } - sfx.confirm(); - } - } else { - for l in lines2 { - transcript.push(l); - } - if won && !won_b { - sfx.win(); - } else if inventory.len() > inv_b { - sfx.pickup(); - } else { - sfx.confirm(); - } - } - } - while transcript.len() > 7 { - transcript.remove(0); - } - } - } - - // --- game cycles & transitions (paused while talking) -------- - if !in_dlg { - acc += get_frame_time(); - let mut guard = 0; - while acc >= CYCLE_DT && guard < 5 { - ego.update(&editor.fb); - acc -= CYCLE_DT; - guard += 1; - } - - // --- walk off an edge with an exit → next room --------------- - let exits = editor.current_exits(); - let trig = 5; - let leave = if ego.x <= 4 + trig && exits[3].is_some() { - Some((exits[3].unwrap(), 3u8)) - } else if ego.x >= PIC_W as i32 - 4 - trig && exits[1].is_some() { - Some((exits[1].unwrap(), 1u8)) - } else if ego.y <= 16 + trig && exits[0].is_some() { - Some((exits[0].unwrap(), 0u8)) - } else if ego.y >= PIC_H as i32 - 4 - trig && exits[2].is_some() { - Some((exits[2].unwrap(), 2u8)) - } else { - None - }; - if let Some((target, from)) = leave { - editor.goto_room(target); - let inset = 14; - match from { - 3 => ego.x = PIC_W as i32 - 4 - inset, - 1 => ego.x = 4 + inset, - 0 => ego.y = PIC_H as i32 - 4 - inset, - _ => ego.y = 16 + inset, - } - if editor.fb.priority_at(ego.x, ego.y) == framebuffer::CTRL_BLOCK { - let (sx, sy) = editor::find_spawn(&editor.fb, editor.spawn()); - ego.x = sx; - ego.y = sy; - } - ego.dir = 0; - ego.move_target = None; - acc = 0.0; - transcript.push(format!("You step into room {}.", editor.current())); - sfx.door(); - while transcript.len() > 7 { - transcript.remove(0); - } - } - } - - // --- live objects → render props (skipping any taken) -------- - let room = editor.current(); - let mut props: Vec = Vec::new(); - for o in editor.objects() { - if taken.iter().any(|(r, n)| *r == room && n == &o.name) { - continue; - } - if let Some(cel) = editor.sprite_cel(&o.sprite) { - props.push(Prop { x: o.x, y: o.y, cel: cel.clone(), fixed_priority: None, name: o.name.clone() }); - } - } - - compose(&mut play_img, &editor.fb, &ego, &props); - play_tex.update(&play_img); - - clear_background(BLACK); - draw_texture_ex( - &play_tex, - layout.x, - layout.y, - WHITE, - DrawTextureParams { dest_size: Some(vec2(layout.w, layout.h)), ..Default::default() }, - ); - if crt { - draw_scanlines(&layout); - } - draw_play_panel(&transcript, &command, &inventory, room, crt, &ai_label, !sfx.muted, dlg.is_some()); - if won { - let sw2 = screen_width(); - let by = screen_height() * 0.34; - draw_rectangle(0.0, by, sw2, 56.0, color_u8!(18, 40, 30, 235)); - let t = "\u{2605} YOU WIN \u{2605}"; - let dim = measure_text(t, None, 40, 1.0); - draw_text(t, (sw2 - dim.width) * 0.5, by + 40.0, 40.0, color_u8!(120, 255, 180, 255)); - } - } - } - - next_frame().await; - } -} - -fn read_keyboard_dir() -> u8 { - let up = is_key_down(KeyCode::Up); - let down = is_key_down(KeyCode::Down); - let left = is_key_down(KeyCode::Left); - let right = is_key_down(KeyCode::Right); - match (up, down, left, right) { - (true, false, false, false) => 1, - (true, false, false, true) => 2, - (false, false, false, true) => 3, - (false, true, false, true) => 4, - (false, true, false, false) => 5, - (false, true, true, false) => 6, - (false, false, true, false) => 7, - (true, false, true, false) => 8, - _ => 0, - } -} - -fn compose(img: &mut Image, room: &FrameBuffer, ego: &ScreenObj, props: &[Prop]) { - let mut vis = room.visual.clone(); - - let mut draws: Vec<(u8, &view::Cel, i32, i32)> = Vec::with_capacity(props.len() + 1); - draws.push((ego.priority(), ego.cel(), ego.x, ego.y)); - for p in props { - draws.push((p.priority(), p.cel(), p.x, p.y)); - } - draws.sort_by_key(|d| d.0); - - for (pri, cel, bx, by) in draws { - let ox = bx - cel.w as i32 / 2; - let oy = by - cel.h as i32; - for cy in 0..cel.h { - for cx in 0..cel.w { - let c = cel.at(cx, cy); - if c == view::TRANSPARENT { - continue; - } - let sx = ox + cx as i32; - let sy = oy + cy as i32; - if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H { - continue; - } - let i = sy as usize * PIC_W + sx as usize; - if pri >= room.priority[i] { - vis[i] = c; - } - } - } - } - - let data = img.get_image_data_mut(); - for (i, &v) in vis.iter().enumerate() { - data[i] = palette::rgba(v); - } -} - -struct Layout { - x: f32, - y: f32, - w: f32, - h: f32, -} - -impl Layout { - fn compute() -> Self { - let sw = screen_width(); - let sh = screen_height(); - let avail_h = (sh - STATUS_H).max(1.0); - let scale = (sw / 320.0).min(avail_h / PIC_H as f32); - let w = 320.0 * scale; - let h = PIC_H as f32 * scale; - Layout { x: (sw - w) * 0.5, y: 0.0, w, h } - } - - fn to_picture(&self, mx: f32, my: f32) -> Option<(i32, i32)> { - let px = (mx - self.x) / (self.w / PIC_W as f32); - let py = (my - self.y) / (self.h / PIC_H as f32); - if px >= 0.0 && py >= 0.0 && px < PIC_W as f32 && py < PIC_H as f32 { - Some((px as i32, py as i32)) - } else { - None - } - } -} - -fn draw_scanlines(l: &Layout) { - let mut y = l.y; - while y < l.y + l.h { - draw_line(l.x, y, l.x + l.w, y, 1.0, Color::new(0.0, 0.0, 0.0, 0.28)); - y += 3.0; - } -} - -fn draw_play_panel(transcript: &[String], command: &str, inventory: &[String], room: u32, crt: bool, ai: &str, sound_on: bool, talking: bool) { - let sw = screen_width(); - let sh = screen_height(); - let top = sh - STATUS_H; - draw_rectangle(0.0, top, sw, STATUS_H, color_u8!(12, 14, 18, 255)); - draw_line(0.0, top, sw, top, 1.0, color_u8!(60, 70, 85, 255)); - - let mut yy = top + 20.0; - let start = transcript.len().saturating_sub(5); - for line in &transcript[start..] { - let col = if line.starts_with('>') { - color_u8!(255, 135, 215, 255) - } else { - color_u8!(205, 213, 225, 255) - }; - draw_text(line, 12.0, yy, 18.0, col); - yy += 18.0; - } - - if talking { - draw_text("(type a number to choose · Esc to leave)", 12.0, sh - 30.0, 18.0, color_u8!(95, 255, 215, 255)); - } else { - draw_text(&format!("> {}_", command), 12.0, sh - 30.0, 20.0, color_u8!(95, 255, 215, 255)); - } - let inv = if inventory.is_empty() { "—".to_string() } else { inventory.join(", ") }; - draw_text( - &format!("room {} · inv: {} · ai: {} · Tab: edit · F1: CRT[{}] · F2: snd[{}] · Esc: quit", room, inv, ai, if crt { "on" } else { "off" }, if sound_on { "on" } else { "off" }), - 12.0, - sh - 8.0, - 14.0, - color_u8!(120, 130, 145, 255), - ); -} - -/// A live, in-progress conversation (a clone of an NPC's dialogue tree). -struct Dlg { - nodes: Vec, - node: usize, -} - -impl Dlg { - fn lines(&self) -> Vec { - let n = &self.nodes[self.node]; - let mut out = vec![format!("\u{201C}{}\u{201D}", n.says)]; - for (i, c) in n.choices.iter().enumerate() { - out.push(format!(" {}) {}", i + 1, c.text)); - } - out - } - fn terminal(&self) -> bool { - self.nodes[self.node].choices.is_empty() - } - /// Pick choice `k` (1-based). Returns (lines to print, conversation ended). - fn choose(&mut self, k: usize) -> (Vec, bool) { - let nlen = self.nodes.len(); - let choices = self.nodes[self.node].choices.clone(); - if k == 0 || k > choices.len() { - return (vec![], false); - } - let c = &choices[k - 1]; - let mut out = vec![format!("> {}", c.text)]; - if c.goto < 0 || c.goto as usize >= nlen { - return (out, true); - } - self.node = c.goto as usize; - out.extend(self.lines()); - (out, self.terminal()) - } -} - -fn digit_pressed() -> Option { - for (k, code) in [ - (1usize, KeyCode::Key1), - (2, KeyCode::Key2), - (3, KeyCode::Key3), - (4, KeyCode::Key4), - (5, KeyCode::Key5), - (6, KeyCode::Key6), - ] { - if is_key_pressed(code) { - return Some(k); - } - } - None -} - -const IGNORE: &[&str] = &["the", "a", "an", "at", "to", "with", "on", "of", "my", "your", "some", "please", "go", "up"]; - -/// Map a typed word to a canonical verb ("" if unknown). -fn canon_verb(w: &str) -> &'static str { - match w { - "look" | "l" | "examine" | "x" | "inspect" | "check" | "read" | "view" | "see" | "study" => "look", - "take" | "get" | "grab" | "pick" | "snatch" | "steal" | "pocket" | "collect" | "nab" => "take", - "use" | "open" | "unlock" | "activate" | "push" | "pull" | "turn" | "operate" => "use", - "talk" | "speak" | "ask" | "chat" | "greet" | "say" => "talk", - "drop" | "discard" | "leave" | "put" => "drop", - "inventory" | "inv" | "i" | "items" | "bag" => "inventory", - "help" | "?" | "commands" | "verbs" => "help", - _ => "", - } -} - -/// Does this object answer to `noun` (its name or any synonym, loosely)? -fn obj_matches(o: &ObjDef, noun: &str) -> bool { - if noun.is_empty() { - return false; - } - let mut names: Vec = vec![o.name.to_lowercase()]; - for s in o.synonyms.split_whitespace() { - names.push(s.to_lowercase()); - } - names.iter().any(|nm| nm.as_str() == noun || nm.contains(noun) || noun.contains(nm.as_str())) -} - -/// Index of a visible (not-taken) object that answers to `noun`. -fn match_obj(objects: &[ObjDef], room: u32, taken: &[(u32, String)], noun: &str) -> Option { - objects.iter().position(|o| { - let visible = !taken.iter().any(|(r, n)| *r == room && *n == o.name); - visible && obj_matches(o, noun) - }) -} - -/// The parser: typed line → ignore-words stripped → canonical verb + noun → -/// response. Deterministic; the AI lane plugs into the catch-all branch later. -fn run_command(input: &str, objects: &[ObjDef], room: u32, inventory: &mut Vec, taken: &mut Vec<(u32, String)>, won: &mut bool, talk_target: &mut Option) -> (Vec, bool) { - let lower = input.trim().to_lowercase(); - let words: Vec<&str> = lower.split_whitespace().filter(|w| !IGNORE.contains(w)).collect(); - if words.is_empty() { - return (vec![], true); - } - let verb = canon_verb(words[0]); - let noun = words[1..].join(" "); - let understood = !verb.is_empty(); - let lines = match verb { - "look" => { - if noun.is_empty() || noun == "around" || noun == "room" || noun == "here" { - let names: Vec = objects - .iter() - .filter(|o| !taken.iter().any(|(r, n)| *r == room && *n == o.name)) - .map(|o| o.name.clone()) - .collect(); - if names.is_empty() { - vec![format!("Room {}. Nothing in particular stands out.", room)] - } else { - vec![format!("Room {}. You see: {}.", room, names.join(", "))] - } - } else if let Some(i) = match_obj(objects, room, taken, &noun) { - vec![objects[i].look.clone()] - } else { - vec![format!("You don't see any '{}' here.", noun)] - } - } - "take" => { - if let Some(i) = match_obj(objects, room, taken, &noun) { - if objects[i].takeable { - inventory.push(objects[i].name.clone()); - taken.push((room, objects[i].name.clone())); - vec![format!("You take the {}.", objects[i].name)] - } else { - vec![format!("You can't take the {}.", objects[i].name)] - } - } else { - vec![format!("There's no '{}' to take.", noun)] - } - } - "drop" => { - if let Some(p) = inventory.iter().position(|n| !noun.is_empty() && n.to_lowercase().contains(noun.as_str())) { - let n = inventory.remove(p); - vec![format!("You drop the {}.", n)] - } else { - vec![format!("You aren't carrying a '{}'.", noun)] - } - } - "inventory" => { - if inventory.is_empty() { - vec!["You're carrying nothing.".to_string()] - } else { - vec![format!("Carrying: {}.", inventory.join(", "))] - } - } - "use" => { - if let Some(i) = match_obj(objects, room, taken, &noun) { - let needs = objects[i].needs.to_lowercase(); - if !needs.is_empty() && !inventory.iter().any(|it| it.to_lowercase() == needs) { - vec![format!("It's locked. You need: {}.", objects[i].needs)] - } else { - let mut out = vec![if objects[i].use_text.is_empty() { - format!("You use the {}. Nothing obvious happens.", objects[i].name) - } else { - objects[i].use_text.clone() - }]; - if objects[i].wins { - *won = true; - out.push("\u{2605} THE END — you win! \u{2605}".to_string()); - } - out - } - } else { - vec![format!("There's no '{}' to use.", noun)] - } - } - "talk" => { - if let Some(i) = match_obj(objects, room, taken, &noun) { - if objects[i].dialogue.is_empty() { - vec![format!("{} has nothing to say.", objects[i].name)] - } else { - *talk_target = Some(i); - vec![] - } - } else { - vec![format!("There's no '{}' to talk to.", noun)] - } - } - "help" => vec!["Verbs: look · take · drop · use · talk · inventory. Arrows walk.".to_string()], - _ => vec![format!("I don't understand \"{}\".", input.trim())], - }; - (lines, understood) -} - -/// A little front-facing party robot. Two cels make a 2-frame walk bob. -fn robot_view() -> View { - let cel0 = cel_from_ascii(&[ - ".....c.....", - ".....8.....", - "...77777...", - "..7777777..", - "..7b777b7..", - "..7777777..", - "...77777...", - "....888....", - "..7777777..", - ".777777777.", - ".7777e7777.", - ".777777777.", - "..7777777..", - "..8.....8..", - "..88...88..", - "..88...88..", - ".888...888.", - ".888...888.", - ]); - let cel1 = cel_from_ascii(&[ - ".....c.....", - ".....8.....", - "...77777...", - "..7777777..", - "..7b777b7..", - "..7777777..", - "...77777...", - "....888....", - "..7777777..", - ".777777777.", - ".7777e7777.", - ".777777777.", - "..7777777..", - "..8.....8..", - "..88...88..", - "..88...88..", - "..888.888..", - "..888.888..", - ]); - View { - loops: vec![ViewLoop { - cels: vec![cel0, cel1], - }], - } -} diff --git a/src/room.rs b/src/room.rs deleted file mode 100644 index 8298c51..0000000 --- a/src/room.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! The demo room — now a showcase for the asset pipeline. It mixes -//! hand-authored vector ops (walls, depth, control lines) with three kinds of -//! image insertion: a TILED floor, an image POURED into a wall shape, and an -//! OBJECT (the crate) you can walk behind — all sourced from PNGs. - -use crate::assets::Assets; -use crate::framebuffer::{band, FrameBuffer, CTRL_BLOCK}; -use crate::picture::{draw, stamp_cel_fit, Fit, PicOp}; -use crate::sprite::Prop; - -pub struct Scene { - pub fb: FrameBuffer, - pub props: Vec, -} - -pub fn demo_room(assets: &Assets) -> Scene { - let mut fb = FrameBuffer::new(); - let col_pri = band(138); - - // 1. Wall + skirting — both barriers (you can't walk into the wall). - draw( - &mut fb, - &[ - PicOp::Rect { x: 0, y: 0, w: 160, h: 72, vis: Some(1), pri: Some(CTRL_BLOCK) }, - PicOp::Rect { x: 0, y: 70, w: 160, h: 2, vis: Some(8), pri: Some(CTRL_BLOCK) }, - ], - ); - - // 2. TILE a pixel-art floor across the play area (keeps default depth bands). - stamp_cel_fit(&mut fb, (0, 72, 160, 96), &assets.floor, Fit::Tile, None); - - // 3. Wall dressing: neon signs + a doorway (the door is a barrier). - draw( - &mut fb, - &[ - PicOp::Rect { x: 12, y: 14, w: 30, h: 8, vis: Some(13), pri: None }, - PicOp::Rect { x: 118, y: 14, w: 30, h: 8, vis: Some(11), pri: None }, - PicOp::Rect { x: 60, y: 8, w: 40, h: 6, vis: Some(14), pri: None }, - PicOp::Rect { x: 66, y: 28, w: 28, h: 44, vis: Some(6), pri: Some(CTRL_BLOCK) }, - PicOp::Rect { x: 70, y: 32, w: 20, h: 40, vis: Some(0), pri: Some(CTRL_BLOCK) }, - PicOp::Pixel { x: 87, y: 52, vis: Some(14), pri: None }, - ], - ); - - // 4. POUR an image into a shape on the wall (the "draw a shape, load an - // image into it" primitive). Visual only — it's flat on the wall. - stamp_cel_fit(&mut fb, (10, 24, 44, 34), &assets.poster, Fit::Stretch, None); - - // 5. The walk-behind column, drawn on top of the floor (depth only). - draw( - &mut fb, - &[ - PicOp::Rect { x: 116, y: 74, w: 12, h: 64, vis: Some(7), pri: Some(col_pri) }, - PicOp::Rect { x: 117, y: 74, w: 3, h: 64, vis: Some(15), pri: Some(col_pri) }, - ], - ); - - // An OBJECT sourced from a PNG. As a live prop it sorts with the ego, so - // you can walk in front of and behind it. - let crate_prop = Prop { - x: 48, - y: 132, - cel: assets.crate_box.clone(), - fixed_priority: None, // depth taken from its feet, like any object - name: "crate".to_string(), - }; - - Scene { - fb, - props: vec![crate_prop], - } -} diff --git a/src/sound.rs b/src/sound.rs deleted file mode 100644 index dd08dd7..0000000 --- a/src/sound.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Tiny chiptune SFX — square waves synthesized to PCM at runtime (no files), -//! in the spirit of AGI's PCjr beeper. Each cue is a short tone or arpeggio. -//! If the audio backend can't start, everything degrades to silence. - -use macroquad::audio::{load_sound_from_bytes, play_sound, stop_sound, PlaySoundParams, Sound}; - -const SR: u32 = 22050; - -pub struct Sfx { - blip: Option, - pickup: Option, - confirm: Option, - error: Option, - door: Option, - win: Option, - pub muted: bool, -} - -impl Sfx { - pub async fn load() -> Self { - Sfx { - blip: snd(&seq(&[(660.0, 18)], 0.18)).await, - pickup: snd(&seq(&[(660.0, 55), (988.0, 80)], 0.20)).await, - confirm: snd(&seq(&[(523.0, 55), (784.0, 85)], 0.20)).await, - error: snd(&seq(&[(196.0, 90), (147.0, 150)], 0.22)).await, - door: snd(&seq(&[(392.0, 60), (294.0, 60), (196.0, 120)], 0.20)).await, - win: snd(&seq(&[(523.0, 90), (659.0, 90), (784.0, 90), (1047.0, 240)], 0.22)).await, - muted: false, - } - } - - fn go(&self, s: &Option, vol: f32) { - if self.muted { - return; - } - if let Some(snd) = s { - play_sound(snd, PlaySoundParams { looped: false, volume: vol }); - } - } - - pub fn blip(&self) { self.go(&self.blip, 0.4); } - pub fn pickup(&self) { self.go(&self.pickup, 0.7); } - pub fn confirm(&self) { self.go(&self.confirm, 0.55); } - pub fn error(&self) { self.go(&self.error, 0.6); } - pub fn door(&self) { self.go(&self.door, 0.6); } - pub fn win(&self) { self.go(&self.win, 0.85); } -} - -async fn snd(bytes: &[u8]) -> Option { - load_sound_from_bytes(bytes).await.ok() -} - -/// Looping ambient room music. Index 0 = silence; 1..=5 are moods that match -/// the editor's mood picker (calm / eerie / tense / jolly / spooky). -pub struct Music { - tracks: Vec>, - current: u8, - pub muted: bool, -} - -impl Music { - pub async fn load() -> Self { - let tracks = vec![ - None, // 0 = silence - snd(&seq(&[(262.0, 300), (0.0, 150), (330.0, 300), (0.0, 150), (392.0, 420), (0.0, 280), (330.0, 280), (0.0, 160), (294.0, 320), (0.0, 700)], 0.16)).await, - snd(&seq(&[(220.0, 420), (0.0, 320), (311.0, 420), (0.0, 520), (233.0, 420), (0.0, 900)], 0.16)).await, - snd(&seq(&[(147.0, 170), (0.0, 110), (147.0, 170), (0.0, 110), (165.0, 170), (0.0, 110), (147.0, 170), (0.0, 520)], 0.18)).await, - snd(&seq(&[(523.0, 150), (659.0, 150), (784.0, 150), (659.0, 150), (523.0, 150), (0.0, 280), (587.0, 150), (784.0, 150), (0.0, 460)], 0.15)).await, - snd(&seq(&[(165.0, 520), (0.0, 700), (196.0, 420), (0.0, 520), (147.0, 620), (0.0, 1100)], 0.16)).await, - ]; - Music { tracks, current: 255, muted: false } - } - - /// Switch to a mood (no-op if already playing it). Loops at low volume. - pub fn set(&mut self, mood: u8) { - if mood == self.current { - return; - } - if let Some(Some(s)) = self.tracks.get(self.current as usize) { - stop_sound(s); - } - self.current = mood; - if !self.muted { - if let Some(Some(s)) = self.tracks.get(mood as usize) { - play_sound(s, PlaySoundParams { looped: true, volume: 0.13 }); - } - } - } - - pub fn set_muted(&mut self, m: bool) { - self.muted = m; - if m { - for t in &self.tracks { - if let Some(s) = t { - stop_sound(s); - } - } - } else { - let c = self.current; - self.current = 255; - self.set(c); // replay current mood - } - } -} - -/// Render a sequence of (frequency Hz, duration ms) square-wave notes to a WAV. -fn seq(notes: &[(f32, u32)], vol: f32) -> Vec { - let mut samples: Vec = Vec::new(); - for &(freq, ms) in notes { - let n = (SR as u64 * ms as u64 / 1000) as usize; - if freq <= 0.0 { - for _ in 0..n { - samples.push(0); - } - continue; - } - let period = (SR as f32 / freq).max(2.0); - for i in 0..n { - let phase = (i as f32 % period) / period; - let square = if phase < 0.5 { 1.0 } else { -1.0 }; - // quick attack, linear decay — keeps it from clicking - let t = i as f32 / n.max(1) as f32; - let env = (t * 10.0).min(1.0) * (1.0 - t); - let v = square * vol * env * 0.7; - samples.push((v * i16::MAX as f32) as i16); - } - } - wav_bytes(&samples) -} - -fn wav_bytes(samples: &[i16]) -> Vec { - let data_len = (samples.len() * 2) as u32; - let mut b = Vec::with_capacity(44 + data_len as usize); - b.extend_from_slice(b"RIFF"); - b.extend_from_slice(&(36 + data_len).to_le_bytes()); - b.extend_from_slice(b"WAVE"); - b.extend_from_slice(b"fmt "); - b.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size - b.extend_from_slice(&1u16.to_le_bytes()); // format = PCM - b.extend_from_slice(&1u16.to_le_bytes()); // channels = mono - b.extend_from_slice(&SR.to_le_bytes()); // sample rate - b.extend_from_slice(&(SR * 2).to_le_bytes()); // byte rate - b.extend_from_slice(&2u16.to_le_bytes()); // block align - b.extend_from_slice(&16u16.to_le_bytes()); // bits per sample - b.extend_from_slice(b"data"); - b.extend_from_slice(&data_len.to_le_bytes()); - for s in samples { - b.extend_from_slice(&s.to_le_bytes()); - } - b -}