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:
type-two 2026-07-05 09:18:33 +10:00
parent d0728e121c
commit 96b45561d0
33 changed files with 4048 additions and 2508 deletions

40
Cargo.lock generated
View File

@ -14,6 +14,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "autocfg"
version = "1.5.1"
@ -60,6 +66,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "color_quant"
version = "1.1.0"
@ -170,6 +182,12 @@ dependencies = [
"foldhash",
]
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "icu_collections"
version = "2.2.0"
@ -371,10 +389,18 @@ dependencies = [
name = "mrpgi"
version = "0.1.0"
dependencies = [
"image",
"macroquad",
"mrpgi-core",
]
[[package]]
name = "mrpgi-core"
version = "0.1.0"
dependencies = [
"image",
"serde",
"serde_json",
"tiny_http",
"ureq",
]
@ -604,6 +630,18 @@ dependencies = [
"syn",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.8.3"

View File

@ -1,18 +1,9 @@
[package]
name = "mrpgi"
version = "0.1.0"
edition = "2021"
description = "Monster Robot Party Game Interpreter — a new-school, Apple-Silicon-native reimagining of the Sierra AGI adventure engine."
[dependencies]
macroquad = "0.4"
image = { version = "0.24", default-features = false, features = ["png"] } # PNG-only: lean builds
serde = { version = "1", features = ["derive"] } # room (.json) save/load
serde_json = "1"
ureq = { version = "2", features = ["json", "tls"] } # AI parser: local Ollama / OpenRouter HTTP
[workspace]
members = ["mrpgi-core", "mrpgi"]
resolver = "2"
# macroquad renders much more smoothly when its dependencies are optimized,
# even during `cargo run` dev builds. Our own crate stays fast to recompile.
# even during `cargo run` dev builds. Our own crates stay fast to recompile.
[profile.dev]
opt-level = 1

View File

@ -12,13 +12,29 @@ embedded scripting language, and an optional AI parser).
## Run it
```sh
cargo run # dev build (fast to recompile)
cargo run --release # smooth, optimized build
cargo run -p mrpgi # the GUI (paint + play) — or ./run.sh
cargo run -p mrpgi --release # smooth, optimized build
```
A window opens with the demo room. **Walk the robot with the arrow keys or by
clicking** — and stroll *behind the column* to watch the priority system place
you correctly in depth. `N` toggles CRT scanlines. `Esc` quits.
A window opens in the editor. Paint a room (or launch with `--sample` for the
3-room demo game), then press **Tab** to walk it — arrows or click to move,
type verbs to play. `F1` toggles CRT scanlines, `F2` sound, `Esc` quits.
## Drive it from anywhere (headless)
The engine is a headless core behind a typed command/event bus; the GUI is
just one consumer. The same bus is exposed as JSONL stdio, HTTP, and MCP:
```sh
cargo run -p mrpgi-core --bin mrpgi-headless -- --sample --game games/demo
echo '{"cmd":"parse","text":"look"}' | cargo run -p mrpgi-core --bin mrpgi-headless -- --game games/demo
mrpgi-headless --serve 127.0.0.1:7777 --game games/demo # HTTP + /frame.png
claude mcp add mrpgi -- mrpgi-headless --mcp --game games/demo # Claude plays & authors
```
A game is a folder of JSON + PNGs — an LLM expansion is just JSON that
deserializes. See **[docs/CONTROL.md](docs/CONTROL.md)** for the full guide
(commands, events, HTTP routes, MCP tools, authoring JSON reference).
## How it works (the AGI model, faithfully)
@ -36,15 +52,27 @@ pixel at a time, *only where its priority ≥ the scenery's* — that's the whol
## Source map
Two crates: **`mrpgi-core`** (the headless engine — zero windowing deps, a
compile error is the guardrail) and **`mrpgi`** (the macroquad GUI).
| File | Role |
|------|------|
| `palette.rs` | the 16-color EGA palette (indices → RGBA) |
| `framebuffer.rs` | the visual + priority buffers and the `band(y)` depth table |
| `picture.rs` | vector room painting (`PicOp` rectangles / lines / pixels) |
| `view.rs` | sprites as loops + cels, authored from tiny ASCII grids |
| `sprite.rs` | the ego: movement, click-to-walk, collision, walk animation |
| `room.rs` | the hand-authored demo room |
| `main.rs` | window, game loop, fixed-step cycles, compositing, HUD |
| `mrpgi-core/src/state.rs` | **the spine**: `GameState`, `Command`/`Event` bus, fixed-step `tick` |
| `mrpgi-core/src/world.rs` | rooms/objects/dialogue data model (all serde), `World`, game folders |
| `mrpgi-core/src/parser.rs` | data-driven verb table, verb-noun parser, dialogue state |
| `mrpgi-core/src/render.rs` | headless compositing → RGBA / PNG (the priority-buffer trick) |
| `mrpgi-core/src/framebuffer.rs` | the visual + priority buffers and the `band(y)` depth table |
| `mrpgi-core/src/picture.rs` | vector room painting (rects / lines / fills / stamps) |
| `mrpgi-core/src/view.rs` | sprites as loops + cels, authored from tiny ASCII grids |
| `mrpgi-core/src/sprite.rs` | the ego: movement, click-to-walk, collision, walk animation |
| `mrpgi-core/src/assets.rs` | PNG → EGA quantize + dither; sprite folders; kit art |
| `mrpgi-core/src/audio.rs` | chiptune synth + audio cues (played by front-ends, or files) |
| `mrpgi-core/src/ai.rs` | AI parser lane (Ollama / OpenRouter / off) on a worker thread |
| `mrpgi-core/src/kit.rs` | sample game + fantasy starter kit, built from the real types |
| `mrpgi-core/src/bin/mrpgi-headless/` | JSONL stdio, `--script` replay, `--render-room`, HTTP, MCP |
| `mrpgi/src/main.rs` | window, play loop over the bus, HUD |
| `mrpgi/src/editor.rs` | the in-engine paint tool (edits go through the same bus) |
| `mrpgi/src/sound.rs` | audio out: game-folder files override the built-in synth |
## Asset pipeline (the new-school bit)
@ -70,9 +98,13 @@ Drop your own art into `assets/`, then press **R** in-game to hot-reload it.
- [ ] **True-color "HD" layer mode**: skip quantization for painted backdrops; the priority buffer stays indexed
- [ ] **Polygon shapes** for `stamp_cel_fit` (not just rectangles)
- [ ] **Directional views** (4 loops + horizontal mirroring) for the robot
- [ ] **Text parser**: vocabulary with synonyms → `said("open","door")` matching, plus an on-screen input line
- [x] **Text parser**: data-driven verb table with synonyms, verb-noun matching, on-screen input line
- [x] **Room transitions**, inventory, flags (with `requires_flag`/`sets_flag` puzzle gating)
- [x] **AI parser adapter**: when the classic parse misses, an LLM maps free text onto an *already-allowed* command (deterministic core stays authoritative; works offline without it)
- [x] **Loadable game folders** (`game.json` + JSON rooms + PNGs) so games ship without recompiling the engine
- [x] **Headless core + control surfaces**: JSONL stdio, script replay, HTTP, MCP, headless PNG rendering — see [docs/CONTROL.md](docs/CONTROL.md)
- [ ] **Rhai room logic**: rooms scripted in an AGI-flavored embedded language (`if said(...) { print(...) }`), hot-reloadable
- [ ] **Room transitions** (`new_room`), inventory, flags & vars
- [ ] **AI parser adapter**: when the classic parse misses, an LLM maps free text onto an *already-allowed* room command (deterministic core stays authoritative; works offline without it)
- [ ] **3-voice + noise chiptune synth**
- [ ] **Loadable game folders** (text/JSON resources) so games ship without recompiling the engine
- [ ] **3-voice + noise chiptune synth** (per-game audio files already override the square-wave synth)
- [ ] **Strudel sink**: browser sidecar over the HTTP surface, driven by the same audio events
- [ ] **Vars & conditions on dialogue/objects** beyond boolean flags; cross-room NPC state
- [ ] **Pathfinding** for click-to-walk (today: greedy steering)

162
docs/CONTROL.md Normal file
View 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=...
```

View File

@ -1,6 +1,11 @@
# MRPGI — Handover: making the engine fully programmatically controllable
> **Status:** design / discussion complete. **No engine code has been changed yet.**
> **Status:****BUILT** (2026-07-05) — P0P6 all landed: `mrpgi-core` workspace carve,
> `GameState` + Command/Event bus, headless JSONL CLI + `--script` replay +
> `--render-room`, game folders (`game.json`), data-driven verbs, flags &
> dialogue gating, HTTP + MCP surfaces, audio events with per-game file
> overrides, headless editor commands. See [CONTROL.md](CONTROL.md) for the
> control-surface guide. This document is kept as the design rationale.
> **Date:** 2026-07-05
> **Purpose:** capture the vision, the current-state analysis, the decisions locked in, and the phased plan so any future session (human or AI) can pick up exactly where we left off.

16
mrpgi-core/Cargo.toml Normal file
View 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"

View File

@ -4,7 +4,7 @@
//! LLM translates the player's free text into ONE *allowed* command — which is
//! then re-run through the real game logic, so logic stays authoritative and
//! the AI can never invent state. The call runs on a worker thread and reports
//! back over a channel, so the game loop never blocks.
//! back over a channel, so no caller ever blocks.
//!
//! Provider is chosen by env vars (default = local Ollama, free + offline):
//! MRPGI_AI ollama (default) | openrouter | off
@ -51,6 +51,10 @@ impl AiConfig {
AiConfig { provider, model, url, key }
}
pub fn off() -> Self {
AiConfig { provider: Provider::Off, model: String::new(), url: String::new(), key: None }
}
pub fn enabled(&self) -> bool {
self.provider != Provider::Off
}
@ -65,10 +69,12 @@ impl AiConfig {
}
/// The instruction we send the model — tight, so even a 4B local model behaves.
pub fn build_prompt(text: &str, things: &str) -> String {
/// `verbs` comes from the live verb table, so the whitelist the AI sees can
/// never drift from what the parser actually accepts.
pub fn build_prompt(text: &str, things: &str, verbs: &str) -> String {
format!(
"You translate a player's sentence into ONE text-adventure command.
Valid verbs: look, examine, take, drop, use, open, inventory.
Valid verbs: {verbs}.
Things you can see: {things}.
Reply with ONLY one short command like `use gate` or `take key`, using a verb above and (if it helps) one of the things listed. If nothing fits, reply `look around`. No quotes, no explanation.
Player: {text}
@ -76,8 +82,8 @@ Command:"
)
}
/// Fire the translation on a worker thread; poll the returned receiver each
/// frame with `try_recv()`. The result is a sanitized command (or "" = no idea).
/// Fire the translation on a worker thread; poll the returned receiver with
/// `try_recv()`. The result is a sanitized command (or "" = no idea).
pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {

View File

@ -6,8 +6,8 @@
//! gradients) and becomes an indexed [`Cel`]. From that moment it depth-sorts,
//! gets occluded by scenery, and walks-behind exactly like hand-authored art.
//!
//! Drop your own PNGs into `assets/` and the engine will use them; the sample
//! art here is generated on first run so the project is playable out of the box.
//! Drop your own PNGs into a game's `sprites/` and the engine will use them;
//! sample art is generated on first run so the project is playable out of the box.
use crate::palette::EGA;
use crate::view::{Cel, TRANSPARENT};
@ -80,147 +80,8 @@ pub fn load_cel(path: &str, dither: bool) -> Cel {
}
}
/// All the art a room might want. Add fields here as games grow; for now this
/// holds the demo's three pieces.
pub struct Assets {
pub poster: Cel,
pub crate_box: Cel,
pub floor: Cel,
}
impl Assets {
pub fn load(dir: &str) -> Self {
Assets {
poster: load_cel(&format!("{dir}/poster.png"), true),
crate_box: load_cel(&format!("{dir}/crate.png"), false),
floor: load_cel(&format!("{dir}/floor.png"), true),
}
}
}
// ---------------------------------------------------------------------------
// Sample-art generation (so the project runs with zero external files).
// These write real PNGs you can open, edit, and hot-reload with `R`.
// ---------------------------------------------------------------------------
pub fn ensure_sample_assets(dir: &str) {
std::fs::create_dir_all(dir).ok();
write_if_missing(&format!("{dir}/poster.png"), gen_poster);
write_if_missing(&format!("{dir}/crate.png"), gen_crate);
write_if_missing(&format!("{dir}/floor.png"), gen_floor);
}
fn write_if_missing(path: &str, f: fn() -> image::RgbaImage) {
if !Path::new(path).exists() {
f().save(path).ok();
}
}
fn put_rect(im: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, c: [u8; 4]) {
for yy in y..(y + h).min(im.height()) {
for xx in x..(x + w).min(im.width()) {
im.put_pixel(xx, yy, image::Rgba(c));
}
}
}
/// A neon "Monster Robot Party" poster — a smooth gradient with a robot face,
/// so dithering has something to chew on.
fn gen_poster() -> image::RgbaImage {
let (w, h) = (64u32, 48u32);
let mut im = image::RgbaImage::new(w, h);
for y in 0..h {
for x in 0..w {
let t = y as f32 / h as f32;
let r = (90.0 * (1.0 - t)) as u8;
let g = (20.0 + 110.0 * t) as u8;
let b = (130.0 * (1.0 - t) + 140.0 * t) as u8;
im.put_pixel(x, y, image::Rgba([r, g, b, 255]));
}
}
// Robot head.
let (cx, cy) = (32.0, 24.0);
for y in 0..h {
for x in 0..w {
let dx = x as f32 - cx;
let dy = (y as f32 - cy) * 1.1;
if dx * dx + dy * dy < 13.0 * 13.0 {
im.put_pixel(x, y, image::Rgba([200, 200, 210, 255]));
}
}
}
put_rect(&mut im, 26, 20, 4, 4, [40, 230, 230, 255]); // eye
put_rect(&mut im, 34, 20, 4, 4, [40, 230, 230, 255]); // eye
put_rect(&mut im, 27, 30, 10, 2, [230, 60, 180, 255]); // mouth
put_rect(&mut im, 31, 8, 2, 6, [220, 220, 220, 255]); // antenna
put_rect(&mut im, 30, 5, 4, 4, [255, 230, 60, 255]); // antenna tip
// Confetti.
let cols = [
[255, 80, 80, 255],
[80, 255, 120, 255],
[255, 230, 60, 255],
[120, 160, 255, 255],
];
for i in 0..44u32 {
let x = (i * 37 + 5) % w;
let y = (i * 23 + 3) % h;
im.put_pixel(x, y, image::Rgba(cols[(i % 4) as usize]));
}
im
}
/// A pixel-art wooden crate (an object you can walk behind).
fn gen_crate() -> image::RgbaImage {
let (w, h) = (28u32, 24u32);
let mut im = image::RgbaImage::new(w, h);
put_rect(&mut im, 0, 0, w, h, [150, 95, 45, 255]); // wood
let dark = [95, 60, 25, 255];
put_rect(&mut im, 0, 0, w, 2, dark);
put_rect(&mut im, 0, h - 2, w, 2, dark);
put_rect(&mut im, 0, 0, 2, h, dark);
put_rect(&mut im, w - 2, 0, 2, h, dark);
let light = [190, 135, 75, 255];
for i in 0..h {
let x = (i * (w - 1) / (h - 1)).min(w - 1);
im.put_pixel(x, i, image::Rgba(light));
im.put_pixel(w - 1 - x, i, image::Rgba(light));
}
im
}
/// A 16x16 dance-floor tile (tiles seamlessly; 16 is a multiple of the dither
/// period so there are no seams).
fn gen_floor() -> image::RgbaImage {
let (w, h) = (16u32, 16u32);
let mut im = image::RgbaImage::new(w, h);
for y in 0..h {
for x in 0..w {
let base = if ((x / 8) + (y / 8)) % 2 == 0 {
[22, 62, 56, 255]
} else {
[16, 48, 46, 255]
};
im.put_pixel(x, y, image::Rgba(base));
}
}
for x in 0..w {
im.put_pixel(x, 0, image::Rgba([10, 30, 30, 255]));
}
for y in 0..h {
im.put_pixel(0, y, image::Rgba([10, 30, 30, 255]));
}
for (x, y, c) in [
(4u32, 5u32, [80, 255, 160, 255]),
(11, 9, [255, 90, 200, 255]),
(7, 13, [120, 160, 255, 255]),
] {
im.put_pixel(x, y, image::Rgba(c));
}
im
}
// ---------------------------------------------------------------------------
// Sprite pipeline — drop PNGs in a folder, the editor places them.
// Sprite pipeline — drop PNGs in a folder, the engine places them.
// ---------------------------------------------------------------------------
/// Load every PNG in `dir` as an EGA-quantized cel (no dither — keep pixel art
@ -270,6 +131,33 @@ pub fn ensure_sample_sprites(dir: &str) {
gen_sign().save(format!("{dir}/sign.png")).ok();
}
fn put_rect(im: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, c: [u8; 4]) {
for yy in y..(y + h).min(im.height()) {
for xx in x..(x + w).min(im.width()) {
im.put_pixel(xx, yy, image::Rgba(c));
}
}
}
/// A pixel-art wooden crate (an object you can walk behind).
fn gen_crate() -> image::RgbaImage {
let (w, h) = (28u32, 24u32);
let mut im = image::RgbaImage::new(w, h);
put_rect(&mut im, 0, 0, w, h, [150, 95, 45, 255]); // wood
let dark = [95, 60, 25, 255];
put_rect(&mut im, 0, 0, w, 2, dark);
put_rect(&mut im, 0, h - 2, w, 2, dark);
put_rect(&mut im, 0, 0, 2, h, dark);
put_rect(&mut im, w - 2, 0, 2, h, dark);
let light = [190, 135, 75, 255];
for i in 0..h {
let x = (i * (w - 1) / (h - 1)).min(w - 1);
im.put_pixel(x, i, image::Rgba(light));
im.put_pixel(w - 1 - x, i, image::Rgba(light));
}
im
}
fn gen_barrel() -> image::RgbaImage {
let (w, h) = (16u32, 22u32);
let mut im = image::RgbaImage::new(w, h);
@ -278,12 +166,12 @@ fn gen_barrel() -> image::RgbaImage {
put_rect(&mut im, 12, 1, 2, 20, [110, 68, 30, 255]);
put_rect(&mut im, 2, 3, 12, 2, [95, 95, 95, 255]);
put_rect(&mut im, 2, 17, 12, 2, [95, 95, 95, 255]);
let _ = (w, h);
im
}
fn gen_sign() -> image::RgbaImage {
let (w, h) = (28u32, 18u32);
let mut im = image::RgbaImage::new(w, h);
let mut im = image::RgbaImage::new(28, 18);
put_rect(&mut im, 12, 8, 4, 10, [120, 75, 35, 255]);
put_rect(&mut im, 2, 1, 24, 9, [200, 170, 60, 255]);
put_rect(&mut im, 2, 1, 24, 2, [150, 120, 40, 255]);
@ -292,7 +180,7 @@ fn gen_sign() -> image::RgbaImage {
// ---------------------------------------------------------------------------
// Fantasy starter-kit placeholder sprites (crude but distinct). Replace with
// real art any time — same filenames in sprites/, press R. See ART_MANIFESTO.
// real art any time — same filenames in sprites/, press R.
// ---------------------------------------------------------------------------
const GREEN: [u8; 4] = [0, 170, 0, 255];
@ -342,7 +230,7 @@ pub fn write_kit_sprites(dir: &str) {
];
for (name, f) in gens {
let p = format!("{dir}/{name}.png");
if !std::path::Path::new(&p).exists() {
if !Path::new(&p).exists() {
f().save(&p).ok();
}
}

116
mrpgi-core/src/audio.rs Normal file
View 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
}

View 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.
";

View 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\"}
";

View 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
View 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
View 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};

View File

@ -1,9 +1,7 @@
//! The 16-color EGA palette MRPGI inherits from the original AGI.
//!
//! Everything in the engine works in palette *indices* (0..=15); we only
//! convert to real RGBA at the very last moment, when we hand pixels to the GPU.
use macroquad::prelude::Color;
//! convert to real RGBA at the very last moment, when pixels leave the core.
/// Standard IBM EGA / CGA 16-color palette, as (R, G, B).
pub const EGA: [(u8, u8, u8); 16] = [
@ -25,16 +23,9 @@ pub const EGA: [(u8, u8, u8); 16] = [
(0xFF, 0xFF, 0xFF), // 15 white
];
/// Raw RGBA bytes for a palette index (for writing straight into an Image).
/// Raw RGBA bytes for a palette index.
#[inline]
pub fn rgba(idx: u8) -> [u8; 4] {
let (r, g, b) = EGA[(idx & 0x0F) as usize];
[r, g, b, 255]
}
/// macroquad `Color` for a palette index (for `draw_*` calls).
#[inline]
pub fn color(idx: u8) -> Color {
let (r, g, b) = EGA[(idx & 0x0F) as usize];
Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
}

268
mrpgi-core/src/parser.rs Normal file
View 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
View 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
View 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);
}
}

View File

@ -31,6 +31,52 @@ pub struct View {
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,
/// `.` or space = transparent. Rows shorter than the widest are right-padded
/// with transparency.

387
mrpgi-core/src/world.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -8,4 +8,6 @@ export MRPGI_AI_MODEL="${MRPGI_AI_MODEL:-hf.co/OBLITERATUS/gemma-4-E4B-it-OBLITE
# Disable the AI lane entirely:
# MRPGI_AI=off ./run.sh
#
exec cargo run "$@"
# Headless surfaces live in the companion binary:
# cargo run -p mrpgi-core --bin mrpgi-headless -- --help
exec cargo run -p mrpgi "$@"

File diff suppressed because it is too large Load Diff

View File

@ -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],
}],
}
}

View File

@ -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],
}
}

View File

@ -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
}