From ce42d69a3aa858448b8b59cc2a7d38ea2f4328dd Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 27 Jul 2026 13:38:33 +1000 Subject: [PATCH] =?UTF-8?q?v0.5=20=E2=80=94=20playtest-driven=20fixes=20+?= =?UTF-8?q?=20engine/UX=20upgrades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: -sheetx.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 --- CLAUDE.md | 51 ++++++ README.md | 12 +- docs/CONTROL.md | 8 +- games/neon-precinct/game.json | 2 +- games/neon-precinct/rooms/room0.json | 6 +- games/neon-precinct/rooms/room1.json | 8 +- games/neon-precinct/rooms/room2.json | 4 +- .../sprites/vendbot-sheet2x1.png | Bin 0 -> 508 bytes games/neon-precinct/sprites/vendbot.png | Bin 311 -> 0 bytes mrpci-core/src/bin/mrpci-headless/mcp.rs | 8 +- mrpci-core/src/bin/mrpci-headless/sample.rs | 47 +++-- mrpci-core/src/parser.rs | 12 +- mrpci-core/src/room.rs | 76 ++++++--- mrpci-core/src/state.rs | 160 +++++++++++++++--- mrpci-core/src/view.rs | 47 +++++ mrpci-core/tests/playthrough.rs | 112 ++++++++++++ mrpci/src/main.rs | 76 +++++++-- 17 files changed, 532 insertions(+), 97 deletions(-) create mode 100644 CLAUDE.md create mode 100644 games/neon-precinct/sprites/vendbot-sheet2x1.png delete mode 100644 games/neon-precinct/sprites/vendbot.png create mode 100644 mrpci-core/tests/playthrough.rs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b80c3dd --- /dev/null +++ b/CLAUDE.md @@ -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 `-sheetx.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). diff --git a/README.md b/README.md index 24e1e8f..c9df538 100644 --- a/README.md +++ b/README.md @@ -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, F1–F4 pick 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) @@ -71,6 +72,9 @@ games/mygame/ # spawn, exits, hotspots, props, npcs, music, script pics/street.png # optional backgrounds (+ street-pri.png / street-ctl.png masks) sprites/*.png # quantized per-room; filename = sprite name + # -sheetx.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 sfx/ music/ # optional audio overrides (else the built-in chip synth plays) 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, overlay views, hotspot/prop placement, `:` commands — all over the 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) - [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core) diff --git a/docs/CONTROL.md b/docs/CONTROL.md index 0574582..84538a4 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -83,10 +83,14 @@ Notes: - The engine only advances when told: `tick` runs fixed 1/30s cycles (movement, timers, triggers, NPC wander). `walk_to` ticks until arrival. - `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`, `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 hotspots must point at rooms that exist) and rejects bad lore with an `error` event. diff --git a/games/neon-precinct/game.json b/games/neon-precinct/game.json index d772049..0ed42ae 100644 --- a/games/neon-precinct/game.json +++ b/games/neon-precinct/game.json @@ -7,9 +7,9 @@ "max_score": 25, "ego_sprite": "", "defaults": { - "look": "Rain-slick chrome and old neon. Nothing more.", "do": "Your servos find no purchase on that.", "listen": "The city hums in B-flat.", + "look": "Rain-slick chrome and old neon. Nothing more.", "smell": "Ozone, rain, and yesterday's synth-noodles." } } \ No newline at end of file diff --git a/games/neon-precinct/rooms/room0.json b/games/neon-precinct/rooms/room0.json index ae42471..e4d7713 100644 --- a/games/neon-precinct/rooms/room0.json +++ b/games/neon-precinct/rooms/room0.json @@ -1105,9 +1105,9 @@ "x": 288, "y": 148, "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.", - "do": "You rummage. Old circuit boards, a single roller skate, regret." + "smell": "Your olfactory sensor files a grievance." }, "takeable": false, "synonyms": "", @@ -1131,7 +1131,7 @@ "npcs": [ { "name": "vend-bot", - "sprite": "vendbot", + "sprite": "vendbot-sheet2x1", "x": 150, "y": 132, "msgs": { diff --git a/games/neon-precinct/rooms/room1.json b/games/neon-precinct/rooms/room1.json index 900ac3e..e1c82c7 100644 --- a/games/neon-precinct/rooms/room1.json +++ b/games/neon-precinct/rooms/room1.json @@ -717,8 +717,8 @@ "x": 286, "y": 138, "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, "synonyms": "", @@ -810,7 +810,7 @@ "choices": [ { "text": "Got it.", - "goto": 0, + "goto": -1, "requires_flag": "", "sets_flag": "", "points": 0, @@ -823,7 +823,7 @@ "choices": [ { "text": "Copy.", - "goto": 0, + "goto": -1, "requires_flag": "", "sets_flag": "", "points": 0, diff --git a/games/neon-precinct/rooms/room2.json b/games/neon-precinct/rooms/room2.json index dd07f76..b478d50 100644 --- a/games/neon-precinct/rooms/room2.json +++ b/games/neon-precinct/rooms/room2.json @@ -570,8 +570,8 @@ ], "poly": [], "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, "arrive": null, diff --git a/games/neon-precinct/sprites/vendbot-sheet2x1.png b/games/neon-precinct/sprites/vendbot-sheet2x1.png new file mode 100644 index 0000000000000000000000000000000000000000..2b8ad26f95a5a2789fd6f06ac1a1ce6331409320 GIT binary patch literal 508 zcmeAS@N?(olHy`uVBq!ia0vp^DnKm9!3HE<9_|TdU|>A#>Eakt!T9z7^8%H~X~#bP zFMrp2;>_|Q@8os4x3@u2X3xB7UhC%>87sZ}S++f^YFWE=_SN;DeC+?fTA%li_uilH z_3<_b&hP)VdUk!Y`TxK7#T}m3i)sDe98js3|Lt!6+3rW;_kJ(0OuYAI_x)MoNA>r7 z_y5cg@-KbiCwuOx|07rYIqwA0aQ-Ps+0XtD-0%Nh{v#dmbAIco|2iS_P9L9d{h%(s zuByA=zWl)7eSf})$=APY_+9(+rEc5=OfkEEcNo9_{&YFoMi^pxQ^m6R^`rd}&k-*p{l^;S)_>`a+wXh|_-xNE`rvB(Z(hb9fA9Yb zWr+K`^}ayE{r_*{S+NU0{aN|-dYv;v-T&?Nq7C8oZ_QaAe6Rl<&RBs}c;%n-O?f+? fE__zc_fOE~iGPJ?bk}EKtTK4I`njxgN@xNA@-r1p literal 0 HcmV?d00001 diff --git a/games/neon-precinct/sprites/vendbot.png b/games/neon-precinct/sprites/vendbot.png deleted file mode 100644 index 88bf77b1569b6b698ca04f9f8eedba4bcaac1ef4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 311 zcmeAS@N?(olHy`uVBq!ia0vp^LO?9X!3HE1dHi&M)IU!b$B+ufw+EOPs6^*=1ucjKhMZm>D|w=?O9dJ+O4y%uK(m?|Nqtcynnp+{(P^G zXPEk5BII9kK&4*(x4ZdgyB~?)`@Ou9LF>Qaia*B}e3EZIRnG(B*i4VFct8Ky{RTL{ z-@g37-+h0+h{@N#YxrIJ^QErczdMZIe}B3hZ6gdZRbk%C=kb59p51?N_WytHxxvO2 z|GaSf-6PkJz1!~`U=H}%@A%Z7ZR+z+AntxfkhXa*yOW#i+2Jl=zFTzK`Rsmqs6&7D cAGBwZyDR;D$A?l+U`Q}{y85}Sb4q9e0E48Sv;Y7A diff --git a/mrpci-core/src/bin/mrpci-headless/mcp.rs b/mrpci-core/src/bin/mrpci-headless/mcp.rs index 0df679a..f615cf1 100644 --- a/mrpci-core/src/bin/mrpci-headless/mcp.rs +++ b/mrpci-core/src/bin/mrpci-headless/mcp.rs @@ -154,7 +154,13 @@ fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result 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 text = serde_json::to_string_pretty(&events).unwrap_or_default(); Ok(json!({ diff --git a/mrpci-core/src/bin/mrpci-headless/sample.rs b/mrpci-core/src/bin/mrpci-headless/sample.rs index e88f44c..ce22dcb 100644 --- a/mrpci-core/src/bin/mrpci-headless/sample.rs +++ b/mrpci-core/src/bin/mrpci-headless/sample.rs @@ -20,7 +20,7 @@ use mrpci_core::palette::PalCycle; use mrpci_core::pic::{CtlInk, Ink, PicOp, PriInk}; use mrpci_core::room::{GameManifest, Hotspot, NpcDef, PropDef, RoomDoc}; use mrpci_core::screens::{band, CTL_BLOCK, CTL_WATER}; -use std::collections::HashMap; +use std::collections::BTreeMap; use std::path::Path; /// 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 } } -fn msgs(pairs: &[(&str, &str)]) -> HashMap { +fn msgs(pairs: &[(&str, &str)]) -> BTreeMap { 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 \ is getting colder. (Right-click cycles verbs. Or just type.)" .into(), - verbs: HashMap::new(), - flags: HashMap::new(), + verbs: BTreeMap::new(), + flags: BTreeMap::new(), max_score: 25, ego_sprite: String::new(), defaults: msgs(&[ @@ -251,7 +251,7 @@ fn room0() -> RoomDoc { }], npcs: vec![NpcDef { name: "vend-bot".into(), - sprite: "vendbot".into(), + sprite: "vendbot-sheet2x1".into(), portrait: "vendbot-face".into(), x: 150, y: 132, @@ -398,11 +398,11 @@ fn room1() -> RoomDoc { }, DlgNode { 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 { 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() @@ -550,7 +550,7 @@ fn default_hotspot() -> Hotspot { name: String::new(), rect: None, poly: Vec::new(), - msgs: HashMap::new(), + msgs: BTreeMap::new(), exit_to: None, arrive: None, 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]); save("sergeant", &sergeant)?; - let mut vendbot = RgbaImage::new(18, 30); - fill(&mut vendbot, 2, 0, 14, 22, [180, 60, 60]); - fill(&mut vendbot, 4, 3, 10, 6, [255, 220, 140]); // menu screen - fill(&mut vendbot, 4, 12, 3, 3, [40, 40, 48]); // buttons - fill(&mut vendbot, 8, 12, 3, 3, [40, 40, 48]); - fill(&mut vendbot, 12, 12, 3, 3, [40, 40, 48]); - fill(&mut vendbot, 4, 17, 10, 3, [30, 30, 34]); // dispense slot - fill(&mut vendbot, 2, 22, 14, 4, [60, 62, 70]); // treads - fill(&mut vendbot, 0, 26, 18, 4, [30, 30, 34]); - save("vendbot", &vendbot)?; + // The vend-bot as a 2-frame sprite sheet (`-sheet2x1`): same body, tread + // lugs alternating — the engine slices it into a rolling walk cycle. + let mut vendbot = RgbaImage::new(36, 30); + for frame in 0..2u32 { + let x0 = frame * 18; + fill(&mut vendbot, x0 + 2, 0, 14, 22, [180, 60, 60]); + fill(&mut vendbot, x0 + 4, 3, 10, 6, [255, 220, 140]); // menu screen + fill(&mut vendbot, x0 + 4, 12, 3, 3, [40, 40, 48]); // buttons + fill(&mut vendbot, x0 + 8, 12, 3, 3, [40, 40, 48]); + 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). let mut serg_face = RgbaImage::new(24, 24); diff --git a/mrpci-core/src/parser.rs b/mrpci-core/src/parser.rs index a19859c..eb77e1c 100644 --- a/mrpci-core/src/parser.rs +++ b/mrpci-core/src/parser.rs @@ -8,7 +8,7 @@ //! verbs. The clever part — resolving nouns against the live room — happens //! 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 /// 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. -pub fn builtin_verbs() -> HashMap { - let mut m = HashMap::new(); +pub fn builtin_verbs() -> BTreeMap { + let mut m = BTreeMap::new(); 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_TAKE.into(), "get grab pick steal".into()); @@ -48,7 +48,7 @@ pub fn builtin_verbs() -> HashMap { } /// Build the reverse map word → canonical verb. -pub fn verb_index(extra: &HashMap) -> HashMap { +pub fn verb_index(extra: &BTreeMap) -> HashMap { let mut idx = HashMap::new(); let mut fold = |canon: &str, syns: &str| { idx.insert(canon.to_string(), canon.to_string()); @@ -121,7 +121,7 @@ pub fn parse(line: &str, verbs: &HashMap) -> Parsed { } /// The verbs an AI lane would be allowed to emit — everything canonical. -pub fn verb_whitelist(extra: &HashMap) -> Vec { +pub fn verb_whitelist(extra: &BTreeMap) -> Vec { let mut v: Vec = builtin_verbs().keys().cloned().collect(); v.extend(extra.keys().cloned()); v.sort(); @@ -135,7 +135,7 @@ mod tests { #[test] fn parses_the_classics() { - let idx = verb_index(&HashMap::new()); + let idx = verb_index(&BTreeMap::new()); assert_eq!( parse("look at the neon sign", &idx), Parsed::Verb { verb: "look".into(), noun: "neon sign".into() } diff --git a/mrpci-core/src/room.rs b/mrpci-core/src/room.rs index c2381aa..a82ad81 100644 --- a/mrpci-core/src/room.rs +++ b/mrpci-core/src/room.rs @@ -11,7 +11,7 @@ use crate::pic::{self, PicOp}; use crate::screens::{Screens, CTL_BLOCK, PIC_H, PIC_W}; use crate::view::{Cel, SpriteSrc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; /// 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", /// "talk", ...) plus "use:" for inventory-on-hotspot. #[serde(default)] - pub msgs: HashMap, + pub msgs: BTreeMap, /// Walking into this region changes rooms (doors!). The ego arrives at /// `arrive` in the target room (or that room's spawn). #[serde(default)] @@ -60,7 +60,7 @@ pub struct PropDef { pub y: i32, /// Verb → response text (same keys as hotspots). "look" is the classic. #[serde(default)] - pub msgs: HashMap, + pub msgs: BTreeMap, #[serde(default)] pub takeable: bool, #[serde(default)] @@ -125,7 +125,7 @@ pub struct NpcDef { pub x: i32, pub y: i32, #[serde(default)] - pub msgs: HashMap, + pub msgs: BTreeMap, #[serde(default)] pub dialogue: Vec, /// Sprite drawn beside this NPC's dialogue window ("" = none). @@ -190,7 +190,7 @@ pub struct RoomDoc { pub weather: u8, /// Per-room default verb responses (checked before the global stock lines). #[serde(default)] - pub defaults: HashMap, + pub defaults: BTreeMap, /// Room logic: rhai source file under `scripts/` ("" = `room.rhai` /// if that file exists, else no script). #[serde(default)] @@ -220,7 +220,7 @@ impl Default for RoomDoc { npcs: Vec::new(), music: 0, weather: 0, - defaults: HashMap::new(), + defaults: BTreeMap::new(), script: String::new(), } } @@ -238,10 +238,10 @@ pub struct GameManifest { pub intro_text: String, /// Extra parser verb synonyms: canonical → space-separated synonyms. #[serde(default)] - pub verbs: HashMap, + pub verbs: BTreeMap, /// Initial flag values for a new game. #[serde(default)] - pub flags: HashMap, + pub flags: BTreeMap, /// Total points on offer; 0 = scoring off. #[serde(default)] pub max_score: u32, @@ -250,7 +250,7 @@ pub struct GameManifest { pub ego_sprite: String, /// Global default verb responses (the last resort before stock lines). #[serde(default)] - pub defaults: HashMap, + pub defaults: BTreeMap, } fn default_name() -> String { @@ -266,11 +266,11 @@ impl Default for GameManifest { name: default_name(), start_room: 0, intro_text: default_intro(), - verbs: HashMap::new(), - flags: HashMap::new(), + verbs: BTreeMap::new(), + flags: BTreeMap::new(), max_score: 0, ego_sprite: String::new(), - defaults: HashMap::new(), + defaults: BTreeMap::new(), } } } @@ -282,15 +282,15 @@ pub struct WorldBundle { #[serde(default)] pub manifest: GameManifest, #[serde(default)] - pub rooms: HashMap, + pub rooms: BTreeMap, /// Script sources by file name (bundles travel with their logic). #[serde(default)] - pub scripts: HashMap, + pub scripts: BTreeMap, /// Sprite art by name (raw RGBA; quantized per room at bake time). /// Optional so old bundles still load — an empty map keeps whatever /// sprites the receiving World already has. #[serde(default)] - pub sprites: HashMap, + pub sprites: BTreeMap, } /// The loaded game: manifest + sprites + the current room baked into the @@ -307,9 +307,9 @@ pub struct World { pub sprites: Vec<(String, SpriteSrc)>, /// This room's cooked cels, matching `palette`. pub cels: Vec<(String, Cel)>, - pub overlay: HashMap, + pub overlay: BTreeMap, /// In-memory scripts (bundles / upserts); disk is the fallback. - pub script_overlay: HashMap, + pub script_overlay: BTreeMap, } impl World { @@ -331,8 +331,8 @@ impl World { palette: Palette::base(), sprites, cels: Vec::new(), - overlay: HashMap::new(), - script_overlay: HashMap::new(), + overlay: BTreeMap::new(), + script_overlay: BTreeMap::new(), }; let start = w.current; w.goto_room(start); @@ -448,6 +448,24 @@ impl World { self.cels.iter().find(|(n, _)| n == name).map(|(_, c)| c) } + /// A sprite as an animated actor view. A sprite named `-sheetx` + /// (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 { + 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::(), r.parse::()) { + 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. pub fn script_source(&self, file: &str) -> Option { if let Some(s) = self.script_overlay.get(file) { @@ -470,7 +488,7 @@ impl World { /// Snapshot the whole world as one JSON document. pub fn to_bundle(&self) -> WorldBundle { - let mut rooms: HashMap = HashMap::new(); + let mut rooms: BTreeMap = BTreeMap::new(); if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) { for e in rd.flatten() { let name = e.file_name().to_string_lossy().into_owned(); @@ -565,8 +583,8 @@ impl World { palette: Palette::base(), sprites: Vec::new(), // skip art: validation is about geometry cels: Vec::new(), - overlay: HashMap::new(), - script_overlay: HashMap::new(), + overlay: BTreeMap::new(), + script_overlay: BTreeMap::new(), }; probe.bake(); 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, /// no water), then anything non-wall, scanning upward from the bottom. pub fn find_spawn(s: &Screens, prefer: (i32, i32)) -> (i32, i32) { diff --git a/mrpci-core/src/state.rs b/mrpci-core/src/state.rs index d34993c..1ff5612 100644 --- a/mrpci-core/src/state.rs +++ b/mrpci-core/src/state.rs @@ -25,7 +25,7 @@ use crate::render::{self, DrawObj}; use crate::room::{find_spawn, prop_visible, RoomDoc, World}; use crate::screens::{PIC_H, PIC_W}; 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 std::collections::{BTreeMap, HashMap}; @@ -241,7 +241,7 @@ pub struct GameState { impl GameState { pub fn new(world: World) -> Self { 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 { ego: Actor::new("ego", default_robot_view(), 160, 170), world, @@ -291,12 +291,14 @@ impl GameState { } } Command::WalkTo { x, y } => { - self.pending_verb = None; - self.ego.walk_to(&self.world.screens, &self.world.doc.scale, x, y); - for _ in 0..2000 { - ev.extend(self.step_cycle()); - if !self.ego.walking() { - break; + if self.dlg.is_none() && !self.ego.frozen { + self.pending_verb = None; + self.ego.walk_to(&self.world.screens, &self.world.doc.scale, x, y); + for _ in 0..2000 { + ev.extend(self.step_cycle()); + if !self.ego.walking() { + break; + } } } ev.push(Event::EgoMoved { x: self.ego.x, y: self.ego.y, arrived: !self.ego.walking() }); @@ -476,8 +478,7 @@ impl GameState { } else { ps.push(prop); } - let alive = self.alive_prop_names(); - self.world.stamp_solid_props(&alive); + self.rebake(); // moved/removed solid footprints need a clean slate } else { let mut doc = self.world.peek_room(n).unwrap_or_default(); let ps = &mut doc.props; @@ -494,8 +495,7 @@ impl GameState { let n = room.unwrap_or(self.world.current); if n == self.world.current { self.world.doc.props.retain(|p| p.name != name); - let alive = self.alive_prop_names(); - self.world.stamp_solid_props(&alive); + self.rebake(); // clear any solid footprint the prop left } else if let Some(mut doc) = self.world.peek_room(n) { doc.props.retain(|p| p.name != name); self.world.overlay.insert(n, doc); @@ -596,7 +596,7 @@ impl GameState { self.dlg = None; self.pending_ai = None; 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.timers.clear(); self.ticks = 0; @@ -685,8 +685,8 @@ impl GameState { fn view_for(&self, sprite: &str) -> View { if !sprite.is_empty() { - if let Some(cel) = self.world.cel(sprite) { - return View { loops: vec![ViewLoop { cels: vec![cel.clone()] }] }; + if let Some(v) = self.world.view(sprite) { + return v; } } default_robot_view() @@ -935,6 +935,8 @@ impl GameState { /// What's under this pixel? Actors first (front-most priority wins), /// 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 { let table = &self.world.doc.scale; let mut best: Option<(u8, Target)> = None; @@ -948,9 +950,7 @@ impl GameState { continue; } let cel = n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel); - let s = n.actor.scale(table); - 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; + let hit = cel_hit(cel, n.actor.x, n.actor.y, n.actor.scale(table), n.actor.mirrored, x, y); consider(n.actor.priority(), Target::Npc(i), hit); } let room = self.world.current; @@ -960,8 +960,7 @@ impl GameState { } let Some(cel) = self.world.cel(&p.sprite) else { continue }; 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 = x >= p.x - sw / 2 && x <= p.x + sw / 2 && y >= p.y - sh && y <= p.y; + let hit = cel_hit(cel, p.x, p.y, s, false, x, y); 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); } @@ -989,15 +988,57 @@ impl GameState { match t { 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::Hotspot(i) => self.world.doc.hotspots.get(*i).map(crate::room::hotspot_center), _ => 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 { + 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 /// inventory application ("use"). fn dispatch(&mut self, verb: &str, item: &str, target: Target, click: Option<(i32, i32)>) -> Vec { if verb == parser::V_WALK { - if let Some((x, y)) = click { + // A click walks to the pixel; "walk to " walks to the thing. + if let Some((x, y)) = click.or_else(|| self.target_pos(&target)) { return self.apply(Command::MoveTo { x, y }); } } @@ -1043,6 +1084,15 @@ impl GameState { if !things.is_empty() { 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), _) => { let name = name.clone(); @@ -1075,7 +1125,7 @@ impl GameState { /// The message cascade: exact verb → "do" fallback for action verbs → /// room defaults → manifest defaults → stock line. - fn cascade(&self, msgs: &HashMap, verb: &str) -> Option { + fn cascade(&self, msgs: &std::collections::BTreeMap, verb: &str) -> Option { if let Some(m) = msgs.get(verb) { return Some(m.clone()); } @@ -1130,8 +1180,14 @@ impl GameState { if (verb == "take" || verb == "do") && p.takeable { self.inventory.push(p.name.clone()); self.taken.push((self.world.current, p.name.clone())); - let alive = self.alive_prop_names(); - self.world.stamp_solid_props(&alive); + if p.solid { + // 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(Event::InventoryChanged { items: self.inventory.clone() }); ev.push(Event::Audio { cue: AudioCue::Pickup }); @@ -1282,7 +1338,10 @@ impl GameState { if let Ok(n) = line.parse::() { return self.apply(Command::Choose { n }); } - ev.push(self.line("(type a number to answer, or end the conversation.)")); + 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; } let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) }; @@ -1319,7 +1378,11 @@ impl GameState { return ev; }; 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 => { ev.push(self.line(&format!("You don't see any {} here.", noun))); ev.push(Event::Audio { cue: AudioCue::Error }); @@ -1327,13 +1390,20 @@ impl GameState { } } 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() { ev.extend(self.dispatch(&verb, "", Target::Room, None)); } else { match self.resolve_noun(&noun) { Some(t) => { // 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)); } 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 { let s: String = slot.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect(); if s.is_empty() { diff --git a/mrpci-core/src/view.rs b/mrpci-core/src/view.rs index 3e4cb25..0725a81 100644 --- a/mrpci-core/src/view.rs +++ b/mrpci-core/src/view.rs @@ -114,6 +114,32 @@ pub fn cel_from_ascii(rows: &[&str]) -> Cel { 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 /// respectable 320-wide-room stature without redrawing it pixel by pixel. 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 } } +#[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 = (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, /// back and side loops (right is mirrored left), two-cel walk bobs. pub fn default_robot_view() -> View { diff --git a/mrpci-core/tests/playthrough.rs b/mrpci-core/tests/playthrough.rs new file mode 100644 index 0000000..884dc6c --- /dev/null +++ b/mrpci-core/tests/playthrough.rs @@ -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 { + 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 { + 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 { + let mut ev = gs.apply(cmd); + ev.extend(gs.settle(2000)); + ev +} + +fn say(gs: &mut GameState, text: &str) -> Vec { + 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()); +} diff --git a/mrpci/src/main.rs b/mrpci/src/main.rs index 83acf2e..db05a8a 100644 --- a/mrpci/src/main.rs +++ b/mrpci/src/main.rs @@ -39,6 +39,11 @@ struct Ui { muted: bool, /// Room-change fade-in, 0 → 1 (a shader uniform, not a draw call). 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 @@ -128,6 +133,9 @@ async fn amain(game_dir: String) { scanlines: true, muted: false, fade: 0.0, + death: None, + toast: None, + last_score: 0, }; // Boot the session properly (intro text, music, checkpoint). @@ -320,6 +328,18 @@ async fn amain(game_dir: String) { audio.tick(); ui.flash = (ui.flash - get_frame_time()).max(0.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 ------------------------------------------------------------ clear_background(Color::from_rgba(12, 12, 16, 255)); @@ -359,7 +379,7 @@ async fn amain(game_dir: String) { if ed.active { ed.draw(&gs); - draw_cursor(&ui, false); + draw_cursor(&ui, false, ""); } else { draw_bar(&gs, &ui); draw_log(&ui); @@ -372,12 +392,28 @@ async fn amain(game_dir: String) { } if gs.won { 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 { 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; @@ -396,7 +432,16 @@ fn handle_events(evs: &[Event], ui: &mut Ui, audio: &mut sound::LazyAudio) { Event::Audio { cue } => audio.play(*cue), Event::Music { mood } => audio.set_music(*mood), 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::Error { 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); 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) { @@ -560,14 +609,15 @@ fn center_banner(title: &str, sub: &str) { let cx = WIN_W as f32 / 2.0; let y = BAR_H + 120.0; 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 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); } -fn draw_cursor(ui: &Ui, in_dialogue: bool) { +fn draw_cursor(ui: &Ui, in_dialogue: bool, hover: &str) { let (mx, my) = mouse_position(); let col = match (in_dialogue, ui.selected_item.is_some(), VERBS[ui.verb]) { (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, my - 9.0, mx, my - 3.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 { - item.clone() + if hover.is_empty() { item.clone() } else { format!("{} \u{2192} {}", item, hover) } } else if in_dialogue { String::new() - } else { + } else if hover.is_empty() { VERBS[ui.verb].to_string() + } else { + format!("{} {}", VERBS[ui.verb], hover) }; 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); } }