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

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

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

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

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

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

51
CLAUDE.md Normal file
View File

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

View File

@ -37,7 +37,8 @@ a lethal fusebox (watch death *not* ruin your evening), and a case to close.
Controls: left-click applies the verb, right-click cycles verbs, F1F4 pick
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
# <name>-sheet<C>x<R>.png = animated actor view:
# C frames across x R direction rows (front/back/left/right,
# trailing rows optional — right mirrors left)
scripts/*.rhai # main.rhai + room scripts
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)

View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

View File

@ -154,7 +154,13 @@ fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result<Value, (i64,
_ => return Err((-32602, format!("unknown tool: {}", name))),
};
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!({

View File

@ -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<String, String> {
fn msgs(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
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);

View File

@ -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<String, String> {
let mut m = HashMap::new();
pub fn builtin_verbs() -> BTreeMap<String, String> {
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<String, String> {
}
/// Build the reverse map word → canonical verb.
pub fn verb_index(extra: &HashMap<String, String>) -> HashMap<String, String> {
pub fn verb_index(extra: &BTreeMap<String, String>) -> HashMap<String, String> {
let mut idx = HashMap::new();
let mut fold = |canon: &str, syns: &str| {
idx.insert(canon.to_string(), canon.to_string());
@ -121,7 +121,7 @@ pub fn parse(line: &str, verbs: &HashMap<String, String>) -> Parsed {
}
/// The verbs an AI lane would be allowed to emit — everything canonical.
pub fn verb_whitelist(extra: &HashMap<String, String>) -> Vec<String> {
pub fn verb_whitelist(extra: &BTreeMap<String, String>) -> Vec<String> {
let mut v: Vec<String> = builtin_verbs().keys().cloned().collect();
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() }

View File

@ -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:<item>" for inventory-on-hotspot.
#[serde(default)]
pub msgs: HashMap<String, String>,
pub msgs: BTreeMap<String, String>,
/// 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<String, String>,
pub msgs: BTreeMap<String, String>,
#[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<String, String>,
pub msgs: BTreeMap<String, String>,
#[serde(default)]
pub dialogue: Vec<DlgNode>,
/// 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<String, String>,
pub defaults: BTreeMap<String, String>,
/// Room logic: rhai source file under `scripts/` ("" = `room<N>.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<String, String>,
pub verbs: BTreeMap<String, String>,
/// Initial flag values for a new game.
#[serde(default)]
pub flags: HashMap<String, bool>,
pub flags: BTreeMap<String, bool>,
/// 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<String, String>,
pub defaults: BTreeMap<String, String>,
}
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<u32, RoomDoc>,
pub rooms: BTreeMap<u32, RoomDoc>,
/// Script sources by file name (bundles travel with their logic).
#[serde(default)]
pub scripts: HashMap<String, String>,
pub scripts: BTreeMap<String, String>,
/// 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<String, SpriteSrc>,
pub sprites: BTreeMap<String, SpriteSrc>,
}
/// 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<u32, RoomDoc>,
pub overlay: BTreeMap<u32, RoomDoc>,
/// In-memory scripts (bundles / upserts); disk is the fallback.
pub script_overlay: HashMap<String, String>,
pub script_overlay: BTreeMap<String, String>,
}
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 `<base>-sheet<C>x<R>`
/// (e.g. `cop-sheet4x3.png`) is sliced into C animation frames × R
/// direction loops; anything else becomes a single static cel. Sheets are
/// for egos and NPCs — props and portraits keep using [`World::cel`].
pub fn view(&self, name: &str) -> Option<crate::view::View> {
let cel = self.cel(name)?;
if let Some((_, dims)) = name.rsplit_once("-sheet") {
if let Some((c, r)) = dims.split_once('x') {
if let (Ok(c), Ok(r)) = (c.parse::<usize>(), r.parse::<usize>()) {
if c >= 1 && r >= 1 && cel.w % c == 0 && cel.h % r == 0 {
return Some(crate::view::slice_sheet(cel, c, r));
}
}
}
}
Some(crate::view::View { loops: vec![crate::view::ViewLoop { cels: vec![cel.clone()] }] })
}
/// Script source by file name: overlay, then `scripts/` on disk.
pub fn script_source(&self, file: &str) -> Option<String> {
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<u32, RoomDoc> = HashMap::new();
let mut rooms: BTreeMap<u32, RoomDoc> = 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) {

View File

@ -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<Event> {
let mut ev = Vec::new();
for _ in 0..max {
if self.dlg.is_some() || (!self.ego.walking() && self.pending_verb.is_none()) {
break;
}
ev.extend(self.step_cycle());
}
ev
}
/// Verb entry point with the walk-then-act deferral. `item` is set for
/// inventory application ("use").
fn dispatch(&mut self, verb: &str, item: &str, target: Target, click: Option<(i32, i32)>) -> Vec<Event> {
if verb == parser::V_WALK {
if let Some((x, y)) = click {
// A click walks to the pixel; "walk to <thing>" walks to the thing.
if let Some((x, y)) = click.or_else(|| self.target_pos(&target)) {
return self.apply(Command::MoveTo { x, y });
}
}
@ -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<String, String>, verb: &str) -> Option<String> {
fn cascade(&self, msgs: &std::collections::BTreeMap<String, String>, verb: &str) -> Option<String> {
if let Some(m) = msgs.get(verb) {
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::<usize>() {
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() {

View File

@ -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<String> = (0..4).map(|_| "1.2.3.4.".to_string()).collect();
let refs: Vec<&str> = rows.iter().map(|s| s.as_str()).collect();
let sheet = cel_from_ascii(&refs);
let v = slice_sheet(&sheet, 4, 2);
assert_eq!(v.loops.len(), 2);
assert_eq!(v.loops[0].cels.len(), 4);
assert_eq!(v.loops[0].cels[0].w, 2);
assert_eq!(v.loops[0].cels[0].h, 2);
assert!(v.loops[0].cels[0].at(0, 0).is_some());
assert!(v.loops[0].cels[0].at(1, 0).is_none(), "transparent survives slicing");
assert_eq!(v.loops[1].cels[2].at(0, 1), Some(3));
}
}
/// The default ego: the party robot, grown up for 320-wide rooms — front,
/// back and side loops (right is mirrored left), two-cel walk bobs.
pub fn default_robot_view() -> View {

View File

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

View File

@ -39,6 +39,11 @@ struct Ui {
muted: bool,
/// 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);
}
}