Carve the engine into a headless core behind a command/event bus
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 <noreply@anthropic.com>
This commit is contained in:
parent
d0728e121c
commit
96b45561d0
40
Cargo.lock
generated
40
Cargo.lock
generated
@ -14,6 +14,12 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@ -60,6 +66,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chunked_transfer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color_quant"
|
name = "color_quant"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -170,6 +182,12 @@ dependencies = [
|
|||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@ -371,10 +389,18 @@ dependencies = [
|
|||||||
name = "mrpgi"
|
name = "mrpgi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
|
||||||
"macroquad",
|
"macroquad",
|
||||||
|
"mrpgi-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mrpgi-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"image",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tiny_http",
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -604,6 +630,18 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
17
Cargo.toml
17
Cargo.toml
@ -1,18 +1,9 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "mrpgi"
|
members = ["mrpgi-core", "mrpgi"]
|
||||||
version = "0.1.0"
|
resolver = "2"
|
||||||
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
|
|
||||||
|
|
||||||
# macroquad renders much more smoothly when its dependencies are optimized,
|
# 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]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|
||||||
|
|||||||
66
README.md
66
README.md
@ -12,13 +12,29 @@ embedded scripting language, and an optional AI parser).
|
|||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run # dev build (fast to recompile)
|
cargo run -p mrpgi # the GUI (paint + play) — or ./run.sh
|
||||||
cargo run --release # smooth, optimized build
|
cargo run -p mrpgi --release # smooth, optimized build
|
||||||
```
|
```
|
||||||
|
|
||||||
A window opens with the demo room. **Walk the robot with the arrow keys or by
|
A window opens in the editor. Paint a room (or launch with `--sample` for the
|
||||||
clicking** — and stroll *behind the column* to watch the priority system place
|
3-room demo game), then press **Tab** to walk it — arrows or click to move,
|
||||||
you correctly in depth. `N` toggles CRT scanlines. `Esc` quits.
|
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)
|
## 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
|
## 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 |
|
| File | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `palette.rs` | the 16-color EGA palette (indices → RGBA) |
|
| `mrpgi-core/src/state.rs` | **the spine**: `GameState`, `Command`/`Event` bus, fixed-step `tick` |
|
||||||
| `framebuffer.rs` | the visual + priority buffers and the `band(y)` depth table |
|
| `mrpgi-core/src/world.rs` | rooms/objects/dialogue data model (all serde), `World`, game folders |
|
||||||
| `picture.rs` | vector room painting (`PicOp` rectangles / lines / pixels) |
|
| `mrpgi-core/src/parser.rs` | data-driven verb table, verb-noun parser, dialogue state |
|
||||||
| `view.rs` | sprites as loops + cels, authored from tiny ASCII grids |
|
| `mrpgi-core/src/render.rs` | headless compositing → RGBA / PNG (the priority-buffer trick) |
|
||||||
| `sprite.rs` | the ego: movement, click-to-walk, collision, walk animation |
|
| `mrpgi-core/src/framebuffer.rs` | the visual + priority buffers and the `band(y)` depth table |
|
||||||
| `room.rs` | the hand-authored demo room |
|
| `mrpgi-core/src/picture.rs` | vector room painting (rects / lines / fills / stamps) |
|
||||||
| `main.rs` | window, game loop, fixed-step cycles, compositing, HUD |
|
| `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)
|
## 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
|
- [ ] **True-color "HD" layer mode**: skip quantization for painted backdrops; the priority buffer stays indexed
|
||||||
- [ ] **Polygon shapes** for `stamp_cel_fit` (not just rectangles)
|
- [ ] **Polygon shapes** for `stamp_cel_fit` (not just rectangles)
|
||||||
- [ ] **Directional views** (4 loops + horizontal mirroring) for the robot
|
- [ ] **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
|
- [ ] **Rhai room logic**: rooms scripted in an AGI-flavored embedded language (`if said(...) { print(...) }`), hot-reloadable
|
||||||
- [ ] **Room transitions** (`new_room`), inventory, flags & vars
|
- [ ] **3-voice + noise chiptune synth** (per-game audio files already override the square-wave synth)
|
||||||
- [ ] **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)
|
- [ ] **Strudel sink**: browser sidecar over the HTTP surface, driven by the same audio events
|
||||||
- [ ] **3-voice + noise chiptune synth**
|
- [ ] **Vars & conditions on dialogue/objects** beyond boolean flags; cross-room NPC state
|
||||||
- [ ] **Loadable game folders** (text/JSON resources) so games ship without recompiling the engine
|
- [ ] **Pathfinding** for click-to-walk (today: greedy steering)
|
||||||
|
|||||||
162
docs/CONTROL.md
Normal file
162
docs/CONTROL.md
Normal file
@ -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=...
|
||||||
|
```
|
||||||
@ -1,6 +1,11 @@
|
|||||||
# MRPGI — Handover: making the engine fully programmatically controllable
|
# 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
|
> **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.
|
> **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.
|
||||||
|
|
||||||
|
|||||||
16
mrpgi-core/Cargo.toml
Normal file
16
mrpgi-core/Cargo.toml
Normal file
@ -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"
|
||||||
@ -4,7 +4,7 @@
|
|||||||
//! LLM translates the player's free text into ONE *allowed* command — which is
|
//! 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
|
//! 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
|
//! 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):
|
//! Provider is chosen by env vars (default = local Ollama, free + offline):
|
||||||
//! MRPGI_AI ollama (default) | openrouter | off
|
//! MRPGI_AI ollama (default) | openrouter | off
|
||||||
@ -51,6 +51,10 @@ impl AiConfig {
|
|||||||
AiConfig { provider, model, url, key }
|
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 {
|
pub fn enabled(&self) -> bool {
|
||||||
self.provider != Provider::Off
|
self.provider != Provider::Off
|
||||||
}
|
}
|
||||||
@ -65,10 +69,12 @@ impl AiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The instruction we send the model — tight, so even a 4B local model behaves.
|
/// 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!(
|
format!(
|
||||||
"You translate a player's sentence into ONE text-adventure command.
|
"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}.
|
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.
|
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}
|
Player: {text}
|
||||||
@ -76,8 +82,8 @@ Command:"
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire the translation on a worker thread; poll the returned receiver each
|
/// Fire the translation on a worker thread; poll the returned receiver with
|
||||||
/// frame with `try_recv()`. The result is a sanitized command (or "" = no idea).
|
/// `try_recv()`. The result is a sanitized command (or "" = no idea).
|
||||||
pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
|
pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@ -6,8 +6,8 @@
|
|||||||
//! gradients) and becomes an indexed [`Cel`]. From that moment it depth-sorts,
|
//! gradients) and becomes an indexed [`Cel`]. From that moment it depth-sorts,
|
||||||
//! gets occluded by scenery, and walks-behind exactly like hand-authored art.
|
//! 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
|
//! Drop your own PNGs into a game's `sprites/` and the engine will use them;
|
||||||
//! art here is generated on first run so the project is playable out of the box.
|
//! sample art is generated on first run so the project is playable out of the box.
|
||||||
|
|
||||||
use crate::palette::EGA;
|
use crate::palette::EGA;
|
||||||
use crate::view::{Cel, TRANSPARENT};
|
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).
|
// Sprite pipeline — drop PNGs in a folder, the engine places them.
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Load every PNG in `dir` as an EGA-quantized cel (no dither — keep pixel art
|
/// 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();
|
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 {
|
fn gen_barrel() -> image::RgbaImage {
|
||||||
let (w, h) = (16u32, 22u32);
|
let (w, h) = (16u32, 22u32);
|
||||||
let mut im = image::RgbaImage::new(w, h);
|
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, 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, 3, 12, 2, [95, 95, 95, 255]);
|
||||||
put_rect(&mut im, 2, 17, 12, 2, [95, 95, 95, 255]);
|
put_rect(&mut im, 2, 17, 12, 2, [95, 95, 95, 255]);
|
||||||
|
let _ = (w, h);
|
||||||
im
|
im
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gen_sign() -> image::RgbaImage {
|
fn gen_sign() -> image::RgbaImage {
|
||||||
let (w, h) = (28u32, 18u32);
|
let mut im = image::RgbaImage::new(28, 18);
|
||||||
let mut im = image::RgbaImage::new(w, h);
|
|
||||||
put_rect(&mut im, 12, 8, 4, 10, [120, 75, 35, 255]);
|
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, 9, [200, 170, 60, 255]);
|
||||||
put_rect(&mut im, 2, 1, 24, 2, [150, 120, 40, 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
|
// 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];
|
const GREEN: [u8; 4] = [0, 170, 0, 255];
|
||||||
@ -342,7 +230,7 @@ pub fn write_kit_sprites(dir: &str) {
|
|||||||
];
|
];
|
||||||
for (name, f) in gens {
|
for (name, f) in gens {
|
||||||
let p = format!("{dir}/{name}.png");
|
let p = format!("{dir}/{name}.png");
|
||||||
if !std::path::Path::new(&p).exists() {
|
if !Path::new(&p).exists() {
|
||||||
f().save(&p).ok();
|
f().save(&p).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
116
mrpgi-core/src/audio.rs
Normal file
116
mrpgi-core/src/audio.rs
Normal file
@ -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<u8> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<u8> {
|
||||||
|
let mut samples: Vec<i16> = 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<u8> {
|
||||||
|
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
|
||||||
|
}
|
||||||
143
mrpgi-core/src/bin/mrpgi-headless/http.rs
Normal file
143
mrpgi-core/src/bin/mrpgi-headless/http.rs
Normal file
@ -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<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Event>, 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<u8>, 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::<Command>(&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<u32> = p.trim_start_matches("/rooms/").parse().ok();
|
||||||
|
let body = read_body(&mut req);
|
||||||
|
match (n, serde_json::from_str::<mrpgi_core::RoomDoc>(&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.
|
||||||
|
";
|
||||||
158
mrpgi-core/src/bin/mrpgi-headless/main.rs
Normal file
158
mrpgi-core/src/bin/mrpgi-headless/main.rs
Normal file
@ -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<String> = std::env::args().skip(1).collect();
|
||||||
|
let get_opt = |flag: &str| -> Option<String> {
|
||||||
|
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::<Command>(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<mrpgi_core::sprite::Prop> = 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\"}
|
||||||
|
";
|
||||||
162
mrpgi-core/src/bin/mrpgi-headless/mcp.rs
Normal file
162
mrpgi-core/src/bin/mrpgi-headless/mcp.rs
Normal file
@ -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::<Value>(&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<Value> {
|
||||||
|
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<Value, (i64, String)> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
278
mrpgi-core/src/kit.rs
Normal file
278
mrpgi-core/src/kit.rs
Normal file
@ -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<Path>) {
|
||||||
|
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<Stroke> {
|
||||||
|
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<Path>) {
|
||||||
|
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<ObjDef> = 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<Path>) -> Vec<ObjDef> {
|
||||||
|
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::<Vec<ObjDef>>(&s) {
|
||||||
|
out.extend(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
25
mrpgi-core/src/lib.rs
Normal file
25
mrpgi-core/src/lib.rs
Normal file
@ -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};
|
||||||
@ -1,9 +1,7 @@
|
|||||||
//! The 16-color EGA palette MRPGI inherits from the original AGI.
|
//! The 16-color EGA palette MRPGI inherits from the original AGI.
|
||||||
//!
|
//!
|
||||||
//! Everything in the engine works in palette *indices* (0..=15); we only
|
//! 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.
|
//! convert to real RGBA at the very last moment, when pixels leave the core.
|
||||||
|
|
||||||
use macroquad::prelude::Color;
|
|
||||||
|
|
||||||
/// Standard IBM EGA / CGA 16-color palette, as (R, G, B).
|
/// Standard IBM EGA / CGA 16-color palette, as (R, G, B).
|
||||||
pub const EGA: [(u8, u8, u8); 16] = [
|
pub const EGA: [(u8, u8, u8); 16] = [
|
||||||
@ -25,16 +23,9 @@ pub const EGA: [(u8, u8, u8); 16] = [
|
|||||||
(0xFF, 0xFF, 0xFF), // 15 white
|
(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]
|
#[inline]
|
||||||
pub fn rgba(idx: u8) -> [u8; 4] {
|
pub fn rgba(idx: u8) -> [u8; 4] {
|
||||||
let (r, g, b) = EGA[(idx & 0x0F) as usize];
|
let (r, g, b) = EGA[(idx & 0x0F) as usize];
|
||||||
[r, g, b, 255]
|
[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)
|
|
||||||
}
|
|
||||||
268
mrpgi-core/src/parser.rs
Normal file
268
mrpgi-core/src/parser.rs
Normal file
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<VerbDef> {
|
||||||
|
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<VerbDef> {
|
||||||
|
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<String> = 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<usize> {
|
||||||
|
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<String, bool>, 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<String>,
|
||||||
|
taken: &mut Vec<(u32, String)>,
|
||||||
|
won: &mut bool,
|
||||||
|
flags: &mut HashMap<String, bool>,
|
||||||
|
talk_target: &mut Option<usize>,
|
||||||
|
) -> (Vec<String>, 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<String> = 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<DlgNode>,
|
||||||
|
pub node: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dlg {
|
||||||
|
fn visible(&self, flags: &HashMap<String, bool>) -> Vec<usize> {
|
||||||
|
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<String, bool>) -> Vec<String> {
|
||||||
|
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<String, bool>) -> 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<String, bool>) -> (Vec<String>, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
61
mrpgi-core/src/render.rs
Normal file
61
mrpgi-core/src/render.rs
Normal file
@ -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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
603
mrpgi-core/src/state.rs
Normal file
603
mrpgi-core/src/state.rs
Normal file
@ -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<u32> },
|
||||||
|
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<u32> },
|
||||||
|
SetMusic { mood: u8 },
|
||||||
|
/// Insert/replace an object (matched by name) in `room` (None = current).
|
||||||
|
UpsertObject { room: Option<u32>, obj: ObjDef },
|
||||||
|
DeleteObject { room: Option<u32>, 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<u32> },
|
||||||
|
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<String> },
|
||||||
|
DialogueOpen { lines: Vec<String> },
|
||||||
|
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<String>,
|
||||||
|
pub won: bool,
|
||||||
|
pub flags: HashMap<String, bool>,
|
||||||
|
pub vars: HashMap<String, i32>,
|
||||||
|
pub exits: [Option<u32>; 4],
|
||||||
|
pub music: u8,
|
||||||
|
pub objects: Vec<ObjSummary>,
|
||||||
|
pub in_dialogue: bool,
|
||||||
|
pub dialogue: Option<Vec<String>>,
|
||||||
|
pub transcript_tail: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GameState {
|
||||||
|
pub world: World,
|
||||||
|
pub ego: ScreenObj,
|
||||||
|
pub inventory: Vec<String>,
|
||||||
|
pub taken: Vec<(u32, String)>,
|
||||||
|
pub won: bool,
|
||||||
|
pub dlg: Option<Dlg>,
|
||||||
|
pub transcript: Vec<String>,
|
||||||
|
pub flags: HashMap<String, bool>,
|
||||||
|
pub vars: HashMap<String, i32>,
|
||||||
|
pub verbs: Vec<VerbDef>,
|
||||||
|
pub ai: ai::AiConfig,
|
||||||
|
pending: Option<std::sync::mpsc::Receiver<String>>,
|
||||||
|
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<Event> {
|
||||||
|
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::<usize>() {
|
||||||
|
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<Event> {
|
||||||
|
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<u32>) -> Vec<Event> {
|
||||||
|
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<Event> {
|
||||||
|
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<Event> {
|
||||||
|
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<Event> {
|
||||||
|
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<usize> = 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<String> = 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<Event> {
|
||||||
|
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<Prop> {
|
||||||
|
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<u8> {
|
||||||
|
render::compose(&self.world.fb, with_ego.then_some(&self.ego), &self.props())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_png(&self, with_ego: bool) -> Vec<u8> {
|
||||||
|
render::encode_png(&self.render_visual(with_ego))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_obj(objects: &mut Vec<ObjDef>, obj: ObjDef) {
|
||||||
|
if let Some(existing) = objects.iter_mut().find(|o| o.name == obj.name) {
|
||||||
|
*existing = obj;
|
||||||
|
} else {
|
||||||
|
objects.push(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,52 @@ pub struct View {
|
|||||||
pub loops: Vec<ViewLoop>,
|
pub loops: Vec<ViewLoop>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
/// 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
|
/// `.` or space = transparent. Rows shorter than the widest are right-padded
|
||||||
/// with transparency.
|
/// with transparency.
|
||||||
387
mrpgi-core/src/world.rs
Normal file
387
mrpgi-core/src/world.rs
Normal file
@ -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<DlgChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<DlgNode>, // node 0 = entry; empty = no conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoomDoc {
|
||||||
|
pub strokes: Vec<Stroke>,
|
||||||
|
pub spawn: (i32, i32),
|
||||||
|
#[serde(default)]
|
||||||
|
pub exits: [Option<u32>; 4], // N, E, S, W → target room index
|
||||||
|
#[serde(default)]
|
||||||
|
pub objects: Vec<ObjDef>,
|
||||||
|
#[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<VerbDef>,
|
||||||
|
/// Initial flag values for a new game.
|
||||||
|
#[serde(default)]
|
||||||
|
pub flags: HashMap<String, bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u32, RoomDoc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf>) -> 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::<RoomDoc>(&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<RoomDoc> {
|
||||||
|
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;
|
||||||
195
mrpgi-core/tests/engine.rs
Normal file
195
mrpgi-core/tests/engine.rs
Normal file
@ -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>) -> (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<String>, 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();
|
||||||
|
}
|
||||||
9
mrpgi/Cargo.toml
Normal file
9
mrpgi/Cargo.toml
Normal file
@ -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"
|
||||||
867
mrpgi/src/editor.rs
Normal file
867
mrpgi/src/editor.rs
Normal file
@ -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<usize>,
|
||||||
|
editing: Focus,
|
||||||
|
archetypes: Vec<ObjDef>,
|
||||||
|
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 <it>'".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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
304
mrpgi/src/main.rs
Normal file
304
mrpgi/src/main.rs
Normal file
@ -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<Event> = 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<usize> {
|
||||||
|
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
|
||||||
|
}
|
||||||
100
mrpgi/src/sound.rs
Normal file
100
mrpgi/src/sound.rs
Normal file
@ -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/<cue>.{wav,ogg,mp3}`, `music/<mood>.{...}`
|
||||||
|
//! (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<Sound>)>,
|
||||||
|
music: Vec<Option<Sound>>, // 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<Vec<u8>> {
|
||||||
|
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<Sound> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
run.sh
4
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:
|
# Disable the AI lane entirely:
|
||||||
# MRPGI_AI=off ./run.sh
|
# 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 "$@"
|
||||||
|
|||||||
1362
src/editor.rs
1362
src/editor.rs
File diff suppressed because it is too large
Load Diff
728
src/main.rs
728
src/main.rs
@ -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<String> = Vec::new();
|
|
||||||
let mut inventory: Vec<String> = Vec::new();
|
|
||||||
let mut taken: Vec<(u32, String)> = Vec::new();
|
|
||||||
let mut won = false;
|
|
||||||
let mut pending: Option<std::sync::mpsc::Receiver<String>> = None;
|
|
||||||
let mut sfx = sound::Sfx::load().await;
|
|
||||||
let mut music = sound::Music::load().await;
|
|
||||||
let mut dlg: Option<Dlg> = 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<usize> = 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<String> = 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<usize> = 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<Prop> = 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<editor::DlgNode>,
|
|
||||||
node: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dlg {
|
|
||||||
fn lines(&self) -> Vec<String> {
|
|
||||||
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<String>, 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<usize> {
|
|
||||||
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<String> = 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<usize> {
|
|
||||||
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<String>, taken: &mut Vec<(u32, String)>, won: &mut bool, talk_target: &mut Option<usize>) -> (Vec<String>, 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<String> = 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],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
72
src/room.rs
72
src/room.rs
@ -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<Prop>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
151
src/sound.rs
151
src/sound.rs
@ -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<Sound>,
|
|
||||||
pickup: Option<Sound>,
|
|
||||||
confirm: Option<Sound>,
|
|
||||||
error: Option<Sound>,
|
|
||||||
door: Option<Sound>,
|
|
||||||
win: Option<Sound>,
|
|
||||||
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<Sound>, 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<Sound> {
|
|
||||||
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<Option<Sound>>,
|
|
||||||
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<u8> {
|
|
||||||
let mut samples: Vec<i16> = 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<u8> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user