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:
parent
6ea7aa6dd4
commit
ce42d69a3a
51
CLAUDE.md
Normal file
51
CLAUDE.md
Normal 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).
|
||||||
12
README.md
12
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
|
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),
|
one, `I` inventory, **Enter types a command** (the parser is always alive),
|
||||||
F5/F7 save/restore, F9 new game, M mute, N scanlines.
|
F5/F7 save/restore, F9 new game, M mute, N scanlines. The cursor shows the
|
||||||
|
sentence a click would say — hover anything and it reads `look neon sign`.
|
||||||
|
|
||||||
## Drive it headless (the point)
|
## Drive it headless (the point)
|
||||||
|
|
||||||
@ -71,6 +72,9 @@ games/mygame/
|
|||||||
# spawn, exits, hotspots, props, npcs, music, script
|
# spawn, exits, hotspots, props, npcs, music, script
|
||||||
pics/street.png # optional backgrounds (+ street-pri.png / street-ctl.png masks)
|
pics/street.png # optional backgrounds (+ street-pri.png / street-ctl.png masks)
|
||||||
sprites/*.png # quantized per-room; filename = sprite name
|
sprites/*.png # quantized per-room; filename = sprite name
|
||||||
|
# <name>-sheet<C>x<R>.png = animated actor view:
|
||||||
|
# C frames across x R direction rows (front/back/left/right,
|
||||||
|
# trailing rows optional — right mirrors left)
|
||||||
scripts/*.rhai # main.rhai + room scripts
|
scripts/*.rhai # main.rhai + room scripts
|
||||||
sfx/ music/ # optional audio overrides (else the built-in chip synth plays)
|
sfx/ music/ # optional audio overrides (else the built-in chip synth plays)
|
||||||
saves/ # save-anywhere slots (gitignored)
|
saves/ # save-anywhere slots (gitignored)
|
||||||
@ -130,5 +134,11 @@ real game logic. The AI can suggest; only the sim decides. Configure with
|
|||||||
- [x] v0.4 — in-engine room editor: F8, four-screen paint meanings,
|
- [x] v0.4 — in-engine room editor: F8, four-screen paint meanings,
|
||||||
overlay views, hotspot/prop placement, `:` commands — all over the
|
overlay views, hotspot/prop placement, `:` commands — all over the
|
||||||
bus, so MCP clients wield identical powers ([docs/EDITOR.md](docs/EDITOR.md))
|
bus, so MCP clients wield identical powers ([docs/EDITOR.md](docs/EDITOR.md))
|
||||||
|
- [x] v0.5 — sprite-sheet actor views (`-sheetCxR`), pixel-accurate click
|
||||||
|
targeting, hotspots as walk/touch targets, MCP auto-settle (distant
|
||||||
|
verbs act synchronously), hover hot-text + death banner + score toast
|
||||||
|
in the GUI, compass walking & `bye`, deterministic room JSON
|
||||||
|
(BTreeMap), solid-prop footprints cleared on take, full-playthrough
|
||||||
|
regression tests
|
||||||
- [ ] Priority-mask art workflow polish (paint depth in any image editor)
|
- [ ] Priority-mask art workflow polish (paint depth in any image editor)
|
||||||
- [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core)
|
- [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core)
|
||||||
|
|||||||
@ -83,10 +83,14 @@ Notes:
|
|||||||
- The engine only advances when told: `tick` runs fixed 1/30s cycles
|
- The engine only advances when told: `tick` runs fixed 1/30s cycles
|
||||||
(movement, timers, triggers, NPC wander). `walk_to` ticks until arrival.
|
(movement, timers, triggers, NPC wander). `walk_to` ticks until arrival.
|
||||||
- `verb_at` verbs: `walk`, `look`, `do`, `talk`. Distant do/talk/take
|
- `verb_at` verbs: `walk`, `look`, `do`, `talk`. Distant do/talk/take
|
||||||
targets are walked to first (the pending verb fires on arrival).
|
targets are walked to first (the pending verb fires on arrival). On raw
|
||||||
|
stdio/HTTP that means: send `tick` afterward or nothing visibly happens —
|
||||||
|
the **MCP surface settles this automatically** and returns the whole
|
||||||
|
walk-then-act outcome in one call.
|
||||||
- `parse` accepts the same things a player types: `look at sign`,
|
- `parse` accepts the same things a player types: `look at sign`,
|
||||||
`pick up the keycard`, `use keycard on locker`, `talk to sergeant`,
|
`pick up the keycard`, `use keycard on locker`, `talk to sergeant`,
|
||||||
`inventory`, `save`, `score`.
|
`walk to door`, compass walking (`go east`, `n`), `inventory`, `save`,
|
||||||
|
`score` — and `bye` to leave a conversation.
|
||||||
- `upsert_room` validates before committing (walkable spawn, exits and door
|
- `upsert_room` validates before committing (walkable spawn, exits and door
|
||||||
hotspots must point at rooms that exist) and rejects bad lore with an
|
hotspots must point at rooms that exist) and rejects bad lore with an
|
||||||
`error` event.
|
`error` event.
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
"max_score": 25,
|
"max_score": 25,
|
||||||
"ego_sprite": "",
|
"ego_sprite": "",
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"look": "Rain-slick chrome and old neon. Nothing more.",
|
|
||||||
"do": "Your servos find no purchase on that.",
|
"do": "Your servos find no purchase on that.",
|
||||||
"listen": "The city hums in B-flat.",
|
"listen": "The city hums in B-flat.",
|
||||||
|
"look": "Rain-slick chrome and old neon. Nothing more.",
|
||||||
"smell": "Ozone, rain, and yesterday's synth-noodles."
|
"smell": "Ozone, rain, and yesterday's synth-noodles."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1105,9 +1105,9 @@
|
|||||||
"x": 288,
|
"x": 288,
|
||||||
"y": 148,
|
"y": 148,
|
||||||
"msgs": {
|
"msgs": {
|
||||||
"smell": "Your olfactory sensor files a grievance.",
|
"do": "You rummage. Old circuit boards, a single roller skate, regret.",
|
||||||
"look": "A city dumpster. Something inside is composting on a geological timescale.",
|
"look": "A city dumpster. Something inside is composting on a geological timescale.",
|
||||||
"do": "You rummage. Old circuit boards, a single roller skate, regret."
|
"smell": "Your olfactory sensor files a grievance."
|
||||||
},
|
},
|
||||||
"takeable": false,
|
"takeable": false,
|
||||||
"synonyms": "",
|
"synonyms": "",
|
||||||
@ -1131,7 +1131,7 @@
|
|||||||
"npcs": [
|
"npcs": [
|
||||||
{
|
{
|
||||||
"name": "vend-bot",
|
"name": "vend-bot",
|
||||||
"sprite": "vendbot",
|
"sprite": "vendbot-sheet2x1",
|
||||||
"x": 150,
|
"x": 150,
|
||||||
"y": 132,
|
"y": 132,
|
||||||
"msgs": {
|
"msgs": {
|
||||||
|
|||||||
@ -717,8 +717,8 @@
|
|||||||
"x": 286,
|
"x": 286,
|
||||||
"y": 138,
|
"y": 138,
|
||||||
"msgs": {
|
"msgs": {
|
||||||
"look": "Evidence locker 47. The reader-slot blinks, wanting a keycard.",
|
"do": "Locked tight. The reader-slot blinks at you, unimpressed.",
|
||||||
"do": "Locked tight. The reader-slot blinks at you, unimpressed."
|
"look": "Evidence locker 47. The reader-slot blinks, wanting a keycard."
|
||||||
},
|
},
|
||||||
"takeable": false,
|
"takeable": false,
|
||||||
"synonyms": "",
|
"synonyms": "",
|
||||||
@ -810,7 +810,7 @@
|
|||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"text": "Got it.",
|
"text": "Got it.",
|
||||||
"goto": 0,
|
"goto": -1,
|
||||||
"requires_flag": "",
|
"requires_flag": "",
|
||||||
"sets_flag": "",
|
"sets_flag": "",
|
||||||
"points": 0,
|
"points": 0,
|
||||||
@ -823,7 +823,7 @@
|
|||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"text": "Copy.",
|
"text": "Copy.",
|
||||||
"goto": 0,
|
"goto": -1,
|
||||||
"requires_flag": "",
|
"requires_flag": "",
|
||||||
"sets_flag": "",
|
"sets_flag": "",
|
||||||
"points": 0,
|
"points": 0,
|
||||||
|
|||||||
@ -570,8 +570,8 @@
|
|||||||
],
|
],
|
||||||
"poly": [],
|
"poly": [],
|
||||||
"msgs": {
|
"msgs": {
|
||||||
"look": "A drainage pipe keeping its own beat.",
|
"listen": "Drip. Drip. Drip. It's in 7/8 time, somehow.",
|
||||||
"listen": "Drip. Drip. Drip. It's in 7/8 time, somehow."
|
"look": "A drainage pipe keeping its own beat."
|
||||||
},
|
},
|
||||||
"exit_to": null,
|
"exit_to": null,
|
||||||
"arrive": null,
|
"arrive": null,
|
||||||
|
|||||||
BIN
games/neon-precinct/sprites/vendbot-sheet2x1.png
Normal file
BIN
games/neon-precinct/sprites/vendbot-sheet2x1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 508 B |
Binary file not shown.
|
Before Width: | Height: | Size: 311 B |
@ -154,7 +154,13 @@ fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result<Value, (i64,
|
|||||||
_ => return Err((-32602, format!("unknown tool: {}", name))),
|
_ => return Err((-32602, format!("unknown tool: {}", name))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let events = gs.apply(cmd);
|
let mut events = gs.apply(cmd);
|
||||||
|
// A verb on a distant target queues a walk-then-act. A GUI resolves it on
|
||||||
|
// its next frames; here nothing ticks unless we tick — so settle now,
|
||||||
|
// and a distant click acts synchronously instead of looking dead.
|
||||||
|
if matches!(name, "mrpci_verb" | "mrpci_use_item" | "mrpci_parse") {
|
||||||
|
events.extend(gs.settle(900));
|
||||||
|
}
|
||||||
let is_error = events.iter().any(|e| matches!(e, Event::Error { .. }));
|
let is_error = events.iter().any(|e| matches!(e, Event::Error { .. }));
|
||||||
let text = serde_json::to_string_pretty(&events).unwrap_or_default();
|
let text = serde_json::to_string_pretty(&events).unwrap_or_default();
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
|
|||||||
@ -20,7 +20,7 @@ use mrpci_core::palette::PalCycle;
|
|||||||
use mrpci_core::pic::{CtlInk, Ink, PicOp, PriInk};
|
use mrpci_core::pic::{CtlInk, Ink, PicOp, PriInk};
|
||||||
use mrpci_core::room::{GameManifest, Hotspot, NpcDef, PropDef, RoomDoc};
|
use mrpci_core::room::{GameManifest, Hotspot, NpcDef, PropDef, RoomDoc};
|
||||||
use mrpci_core::screens::{band, CTL_BLOCK, CTL_WATER};
|
use mrpci_core::screens::{band, CTL_BLOCK, CTL_WATER};
|
||||||
use std::collections::HashMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Index into the base palette's 6x6x6 cube (components 0..=5).
|
/// Index into the base palette's 6x6x6 cube (components 0..=5).
|
||||||
@ -42,7 +42,7 @@ fn ink_wall(c: u8) -> Ink {
|
|||||||
Ink { color: Some(c), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_BLOCK), hot: None }
|
Ink { color: Some(c), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_BLOCK), hot: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn msgs(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
fn msgs(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,8 +61,8 @@ pub fn write_sample(dir: &str) -> std::io::Result<()> {
|
|||||||
intro_text: "Rain on chrome. You are Officer Morp-9, and somewhere in this city a case \
|
intro_text: "Rain on chrome. You are Officer Morp-9, and somewhere in this city a case \
|
||||||
is getting colder. (Right-click cycles verbs. Or just type.)"
|
is getting colder. (Right-click cycles verbs. Or just type.)"
|
||||||
.into(),
|
.into(),
|
||||||
verbs: HashMap::new(),
|
verbs: BTreeMap::new(),
|
||||||
flags: HashMap::new(),
|
flags: BTreeMap::new(),
|
||||||
max_score: 25,
|
max_score: 25,
|
||||||
ego_sprite: String::new(),
|
ego_sprite: String::new(),
|
||||||
defaults: msgs(&[
|
defaults: msgs(&[
|
||||||
@ -251,7 +251,7 @@ fn room0() -> RoomDoc {
|
|||||||
}],
|
}],
|
||||||
npcs: vec![NpcDef {
|
npcs: vec![NpcDef {
|
||||||
name: "vend-bot".into(),
|
name: "vend-bot".into(),
|
||||||
sprite: "vendbot".into(),
|
sprite: "vendbot-sheet2x1".into(),
|
||||||
portrait: "vendbot-face".into(),
|
portrait: "vendbot-face".into(),
|
||||||
x: 150,
|
x: 150,
|
||||||
y: 132,
|
y: 132,
|
||||||
@ -398,11 +398,11 @@ fn room1() -> RoomDoc {
|
|||||||
},
|
},
|
||||||
DlgNode {
|
DlgNode {
|
||||||
says: "Perp dumped a keycard in Rain Alley, east off Neon Row. It opens evidence locker 47.".into(),
|
says: "Perp dumped a keycard in Rain Alley, east off Neon Row. It opens evidence locker 47.".into(),
|
||||||
choices: vec![DlgChoice { text: "Got it.".into(), goto: 0, ..Default::default() }],
|
choices: vec![DlgChoice { text: "Got it.".into(), goto: -1, ..Default::default() }],
|
||||||
},
|
},
|
||||||
DlgNode {
|
DlgNode {
|
||||||
says: "Slot the slate into the case terminal, west wall. Then we both go home.".into(),
|
says: "Slot the slate into the case terminal, west wall. Then we both go home.".into(),
|
||||||
choices: vec![DlgChoice { text: "Copy.".into(), goto: 0, ..Default::default() }],
|
choices: vec![DlgChoice { text: "Copy.".into(), goto: -1, ..Default::default() }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@ -550,7 +550,7 @@ fn default_hotspot() -> Hotspot {
|
|||||||
name: String::new(),
|
name: String::new(),
|
||||||
rect: None,
|
rect: None,
|
||||||
poly: Vec::new(),
|
poly: Vec::new(),
|
||||||
msgs: HashMap::new(),
|
msgs: BTreeMap::new(),
|
||||||
exit_to: None,
|
exit_to: None,
|
||||||
arrive: None,
|
arrive: None,
|
||||||
requires_flag: String::new(),
|
requires_flag: String::new(),
|
||||||
@ -688,16 +688,27 @@ fn write_sprites(dir: &Path) -> std::io::Result<()> {
|
|||||||
fill(&mut sergeant, 11, 24, 4, 12, [40, 44, 54]);
|
fill(&mut sergeant, 11, 24, 4, 12, [40, 44, 54]);
|
||||||
save("sergeant", &sergeant)?;
|
save("sergeant", &sergeant)?;
|
||||||
|
|
||||||
let mut vendbot = RgbaImage::new(18, 30);
|
// The vend-bot as a 2-frame sprite sheet (`-sheet2x1`): same body, tread
|
||||||
fill(&mut vendbot, 2, 0, 14, 22, [180, 60, 60]);
|
// lugs alternating — the engine slices it into a rolling walk cycle.
|
||||||
fill(&mut vendbot, 4, 3, 10, 6, [255, 220, 140]); // menu screen
|
let mut vendbot = RgbaImage::new(36, 30);
|
||||||
fill(&mut vendbot, 4, 12, 3, 3, [40, 40, 48]); // buttons
|
for frame in 0..2u32 {
|
||||||
fill(&mut vendbot, 8, 12, 3, 3, [40, 40, 48]);
|
let x0 = frame * 18;
|
||||||
fill(&mut vendbot, 12, 12, 3, 3, [40, 40, 48]);
|
fill(&mut vendbot, x0 + 2, 0, 14, 22, [180, 60, 60]);
|
||||||
fill(&mut vendbot, 4, 17, 10, 3, [30, 30, 34]); // dispense slot
|
fill(&mut vendbot, x0 + 4, 3, 10, 6, [255, 220, 140]); // menu screen
|
||||||
fill(&mut vendbot, 2, 22, 14, 4, [60, 62, 70]); // treads
|
fill(&mut vendbot, x0 + 4, 12, 3, 3, [40, 40, 48]); // buttons
|
||||||
fill(&mut vendbot, 0, 26, 18, 4, [30, 30, 34]);
|
fill(&mut vendbot, x0 + 8, 12, 3, 3, [40, 40, 48]);
|
||||||
save("vendbot", &vendbot)?;
|
fill(&mut vendbot, x0 + 12, 12, 3, 3, [40, 40, 48]);
|
||||||
|
fill(&mut vendbot, x0 + 4, 17, 10, 3, [30, 30, 34]); // dispense slot
|
||||||
|
fill(&mut vendbot, x0 + 2, 22, 14, 4, [60, 62, 70]); // treads
|
||||||
|
fill(&mut vendbot, x0, 26, 18, 4, [30, 30, 34]);
|
||||||
|
// tread lugs, offset per frame so the treads appear to roll
|
||||||
|
let mut lug = if frame == 0 { 1 } else { 3 };
|
||||||
|
while lug < 17 {
|
||||||
|
fill(&mut vendbot, x0 + lug, 27, 2, 2, [90, 94, 104]);
|
||||||
|
lug += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save("vendbot-sheet2x1", &vendbot)?;
|
||||||
|
|
||||||
// Talker portraits (drawn 2x in the core window, 6x in the GUI).
|
// Talker portraits (drawn 2x in the core window, 6x in the GUI).
|
||||||
let mut serg_face = RgbaImage::new(24, 24);
|
let mut serg_face = RgbaImage::new(24, 24);
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
//! verbs. The clever part — resolving nouns against the live room — happens
|
//! verbs. The clever part — resolving nouns against the live room — happens
|
||||||
//! where the live room actually is (state.rs).
|
//! where the live room actually is (state.rs).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
/// Canonical verbs the engine acts on. Authors may key `msgs` with any verb
|
/// Canonical verbs the engine acts on. Authors may key `msgs` with any verb
|
||||||
/// string; these are the ones with built-in behavior.
|
/// string; these are the ones with built-in behavior.
|
||||||
@ -35,8 +35,8 @@ pub enum Parsed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// canonical verb → space-separated synonyms. Game manifests merge over this.
|
/// canonical verb → space-separated synonyms. Game manifests merge over this.
|
||||||
pub fn builtin_verbs() -> HashMap<String, String> {
|
pub fn builtin_verbs() -> BTreeMap<String, String> {
|
||||||
let mut m = HashMap::new();
|
let mut m = BTreeMap::new();
|
||||||
m.insert(V_LOOK.into(), "l x examine inspect read watch".into());
|
m.insert(V_LOOK.into(), "l x examine inspect read watch".into());
|
||||||
m.insert(V_DO.into(), "use touch push pull open close press operate activate turn flip".into());
|
m.insert(V_DO.into(), "use touch push pull open close press operate activate turn flip".into());
|
||||||
m.insert(V_TAKE.into(), "get grab pick steal".into());
|
m.insert(V_TAKE.into(), "get grab pick steal".into());
|
||||||
@ -48,7 +48,7 @@ pub fn builtin_verbs() -> HashMap<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the reverse map word → canonical verb.
|
/// Build the reverse map word → canonical verb.
|
||||||
pub fn verb_index(extra: &HashMap<String, String>) -> HashMap<String, String> {
|
pub fn verb_index(extra: &BTreeMap<String, String>) -> HashMap<String, String> {
|
||||||
let mut idx = HashMap::new();
|
let mut idx = HashMap::new();
|
||||||
let mut fold = |canon: &str, syns: &str| {
|
let mut fold = |canon: &str, syns: &str| {
|
||||||
idx.insert(canon.to_string(), canon.to_string());
|
idx.insert(canon.to_string(), canon.to_string());
|
||||||
@ -121,7 +121,7 @@ pub fn parse(line: &str, verbs: &HashMap<String, String>) -> Parsed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The verbs an AI lane would be allowed to emit — everything canonical.
|
/// The verbs an AI lane would be allowed to emit — everything canonical.
|
||||||
pub fn verb_whitelist(extra: &HashMap<String, String>) -> Vec<String> {
|
pub fn verb_whitelist(extra: &BTreeMap<String, String>) -> Vec<String> {
|
||||||
let mut v: Vec<String> = builtin_verbs().keys().cloned().collect();
|
let mut v: Vec<String> = builtin_verbs().keys().cloned().collect();
|
||||||
v.extend(extra.keys().cloned());
|
v.extend(extra.keys().cloned());
|
||||||
v.sort();
|
v.sort();
|
||||||
@ -135,7 +135,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_the_classics() {
|
fn parses_the_classics() {
|
||||||
let idx = verb_index(&HashMap::new());
|
let idx = verb_index(&BTreeMap::new());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse("look at the neon sign", &idx),
|
parse("look at the neon sign", &idx),
|
||||||
Parsed::Verb { verb: "look".into(), noun: "neon sign".into() }
|
Parsed::Verb { verb: "look".into(), noun: "neon sign".into() }
|
||||||
|
|||||||
@ -11,7 +11,7 @@ use crate::pic::{self, PicOp};
|
|||||||
use crate::screens::{Screens, CTL_BLOCK, PIC_H, PIC_W};
|
use crate::screens::{Screens, CTL_BLOCK, PIC_H, PIC_W};
|
||||||
use crate::view::{Cel, SpriteSrc};
|
use crate::view::{Cel, SpriteSrc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// A clickable/steppable region. Painted into the hotspot screen by id, so
|
/// A clickable/steppable region. Painted into the hotspot screen by id, so
|
||||||
@ -28,7 +28,7 @@ pub struct Hotspot {
|
|||||||
/// Verb → response text. Keys are canonical verbs ("look", "do",
|
/// Verb → response text. Keys are canonical verbs ("look", "do",
|
||||||
/// "talk", ...) plus "use:<item>" for inventory-on-hotspot.
|
/// "talk", ...) plus "use:<item>" for inventory-on-hotspot.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub msgs: HashMap<String, String>,
|
pub msgs: BTreeMap<String, String>,
|
||||||
/// Walking into this region changes rooms (doors!). The ego arrives at
|
/// Walking into this region changes rooms (doors!). The ego arrives at
|
||||||
/// `arrive` in the target room (or that room's spawn).
|
/// `arrive` in the target room (or that room's spawn).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -60,7 +60,7 @@ pub struct PropDef {
|
|||||||
pub y: i32,
|
pub y: i32,
|
||||||
/// Verb → response text (same keys as hotspots). "look" is the classic.
|
/// Verb → response text (same keys as hotspots). "look" is the classic.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub msgs: HashMap<String, String>,
|
pub msgs: BTreeMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub takeable: bool,
|
pub takeable: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -125,7 +125,7 @@ pub struct NpcDef {
|
|||||||
pub x: i32,
|
pub x: i32,
|
||||||
pub y: i32,
|
pub y: i32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub msgs: HashMap<String, String>,
|
pub msgs: BTreeMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dialogue: Vec<DlgNode>,
|
pub dialogue: Vec<DlgNode>,
|
||||||
/// Sprite drawn beside this NPC's dialogue window ("" = none).
|
/// Sprite drawn beside this NPC's dialogue window ("" = none).
|
||||||
@ -190,7 +190,7 @@ pub struct RoomDoc {
|
|||||||
pub weather: u8,
|
pub weather: u8,
|
||||||
/// Per-room default verb responses (checked before the global stock lines).
|
/// Per-room default verb responses (checked before the global stock lines).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub defaults: HashMap<String, String>,
|
pub defaults: BTreeMap<String, String>,
|
||||||
/// Room logic: rhai source file under `scripts/` ("" = `room<N>.rhai`
|
/// Room logic: rhai source file under `scripts/` ("" = `room<N>.rhai`
|
||||||
/// if that file exists, else no script).
|
/// if that file exists, else no script).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -220,7 +220,7 @@ impl Default for RoomDoc {
|
|||||||
npcs: Vec::new(),
|
npcs: Vec::new(),
|
||||||
music: 0,
|
music: 0,
|
||||||
weather: 0,
|
weather: 0,
|
||||||
defaults: HashMap::new(),
|
defaults: BTreeMap::new(),
|
||||||
script: String::new(),
|
script: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -238,10 +238,10 @@ pub struct GameManifest {
|
|||||||
pub intro_text: String,
|
pub intro_text: String,
|
||||||
/// Extra parser verb synonyms: canonical → space-separated synonyms.
|
/// Extra parser verb synonyms: canonical → space-separated synonyms.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub verbs: HashMap<String, String>,
|
pub verbs: BTreeMap<String, String>,
|
||||||
/// Initial flag values for a new game.
|
/// Initial flag values for a new game.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub flags: HashMap<String, bool>,
|
pub flags: BTreeMap<String, bool>,
|
||||||
/// Total points on offer; 0 = scoring off.
|
/// Total points on offer; 0 = scoring off.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_score: u32,
|
pub max_score: u32,
|
||||||
@ -250,7 +250,7 @@ pub struct GameManifest {
|
|||||||
pub ego_sprite: String,
|
pub ego_sprite: String,
|
||||||
/// Global default verb responses (the last resort before stock lines).
|
/// Global default verb responses (the last resort before stock lines).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub defaults: HashMap<String, String>,
|
pub defaults: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_name() -> String {
|
fn default_name() -> String {
|
||||||
@ -266,11 +266,11 @@ impl Default for GameManifest {
|
|||||||
name: default_name(),
|
name: default_name(),
|
||||||
start_room: 0,
|
start_room: 0,
|
||||||
intro_text: default_intro(),
|
intro_text: default_intro(),
|
||||||
verbs: HashMap::new(),
|
verbs: BTreeMap::new(),
|
||||||
flags: HashMap::new(),
|
flags: BTreeMap::new(),
|
||||||
max_score: 0,
|
max_score: 0,
|
||||||
ego_sprite: String::new(),
|
ego_sprite: String::new(),
|
||||||
defaults: HashMap::new(),
|
defaults: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -282,15 +282,15 @@ pub struct WorldBundle {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manifest: GameManifest,
|
pub manifest: GameManifest,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rooms: HashMap<u32, RoomDoc>,
|
pub rooms: BTreeMap<u32, RoomDoc>,
|
||||||
/// Script sources by file name (bundles travel with their logic).
|
/// Script sources by file name (bundles travel with their logic).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scripts: HashMap<String, String>,
|
pub scripts: BTreeMap<String, String>,
|
||||||
/// Sprite art by name (raw RGBA; quantized per room at bake time).
|
/// Sprite art by name (raw RGBA; quantized per room at bake time).
|
||||||
/// Optional so old bundles still load — an empty map keeps whatever
|
/// Optional so old bundles still load — an empty map keeps whatever
|
||||||
/// sprites the receiving World already has.
|
/// sprites the receiving World already has.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sprites: HashMap<String, SpriteSrc>,
|
pub sprites: BTreeMap<String, SpriteSrc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The loaded game: manifest + sprites + the current room baked into the
|
/// The loaded game: manifest + sprites + the current room baked into the
|
||||||
@ -307,9 +307,9 @@ pub struct World {
|
|||||||
pub sprites: Vec<(String, SpriteSrc)>,
|
pub sprites: Vec<(String, SpriteSrc)>,
|
||||||
/// This room's cooked cels, matching `palette`.
|
/// This room's cooked cels, matching `palette`.
|
||||||
pub cels: Vec<(String, Cel)>,
|
pub cels: Vec<(String, Cel)>,
|
||||||
pub overlay: HashMap<u32, RoomDoc>,
|
pub overlay: BTreeMap<u32, RoomDoc>,
|
||||||
/// In-memory scripts (bundles / upserts); disk is the fallback.
|
/// In-memory scripts (bundles / upserts); disk is the fallback.
|
||||||
pub script_overlay: HashMap<String, String>,
|
pub script_overlay: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl World {
|
impl World {
|
||||||
@ -331,8 +331,8 @@ impl World {
|
|||||||
palette: Palette::base(),
|
palette: Palette::base(),
|
||||||
sprites,
|
sprites,
|
||||||
cels: Vec::new(),
|
cels: Vec::new(),
|
||||||
overlay: HashMap::new(),
|
overlay: BTreeMap::new(),
|
||||||
script_overlay: HashMap::new(),
|
script_overlay: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
let start = w.current;
|
let start = w.current;
|
||||||
w.goto_room(start);
|
w.goto_room(start);
|
||||||
@ -448,6 +448,24 @@ impl World {
|
|||||||
self.cels.iter().find(|(n, _)| n == name).map(|(_, c)| c)
|
self.cels.iter().find(|(n, _)| n == name).map(|(_, c)| c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sprite as an animated actor view. A sprite named `<base>-sheet<C>x<R>`
|
||||||
|
/// (e.g. `cop-sheet4x3.png`) is sliced into C animation frames × R
|
||||||
|
/// direction loops; anything else becomes a single static cel. Sheets are
|
||||||
|
/// for egos and NPCs — props and portraits keep using [`World::cel`].
|
||||||
|
pub fn view(&self, name: &str) -> Option<crate::view::View> {
|
||||||
|
let cel = self.cel(name)?;
|
||||||
|
if let Some((_, dims)) = name.rsplit_once("-sheet") {
|
||||||
|
if let Some((c, r)) = dims.split_once('x') {
|
||||||
|
if let (Ok(c), Ok(r)) = (c.parse::<usize>(), r.parse::<usize>()) {
|
||||||
|
if c >= 1 && r >= 1 && cel.w % c == 0 && cel.h % r == 0 {
|
||||||
|
return Some(crate::view::slice_sheet(cel, c, r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::view::View { loops: vec![crate::view::ViewLoop { cels: vec![cel.clone()] }] })
|
||||||
|
}
|
||||||
|
|
||||||
/// Script source by file name: overlay, then `scripts/` on disk.
|
/// Script source by file name: overlay, then `scripts/` on disk.
|
||||||
pub fn script_source(&self, file: &str) -> Option<String> {
|
pub fn script_source(&self, file: &str) -> Option<String> {
|
||||||
if let Some(s) = self.script_overlay.get(file) {
|
if let Some(s) = self.script_overlay.get(file) {
|
||||||
@ -470,7 +488,7 @@ impl World {
|
|||||||
|
|
||||||
/// Snapshot the whole world as one JSON document.
|
/// Snapshot the whole world as one JSON document.
|
||||||
pub fn to_bundle(&self) -> WorldBundle {
|
pub fn to_bundle(&self) -> WorldBundle {
|
||||||
let mut rooms: HashMap<u32, RoomDoc> = HashMap::new();
|
let mut rooms: BTreeMap<u32, RoomDoc> = BTreeMap::new();
|
||||||
if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) {
|
if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) {
|
||||||
for e in rd.flatten() {
|
for e in rd.flatten() {
|
||||||
let name = e.file_name().to_string_lossy().into_owned();
|
let name = e.file_name().to_string_lossy().into_owned();
|
||||||
@ -565,8 +583,8 @@ impl World {
|
|||||||
palette: Palette::base(),
|
palette: Palette::base(),
|
||||||
sprites: Vec::new(), // skip art: validation is about geometry
|
sprites: Vec::new(), // skip art: validation is about geometry
|
||||||
cels: Vec::new(),
|
cels: Vec::new(),
|
||||||
overlay: HashMap::new(),
|
overlay: BTreeMap::new(),
|
||||||
script_overlay: HashMap::new(),
|
script_overlay: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
probe.bake();
|
probe.bake();
|
||||||
let (sx, sy) = find_spawn(&probe.screens, doc.spawn);
|
let (sx, sy) = find_spawn(&probe.screens, doc.spawn);
|
||||||
@ -597,6 +615,20 @@ impl World {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A representative point for a hotspot — rect center or polygon centroid.
|
||||||
|
/// This is what "walk to the door" walks toward.
|
||||||
|
pub fn hotspot_center(h: &Hotspot) -> (i32, i32) {
|
||||||
|
if let Some([x, y, w, hh]) = h.rect {
|
||||||
|
return (x + w / 2, y + hh / 2);
|
||||||
|
}
|
||||||
|
if !h.poly.is_empty() {
|
||||||
|
let n = h.poly.len() as i32;
|
||||||
|
let (sx, sy) = h.poly.iter().fold((0, 0), |a, p| (a.0 + p.0, a.1 + p.1));
|
||||||
|
return (sx / n, sy / n);
|
||||||
|
}
|
||||||
|
(PIC_W as i32 / 2, PIC_H as i32 / 2)
|
||||||
|
}
|
||||||
|
|
||||||
/// Find a standable spawn near `prefer`: fully open ground first (no wall,
|
/// Find a standable spawn near `prefer`: fully open ground first (no wall,
|
||||||
/// no water), then anything non-wall, scanning upward from the bottom.
|
/// no water), then anything non-wall, scanning upward from the bottom.
|
||||||
pub fn find_spawn(s: &Screens, prefer: (i32, i32)) -> (i32, i32) {
|
pub fn find_spawn(s: &Screens, prefer: (i32, i32)) -> (i32, i32) {
|
||||||
|
|||||||
@ -25,7 +25,7 @@ use crate::render::{self, DrawObj};
|
|||||||
use crate::room::{find_spawn, prop_visible, RoomDoc, World};
|
use crate::room::{find_spawn, prop_visible, RoomDoc, World};
|
||||||
use crate::screens::{PIC_H, PIC_W};
|
use crate::screens::{PIC_H, PIC_W};
|
||||||
use crate::script::{Fx, ScriptHost};
|
use crate::script::{Fx, ScriptHost};
|
||||||
use crate::view::{default_robot_view, View, ViewLoop};
|
use crate::view::{default_robot_view, View};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
@ -241,7 +241,7 @@ pub struct GameState {
|
|||||||
impl GameState {
|
impl GameState {
|
||||||
pub fn new(world: World) -> Self {
|
pub fn new(world: World) -> Self {
|
||||||
let verb_idx = parser::verb_index(&world.manifest.verbs);
|
let verb_idx = parser::verb_index(&world.manifest.verbs);
|
||||||
let flags = world.manifest.flags.clone();
|
let flags = world.manifest.flags.iter().map(|(k, v)| (k.clone(), *v)).collect();
|
||||||
let mut gs = GameState {
|
let mut gs = GameState {
|
||||||
ego: Actor::new("ego", default_robot_view(), 160, 170),
|
ego: Actor::new("ego", default_robot_view(), 160, 170),
|
||||||
world,
|
world,
|
||||||
@ -291,12 +291,14 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::WalkTo { x, y } => {
|
Command::WalkTo { x, y } => {
|
||||||
self.pending_verb = None;
|
if self.dlg.is_none() && !self.ego.frozen {
|
||||||
self.ego.walk_to(&self.world.screens, &self.world.doc.scale, x, y);
|
self.pending_verb = None;
|
||||||
for _ in 0..2000 {
|
self.ego.walk_to(&self.world.screens, &self.world.doc.scale, x, y);
|
||||||
ev.extend(self.step_cycle());
|
for _ in 0..2000 {
|
||||||
if !self.ego.walking() {
|
ev.extend(self.step_cycle());
|
||||||
break;
|
if !self.ego.walking() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ev.push(Event::EgoMoved { x: self.ego.x, y: self.ego.y, arrived: !self.ego.walking() });
|
ev.push(Event::EgoMoved { x: self.ego.x, y: self.ego.y, arrived: !self.ego.walking() });
|
||||||
@ -476,8 +478,7 @@ impl GameState {
|
|||||||
} else {
|
} else {
|
||||||
ps.push(prop);
|
ps.push(prop);
|
||||||
}
|
}
|
||||||
let alive = self.alive_prop_names();
|
self.rebake(); // moved/removed solid footprints need a clean slate
|
||||||
self.world.stamp_solid_props(&alive);
|
|
||||||
} else {
|
} else {
|
||||||
let mut doc = self.world.peek_room(n).unwrap_or_default();
|
let mut doc = self.world.peek_room(n).unwrap_or_default();
|
||||||
let ps = &mut doc.props;
|
let ps = &mut doc.props;
|
||||||
@ -494,8 +495,7 @@ impl GameState {
|
|||||||
let n = room.unwrap_or(self.world.current);
|
let n = room.unwrap_or(self.world.current);
|
||||||
if n == self.world.current {
|
if n == self.world.current {
|
||||||
self.world.doc.props.retain(|p| p.name != name);
|
self.world.doc.props.retain(|p| p.name != name);
|
||||||
let alive = self.alive_prop_names();
|
self.rebake(); // clear any solid footprint the prop left
|
||||||
self.world.stamp_solid_props(&alive);
|
|
||||||
} else if let Some(mut doc) = self.world.peek_room(n) {
|
} else if let Some(mut doc) = self.world.peek_room(n) {
|
||||||
doc.props.retain(|p| p.name != name);
|
doc.props.retain(|p| p.name != name);
|
||||||
self.world.overlay.insert(n, doc);
|
self.world.overlay.insert(n, doc);
|
||||||
@ -596,7 +596,7 @@ impl GameState {
|
|||||||
self.dlg = None;
|
self.dlg = None;
|
||||||
self.pending_ai = None;
|
self.pending_ai = None;
|
||||||
self.transcript.clear();
|
self.transcript.clear();
|
||||||
self.flags = self.world.manifest.flags.clone();
|
self.flags = self.world.manifest.flags.iter().map(|(k, v)| (k.clone(), *v)).collect();
|
||||||
self.vars.clear();
|
self.vars.clear();
|
||||||
self.timers.clear();
|
self.timers.clear();
|
||||||
self.ticks = 0;
|
self.ticks = 0;
|
||||||
@ -685,8 +685,8 @@ impl GameState {
|
|||||||
|
|
||||||
fn view_for(&self, sprite: &str) -> View {
|
fn view_for(&self, sprite: &str) -> View {
|
||||||
if !sprite.is_empty() {
|
if !sprite.is_empty() {
|
||||||
if let Some(cel) = self.world.cel(sprite) {
|
if let Some(v) = self.world.view(sprite) {
|
||||||
return View { loops: vec![ViewLoop { cels: vec![cel.clone()] }] };
|
return v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_robot_view()
|
default_robot_view()
|
||||||
@ -935,6 +935,8 @@ impl GameState {
|
|||||||
|
|
||||||
/// What's under this pixel? Actors first (front-most priority wins),
|
/// What's under this pixel? Actors first (front-most priority wins),
|
||||||
/// then props, then the hotspot screen, then the room itself.
|
/// then props, then the hotspot screen, then the room itself.
|
||||||
|
/// Hits are **pixel-accurate** (with ~1px slop): a sprite's transparent
|
||||||
|
/// corners don't swallow clicks meant for the hotspot behind it.
|
||||||
fn target_at(&self, x: i32, y: i32) -> Target {
|
fn target_at(&self, x: i32, y: i32) -> Target {
|
||||||
let table = &self.world.doc.scale;
|
let table = &self.world.doc.scale;
|
||||||
let mut best: Option<(u8, Target)> = None;
|
let mut best: Option<(u8, Target)> = None;
|
||||||
@ -948,9 +950,7 @@ impl GameState {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cel = n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel);
|
let cel = n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel);
|
||||||
let s = n.actor.scale(table);
|
let hit = cel_hit(cel, n.actor.x, n.actor.y, n.actor.scale(table), n.actor.mirrored, x, y);
|
||||||
let (sw, sh) = (((cel.w as f32 * s) as i32).max(1), ((cel.h as f32 * s) as i32).max(1));
|
|
||||||
let hit = x >= n.actor.x - sw / 2 && x <= n.actor.x + sw / 2 && y >= n.actor.y - sh && y <= n.actor.y;
|
|
||||||
consider(n.actor.priority(), Target::Npc(i), hit);
|
consider(n.actor.priority(), Target::Npc(i), hit);
|
||||||
}
|
}
|
||||||
let room = self.world.current;
|
let room = self.world.current;
|
||||||
@ -960,8 +960,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
let Some(cel) = self.world.cel(&p.sprite) else { continue };
|
let Some(cel) = self.world.cel(&p.sprite) else { continue };
|
||||||
let s = p.fixed_scale.unwrap_or_else(|| table.scale_at(p.y));
|
let s = p.fixed_scale.unwrap_or_else(|| table.scale_at(p.y));
|
||||||
let (sw, sh) = (((cel.w as f32 * s) as i32).max(1), ((cel.h as f32 * s) as i32).max(1));
|
let hit = cel_hit(cel, p.x, p.y, s, false, x, y);
|
||||||
let hit = x >= p.x - sw / 2 && x <= p.x + sw / 2 && y >= p.y - sh && y <= p.y;
|
|
||||||
let pri = p.fixed_priority.unwrap_or_else(|| crate::screens::band(p.y.clamp(0, PIC_H as i32 - 1) as usize));
|
let pri = p.fixed_priority.unwrap_or_else(|| crate::screens::band(p.y.clamp(0, PIC_H as i32 - 1) as usize));
|
||||||
consider(pri, Target::Prop(i), hit);
|
consider(pri, Target::Prop(i), hit);
|
||||||
}
|
}
|
||||||
@ -989,15 +988,57 @@ impl GameState {
|
|||||||
match t {
|
match t {
|
||||||
Target::Prop(i) => self.world.doc.props.get(*i).map(|p| (p.x, p.y)),
|
Target::Prop(i) => self.world.doc.props.get(*i).map(|p| (p.x, p.y)),
|
||||||
Target::Npc(i) => self.npcs.get(*i).map(|n| (n.actor.x, n.actor.y)),
|
Target::Npc(i) => self.npcs.get(*i).map(|n| (n.actor.x, n.actor.y)),
|
||||||
|
Target::Hotspot(i) => self.world.doc.hotspots.get(*i).map(crate::room::hotspot_center),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a typed command should "click" its target: mid-sprite — a solid
|
||||||
|
/// pixel on any normal sprite — rather than the feet point, which for an
|
||||||
|
/// actor lands between the legs now that hits are pixel-accurate.
|
||||||
|
fn target_click_pos(&self, t: &Target) -> Option<(i32, i32)> {
|
||||||
|
let table = &self.world.doc.scale;
|
||||||
|
match t {
|
||||||
|
Target::Prop(i) => self.world.doc.props.get(*i).map(|p| {
|
||||||
|
let s = p.fixed_scale.unwrap_or_else(|| table.scale_at(p.y));
|
||||||
|
let sh = self.world.cel(&p.sprite).map(|c| (c.h as f32 * s) as i32).unwrap_or(0);
|
||||||
|
(p.x, p.y - sh / 2)
|
||||||
|
}),
|
||||||
|
Target::Npc(i) => self.npcs.get(*i).map(|n| {
|
||||||
|
let cel = n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel);
|
||||||
|
let sh = (cel.h as f32 * n.actor.scale(table)) as i32;
|
||||||
|
(n.actor.x, n.actor.y - sh / 2)
|
||||||
|
}),
|
||||||
|
_ => self.target_pos(t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of whatever a verb would land on at this pixel ("" = the room).
|
||||||
|
/// The GUI's hover hot-text; also handy for agent surfaces.
|
||||||
|
pub fn name_at(&self, x: i32, y: i32) -> String {
|
||||||
|
self.target_name(&self.target_at(x, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run cycles until any in-flight walk-then-act resolves (bounded).
|
||||||
|
/// Agent surfaces call this after a verb so a distant click acts
|
||||||
|
/// synchronously instead of looking like a dead click.
|
||||||
|
pub fn settle(&mut self, max: u32) -> Vec<Event> {
|
||||||
|
let mut ev = Vec::new();
|
||||||
|
for _ in 0..max {
|
||||||
|
if self.dlg.is_some() || (!self.ego.walking() && self.pending_verb.is_none()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ev.extend(self.step_cycle());
|
||||||
|
}
|
||||||
|
ev
|
||||||
|
}
|
||||||
|
|
||||||
/// Verb entry point with the walk-then-act deferral. `item` is set for
|
/// Verb entry point with the walk-then-act deferral. `item` is set for
|
||||||
/// inventory application ("use").
|
/// inventory application ("use").
|
||||||
fn dispatch(&mut self, verb: &str, item: &str, target: Target, click: Option<(i32, i32)>) -> Vec<Event> {
|
fn dispatch(&mut self, verb: &str, item: &str, target: Target, click: Option<(i32, i32)>) -> Vec<Event> {
|
||||||
if verb == parser::V_WALK {
|
if verb == parser::V_WALK {
|
||||||
if let Some((x, y)) = click {
|
// A click walks to the pixel; "walk to <thing>" walks to the thing.
|
||||||
|
if let Some((x, y)) = click.or_else(|| self.target_pos(&target)) {
|
||||||
return self.apply(Command::MoveTo { x, y });
|
return self.apply(Command::MoveTo { x, y });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1043,6 +1084,15 @@ impl GameState {
|
|||||||
if !things.is_empty() {
|
if !things.is_empty() {
|
||||||
ev.push(self.line(&format!("You notice: {}.", things.join(", "))));
|
ev.push(self.line(&format!("You notice: {}.", things.join(", "))));
|
||||||
}
|
}
|
||||||
|
let dirs: Vec<&str> = ["north", "east", "south", "west"]
|
||||||
|
.iter()
|
||||||
|
.zip(self.world.doc.exits.iter())
|
||||||
|
.filter(|(_, e)| e.is_some())
|
||||||
|
.map(|(d, _)| *d)
|
||||||
|
.collect();
|
||||||
|
if !dirs.is_empty() {
|
||||||
|
ev.push(self.line(&format!("Exits: {}.", dirs.join(", "))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(Target::Item(name), _) => {
|
(Target::Item(name), _) => {
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
@ -1075,7 +1125,7 @@ impl GameState {
|
|||||||
|
|
||||||
/// The message cascade: exact verb → "do" fallback for action verbs →
|
/// The message cascade: exact verb → "do" fallback for action verbs →
|
||||||
/// room defaults → manifest defaults → stock line.
|
/// room defaults → manifest defaults → stock line.
|
||||||
fn cascade(&self, msgs: &HashMap<String, String>, verb: &str) -> Option<String> {
|
fn cascade(&self, msgs: &std::collections::BTreeMap<String, String>, verb: &str) -> Option<String> {
|
||||||
if let Some(m) = msgs.get(verb) {
|
if let Some(m) = msgs.get(verb) {
|
||||||
return Some(m.clone());
|
return Some(m.clone());
|
||||||
}
|
}
|
||||||
@ -1130,8 +1180,14 @@ impl GameState {
|
|||||||
if (verb == "take" || verb == "do") && p.takeable {
|
if (verb == "take" || verb == "do") && p.takeable {
|
||||||
self.inventory.push(p.name.clone());
|
self.inventory.push(p.name.clone());
|
||||||
self.taken.push((self.world.current, p.name.clone()));
|
self.taken.push((self.world.current, p.name.clone()));
|
||||||
let alive = self.alive_prop_names();
|
if p.solid {
|
||||||
self.world.stamp_solid_props(&alive);
|
// A stamped footprint can only be cleared by rebaking —
|
||||||
|
// otherwise the taken crate leaves an invisible wall.
|
||||||
|
self.rebake();
|
||||||
|
} else {
|
||||||
|
let alive = self.alive_prop_names();
|
||||||
|
self.world.stamp_solid_props(&alive);
|
||||||
|
}
|
||||||
ev.push(self.line(&format!("You take the {}.", p.name)));
|
ev.push(self.line(&format!("You take the {}.", p.name)));
|
||||||
ev.push(Event::InventoryChanged { items: self.inventory.clone() });
|
ev.push(Event::InventoryChanged { items: self.inventory.clone() });
|
||||||
ev.push(Event::Audio { cue: AudioCue::Pickup });
|
ev.push(Event::Audio { cue: AudioCue::Pickup });
|
||||||
@ -1282,7 +1338,10 @@ impl GameState {
|
|||||||
if let Ok(n) = line.parse::<usize>() {
|
if let Ok(n) = line.parse::<usize>() {
|
||||||
return self.apply(Command::Choose { n });
|
return self.apply(Command::Choose { n });
|
||||||
}
|
}
|
||||||
ev.push(self.line("(type a number to answer, or end the conversation.)"));
|
if matches!(line.to_lowercase().as_str(), "bye" | "goodbye" | "leave" | "done" | "end" | "exit" | "q") {
|
||||||
|
return self.apply(Command::EndDialogue);
|
||||||
|
}
|
||||||
|
ev.push(self.line("(type a number to answer, or 'bye' to end the conversation.)"));
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) };
|
let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) };
|
||||||
@ -1319,7 +1378,11 @@ impl GameState {
|
|||||||
return ev;
|
return ev;
|
||||||
};
|
};
|
||||||
match self.resolve_noun(&noun) {
|
match self.resolve_noun(&noun) {
|
||||||
Some(t) => ev.extend(self.dispatch("use", &item, t, None)),
|
Some(t) => {
|
||||||
|
// Typed use walks over exactly like a clicked one.
|
||||||
|
let click = self.target_click_pos(&t);
|
||||||
|
ev.extend(self.dispatch("use", &item, t, click));
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
ev.push(self.line(&format!("You don't see any {} here.", noun)));
|
ev.push(self.line(&format!("You don't see any {} here.", noun)));
|
||||||
ev.push(Event::Audio { cue: AudioCue::Error });
|
ev.push(Event::Audio { cue: AudioCue::Error });
|
||||||
@ -1327,13 +1390,20 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Parsed::Verb { verb, noun } => {
|
Parsed::Verb { verb, noun } => {
|
||||||
|
// "go north" / "walk e" — SCI0 compass walking, via the pathfinder.
|
||||||
|
if verb == parser::V_WALK {
|
||||||
|
if let Some((x, y)) = compass_target(&noun, self.ego.x, self.ego.y) {
|
||||||
|
ev.extend(self.apply(Command::MoveTo { x, y }));
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
if noun.is_empty() {
|
if noun.is_empty() {
|
||||||
ev.extend(self.dispatch(&verb, "", Target::Room, None));
|
ev.extend(self.dispatch(&verb, "", Target::Room, None));
|
||||||
} else {
|
} else {
|
||||||
match self.resolve_noun(&noun) {
|
match self.resolve_noun(&noun) {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
// Typed commands walk over too, using the target's position.
|
// Typed commands walk over too, using the target's position.
|
||||||
let click = self.target_pos(&t);
|
let click = self.target_click_pos(&t);
|
||||||
ev.extend(self.dispatch(&verb, "", t, click));
|
ev.extend(self.dispatch(&verb, "", t, click));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@ -1748,6 +1818,42 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does screen pixel (x, y) land on a *solid* pixel of a cel standing at
|
||||||
|
/// feet point (fx, fy) at `scale`? Checked with 1px of slop so thin sprites
|
||||||
|
/// stay clickable.
|
||||||
|
fn cel_hit(cel: &crate::view::Cel, fx: i32, fy: i32, scale: f32, mirrored: bool, x: i32, y: i32) -> bool {
|
||||||
|
let sw = ((cel.w as f32 * scale) as i32).max(1);
|
||||||
|
let sh = ((cel.h as f32 * scale) as i32).max(1);
|
||||||
|
let (ox, oy) = (fx - sw / 2, fy - sh);
|
||||||
|
for dy in -1..=1i32 {
|
||||||
|
for dx in -1..=1i32 {
|
||||||
|
let (px, py) = (x + dx, y + dy);
|
||||||
|
if px < ox || px >= ox + sw || py < oy || py >= oy + sh {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cx = ((px - ox) * cel.w as i32 / sw) as usize;
|
||||||
|
let cy = ((py - oy) * cel.h as i32 / sh) as usize;
|
||||||
|
let solid = if mirrored { cel.at_mirrored(cx, cy) } else { cel.at(cx, cy) };
|
||||||
|
if solid.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "north"/"n"/… → a walk target at that edge of the play area.
|
||||||
|
fn compass_target(noun: &str, ex: i32, ey: i32) -> Option<(i32, i32)> {
|
||||||
|
let (w, h) = (PIC_W as i32, PIC_H as i32);
|
||||||
|
match noun {
|
||||||
|
"north" | "n" | "up" => Some((ex, 10)),
|
||||||
|
"east" | "e" | "right" => Some((w - 6, ey)),
|
||||||
|
"south" | "s" | "down" => Some((ex, h - 4)),
|
||||||
|
"west" | "w" | "left" => Some((6, ey)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn safe_slot(slot: &str) -> String {
|
fn safe_slot(slot: &str) -> String {
|
||||||
let s: String = slot.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect();
|
let s: String = slot.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect();
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
|
|||||||
@ -114,6 +114,32 @@ pub fn cel_from_ascii(rows: &[&str]) -> Cel {
|
|||||||
Cel { w, h, pix, solid }
|
Cel { w, h, pix, solid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slice a sheet cel into an animated View: `cols` animation frames across ×
|
||||||
|
/// `rows` direction loops down (row order: front/S, back/N, left/W, right/E —
|
||||||
|
/// trailing rows optional, missing directions fall back per the table above).
|
||||||
|
/// This is how one PNG becomes a walking, facing actor.
|
||||||
|
pub fn slice_sheet(sheet: &Cel, cols: usize, rows: usize) -> View {
|
||||||
|
let (cw, ch) = (sheet.w / cols, sheet.h / rows);
|
||||||
|
let mut loops = Vec::with_capacity(rows);
|
||||||
|
for r in 0..rows {
|
||||||
|
let mut cels = Vec::with_capacity(cols);
|
||||||
|
for c in 0..cols {
|
||||||
|
let mut pix = vec![0u8; cw * ch];
|
||||||
|
let mut solid = vec![false; cw * ch];
|
||||||
|
for y in 0..ch {
|
||||||
|
for x in 0..cw {
|
||||||
|
let src = (r * ch + y) * sheet.w + (c * cw + x);
|
||||||
|
pix[y * cw + x] = sheet.pix[src];
|
||||||
|
solid[y * cw + x] = sheet.solid[src];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cels.push(Cel { w: cw, h: ch, pix, solid });
|
||||||
|
}
|
||||||
|
loops.push(ViewLoop { cels });
|
||||||
|
}
|
||||||
|
View { loops }
|
||||||
|
}
|
||||||
|
|
||||||
/// Integer-upscale a cel (nearest neighbor) — how the ASCII robot gets to a
|
/// Integer-upscale a cel (nearest neighbor) — how the ASCII robot gets to a
|
||||||
/// respectable 320-wide-room stature without redrawing it pixel by pixel.
|
/// respectable 320-wide-room stature without redrawing it pixel by pixel.
|
||||||
pub fn upscale(cel: &Cel, f: usize) -> Cel {
|
pub fn upscale(cel: &Cel, f: usize) -> Cel {
|
||||||
@ -130,6 +156,27 @@ pub fn upscale(cel: &Cel, f: usize) -> Cel {
|
|||||||
Cel { w, h, pix, solid }
|
Cel { w, h, pix, solid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_slices_into_loops_and_frames() {
|
||||||
|
// 4 cols x 2 rows over an 8x4 image: cel (col, row) is solid iff col even.
|
||||||
|
let rows: Vec<String> = (0..4).map(|_| "1.2.3.4.".to_string()).collect();
|
||||||
|
let refs: Vec<&str> = rows.iter().map(|s| s.as_str()).collect();
|
||||||
|
let sheet = cel_from_ascii(&refs);
|
||||||
|
let v = slice_sheet(&sheet, 4, 2);
|
||||||
|
assert_eq!(v.loops.len(), 2);
|
||||||
|
assert_eq!(v.loops[0].cels.len(), 4);
|
||||||
|
assert_eq!(v.loops[0].cels[0].w, 2);
|
||||||
|
assert_eq!(v.loops[0].cels[0].h, 2);
|
||||||
|
assert!(v.loops[0].cels[0].at(0, 0).is_some());
|
||||||
|
assert!(v.loops[0].cels[0].at(1, 0).is_none(), "transparent survives slicing");
|
||||||
|
assert_eq!(v.loops[1].cels[2].at(0, 1), Some(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The default ego: the party robot, grown up for 320-wide rooms — front,
|
/// The default ego: the party robot, grown up for 320-wide rooms — front,
|
||||||
/// back and side loops (right is mirrored left), two-cel walk bobs.
|
/// back and side loops (right is mirrored left), two-cel walk bobs.
|
||||||
pub fn default_robot_view() -> View {
|
pub fn default_robot_view() -> View {
|
||||||
|
|||||||
112
mrpci-core/tests/playthrough.rs
Normal file
112
mrpci-core/tests/playthrough.rs
Normal 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());
|
||||||
|
}
|
||||||
@ -39,6 +39,11 @@ struct Ui {
|
|||||||
muted: bool,
|
muted: bool,
|
||||||
/// Room-change fade-in, 0 → 1 (a shader uniform, not a draw call).
|
/// Room-change fade-in, 0 → 1 (a shader uniform, not a draw call).
|
||||||
fade: f32,
|
fade: f32,
|
||||||
|
/// Death message + seconds remaining on its banner.
|
||||||
|
death: Option<(String, f32)>,
|
||||||
|
/// "+N" score toast + seconds remaining.
|
||||||
|
toast: Option<(String, f32)>,
|
||||||
|
last_score: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The palette blit, moved to the GPU (thanks, Lague): the frame crosses to
|
/// The palette blit, moved to the GPU (thanks, Lague): the frame crosses to
|
||||||
@ -128,6 +133,9 @@ async fn amain(game_dir: String) {
|
|||||||
scanlines: true,
|
scanlines: true,
|
||||||
muted: false,
|
muted: false,
|
||||||
fade: 0.0,
|
fade: 0.0,
|
||||||
|
death: None,
|
||||||
|
toast: None,
|
||||||
|
last_score: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Boot the session properly (intro text, music, checkpoint).
|
// Boot the session properly (intro text, music, checkpoint).
|
||||||
@ -320,6 +328,18 @@ async fn amain(game_dir: String) {
|
|||||||
audio.tick();
|
audio.tick();
|
||||||
ui.flash = (ui.flash - get_frame_time()).max(0.0);
|
ui.flash = (ui.flash - get_frame_time()).max(0.0);
|
||||||
ui.fade = (ui.fade + get_frame_time() * 2.6).min(1.0);
|
ui.fade = (ui.fade + get_frame_time() * 2.6).min(1.0);
|
||||||
|
if let Some((_, t)) = &mut ui.death {
|
||||||
|
*t -= get_frame_time();
|
||||||
|
if *t <= 0.0 {
|
||||||
|
ui.death = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((_, t)) = &mut ui.toast {
|
||||||
|
*t -= get_frame_time();
|
||||||
|
if *t <= 0.0 {
|
||||||
|
ui.toast = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- draw ------------------------------------------------------------
|
// --- draw ------------------------------------------------------------
|
||||||
clear_background(Color::from_rgba(12, 12, 16, 255));
|
clear_background(Color::from_rgba(12, 12, 16, 255));
|
||||||
@ -359,7 +379,7 @@ async fn amain(game_dir: String) {
|
|||||||
|
|
||||||
if ed.active {
|
if ed.active {
|
||||||
ed.draw(&gs);
|
ed.draw(&gs);
|
||||||
draw_cursor(&ui, false);
|
draw_cursor(&ui, false, "");
|
||||||
} else {
|
} else {
|
||||||
draw_bar(&gs, &ui);
|
draw_bar(&gs, &ui);
|
||||||
draw_log(&ui);
|
draw_log(&ui);
|
||||||
@ -372,12 +392,28 @@ async fn amain(game_dir: String) {
|
|||||||
}
|
}
|
||||||
if gs.won {
|
if gs.won {
|
||||||
center_banner("CASE CLOSED", "The city sleeps a little safer. F9 starts a new shift.");
|
center_banner("CASE CLOSED", "The city sleeps a little safer. F9 starts a new shift.");
|
||||||
|
} else if let Some((msg, _)) = ui.death.clone() {
|
||||||
|
center_banner("YOU HAVE DIED", &format!("{} (time rewinds to the room's door.)", msg));
|
||||||
}
|
}
|
||||||
if ui.typing {
|
if ui.typing {
|
||||||
draw_input(&ui);
|
draw_input(&ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_cursor(&ui, gs.dlg.is_some());
|
// Hover hot-text: name whatever the cursor is over, SCI-style
|
||||||
|
// "look neon sign" — discoverability without pixel hunting.
|
||||||
|
let hover = {
|
||||||
|
let (mx, my) = mouse_position();
|
||||||
|
let px = (mx / SCALE) as i32;
|
||||||
|
let py = ((my - BAR_H) / SCALE) as i32;
|
||||||
|
if gs.dlg.is_none() && !ui.inventory_open && !ui.typing
|
||||||
|
&& px >= 0 && px < PIC_W as i32 && py >= 0 && py < PIC_H as i32
|
||||||
|
{
|
||||||
|
gs.name_at(px, py)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
draw_cursor(&ui, gs.dlg.is_some(), &hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
next_frame().await;
|
next_frame().await;
|
||||||
@ -396,7 +432,16 @@ fn handle_events(evs: &[Event], ui: &mut Ui, audio: &mut sound::LazyAudio) {
|
|||||||
Event::Audio { cue } => audio.play(*cue),
|
Event::Audio { cue } => audio.play(*cue),
|
||||||
Event::Music { mood } => audio.set_music(*mood),
|
Event::Music { mood } => audio.set_music(*mood),
|
||||||
Event::RoomChanged { .. } => ui.fade = 0.0, // shader fades the room in
|
Event::RoomChanged { .. } => ui.fade = 0.0, // shader fades the room in
|
||||||
Event::Died { .. } => ui.flash = 1.2,
|
Event::Died { message } => {
|
||||||
|
ui.flash = 1.2;
|
||||||
|
ui.death = Some((message.clone(), 4.0));
|
||||||
|
}
|
||||||
|
Event::ScoreChanged { score, .. } => {
|
||||||
|
if *score > ui.last_score {
|
||||||
|
ui.toast = Some((format!("+{}", score - ui.last_score), 2.0));
|
||||||
|
}
|
||||||
|
ui.last_score = *score;
|
||||||
|
}
|
||||||
Event::Won => audio.play(AudioCue::Win),
|
Event::Won => audio.play(AudioCue::Win),
|
||||||
Event::Error { message } => {
|
Event::Error { message } => {
|
||||||
ui.log.push(format!("[engine] {}", message));
|
ui.log.push(format!("[engine] {}", message));
|
||||||
@ -455,6 +500,10 @@ fn draw_bar(gs: &GameState, ui: &Ui) {
|
|||||||
let s = format!("score {} / {}", gs.score, gs.world.manifest.max_score);
|
let s = format!("score {} / {}", gs.score, gs.world.manifest.max_score);
|
||||||
draw_text(&s, WIN_W as f32 - 150.0, 29.0, 22.0, Color::from_rgba(230, 210, 120, 255));
|
draw_text(&s, WIN_W as f32 - 150.0, 29.0, 22.0, Color::from_rgba(230, 210, 120, 255));
|
||||||
}
|
}
|
||||||
|
if let Some((txt, t)) = &ui.toast {
|
||||||
|
let a = (t / 2.0 * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
draw_text(txt, WIN_W as f32 - 180.0, 29.0, 24.0, Color::from_rgba(120, 240, 140, a));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_log(ui: &Ui) {
|
fn draw_log(ui: &Ui) {
|
||||||
@ -560,14 +609,15 @@ fn center_banner(title: &str, sub: &str) {
|
|||||||
let cx = WIN_W as f32 / 2.0;
|
let cx = WIN_W as f32 / 2.0;
|
||||||
let y = BAR_H + 120.0;
|
let y = BAR_H + 120.0;
|
||||||
let tw = measure_text(title, None, 52, 1.0).width;
|
let tw = measure_text(title, None, 52, 1.0).width;
|
||||||
draw_rectangle(cx - tw / 2.0 - 30.0, y - 50.0, tw + 60.0, 100.0, Color::from_rgba(10, 12, 18, 235));
|
|
||||||
draw_rectangle_lines(cx - tw / 2.0 - 30.0, y - 50.0, tw + 60.0, 100.0, 3.0, Color::from_rgba(230, 210, 120, 255));
|
|
||||||
draw_text(title, cx - tw / 2.0, y, 52.0, Color::from_rgba(230, 210, 120, 255));
|
|
||||||
let sw = measure_text(sub, None, 20, 1.0).width;
|
let sw = measure_text(sub, None, 20, 1.0).width;
|
||||||
|
let bw = tw.max(sw) + 60.0;
|
||||||
|
draw_rectangle(cx - bw / 2.0, y - 50.0, bw, 100.0, Color::from_rgba(10, 12, 18, 235));
|
||||||
|
draw_rectangle_lines(cx - bw / 2.0, y - 50.0, bw, 100.0, 3.0, Color::from_rgba(230, 210, 120, 255));
|
||||||
|
draw_text(title, cx - tw / 2.0, y, 52.0, Color::from_rgba(230, 210, 120, 255));
|
||||||
draw_text(sub, cx - sw / 2.0, y + 32.0, 20.0, WHITE);
|
draw_text(sub, cx - sw / 2.0, y + 32.0, 20.0, WHITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_cursor(ui: &Ui, in_dialogue: bool) {
|
fn draw_cursor(ui: &Ui, in_dialogue: bool, hover: &str) {
|
||||||
let (mx, my) = mouse_position();
|
let (mx, my) = mouse_position();
|
||||||
let col = match (in_dialogue, ui.selected_item.is_some(), VERBS[ui.verb]) {
|
let col = match (in_dialogue, ui.selected_item.is_some(), VERBS[ui.verb]) {
|
||||||
(true, _, _) => Color::from_rgba(255, 230, 150, 255),
|
(true, _, _) => Color::from_rgba(255, 230, 150, 255),
|
||||||
@ -581,14 +631,20 @@ fn draw_cursor(ui: &Ui, in_dialogue: bool) {
|
|||||||
draw_line(mx + 3.0, my, mx + 9.0, my, 2.0, col);
|
draw_line(mx + 3.0, my, mx + 9.0, my, 2.0, col);
|
||||||
draw_line(mx, my - 9.0, mx, my - 3.0, 2.0, col);
|
draw_line(mx, my - 9.0, mx, my - 3.0, 2.0, col);
|
||||||
draw_line(mx, my + 3.0, mx, my + 9.0, 2.0, col);
|
draw_line(mx, my + 3.0, mx, my + 9.0, 2.0, col);
|
||||||
|
// The tag reads as the sentence the click would say: "look neon sign",
|
||||||
|
// "keycard → locker".
|
||||||
let tag = if let Some(item) = &ui.selected_item {
|
let tag = if let Some(item) = &ui.selected_item {
|
||||||
item.clone()
|
if hover.is_empty() { item.clone() } else { format!("{} \u{2192} {}", item, hover) }
|
||||||
} else if in_dialogue {
|
} else if in_dialogue {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else if hover.is_empty() {
|
||||||
VERBS[ui.verb].to_string()
|
VERBS[ui.verb].to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", VERBS[ui.verb], hover)
|
||||||
};
|
};
|
||||||
if !tag.is_empty() {
|
if !tag.is_empty() {
|
||||||
draw_text(&tag, mx + 12.0, my + 14.0, 18.0, col);
|
let w = measure_text(&tag, None, 18, 1.0).width;
|
||||||
|
draw_rectangle(mx + 10.0, my + 2.0, w + 6.0, 17.0, Color::from_rgba(10, 12, 18, 180));
|
||||||
|
draw_text(&tag, mx + 13.0, my + 15.0, 18.0, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user