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
|
||||
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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
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))),
|
||||
};
|
||||
|
||||
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!({
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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() }
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
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,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user