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 0000000..2b8ad26
Binary files /dev/null and b/games/neon-precinct/sprites/vendbot-sheet2x1.png differ
diff --git a/games/neon-precinct/sprites/vendbot.png b/games/neon-precinct/sprites/vendbot.png
deleted file mode 100644
index 88bf77b..0000000
Binary files a/games/neon-precinct/sprites/vendbot.png and /dev/null differ
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);
}
}