v0.5 — playtest-driven fixes + engine/UX upgrades

Bugs found by playing the game over the bus:
- hotspots now have positions: 'walk to precinct door' pathfinds there,
  and touch verbs walk over to hotspots like they do to props/NPCs
- GameState::settle(): the MCP surface resolves walk-then-act in one call
  instead of returning zero events on a distant verb
- typed 'use X on Y' walks over like a clicked use (was unlimited range)
- taking/deleting a solid prop rebakes — no more lingering invisible walls
- sergeant dialogue no longer loops back to its greeting
- serde-facing maps are BTreeMap: room JSON and --sample are byte-stable

Engine:
- sprite-sheet actor views: <name>-sheet<C>x<R>.png slices into C frames x
  R direction loops (vend-bot now rolls on animated treads)
- pixel-accurate click targeting with 1px slop; typed commands click
  mid-sprite (a feet click lands between an actor's legs)
- parser: compass walking (go east / n), 'bye' ends conversations,
  'look' lists exits

GUI:
- hover hot-text: cursor reads the sentence a click would say
- death banner + '+N' score toast

Tests: full-playthrough regression suite (win path, death rewind,
walk-over dialogue, solid-prop footprint) + sheet slicing unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 13:38:33 +10:00
parent 6ea7aa6dd4
commit ce42d69a3a
17 changed files with 532 additions and 97 deletions

51
CLAUDE.md Normal file
View File

@ -0,0 +1,51 @@
# MRPCI — working notes for Claude
SCI-generation adventure engine (sibling of MRPGI). Rust workspace:
`mrpci-core` (headless sim — **must never depend on macroquad**; that's the
architectural guardrail) and `mrpci` (the macroquad GUI, a thin bus client).
## Build / test / run
```sh
cargo build --workspace
cargo test --workspace # includes full-playthrough regression tests
cargo run -p mrpci # play Neon Precinct in a window
target/debug/mrpci-headless --game games/neon-precinct --render-room out.png
```
## Invariants — do not break
- **Everything goes through the bus.** All mutation is a `Command` on
`GameState::apply`; all output is `Event`s. GUI, stdio, HTTP, MCP and the
in-engine editor are thin adapters with zero private powers.
- **Determinism.** Fixed 30Hz cycles; timers count ticks; the only RNG is
the seeded xorshift in `ScriptCtx`. `--script` replays must stay
byte-identical (the AI parser lane is force-disabled there). Weather
particles run a separate RNG stream on purpose.
- **Deterministic serialization.** Serde-facing maps are `BTreeMap` so room
JSON/bundles are diffable and `--sample` output is byte-stable.
- **Scripts can't corrupt the sim.** rhai reads a `ScriptCtx` snapshot,
writes queue as `Fx` — keep that airlock.
- **Missing resources degrade, never crash** (placeholders + `error` event).
## Gotchas
- `games/neon-precinct/` is **generated** by `mrpci-headless --sample`
(source: `mrpci-core/src/bin/mrpci-headless/sample.rs`). Edit the
generator, then regenerate — don't hand-edit the JSON.
- Distant verbs queue a walk-then-act (`pending_verb`); real-time surfaces
resolve it on later ticks, the MCP adapter calls `GameState::settle()`.
Raw stdio/HTTP callers must send `tick`.
- Sprites named `<base>-sheet<C>x<R>.png` are sliced into animated actor
views (rows: front/back/left/right, right falls back to mirrored left).
Sheets are for egos/NPCs; props/portraits use plain single-cel sprites.
- Solid props stamp CTL_BLOCK footprints; anything that can remove one must
`rebake()`, not just re-stamp (else invisible walls linger).
- Hit-testing is pixel-accurate with 1px slop (`cel_hit`); typed commands
"click" mid-sprite via `target_click_pos` (a feet-point click lands
between an actor's legs).
## Docs
`README.md` (architecture + game-folder format), `docs/CONTROL.md` (bus
reference: every Command, all four surfaces), `docs/EDITOR.md` (F8 editor).

View File

@ -37,7 +37,8 @@ a lethal fusebox (watch death *not* ruin your evening), and a case to close.
Controls: left-click applies the verb, right-click cycles verbs, F1F4 pick Controls: left-click applies the verb, right-click cycles verbs, F1F4 pick
one, `I` inventory, **Enter types a command** (the parser is always alive), one, `I` inventory, **Enter types a command** (the parser is always alive),
F5/F7 save/restore, F9 new game, M mute, N scanlines. F5/F7 save/restore, F9 new game, M mute, N scanlines. The cursor shows the
sentence a click would say — hover anything and it reads `look neon sign`.
## Drive it headless (the point) ## Drive it headless (the point)
@ -71,6 +72,9 @@ games/mygame/
# spawn, exits, hotspots, props, npcs, music, script # spawn, exits, hotspots, props, npcs, music, script
pics/street.png # optional backgrounds (+ street-pri.png / street-ctl.png masks) pics/street.png # optional backgrounds (+ street-pri.png / street-ctl.png masks)
sprites/*.png # quantized per-room; filename = sprite name sprites/*.png # quantized per-room; filename = sprite name
# <name>-sheet<C>x<R>.png = animated actor view:
# C frames across x R direction rows (front/back/left/right,
# trailing rows optional — right mirrors left)
scripts/*.rhai # main.rhai + room scripts scripts/*.rhai # main.rhai + room scripts
sfx/ music/ # optional audio overrides (else the built-in chip synth plays) sfx/ music/ # optional audio overrides (else the built-in chip synth plays)
saves/ # save-anywhere slots (gitignored) saves/ # save-anywhere slots (gitignored)
@ -130,5 +134,11 @@ real game logic. The AI can suggest; only the sim decides. Configure with
- [x] v0.4 — in-engine room editor: F8, four-screen paint meanings, - [x] v0.4 — in-engine room editor: F8, four-screen paint meanings,
overlay views, hotspot/prop placement, `:` commands — all over the overlay views, hotspot/prop placement, `:` commands — all over the
bus, so MCP clients wield identical powers ([docs/EDITOR.md](docs/EDITOR.md)) bus, so MCP clients wield identical powers ([docs/EDITOR.md](docs/EDITOR.md))
- [x] v0.5 — sprite-sheet actor views (`-sheetCxR`), pixel-accurate click
targeting, hotspots as walk/touch targets, MCP auto-settle (distant
verbs act synchronously), hover hot-text + death banner + score toast
in the GUI, compass walking & `bye`, deterministic room JSON
(BTreeMap), solid-prop footprints cleared on take, full-playthrough
regression tests
- [ ] Priority-mask art workflow polish (paint depth in any image editor) - [ ] Priority-mask art workflow polish (paint depth in any image editor)
- [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core) - [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core)

View File

@ -83,10 +83,14 @@ Notes:
- The engine only advances when told: `tick` runs fixed 1/30s cycles - The engine only advances when told: `tick` runs fixed 1/30s cycles
(movement, timers, triggers, NPC wander). `walk_to` ticks until arrival. (movement, timers, triggers, NPC wander). `walk_to` ticks until arrival.
- `verb_at` verbs: `walk`, `look`, `do`, `talk`. Distant do/talk/take - `verb_at` verbs: `walk`, `look`, `do`, `talk`. Distant do/talk/take
targets are walked to first (the pending verb fires on arrival). targets are walked to first (the pending verb fires on arrival). On raw
stdio/HTTP that means: send `tick` afterward or nothing visibly happens —
the **MCP surface settles this automatically** and returns the whole
walk-then-act outcome in one call.
- `parse` accepts the same things a player types: `look at sign`, - `parse` accepts the same things a player types: `look at sign`,
`pick up the keycard`, `use keycard on locker`, `talk to sergeant`, `pick up the keycard`, `use keycard on locker`, `talk to sergeant`,
`inventory`, `save`, `score`. `walk to door`, compass walking (`go east`, `n`), `inventory`, `save`,
`score` — and `bye` to leave a conversation.
- `upsert_room` validates before committing (walkable spawn, exits and door - `upsert_room` validates before committing (walkable spawn, exits and door
hotspots must point at rooms that exist) and rejects bad lore with an hotspots must point at rooms that exist) and rejects bad lore with an
`error` event. `error` event.

View File

@ -7,9 +7,9 @@
"max_score": 25, "max_score": 25,
"ego_sprite": "", "ego_sprite": "",
"defaults": { "defaults": {
"look": "Rain-slick chrome and old neon. Nothing more.",
"do": "Your servos find no purchase on that.", "do": "Your servos find no purchase on that.",
"listen": "The city hums in B-flat.", "listen": "The city hums in B-flat.",
"look": "Rain-slick chrome and old neon. Nothing more.",
"smell": "Ozone, rain, and yesterday's synth-noodles." "smell": "Ozone, rain, and yesterday's synth-noodles."
} }
} }

View File

@ -1105,9 +1105,9 @@
"x": 288, "x": 288,
"y": 148, "y": 148,
"msgs": { "msgs": {
"smell": "Your olfactory sensor files a grievance.", "do": "You rummage. Old circuit boards, a single roller skate, regret.",
"look": "A city dumpster. Something inside is composting on a geological timescale.", "look": "A city dumpster. Something inside is composting on a geological timescale.",
"do": "You rummage. Old circuit boards, a single roller skate, regret." "smell": "Your olfactory sensor files a grievance."
}, },
"takeable": false, "takeable": false,
"synonyms": "", "synonyms": "",
@ -1131,7 +1131,7 @@
"npcs": [ "npcs": [
{ {
"name": "vend-bot", "name": "vend-bot",
"sprite": "vendbot", "sprite": "vendbot-sheet2x1",
"x": 150, "x": 150,
"y": 132, "y": 132,
"msgs": { "msgs": {

View File

@ -717,8 +717,8 @@
"x": 286, "x": 286,
"y": 138, "y": 138,
"msgs": { "msgs": {
"look": "Evidence locker 47. The reader-slot blinks, wanting a keycard.", "do": "Locked tight. The reader-slot blinks at you, unimpressed.",
"do": "Locked tight. The reader-slot blinks at you, unimpressed." "look": "Evidence locker 47. The reader-slot blinks, wanting a keycard."
}, },
"takeable": false, "takeable": false,
"synonyms": "", "synonyms": "",
@ -810,7 +810,7 @@
"choices": [ "choices": [
{ {
"text": "Got it.", "text": "Got it.",
"goto": 0, "goto": -1,
"requires_flag": "", "requires_flag": "",
"sets_flag": "", "sets_flag": "",
"points": 0, "points": 0,
@ -823,7 +823,7 @@
"choices": [ "choices": [
{ {
"text": "Copy.", "text": "Copy.",
"goto": 0, "goto": -1,
"requires_flag": "", "requires_flag": "",
"sets_flag": "", "sets_flag": "",
"points": 0, "points": 0,

View File

@ -570,8 +570,8 @@
], ],
"poly": [], "poly": [],
"msgs": { "msgs": {
"look": "A drainage pipe keeping its own beat.", "listen": "Drip. Drip. Drip. It's in 7/8 time, somehow.",
"listen": "Drip. Drip. Drip. It's in 7/8 time, somehow." "look": "A drainage pipe keeping its own beat."
}, },
"exit_to": null, "exit_to": null,
"arrive": null, "arrive": null,

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

View File

@ -154,7 +154,13 @@ fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result<Value, (i64,
_ => return Err((-32602, format!("unknown tool: {}", name))), _ => return Err((-32602, format!("unknown tool: {}", name))),
}; };
let events = gs.apply(cmd); let mut events = gs.apply(cmd);
// A verb on a distant target queues a walk-then-act. A GUI resolves it on
// its next frames; here nothing ticks unless we tick — so settle now,
// and a distant click acts synchronously instead of looking dead.
if matches!(name, "mrpci_verb" | "mrpci_use_item" | "mrpci_parse") {
events.extend(gs.settle(900));
}
let is_error = events.iter().any(|e| matches!(e, Event::Error { .. })); let is_error = events.iter().any(|e| matches!(e, Event::Error { .. }));
let text = serde_json::to_string_pretty(&events).unwrap_or_default(); let text = serde_json::to_string_pretty(&events).unwrap_or_default();
Ok(json!({ Ok(json!({

View File

@ -20,7 +20,7 @@ use mrpci_core::palette::PalCycle;
use mrpci_core::pic::{CtlInk, Ink, PicOp, PriInk}; use mrpci_core::pic::{CtlInk, Ink, PicOp, PriInk};
use mrpci_core::room::{GameManifest, Hotspot, NpcDef, PropDef, RoomDoc}; use mrpci_core::room::{GameManifest, Hotspot, NpcDef, PropDef, RoomDoc};
use mrpci_core::screens::{band, CTL_BLOCK, CTL_WATER}; use mrpci_core::screens::{band, CTL_BLOCK, CTL_WATER};
use std::collections::HashMap; use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
/// Index into the base palette's 6x6x6 cube (components 0..=5). /// Index into the base palette's 6x6x6 cube (components 0..=5).
@ -42,7 +42,7 @@ fn ink_wall(c: u8) -> Ink {
Ink { color: Some(c), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_BLOCK), hot: None } Ink { color: Some(c), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_BLOCK), hot: None }
} }
fn msgs(pairs: &[(&str, &str)]) -> HashMap<String, String> { fn msgs(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
} }
@ -61,8 +61,8 @@ pub fn write_sample(dir: &str) -> std::io::Result<()> {
intro_text: "Rain on chrome. You are Officer Morp-9, and somewhere in this city a case \ intro_text: "Rain on chrome. You are Officer Morp-9, and somewhere in this city a case \
is getting colder. (Right-click cycles verbs. Or just type.)" is getting colder. (Right-click cycles verbs. Or just type.)"
.into(), .into(),
verbs: HashMap::new(), verbs: BTreeMap::new(),
flags: HashMap::new(), flags: BTreeMap::new(),
max_score: 25, max_score: 25,
ego_sprite: String::new(), ego_sprite: String::new(),
defaults: msgs(&[ defaults: msgs(&[
@ -251,7 +251,7 @@ fn room0() -> RoomDoc {
}], }],
npcs: vec![NpcDef { npcs: vec![NpcDef {
name: "vend-bot".into(), name: "vend-bot".into(),
sprite: "vendbot".into(), sprite: "vendbot-sheet2x1".into(),
portrait: "vendbot-face".into(), portrait: "vendbot-face".into(),
x: 150, x: 150,
y: 132, y: 132,
@ -398,11 +398,11 @@ fn room1() -> RoomDoc {
}, },
DlgNode { DlgNode {
says: "Perp dumped a keycard in Rain Alley, east off Neon Row. It opens evidence locker 47.".into(), says: "Perp dumped a keycard in Rain Alley, east off Neon Row. It opens evidence locker 47.".into(),
choices: vec![DlgChoice { text: "Got it.".into(), goto: 0, ..Default::default() }], choices: vec![DlgChoice { text: "Got it.".into(), goto: -1, ..Default::default() }],
}, },
DlgNode { DlgNode {
says: "Slot the slate into the case terminal, west wall. Then we both go home.".into(), says: "Slot the slate into the case terminal, west wall. Then we both go home.".into(),
choices: vec![DlgChoice { text: "Copy.".into(), goto: 0, ..Default::default() }], choices: vec![DlgChoice { text: "Copy.".into(), goto: -1, ..Default::default() }],
}, },
], ],
..Default::default() ..Default::default()
@ -550,7 +550,7 @@ fn default_hotspot() -> Hotspot {
name: String::new(), name: String::new(),
rect: None, rect: None,
poly: Vec::new(), poly: Vec::new(),
msgs: HashMap::new(), msgs: BTreeMap::new(),
exit_to: None, exit_to: None,
arrive: None, arrive: None,
requires_flag: String::new(), requires_flag: String::new(),
@ -688,16 +688,27 @@ fn write_sprites(dir: &Path) -> std::io::Result<()> {
fill(&mut sergeant, 11, 24, 4, 12, [40, 44, 54]); fill(&mut sergeant, 11, 24, 4, 12, [40, 44, 54]);
save("sergeant", &sergeant)?; save("sergeant", &sergeant)?;
let mut vendbot = RgbaImage::new(18, 30); // The vend-bot as a 2-frame sprite sheet (`-sheet2x1`): same body, tread
fill(&mut vendbot, 2, 0, 14, 22, [180, 60, 60]); // lugs alternating — the engine slices it into a rolling walk cycle.
fill(&mut vendbot, 4, 3, 10, 6, [255, 220, 140]); // menu screen let mut vendbot = RgbaImage::new(36, 30);
fill(&mut vendbot, 4, 12, 3, 3, [40, 40, 48]); // buttons for frame in 0..2u32 {
fill(&mut vendbot, 8, 12, 3, 3, [40, 40, 48]); let x0 = frame * 18;
fill(&mut vendbot, 12, 12, 3, 3, [40, 40, 48]); fill(&mut vendbot, x0 + 2, 0, 14, 22, [180, 60, 60]);
fill(&mut vendbot, 4, 17, 10, 3, [30, 30, 34]); // dispense slot fill(&mut vendbot, x0 + 4, 3, 10, 6, [255, 220, 140]); // menu screen
fill(&mut vendbot, 2, 22, 14, 4, [60, 62, 70]); // treads fill(&mut vendbot, x0 + 4, 12, 3, 3, [40, 40, 48]); // buttons
fill(&mut vendbot, 0, 26, 18, 4, [30, 30, 34]); fill(&mut vendbot, x0 + 8, 12, 3, 3, [40, 40, 48]);
save("vendbot", &vendbot)?; fill(&mut vendbot, x0 + 12, 12, 3, 3, [40, 40, 48]);
fill(&mut vendbot, x0 + 4, 17, 10, 3, [30, 30, 34]); // dispense slot
fill(&mut vendbot, x0 + 2, 22, 14, 4, [60, 62, 70]); // treads
fill(&mut vendbot, x0, 26, 18, 4, [30, 30, 34]);
// tread lugs, offset per frame so the treads appear to roll
let mut lug = if frame == 0 { 1 } else { 3 };
while lug < 17 {
fill(&mut vendbot, x0 + lug, 27, 2, 2, [90, 94, 104]);
lug += 4;
}
}
save("vendbot-sheet2x1", &vendbot)?;
// Talker portraits (drawn 2x in the core window, 6x in the GUI). // Talker portraits (drawn 2x in the core window, 6x in the GUI).
let mut serg_face = RgbaImage::new(24, 24); let mut serg_face = RgbaImage::new(24, 24);

View File

@ -8,7 +8,7 @@
//! verbs. The clever part — resolving nouns against the live room — happens //! verbs. The clever part — resolving nouns against the live room — happens
//! where the live room actually is (state.rs). //! where the live room actually is (state.rs).
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
/// Canonical verbs the engine acts on. Authors may key `msgs` with any verb /// Canonical verbs the engine acts on. Authors may key `msgs` with any verb
/// string; these are the ones with built-in behavior. /// string; these are the ones with built-in behavior.
@ -35,8 +35,8 @@ pub enum Parsed {
} }
/// canonical verb → space-separated synonyms. Game manifests merge over this. /// canonical verb → space-separated synonyms. Game manifests merge over this.
pub fn builtin_verbs() -> HashMap<String, String> { pub fn builtin_verbs() -> BTreeMap<String, String> {
let mut m = HashMap::new(); let mut m = BTreeMap::new();
m.insert(V_LOOK.into(), "l x examine inspect read watch".into()); m.insert(V_LOOK.into(), "l x examine inspect read watch".into());
m.insert(V_DO.into(), "use touch push pull open close press operate activate turn flip".into()); m.insert(V_DO.into(), "use touch push pull open close press operate activate turn flip".into());
m.insert(V_TAKE.into(), "get grab pick steal".into()); m.insert(V_TAKE.into(), "get grab pick steal".into());
@ -48,7 +48,7 @@ pub fn builtin_verbs() -> HashMap<String, String> {
} }
/// Build the reverse map word → canonical verb. /// Build the reverse map word → canonical verb.
pub fn verb_index(extra: &HashMap<String, String>) -> HashMap<String, String> { pub fn verb_index(extra: &BTreeMap<String, String>) -> HashMap<String, String> {
let mut idx = HashMap::new(); let mut idx = HashMap::new();
let mut fold = |canon: &str, syns: &str| { let mut fold = |canon: &str, syns: &str| {
idx.insert(canon.to_string(), canon.to_string()); idx.insert(canon.to_string(), canon.to_string());
@ -121,7 +121,7 @@ pub fn parse(line: &str, verbs: &HashMap<String, String>) -> Parsed {
} }
/// The verbs an AI lane would be allowed to emit — everything canonical. /// The verbs an AI lane would be allowed to emit — everything canonical.
pub fn verb_whitelist(extra: &HashMap<String, String>) -> Vec<String> { pub fn verb_whitelist(extra: &BTreeMap<String, String>) -> Vec<String> {
let mut v: Vec<String> = builtin_verbs().keys().cloned().collect(); let mut v: Vec<String> = builtin_verbs().keys().cloned().collect();
v.extend(extra.keys().cloned()); v.extend(extra.keys().cloned());
v.sort(); v.sort();
@ -135,7 +135,7 @@ mod tests {
#[test] #[test]
fn parses_the_classics() { fn parses_the_classics() {
let idx = verb_index(&HashMap::new()); let idx = verb_index(&BTreeMap::new());
assert_eq!( assert_eq!(
parse("look at the neon sign", &idx), parse("look at the neon sign", &idx),
Parsed::Verb { verb: "look".into(), noun: "neon sign".into() } Parsed::Verb { verb: "look".into(), noun: "neon sign".into() }

View File

@ -11,7 +11,7 @@ use crate::pic::{self, PicOp};
use crate::screens::{Screens, CTL_BLOCK, PIC_H, PIC_W}; use crate::screens::{Screens, CTL_BLOCK, PIC_H, PIC_W};
use crate::view::{Cel, SpriteSrc}; use crate::view::{Cel, SpriteSrc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf; use std::path::PathBuf;
/// A clickable/steppable region. Painted into the hotspot screen by id, so /// A clickable/steppable region. Painted into the hotspot screen by id, so
@ -28,7 +28,7 @@ pub struct Hotspot {
/// Verb → response text. Keys are canonical verbs ("look", "do", /// Verb → response text. Keys are canonical verbs ("look", "do",
/// "talk", ...) plus "use:<item>" for inventory-on-hotspot. /// "talk", ...) plus "use:<item>" for inventory-on-hotspot.
#[serde(default)] #[serde(default)]
pub msgs: HashMap<String, String>, pub msgs: BTreeMap<String, String>,
/// Walking into this region changes rooms (doors!). The ego arrives at /// Walking into this region changes rooms (doors!). The ego arrives at
/// `arrive` in the target room (or that room's spawn). /// `arrive` in the target room (or that room's spawn).
#[serde(default)] #[serde(default)]
@ -60,7 +60,7 @@ pub struct PropDef {
pub y: i32, pub y: i32,
/// Verb → response text (same keys as hotspots). "look" is the classic. /// Verb → response text (same keys as hotspots). "look" is the classic.
#[serde(default)] #[serde(default)]
pub msgs: HashMap<String, String>, pub msgs: BTreeMap<String, String>,
#[serde(default)] #[serde(default)]
pub takeable: bool, pub takeable: bool,
#[serde(default)] #[serde(default)]
@ -125,7 +125,7 @@ pub struct NpcDef {
pub x: i32, pub x: i32,
pub y: i32, pub y: i32,
#[serde(default)] #[serde(default)]
pub msgs: HashMap<String, String>, pub msgs: BTreeMap<String, String>,
#[serde(default)] #[serde(default)]
pub dialogue: Vec<DlgNode>, pub dialogue: Vec<DlgNode>,
/// Sprite drawn beside this NPC's dialogue window ("" = none). /// Sprite drawn beside this NPC's dialogue window ("" = none).
@ -190,7 +190,7 @@ pub struct RoomDoc {
pub weather: u8, pub weather: u8,
/// Per-room default verb responses (checked before the global stock lines). /// Per-room default verb responses (checked before the global stock lines).
#[serde(default)] #[serde(default)]
pub defaults: HashMap<String, String>, pub defaults: BTreeMap<String, String>,
/// Room logic: rhai source file under `scripts/` ("" = `room<N>.rhai` /// Room logic: rhai source file under `scripts/` ("" = `room<N>.rhai`
/// if that file exists, else no script). /// if that file exists, else no script).
#[serde(default)] #[serde(default)]
@ -220,7 +220,7 @@ impl Default for RoomDoc {
npcs: Vec::new(), npcs: Vec::new(),
music: 0, music: 0,
weather: 0, weather: 0,
defaults: HashMap::new(), defaults: BTreeMap::new(),
script: String::new(), script: String::new(),
} }
} }
@ -238,10 +238,10 @@ pub struct GameManifest {
pub intro_text: String, pub intro_text: String,
/// Extra parser verb synonyms: canonical → space-separated synonyms. /// Extra parser verb synonyms: canonical → space-separated synonyms.
#[serde(default)] #[serde(default)]
pub verbs: HashMap<String, String>, pub verbs: BTreeMap<String, String>,
/// Initial flag values for a new game. /// Initial flag values for a new game.
#[serde(default)] #[serde(default)]
pub flags: HashMap<String, bool>, pub flags: BTreeMap<String, bool>,
/// Total points on offer; 0 = scoring off. /// Total points on offer; 0 = scoring off.
#[serde(default)] #[serde(default)]
pub max_score: u32, pub max_score: u32,
@ -250,7 +250,7 @@ pub struct GameManifest {
pub ego_sprite: String, pub ego_sprite: String,
/// Global default verb responses (the last resort before stock lines). /// Global default verb responses (the last resort before stock lines).
#[serde(default)] #[serde(default)]
pub defaults: HashMap<String, String>, pub defaults: BTreeMap<String, String>,
} }
fn default_name() -> String { fn default_name() -> String {
@ -266,11 +266,11 @@ impl Default for GameManifest {
name: default_name(), name: default_name(),
start_room: 0, start_room: 0,
intro_text: default_intro(), intro_text: default_intro(),
verbs: HashMap::new(), verbs: BTreeMap::new(),
flags: HashMap::new(), flags: BTreeMap::new(),
max_score: 0, max_score: 0,
ego_sprite: String::new(), ego_sprite: String::new(),
defaults: HashMap::new(), defaults: BTreeMap::new(),
} }
} }
} }
@ -282,15 +282,15 @@ pub struct WorldBundle {
#[serde(default)] #[serde(default)]
pub manifest: GameManifest, pub manifest: GameManifest,
#[serde(default)] #[serde(default)]
pub rooms: HashMap<u32, RoomDoc>, pub rooms: BTreeMap<u32, RoomDoc>,
/// Script sources by file name (bundles travel with their logic). /// Script sources by file name (bundles travel with their logic).
#[serde(default)] #[serde(default)]
pub scripts: HashMap<String, String>, pub scripts: BTreeMap<String, String>,
/// Sprite art by name (raw RGBA; quantized per room at bake time). /// Sprite art by name (raw RGBA; quantized per room at bake time).
/// Optional so old bundles still load — an empty map keeps whatever /// Optional so old bundles still load — an empty map keeps whatever
/// sprites the receiving World already has. /// sprites the receiving World already has.
#[serde(default)] #[serde(default)]
pub sprites: HashMap<String, SpriteSrc>, pub sprites: BTreeMap<String, SpriteSrc>,
} }
/// The loaded game: manifest + sprites + the current room baked into the /// The loaded game: manifest + sprites + the current room baked into the
@ -307,9 +307,9 @@ pub struct World {
pub sprites: Vec<(String, SpriteSrc)>, pub sprites: Vec<(String, SpriteSrc)>,
/// This room's cooked cels, matching `palette`. /// This room's cooked cels, matching `palette`.
pub cels: Vec<(String, Cel)>, pub cels: Vec<(String, Cel)>,
pub overlay: HashMap<u32, RoomDoc>, pub overlay: BTreeMap<u32, RoomDoc>,
/// In-memory scripts (bundles / upserts); disk is the fallback. /// In-memory scripts (bundles / upserts); disk is the fallback.
pub script_overlay: HashMap<String, String>, pub script_overlay: BTreeMap<String, String>,
} }
impl World { impl World {
@ -331,8 +331,8 @@ impl World {
palette: Palette::base(), palette: Palette::base(),
sprites, sprites,
cels: Vec::new(), cels: Vec::new(),
overlay: HashMap::new(), overlay: BTreeMap::new(),
script_overlay: HashMap::new(), script_overlay: BTreeMap::new(),
}; };
let start = w.current; let start = w.current;
w.goto_room(start); w.goto_room(start);
@ -448,6 +448,24 @@ impl World {
self.cels.iter().find(|(n, _)| n == name).map(|(_, c)| c) self.cels.iter().find(|(n, _)| n == name).map(|(_, c)| c)
} }
/// A sprite as an animated actor view. A sprite named `<base>-sheet<C>x<R>`
/// (e.g. `cop-sheet4x3.png`) is sliced into C animation frames × R
/// direction loops; anything else becomes a single static cel. Sheets are
/// for egos and NPCs — props and portraits keep using [`World::cel`].
pub fn view(&self, name: &str) -> Option<crate::view::View> {
let cel = self.cel(name)?;
if let Some((_, dims)) = name.rsplit_once("-sheet") {
if let Some((c, r)) = dims.split_once('x') {
if let (Ok(c), Ok(r)) = (c.parse::<usize>(), r.parse::<usize>()) {
if c >= 1 && r >= 1 && cel.w % c == 0 && cel.h % r == 0 {
return Some(crate::view::slice_sheet(cel, c, r));
}
}
}
}
Some(crate::view::View { loops: vec![crate::view::ViewLoop { cels: vec![cel.clone()] }] })
}
/// Script source by file name: overlay, then `scripts/` on disk. /// Script source by file name: overlay, then `scripts/` on disk.
pub fn script_source(&self, file: &str) -> Option<String> { pub fn script_source(&self, file: &str) -> Option<String> {
if let Some(s) = self.script_overlay.get(file) { if let Some(s) = self.script_overlay.get(file) {
@ -470,7 +488,7 @@ impl World {
/// Snapshot the whole world as one JSON document. /// Snapshot the whole world as one JSON document.
pub fn to_bundle(&self) -> WorldBundle { pub fn to_bundle(&self) -> WorldBundle {
let mut rooms: HashMap<u32, RoomDoc> = HashMap::new(); let mut rooms: BTreeMap<u32, RoomDoc> = BTreeMap::new();
if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) { if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) {
for e in rd.flatten() { for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned(); let name = e.file_name().to_string_lossy().into_owned();
@ -565,8 +583,8 @@ impl World {
palette: Palette::base(), palette: Palette::base(),
sprites: Vec::new(), // skip art: validation is about geometry sprites: Vec::new(), // skip art: validation is about geometry
cels: Vec::new(), cels: Vec::new(),
overlay: HashMap::new(), overlay: BTreeMap::new(),
script_overlay: HashMap::new(), script_overlay: BTreeMap::new(),
}; };
probe.bake(); probe.bake();
let (sx, sy) = find_spawn(&probe.screens, doc.spawn); let (sx, sy) = find_spawn(&probe.screens, doc.spawn);
@ -597,6 +615,20 @@ impl World {
} }
} }
/// A representative point for a hotspot — rect center or polygon centroid.
/// This is what "walk to the door" walks toward.
pub fn hotspot_center(h: &Hotspot) -> (i32, i32) {
if let Some([x, y, w, hh]) = h.rect {
return (x + w / 2, y + hh / 2);
}
if !h.poly.is_empty() {
let n = h.poly.len() as i32;
let (sx, sy) = h.poly.iter().fold((0, 0), |a, p| (a.0 + p.0, a.1 + p.1));
return (sx / n, sy / n);
}
(PIC_W as i32 / 2, PIC_H as i32 / 2)
}
/// Find a standable spawn near `prefer`: fully open ground first (no wall, /// Find a standable spawn near `prefer`: fully open ground first (no wall,
/// no water), then anything non-wall, scanning upward from the bottom. /// no water), then anything non-wall, scanning upward from the bottom.
pub fn find_spawn(s: &Screens, prefer: (i32, i32)) -> (i32, i32) { pub fn find_spawn(s: &Screens, prefer: (i32, i32)) -> (i32, i32) {

View File

@ -25,7 +25,7 @@ use crate::render::{self, DrawObj};
use crate::room::{find_spawn, prop_visible, RoomDoc, World}; use crate::room::{find_spawn, prop_visible, RoomDoc, World};
use crate::screens::{PIC_H, PIC_W}; use crate::screens::{PIC_H, PIC_W};
use crate::script::{Fx, ScriptHost}; use crate::script::{Fx, ScriptHost};
use crate::view::{default_robot_view, View, ViewLoop}; use crate::view::{default_robot_view, View};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
@ -241,7 +241,7 @@ pub struct GameState {
impl GameState { impl GameState {
pub fn new(world: World) -> Self { pub fn new(world: World) -> Self {
let verb_idx = parser::verb_index(&world.manifest.verbs); let verb_idx = parser::verb_index(&world.manifest.verbs);
let flags = world.manifest.flags.clone(); let flags = world.manifest.flags.iter().map(|(k, v)| (k.clone(), *v)).collect();
let mut gs = GameState { let mut gs = GameState {
ego: Actor::new("ego", default_robot_view(), 160, 170), ego: Actor::new("ego", default_robot_view(), 160, 170),
world, world,
@ -291,12 +291,14 @@ impl GameState {
} }
} }
Command::WalkTo { x, y } => { Command::WalkTo { x, y } => {
self.pending_verb = None; if self.dlg.is_none() && !self.ego.frozen {
self.ego.walk_to(&self.world.screens, &self.world.doc.scale, x, y); self.pending_verb = None;
for _ in 0..2000 { self.ego.walk_to(&self.world.screens, &self.world.doc.scale, x, y);
ev.extend(self.step_cycle()); for _ in 0..2000 {
if !self.ego.walking() { ev.extend(self.step_cycle());
break; if !self.ego.walking() {
break;
}
} }
} }
ev.push(Event::EgoMoved { x: self.ego.x, y: self.ego.y, arrived: !self.ego.walking() }); ev.push(Event::EgoMoved { x: self.ego.x, y: self.ego.y, arrived: !self.ego.walking() });
@ -476,8 +478,7 @@ impl GameState {
} else { } else {
ps.push(prop); ps.push(prop);
} }
let alive = self.alive_prop_names(); self.rebake(); // moved/removed solid footprints need a clean slate
self.world.stamp_solid_props(&alive);
} else { } else {
let mut doc = self.world.peek_room(n).unwrap_or_default(); let mut doc = self.world.peek_room(n).unwrap_or_default();
let ps = &mut doc.props; let ps = &mut doc.props;
@ -494,8 +495,7 @@ impl GameState {
let n = room.unwrap_or(self.world.current); let n = room.unwrap_or(self.world.current);
if n == self.world.current { if n == self.world.current {
self.world.doc.props.retain(|p| p.name != name); self.world.doc.props.retain(|p| p.name != name);
let alive = self.alive_prop_names(); self.rebake(); // clear any solid footprint the prop left
self.world.stamp_solid_props(&alive);
} else if let Some(mut doc) = self.world.peek_room(n) { } else if let Some(mut doc) = self.world.peek_room(n) {
doc.props.retain(|p| p.name != name); doc.props.retain(|p| p.name != name);
self.world.overlay.insert(n, doc); self.world.overlay.insert(n, doc);
@ -596,7 +596,7 @@ impl GameState {
self.dlg = None; self.dlg = None;
self.pending_ai = None; self.pending_ai = None;
self.transcript.clear(); self.transcript.clear();
self.flags = self.world.manifest.flags.clone(); self.flags = self.world.manifest.flags.iter().map(|(k, v)| (k.clone(), *v)).collect();
self.vars.clear(); self.vars.clear();
self.timers.clear(); self.timers.clear();
self.ticks = 0; self.ticks = 0;
@ -685,8 +685,8 @@ impl GameState {
fn view_for(&self, sprite: &str) -> View { fn view_for(&self, sprite: &str) -> View {
if !sprite.is_empty() { if !sprite.is_empty() {
if let Some(cel) = self.world.cel(sprite) { if let Some(v) = self.world.view(sprite) {
return View { loops: vec![ViewLoop { cels: vec![cel.clone()] }] }; return v;
} }
} }
default_robot_view() default_robot_view()
@ -935,6 +935,8 @@ impl GameState {
/// What's under this pixel? Actors first (front-most priority wins), /// What's under this pixel? Actors first (front-most priority wins),
/// then props, then the hotspot screen, then the room itself. /// then props, then the hotspot screen, then the room itself.
/// Hits are **pixel-accurate** (with ~1px slop): a sprite's transparent
/// corners don't swallow clicks meant for the hotspot behind it.
fn target_at(&self, x: i32, y: i32) -> Target { fn target_at(&self, x: i32, y: i32) -> Target {
let table = &self.world.doc.scale; let table = &self.world.doc.scale;
let mut best: Option<(u8, Target)> = None; let mut best: Option<(u8, Target)> = None;
@ -948,9 +950,7 @@ impl GameState {
continue; continue;
} }
let cel = n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel); let cel = n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel);
let s = n.actor.scale(table); let hit = cel_hit(cel, n.actor.x, n.actor.y, n.actor.scale(table), n.actor.mirrored, x, y);
let (sw, sh) = (((cel.w as f32 * s) as i32).max(1), ((cel.h as f32 * s) as i32).max(1));
let hit = x >= n.actor.x - sw / 2 && x <= n.actor.x + sw / 2 && y >= n.actor.y - sh && y <= n.actor.y;
consider(n.actor.priority(), Target::Npc(i), hit); consider(n.actor.priority(), Target::Npc(i), hit);
} }
let room = self.world.current; let room = self.world.current;
@ -960,8 +960,7 @@ impl GameState {
} }
let Some(cel) = self.world.cel(&p.sprite) else { continue }; let Some(cel) = self.world.cel(&p.sprite) else { continue };
let s = p.fixed_scale.unwrap_or_else(|| table.scale_at(p.y)); let s = p.fixed_scale.unwrap_or_else(|| table.scale_at(p.y));
let (sw, sh) = (((cel.w as f32 * s) as i32).max(1), ((cel.h as f32 * s) as i32).max(1)); let hit = cel_hit(cel, p.x, p.y, s, false, x, y);
let hit = x >= p.x - sw / 2 && x <= p.x + sw / 2 && y >= p.y - sh && y <= p.y;
let pri = p.fixed_priority.unwrap_or_else(|| crate::screens::band(p.y.clamp(0, PIC_H as i32 - 1) as usize)); let pri = p.fixed_priority.unwrap_or_else(|| crate::screens::band(p.y.clamp(0, PIC_H as i32 - 1) as usize));
consider(pri, Target::Prop(i), hit); consider(pri, Target::Prop(i), hit);
} }
@ -989,15 +988,57 @@ impl GameState {
match t { match t {
Target::Prop(i) => self.world.doc.props.get(*i).map(|p| (p.x, p.y)), Target::Prop(i) => self.world.doc.props.get(*i).map(|p| (p.x, p.y)),
Target::Npc(i) => self.npcs.get(*i).map(|n| (n.actor.x, n.actor.y)), Target::Npc(i) => self.npcs.get(*i).map(|n| (n.actor.x, n.actor.y)),
Target::Hotspot(i) => self.world.doc.hotspots.get(*i).map(crate::room::hotspot_center),
_ => None, _ => None,
} }
} }
/// Where a typed command should "click" its target: mid-sprite — a solid
/// pixel on any normal sprite — rather than the feet point, which for an
/// actor lands between the legs now that hits are pixel-accurate.
fn target_click_pos(&self, t: &Target) -> Option<(i32, i32)> {
let table = &self.world.doc.scale;
match t {
Target::Prop(i) => self.world.doc.props.get(*i).map(|p| {
let s = p.fixed_scale.unwrap_or_else(|| table.scale_at(p.y));
let sh = self.world.cel(&p.sprite).map(|c| (c.h as f32 * s) as i32).unwrap_or(0);
(p.x, p.y - sh / 2)
}),
Target::Npc(i) => self.npcs.get(*i).map(|n| {
let cel = n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel);
let sh = (cel.h as f32 * n.actor.scale(table)) as i32;
(n.actor.x, n.actor.y - sh / 2)
}),
_ => self.target_pos(t),
}
}
/// Name of whatever a verb would land on at this pixel ("" = the room).
/// The GUI's hover hot-text; also handy for agent surfaces.
pub fn name_at(&self, x: i32, y: i32) -> String {
self.target_name(&self.target_at(x, y))
}
/// Run cycles until any in-flight walk-then-act resolves (bounded).
/// Agent surfaces call this after a verb so a distant click acts
/// synchronously instead of looking like a dead click.
pub fn settle(&mut self, max: u32) -> Vec<Event> {
let mut ev = Vec::new();
for _ in 0..max {
if self.dlg.is_some() || (!self.ego.walking() && self.pending_verb.is_none()) {
break;
}
ev.extend(self.step_cycle());
}
ev
}
/// Verb entry point with the walk-then-act deferral. `item` is set for /// Verb entry point with the walk-then-act deferral. `item` is set for
/// inventory application ("use"). /// inventory application ("use").
fn dispatch(&mut self, verb: &str, item: &str, target: Target, click: Option<(i32, i32)>) -> Vec<Event> { fn dispatch(&mut self, verb: &str, item: &str, target: Target, click: Option<(i32, i32)>) -> Vec<Event> {
if verb == parser::V_WALK { if verb == parser::V_WALK {
if let Some((x, y)) = click { // A click walks to the pixel; "walk to <thing>" walks to the thing.
if let Some((x, y)) = click.or_else(|| self.target_pos(&target)) {
return self.apply(Command::MoveTo { x, y }); return self.apply(Command::MoveTo { x, y });
} }
} }
@ -1043,6 +1084,15 @@ impl GameState {
if !things.is_empty() { if !things.is_empty() {
ev.push(self.line(&format!("You notice: {}.", things.join(", ")))); ev.push(self.line(&format!("You notice: {}.", things.join(", "))));
} }
let dirs: Vec<&str> = ["north", "east", "south", "west"]
.iter()
.zip(self.world.doc.exits.iter())
.filter(|(_, e)| e.is_some())
.map(|(d, _)| *d)
.collect();
if !dirs.is_empty() {
ev.push(self.line(&format!("Exits: {}.", dirs.join(", "))));
}
} }
(Target::Item(name), _) => { (Target::Item(name), _) => {
let name = name.clone(); let name = name.clone();
@ -1075,7 +1125,7 @@ impl GameState {
/// The message cascade: exact verb → "do" fallback for action verbs → /// The message cascade: exact verb → "do" fallback for action verbs →
/// room defaults → manifest defaults → stock line. /// room defaults → manifest defaults → stock line.
fn cascade(&self, msgs: &HashMap<String, String>, verb: &str) -> Option<String> { fn cascade(&self, msgs: &std::collections::BTreeMap<String, String>, verb: &str) -> Option<String> {
if let Some(m) = msgs.get(verb) { if let Some(m) = msgs.get(verb) {
return Some(m.clone()); return Some(m.clone());
} }
@ -1130,8 +1180,14 @@ impl GameState {
if (verb == "take" || verb == "do") && p.takeable { if (verb == "take" || verb == "do") && p.takeable {
self.inventory.push(p.name.clone()); self.inventory.push(p.name.clone());
self.taken.push((self.world.current, p.name.clone())); self.taken.push((self.world.current, p.name.clone()));
let alive = self.alive_prop_names(); if p.solid {
self.world.stamp_solid_props(&alive); // A stamped footprint can only be cleared by rebaking —
// otherwise the taken crate leaves an invisible wall.
self.rebake();
} else {
let alive = self.alive_prop_names();
self.world.stamp_solid_props(&alive);
}
ev.push(self.line(&format!("You take the {}.", p.name))); ev.push(self.line(&format!("You take the {}.", p.name)));
ev.push(Event::InventoryChanged { items: self.inventory.clone() }); ev.push(Event::InventoryChanged { items: self.inventory.clone() });
ev.push(Event::Audio { cue: AudioCue::Pickup }); ev.push(Event::Audio { cue: AudioCue::Pickup });
@ -1282,7 +1338,10 @@ impl GameState {
if let Ok(n) = line.parse::<usize>() { if let Ok(n) = line.parse::<usize>() {
return self.apply(Command::Choose { n }); return self.apply(Command::Choose { n });
} }
ev.push(self.line("(type a number to answer, or end the conversation.)")); if matches!(line.to_lowercase().as_str(), "bye" | "goodbye" | "leave" | "done" | "end" | "exit" | "q") {
return self.apply(Command::EndDialogue);
}
ev.push(self.line("(type a number to answer, or 'bye' to end the conversation.)"));
return ev; return ev;
} }
let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) }; let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) };
@ -1319,7 +1378,11 @@ impl GameState {
return ev; return ev;
}; };
match self.resolve_noun(&noun) { match self.resolve_noun(&noun) {
Some(t) => ev.extend(self.dispatch("use", &item, t, None)), Some(t) => {
// Typed use walks over exactly like a clicked one.
let click = self.target_click_pos(&t);
ev.extend(self.dispatch("use", &item, t, click));
}
None => { None => {
ev.push(self.line(&format!("You don't see any {} here.", noun))); ev.push(self.line(&format!("You don't see any {} here.", noun)));
ev.push(Event::Audio { cue: AudioCue::Error }); ev.push(Event::Audio { cue: AudioCue::Error });
@ -1327,13 +1390,20 @@ impl GameState {
} }
} }
Parsed::Verb { verb, noun } => { Parsed::Verb { verb, noun } => {
// "go north" / "walk e" — SCI0 compass walking, via the pathfinder.
if verb == parser::V_WALK {
if let Some((x, y)) = compass_target(&noun, self.ego.x, self.ego.y) {
ev.extend(self.apply(Command::MoveTo { x, y }));
return ev;
}
}
if noun.is_empty() { if noun.is_empty() {
ev.extend(self.dispatch(&verb, "", Target::Room, None)); ev.extend(self.dispatch(&verb, "", Target::Room, None));
} else { } else {
match self.resolve_noun(&noun) { match self.resolve_noun(&noun) {
Some(t) => { Some(t) => {
// Typed commands walk over too, using the target's position. // Typed commands walk over too, using the target's position.
let click = self.target_pos(&t); let click = self.target_click_pos(&t);
ev.extend(self.dispatch(&verb, "", t, click)); ev.extend(self.dispatch(&verb, "", t, click));
} }
None => { None => {
@ -1748,6 +1818,42 @@ impl GameState {
} }
} }
/// Does screen pixel (x, y) land on a *solid* pixel of a cel standing at
/// feet point (fx, fy) at `scale`? Checked with 1px of slop so thin sprites
/// stay clickable.
fn cel_hit(cel: &crate::view::Cel, fx: i32, fy: i32, scale: f32, mirrored: bool, x: i32, y: i32) -> bool {
let sw = ((cel.w as f32 * scale) as i32).max(1);
let sh = ((cel.h as f32 * scale) as i32).max(1);
let (ox, oy) = (fx - sw / 2, fy - sh);
for dy in -1..=1i32 {
for dx in -1..=1i32 {
let (px, py) = (x + dx, y + dy);
if px < ox || px >= ox + sw || py < oy || py >= oy + sh {
continue;
}
let cx = ((px - ox) * cel.w as i32 / sw) as usize;
let cy = ((py - oy) * cel.h as i32 / sh) as usize;
let solid = if mirrored { cel.at_mirrored(cx, cy) } else { cel.at(cx, cy) };
if solid.is_some() {
return true;
}
}
}
false
}
/// "north"/"n"/… → a walk target at that edge of the play area.
fn compass_target(noun: &str, ex: i32, ey: i32) -> Option<(i32, i32)> {
let (w, h) = (PIC_W as i32, PIC_H as i32);
match noun {
"north" | "n" | "up" => Some((ex, 10)),
"east" | "e" | "right" => Some((w - 6, ey)),
"south" | "s" | "down" => Some((ex, h - 4)),
"west" | "w" | "left" => Some((6, ey)),
_ => None,
}
}
fn safe_slot(slot: &str) -> String { fn safe_slot(slot: &str) -> String {
let s: String = slot.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect(); let s: String = slot.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect();
if s.is_empty() { if s.is_empty() {

View File

@ -114,6 +114,32 @@ pub fn cel_from_ascii(rows: &[&str]) -> Cel {
Cel { w, h, pix, solid } Cel { w, h, pix, solid }
} }
/// Slice a sheet cel into an animated View: `cols` animation frames across ×
/// `rows` direction loops down (row order: front/S, back/N, left/W, right/E —
/// trailing rows optional, missing directions fall back per the table above).
/// This is how one PNG becomes a walking, facing actor.
pub fn slice_sheet(sheet: &Cel, cols: usize, rows: usize) -> View {
let (cw, ch) = (sheet.w / cols, sheet.h / rows);
let mut loops = Vec::with_capacity(rows);
for r in 0..rows {
let mut cels = Vec::with_capacity(cols);
for c in 0..cols {
let mut pix = vec![0u8; cw * ch];
let mut solid = vec![false; cw * ch];
for y in 0..ch {
for x in 0..cw {
let src = (r * ch + y) * sheet.w + (c * cw + x);
pix[y * cw + x] = sheet.pix[src];
solid[y * cw + x] = sheet.solid[src];
}
}
cels.push(Cel { w: cw, h: ch, pix, solid });
}
loops.push(ViewLoop { cels });
}
View { loops }
}
/// Integer-upscale a cel (nearest neighbor) — how the ASCII robot gets to a /// Integer-upscale a cel (nearest neighbor) — how the ASCII robot gets to a
/// respectable 320-wide-room stature without redrawing it pixel by pixel. /// respectable 320-wide-room stature without redrawing it pixel by pixel.
pub fn upscale(cel: &Cel, f: usize) -> Cel { pub fn upscale(cel: &Cel, f: usize) -> Cel {
@ -130,6 +156,27 @@ pub fn upscale(cel: &Cel, f: usize) -> Cel {
Cel { w, h, pix, solid } Cel { w, h, pix, solid }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sheet_slices_into_loops_and_frames() {
// 4 cols x 2 rows over an 8x4 image: cel (col, row) is solid iff col even.
let rows: Vec<String> = (0..4).map(|_| "1.2.3.4.".to_string()).collect();
let refs: Vec<&str> = rows.iter().map(|s| s.as_str()).collect();
let sheet = cel_from_ascii(&refs);
let v = slice_sheet(&sheet, 4, 2);
assert_eq!(v.loops.len(), 2);
assert_eq!(v.loops[0].cels.len(), 4);
assert_eq!(v.loops[0].cels[0].w, 2);
assert_eq!(v.loops[0].cels[0].h, 2);
assert!(v.loops[0].cels[0].at(0, 0).is_some());
assert!(v.loops[0].cels[0].at(1, 0).is_none(), "transparent survives slicing");
assert_eq!(v.loops[1].cels[2].at(0, 1), Some(3));
}
}
/// The default ego: the party robot, grown up for 320-wide rooms — front, /// The default ego: the party robot, grown up for 320-wide rooms — front,
/// back and side loops (right is mirrored left), two-cel walk bobs. /// back and side loops (right is mirrored left), two-cel walk bobs.
pub fn default_robot_view() -> View { pub fn default_robot_view() -> View {

View File

@ -0,0 +1,112 @@
//! The whole demo game, played to the win over the bus — the regression net
//! for verbs, walk-then-act, parser targeting, death-rewind and scoring.
//! (Run `mrpci-headless --sample` first; CI does.)
use mrpci_core::{Command, Event, GameState, World};
fn game_dir() -> Option<String> {
for p in ["games/neon-precinct", "../games/neon-precinct"] {
if std::path::Path::new(p).join("game.json").exists() {
return Some(p.to_string());
}
}
None
}
fn boot() -> Option<GameState> {
let dir = game_dir()?;
let mut gs = GameState::new(World::load_game(dir));
gs.apply(Command::NewGame { room: None });
Some(gs)
}
/// Apply a command, then settle any walk-then-act it queued.
fn act(gs: &mut GameState, cmd: Command) -> Vec<Event> {
let mut ev = gs.apply(cmd);
ev.extend(gs.settle(2000));
ev
}
fn say(gs: &mut GameState, text: &str) -> Vec<Event> {
act(gs, Command::Parse { text: text.into() })
}
#[test]
fn full_case_closes_at_25_points() {
let Some(mut gs) = boot() else { return };
// Street → alley → keycard.
say(&mut gs, "go east");
assert_eq!(gs.world.current, 2, "east edge exit reaches Rain Alley");
say(&mut gs, "take keycard");
assert!(gs.inventory.contains(&"keycard".to_string()), "keycard picked up (walk-then-act)");
assert_eq!(gs.score, 5);
// Back to the street; typed hotspot walking must reach the precinct.
say(&mut gs, "go west");
assert_eq!(gs.world.current, 0);
say(&mut gs, "walk to precinct door");
assert_eq!(gs.world.current, 1, "'walk to precinct door' pathfinds onto the door hotspot");
// Locker: keycard opens it, script hands over the evidence.
say(&mut gs, "use keycard on locker");
assert!(gs.flags.get("locker_open").copied().unwrap_or(false));
assert_eq!(gs.score, 15);
say(&mut gs, "look at locker");
assert!(gs.inventory.contains(&"evidence".to_string()), "room script bags the data-slate");
// Terminal closes the case.
say(&mut gs, "use evidence on terminal");
assert!(gs.won, "terminal win fires");
assert_eq!(gs.score, 25, "all points on the table");
assert!(!gs.inventory.contains(&"evidence".to_string()), "terminal consumes the slate");
}
#[test]
fn death_rewinds_to_room_entry_not_the_start() {
let Some(mut gs) = boot() else { return };
act(&mut gs, Command::GotoRoom { n: 2 });
say(&mut gs, "take keycard");
assert_eq!(gs.score, 5);
let ev = say(&mut gs, "touch fusebox");
assert!(ev.iter().any(|e| matches!(e, Event::Died { .. })), "the fusebox kills");
assert_eq!(gs.world.current, 2, "rewind lands at this room's entry, not a restart");
assert_eq!(gs.score, 0, "points rewind with the checkpoint");
assert!(gs.inventory.is_empty(), "so does the pocket");
}
#[test]
fn taking_a_solid_prop_clears_its_footprint() {
let Some(mut gs) = boot() else { return };
let prop = mrpci_core::PropDef {
name: "loot crate".into(),
sprite: "crate".into(),
x: 160,
y: 160,
takeable: true,
solid: true,
..Default::default()
};
act(&mut gs, Command::UpsertProp { room: None, prop });
assert!(!gs.world.screens.walkable(160, 158), "solid prop blocks while present");
say(&mut gs, "take loot crate");
assert!(gs.inventory.contains(&"loot crate".to_string()));
assert!(gs.world.screens.walkable(160, 158), "footprint cleared once taken — no invisible wall");
}
#[test]
fn dialogue_ends_on_bye_and_talk_walks_over() {
let Some(mut gs) = boot() else { return };
say(&mut gs, "walk to precinct door");
assert_eq!(gs.world.current, 1);
let ev = say(&mut gs, "talk to sergeant");
assert!(
ev.iter().any(|e| matches!(e, Event::DialogueOpen { .. })),
"a distant talk walks over and still opens the conversation"
);
let ev = say(&mut gs, "bye");
assert!(ev.iter().any(|e| matches!(e, Event::DialogueClosed)), "'bye' ends it");
assert!(gs.dlg.is_none());
}

View File

@ -39,6 +39,11 @@ struct Ui {
muted: bool, muted: bool,
/// Room-change fade-in, 0 → 1 (a shader uniform, not a draw call). /// Room-change fade-in, 0 → 1 (a shader uniform, not a draw call).
fade: f32, fade: f32,
/// Death message + seconds remaining on its banner.
death: Option<(String, f32)>,
/// "+N" score toast + seconds remaining.
toast: Option<(String, f32)>,
last_score: u32,
} }
/// The palette blit, moved to the GPU (thanks, Lague): the frame crosses to /// The palette blit, moved to the GPU (thanks, Lague): the frame crosses to
@ -128,6 +133,9 @@ async fn amain(game_dir: String) {
scanlines: true, scanlines: true,
muted: false, muted: false,
fade: 0.0, fade: 0.0,
death: None,
toast: None,
last_score: 0,
}; };
// Boot the session properly (intro text, music, checkpoint). // Boot the session properly (intro text, music, checkpoint).
@ -320,6 +328,18 @@ async fn amain(game_dir: String) {
audio.tick(); audio.tick();
ui.flash = (ui.flash - get_frame_time()).max(0.0); ui.flash = (ui.flash - get_frame_time()).max(0.0);
ui.fade = (ui.fade + get_frame_time() * 2.6).min(1.0); ui.fade = (ui.fade + get_frame_time() * 2.6).min(1.0);
if let Some((_, t)) = &mut ui.death {
*t -= get_frame_time();
if *t <= 0.0 {
ui.death = None;
}
}
if let Some((_, t)) = &mut ui.toast {
*t -= get_frame_time();
if *t <= 0.0 {
ui.toast = None;
}
}
// --- draw ------------------------------------------------------------ // --- draw ------------------------------------------------------------
clear_background(Color::from_rgba(12, 12, 16, 255)); clear_background(Color::from_rgba(12, 12, 16, 255));
@ -359,7 +379,7 @@ async fn amain(game_dir: String) {
if ed.active { if ed.active {
ed.draw(&gs); ed.draw(&gs);
draw_cursor(&ui, false); draw_cursor(&ui, false, "");
} else { } else {
draw_bar(&gs, &ui); draw_bar(&gs, &ui);
draw_log(&ui); draw_log(&ui);
@ -372,12 +392,28 @@ async fn amain(game_dir: String) {
} }
if gs.won { if gs.won {
center_banner("CASE CLOSED", "The city sleeps a little safer. F9 starts a new shift."); center_banner("CASE CLOSED", "The city sleeps a little safer. F9 starts a new shift.");
} else if let Some((msg, _)) = ui.death.clone() {
center_banner("YOU HAVE DIED", &format!("{} (time rewinds to the room's door.)", msg));
} }
if ui.typing { if ui.typing {
draw_input(&ui); draw_input(&ui);
} }
draw_cursor(&ui, gs.dlg.is_some()); // Hover hot-text: name whatever the cursor is over, SCI-style
// "look neon sign" — discoverability without pixel hunting.
let hover = {
let (mx, my) = mouse_position();
let px = (mx / SCALE) as i32;
let py = ((my - BAR_H) / SCALE) as i32;
if gs.dlg.is_none() && !ui.inventory_open && !ui.typing
&& px >= 0 && px < PIC_W as i32 && py >= 0 && py < PIC_H as i32
{
gs.name_at(px, py)
} else {
String::new()
}
};
draw_cursor(&ui, gs.dlg.is_some(), &hover);
} }
next_frame().await; next_frame().await;
@ -396,7 +432,16 @@ fn handle_events(evs: &[Event], ui: &mut Ui, audio: &mut sound::LazyAudio) {
Event::Audio { cue } => audio.play(*cue), Event::Audio { cue } => audio.play(*cue),
Event::Music { mood } => audio.set_music(*mood), Event::Music { mood } => audio.set_music(*mood),
Event::RoomChanged { .. } => ui.fade = 0.0, // shader fades the room in Event::RoomChanged { .. } => ui.fade = 0.0, // shader fades the room in
Event::Died { .. } => ui.flash = 1.2, Event::Died { message } => {
ui.flash = 1.2;
ui.death = Some((message.clone(), 4.0));
}
Event::ScoreChanged { score, .. } => {
if *score > ui.last_score {
ui.toast = Some((format!("+{}", score - ui.last_score), 2.0));
}
ui.last_score = *score;
}
Event::Won => audio.play(AudioCue::Win), Event::Won => audio.play(AudioCue::Win),
Event::Error { message } => { Event::Error { message } => {
ui.log.push(format!("[engine] {}", message)); ui.log.push(format!("[engine] {}", message));
@ -455,6 +500,10 @@ fn draw_bar(gs: &GameState, ui: &Ui) {
let s = format!("score {} / {}", gs.score, gs.world.manifest.max_score); let s = format!("score {} / {}", gs.score, gs.world.manifest.max_score);
draw_text(&s, WIN_W as f32 - 150.0, 29.0, 22.0, Color::from_rgba(230, 210, 120, 255)); draw_text(&s, WIN_W as f32 - 150.0, 29.0, 22.0, Color::from_rgba(230, 210, 120, 255));
} }
if let Some((txt, t)) = &ui.toast {
let a = (t / 2.0 * 255.0).clamp(0.0, 255.0) as u8;
draw_text(txt, WIN_W as f32 - 180.0, 29.0, 24.0, Color::from_rgba(120, 240, 140, a));
}
} }
fn draw_log(ui: &Ui) { fn draw_log(ui: &Ui) {
@ -560,14 +609,15 @@ fn center_banner(title: &str, sub: &str) {
let cx = WIN_W as f32 / 2.0; let cx = WIN_W as f32 / 2.0;
let y = BAR_H + 120.0; let y = BAR_H + 120.0;
let tw = measure_text(title, None, 52, 1.0).width; let tw = measure_text(title, None, 52, 1.0).width;
draw_rectangle(cx - tw / 2.0 - 30.0, y - 50.0, tw + 60.0, 100.0, Color::from_rgba(10, 12, 18, 235));
draw_rectangle_lines(cx - tw / 2.0 - 30.0, y - 50.0, tw + 60.0, 100.0, 3.0, Color::from_rgba(230, 210, 120, 255));
draw_text(title, cx - tw / 2.0, y, 52.0, Color::from_rgba(230, 210, 120, 255));
let sw = measure_text(sub, None, 20, 1.0).width; let sw = measure_text(sub, None, 20, 1.0).width;
let bw = tw.max(sw) + 60.0;
draw_rectangle(cx - bw / 2.0, y - 50.0, bw, 100.0, Color::from_rgba(10, 12, 18, 235));
draw_rectangle_lines(cx - bw / 2.0, y - 50.0, bw, 100.0, 3.0, Color::from_rgba(230, 210, 120, 255));
draw_text(title, cx - tw / 2.0, y, 52.0, Color::from_rgba(230, 210, 120, 255));
draw_text(sub, cx - sw / 2.0, y + 32.0, 20.0, WHITE); draw_text(sub, cx - sw / 2.0, y + 32.0, 20.0, WHITE);
} }
fn draw_cursor(ui: &Ui, in_dialogue: bool) { fn draw_cursor(ui: &Ui, in_dialogue: bool, hover: &str) {
let (mx, my) = mouse_position(); let (mx, my) = mouse_position();
let col = match (in_dialogue, ui.selected_item.is_some(), VERBS[ui.verb]) { let col = match (in_dialogue, ui.selected_item.is_some(), VERBS[ui.verb]) {
(true, _, _) => Color::from_rgba(255, 230, 150, 255), (true, _, _) => Color::from_rgba(255, 230, 150, 255),
@ -581,14 +631,20 @@ fn draw_cursor(ui: &Ui, in_dialogue: bool) {
draw_line(mx + 3.0, my, mx + 9.0, my, 2.0, col); draw_line(mx + 3.0, my, mx + 9.0, my, 2.0, col);
draw_line(mx, my - 9.0, mx, my - 3.0, 2.0, col); draw_line(mx, my - 9.0, mx, my - 3.0, 2.0, col);
draw_line(mx, my + 3.0, mx, my + 9.0, 2.0, col); draw_line(mx, my + 3.0, mx, my + 9.0, 2.0, col);
// The tag reads as the sentence the click would say: "look neon sign",
// "keycard → locker".
let tag = if let Some(item) = &ui.selected_item { let tag = if let Some(item) = &ui.selected_item {
item.clone() if hover.is_empty() { item.clone() } else { format!("{} \u{2192} {}", item, hover) }
} else if in_dialogue { } else if in_dialogue {
String::new() String::new()
} else { } else if hover.is_empty() {
VERBS[ui.verb].to_string() VERBS[ui.verb].to_string()
} else {
format!("{} {}", VERBS[ui.verb], hover)
}; };
if !tag.is_empty() { if !tag.is_empty() {
draw_text(&tag, mx + 12.0, my + 14.0, 18.0, col); let w = measure_text(&tag, None, 18, 1.0).width;
draw_rectangle(mx + 10.0, my + 2.0, w + 6.0, 17.0, Color::from_rgba(10, 12, 18, 180));
draw_text(&tag, mx + 13.0, my + 15.0, 18.0, col);
} }
} }