From c83c90711d9d84b584de6ac55b21beb3b7f0cf73 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sat, 25 Jul 2026 22:52:39 +1000 Subject: [PATCH] =?UTF-8?q?MRPCI=20v0.1=20=E2=80=94=20the=20SCI-generation?= =?UTF-8?q?=20Monster=20Robot=20Party=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless core (mrpci-core) + macroquad GUI (mrpci), inheriting MRPGI's Command/Event bus architecture and jumping a Sierra generation: - 256-color palettes with live median-cut quantization + palette cycling - four screens: visual / priority / control / hotspot - A* pathfinding with LOS-verified edges and line-exact path following - perspective scale tables (actors shrink toward the horizon) - point-and-click verb bar AND a text parser, one shared verb pipeline - rhai room scripts (on_enter/on_verb/on_trigger + after() tick timers) - deterministic 30Hz cycles, seeded RNG — byte-identical script replays - checkpoint at room entry; death rewinds instead of restarting - save-anywhere slots; multi-voice chip synth; JSONL/HTTP/MCP surfaces - demo game: Neon Precinct (3 rooms, 25 points, one merciful fusebox) Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + Cargo.lock | 857 +++++++++++ Cargo.toml | 11 + README.md | 109 ++ docs/CONTROL.md | 140 ++ games/neon-precinct/game.json | 15 + games/neon-precinct/rooms/room0.json | 1149 +++++++++++++++ games/neon-precinct/rooms/room1.json | 842 +++++++++++ games/neon-precinct/rooms/room2.json | 684 +++++++++ games/neon-precinct/scripts/main.rhai | 8 + games/neon-precinct/scripts/room0.rhai | 14 + games/neon-precinct/scripts/room1.rhai | 10 + games/neon-precinct/scripts/room2.rhai | 18 + games/neon-precinct/sprites/crate.png | Bin 0 -> 240 bytes games/neon-precinct/sprites/dumpster.png | Bin 0 -> 260 bytes games/neon-precinct/sprites/fusebox.png | Bin 0 -> 244 bytes games/neon-precinct/sprites/keycard.png | Bin 0 -> 169 bytes games/neon-precinct/sprites/locker.png | Bin 0 -> 347 bytes games/neon-precinct/sprites/sergeant.png | Bin 0 -> 331 bytes games/neon-precinct/sprites/terminal.png | Bin 0 -> 314 bytes games/neon-precinct/sprites/vendbot.png | Bin 0 -> 311 bytes mrpci-core/Cargo.toml | 22 + mrpci-core/src/actor.rs | 302 ++++ mrpci-core/src/assets.rs | 189 +++ mrpci-core/src/audio.rs | 225 +++ mrpci-core/src/bin/mrpci-headless/http.rs | 143 ++ mrpci-core/src/bin/mrpci-headless/main.rs | 129 ++ mrpci-core/src/bin/mrpci-headless/mcp.rs | 185 +++ mrpci-core/src/bin/mrpci-headless/sample.rs | 658 +++++++++ mrpci-core/src/dialogue.rs | 103 ++ mrpci-core/src/lib.rs | 34 + mrpci-core/src/palette.rs | 173 +++ mrpci-core/src/parser.rs | 154 ++ mrpci-core/src/path.rs | 257 ++++ mrpci-core/src/pic.rs | 290 ++++ mrpci-core/src/render.rs | 99 ++ mrpci-core/src/room.rs | 599 ++++++++ mrpci-core/src/screens.rs | 124 ++ mrpci-core/src/script.rs | 238 +++ mrpci-core/src/state.rs | 1475 +++++++++++++++++++ mrpci-core/src/view.rs | 262 ++++ mrpci-core/tests/walk.rs | 44 + mrpci/Cargo.toml | 9 + mrpci/src/main.rs | 482 ++++++ mrpci/src/sound.rs | 165 +++ run.sh | 7 + 46 files changed, 10228 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 docs/CONTROL.md create mode 100644 games/neon-precinct/game.json create mode 100644 games/neon-precinct/rooms/room0.json create mode 100644 games/neon-precinct/rooms/room1.json create mode 100644 games/neon-precinct/rooms/room2.json create mode 100644 games/neon-precinct/scripts/main.rhai create mode 100644 games/neon-precinct/scripts/room0.rhai create mode 100644 games/neon-precinct/scripts/room1.rhai create mode 100644 games/neon-precinct/scripts/room2.rhai create mode 100644 games/neon-precinct/sprites/crate.png create mode 100644 games/neon-precinct/sprites/dumpster.png create mode 100644 games/neon-precinct/sprites/fusebox.png create mode 100644 games/neon-precinct/sprites/keycard.png create mode 100644 games/neon-precinct/sprites/locker.png create mode 100644 games/neon-precinct/sprites/sergeant.png create mode 100644 games/neon-precinct/sprites/terminal.png create mode 100644 games/neon-precinct/sprites/vendbot.png create mode 100644 mrpci-core/Cargo.toml create mode 100644 mrpci-core/src/actor.rs create mode 100644 mrpci-core/src/assets.rs create mode 100644 mrpci-core/src/audio.rs create mode 100644 mrpci-core/src/bin/mrpci-headless/http.rs create mode 100644 mrpci-core/src/bin/mrpci-headless/main.rs create mode 100644 mrpci-core/src/bin/mrpci-headless/mcp.rs create mode 100644 mrpci-core/src/bin/mrpci-headless/sample.rs create mode 100644 mrpci-core/src/dialogue.rs create mode 100644 mrpci-core/src/lib.rs create mode 100644 mrpci-core/src/palette.rs create mode 100644 mrpci-core/src/parser.rs create mode 100644 mrpci-core/src/path.rs create mode 100644 mrpci-core/src/pic.rs create mode 100644 mrpci-core/src/render.rs create mode 100644 mrpci-core/src/room.rs create mode 100644 mrpci-core/src/screens.rs create mode 100644 mrpci-core/src/script.rs create mode 100644 mrpci-core/src/state.rs create mode 100644 mrpci-core/src/view.rs create mode 100644 mrpci-core/tests/walk.rs create mode 100644 mrpci/Cargo.toml create mode 100644 mrpci/src/main.rs create mode 100644 mrpci/src/sound.rs create mode 100755 run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ebe09b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +games/*/saves/ +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bbf2a88 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,857 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "audir-sles" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea47348666a8edb7ad80cbee3940eb2bccf70df0e6ce09009abe1a836cb779f5" + +[[package]] +name = "audrey" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b92a84e89497e3cd25d3672cd5d1c288abaac02c18ff21283f17d118b889b8" +dependencies = [ + "dasp_frame", + "dasp_sample", + "hound", + "lewton", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown", + "ttf-parser", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lewton" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d542c1a317036c45c2aa1cf10cc9d403ca91eb2d333ef1a4917e5cb10628bd0" +dependencies = [ + "byteorder", + "ogg", + "smallvec 0.6.14", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macroquad" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f7d60318b52b19e909db1f01c522977da28a5e515127a73ecd8b7488498edb" +dependencies = [ + "fontdue", + "glam", + "image", + "macroquad_macro", + "miniquad", + "quad-rand", + "quad-snd", +] + +[[package]] +name = "macroquad_macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b1d96218903768c1ce078b657c0d5965465c95a60d2682fd97443c9d2483dd" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniquad" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f64fd94ff70fdc425766c78a3fa9f263d02cb25725a49f255f6a66d8a186c3f" +dependencies = [ + "libc", + "ndk-sys", + "objc-rs", + "winapi", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mrpci" +version = "0.1.0" +dependencies = [ + "macroquad", + "mrpci-core", +] + +[[package]] +name = "mrpci-core" +version = "0.1.0" +dependencies = [ + "image", + "rhai", + "serde", + "serde_json", + "tiny_http", +] + +[[package]] +name = "ndk-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121" + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc-rs" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a1e7069a2525126bf12a9f1f7916835fafade384fb27cabf698e745e2a1eb8" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "ogg" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e571c3517af9e1729d4c63571a27edd660ade0667973bfc74a67c660c2b651" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quad-alsa-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66c2f04a6946293477973d85adc251d502da51c57b08cd9c997f0cfd8dcd4b5" +dependencies = [ + "libc", +] + +[[package]] +name = "quad-rand" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" + +[[package]] +name = "quad-snd" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba0c4943fc67147fbe9d1eb731fb9e678bfc9d926507eebbbfe0103e154e5b0" +dependencies = [ + "audir-sles", + "audrey", + "libc", + "quad-alsa-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rhai" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" +dependencies = [ + "ahash", + "bitflags 2.13.1", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec 1.15.2", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" +dependencies = [ + "maybe-uninit", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..16b14c8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +members = ["mrpci-core", "mrpci"] +resolver = "2" + +# macroquad renders much more smoothly when its dependencies are optimized, +# even during `cargo run` dev builds. Our own crates stay fast to recompile. +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b13acd --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# MRPCI — Monster Robot Party Creative Interpreter + +The **SCI generation** of the Monster Robot Party engine family. Where +[MRPGI](../mrpgi) reimagines Sierra's 1980s AGI (16 colors, text parser, +one merged priority buffer), MRPCI reimagines the tech behind *King's Quest V* +and *Space Quest IV* — and then fixes everything Sierra never could: + +| Sierra SCI (1990) | MRPCI (now) | +|---|---| +| 256-color VGA, hand-cut palettes | 256-color palettes **quantized live from any PNG** (median cut), EGA/gray/ember ranges kept stable for sprites & cycles | +| Palette cycling (waterfalls, neon) | Same trick, `cycles` are room data + script-toggleable | +| Priority + control screens | **Four** screens: visual / priority / control / hotspot — hit-testing any pixel is one array read | +| Polygon avoidance that wedged egos into corners | Real **A\*** with LOS-verified edges + string-pulling; actors walk the exact verified line, so a legal path can never clip | +| Scale tables (SCI1.1) | Per-room perspective: actors shrink toward the horizon, walk speed scales to match | +| CPU-speed timers → Error 52, instant Sequel Police | Fixed **30Hz deterministic cycles**; `after()` timers count ticks; the only RNG is seeded xorshift — replays are byte-identical | +| `Error 47: Not an object` → crash to DOS | Missing resources = placeholders + an `error` event. A compile-error script = a log line, not a hang | +| Die 3 hours after the dead-end | **Checkpoint at every room entry; death rewinds to it.** Save-anywhere slots on top | +| OOP bytecode scripts, priest-tier tooling | **rhai** room scripts: `on_enter`, `on_verb(v,n)`, `on_trigger`, `after(ticks,"fn")` — hot-swappable over the bus | +| Icon bar (SCI1) *or* parser (SCI0) | **Both.** Clicks and typed commands converge on one verb pipeline | + +The architecture is inherited from MRPGI and kept sacred: **`mrpci-core` is +headless** (zero windowing deps — a compile error is the guardrail). The +whole sim is a `GameState` behind a typed Command/Event bus, and every +control surface — GUI, JSONL stdio, HTTP, MCP — is a thin adapter. + +## Run it + +```sh +cargo run -p mrpci-core --bin mrpci-headless -- --sample # write the demo game +cargo run -p mrpci # play Neon Precinct +``` + +**Neon Precinct**: you are Officer Morp-9. Rain, neon that actually cycles, +a vend-bot with a tip, a keycard behind crates (watch the A\* go around), +a lethal fusebox (watch death *not* ruin your evening), and a case to close. +25 points. + +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. + +## Drive it headless (the point) + +```sh +cargo build -p mrpci-core +target/debug/mrpci-headless --game games/neon-precinct # JSONL stdio +target/debug/mrpci-headless --game games/neon-precinct --serve 8093 # HTTP +target/debug/mrpci-headless --game games/neon-precinct --mcp # MCP for Claude +target/debug/mrpci-headless --game games/neon-precinct --script play.jsonl # golden replay +target/debug/mrpci-headless --game games/neon-precinct --room 2 --ticks 45 --render-room out.png +target/debug/mrpci-headless --game games/neon-precinct --screens shots/ # all four screens +``` + +Register with Claude: + +```sh +claude mcp add mrpci -- $PWD/target/debug/mrpci-headless --mcp --game $PWD/games/neon-precinct +``` + +Claude can then *play* the game (verbs, walks, dialogue), *see* it +(`mrpci_render_frame`, plus the invisible priority/control/hotspot screens), +and *author* it (upsert rooms and rhai scripts live, validated before commit). +See [docs/CONTROL.md](docs/CONTROL.md) for the full bus reference. + +## A game is a folder + +``` +games/mygame/ + game.json # manifest: name, start_room, intro, verbs, flags, max_score + rooms/room0.json # RoomDoc: ops (paint), background, palette, cycles, scale, + # 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 + scripts/*.rhai # main.rhai + room scripts + sfx/ music/ # optional audio overrides (else the built-in chip synth plays) + saves/ # save-anywhere slots (gitignored) +``` + +Rooms paint with `PicOp`s — rects, polygons, floods, **dithered gradients** — +through an `Ink` that writes any subset of the four screens at once. Or drop +a PNG in `pics/` and let the quantizer fold it into the palette. + +## Source map + +| Crate / file | Role | +|---|---| +| `mrpci-core/screens.rs` | the four aligned screens + depth bands | +| `mrpci-core/palette.rs` | 256-color palettes, cycling LUTs | +| `mrpci-core/pic.rs` | vector paint ops, Bayer-dithered gradients | +| `mrpci-core/assets.rs` | median-cut quantizer, masks, sprite pipeline | +| `mrpci-core/view.rs` | loops/cels, ASCII sprites, mirroring | +| `mrpci-core/actor.rs` | ego + NPCs: scaling, line-exact path following | +| `mrpci-core/path.rs` | A\*, LOS smoothing, `walk_line` | +| `mrpci-core/room.rs` | RoomDoc / World / game folders / validation | +| `mrpci-core/script.rs` | rhai host: hooks, Fx airlock, tick timers | +| `mrpci-core/state.rs` | **the bus**: GameState, Command/Event, checkpoints | +| `mrpci-core/render.rs` | headless compositing → RGBA/PNG | +| `mrpci-core/audio.rs` | multi-voice chip synth (square/pulse/tri/noise) | +| `mrpci-core/bin/mrpci-headless/` | stdio / HTTP / MCP / replay / sample | +| `mrpci/` | the macroquad GUI (icon bar, dialogue windows, sound) | + +## Roadmap + +- [x] v0.1 — the engine above + Neon Precinct +- [ ] Priority-mask art workflow polish (paint depth in any image editor) +- [ ] In-engine room editor (MRPGI's, upgraded to four screens) +- [ ] AI parser lane (LLM maps free text onto already-legal commands, like MRPGI) +- [ ] Portraits/talkers in dialogue windows +- [ ] wasm build (`mrpci-core` is already windowless — the web is an adapter away) diff --git a/docs/CONTROL.md b/docs/CONTROL.md new file mode 100644 index 0000000..9ab7ff6 --- /dev/null +++ b/docs/CONTROL.md @@ -0,0 +1,140 @@ +# Driving MRPCI programmatically + +The engine core (`mrpci-core`) is fully headless: a `GameState` driven by +JSON `Command`s in and `Event`s out, over your choice of surface. The GUI is +just one more consumer of the same bus. + +``` +GUI (macroquad) ─┐ +CLI / stdio ─────┤ +HTTP ────────────┼──▶ Command ▶ GameState ▶ Event +MCP (Claude) ────┤ +Python ──────────┘ +``` + +Everything below uses one binary: + +```sh +cargo build -p mrpci-core # builds target/debug/mrpci-headless +mrpci-headless --help +``` + +`--game DIR` points any mode at a game folder. Useful boot flags: +`--seed N` (deterministic RNG), `--room N`, `--ticks N`, +`--render-room out.png`, `--screens dir/`. + +## 1. JSONL stdio (the substrate) + +One JSON command per line on stdin, events stream back on stdout. +Lines starting with `#` are comments (script files too). + +```jsonl +{"cmd":"verb_at","verb":"look","x":100,"y":120} +{"cmd":"verb_at","verb":"do","x":268,"y":112} +{"cmd":"use_item_at","item":"keycard","x":286,"y":138} +{"cmd":"parse","text":"use keycard on locker"} +{"cmd":"move_to","x":150,"y":150} +{"cmd":"walk_to","x":316,"y":150} +{"cmd":"tick","n":30} +{"cmd":"choose","n":1} +{"cmd":"end_dialogue"} +{"cmd":"query"} +{"cmd":"new_game"} +{"cmd":"goto_room","n":2} +{"cmd":"set_flag","name":"locker_open","value":true} +{"cmd":"set_var","name":"drip_armed","value":0} +{"cmd":"set_seed","seed":41} +{"cmd":"save_game","slot":"street"} +{"cmd":"restore_game","slot":"street"} +{"cmd":"upsert_room","n":7,"doc":{"spawn":[160,170]},"persist":true} +{"cmd":"upsert_script","file":"room7.rhai","source":"fn on_enter() { say(\"hi\"); }"} +{"cmd":"save_room"} +{"cmd":"load_game","dir":"games/other"} +{"cmd":"reload_assets"} +``` + +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). +- `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`. +- `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. +- Events: `transcript`, `room_changed`, `inventory_changed`, `dialogue_open` + / `dialogue_closed`, `score_changed`, `died` (checkpoint rewind follows), + `won`, `audio` (cue name), `music` (mood), `ego_moved`, `state`, + `saved_game` / `restored`, `room_upserted`, `room_saved`, `game_loaded`, + `error`. + +### Script replay (deterministic) + +```sh +mrpci-headless --game games/neon-precinct --script play.jsonl > events.log +``` + +Fixed cycles + seeded RNG + tick-based timers ⇒ the same script produces +**byte-identical** events on any machine, any speed. That's the golden-test +workflow for CI — and the reason none of the classic Sierra speed bugs can +exist here. + +## 2. HTTP + +```sh +mrpci-headless --game games/neon-precinct --serve 8093 +``` + +``` +POST /command one Command JSON → JSON array of Events +GET /state StateSnapshot +GET /frame.png current frame, headless-rendered (cycles applied) +GET /screen/priority.png depth bands, visualized +GET /screen/control.png walls red, water blue +GET /screen/hotspot.png hotspot id map +GET /events?since=N cursor-polled event log +POST /rooms/{n}[?persist] body = RoomDoc JSON (the lore drop) +``` + +## 3. MCP (Claude as player, tester, co-author) + +```sh +claude mcp add mrpci -- /path/to/mrpci-headless --mcp --game /path/to/games/neon-precinct +``` + +Tools: `mrpci_verb`, `mrpci_use_item`, `mrpci_parse`, `mrpci_walk_to`, +`mrpci_choose`, `mrpci_tick`, `mrpci_state`, `mrpci_render_frame`, +`mrpci_render_screen`, `mrpci_save`, `mrpci_restore`, `mrpci_new_game`, +`mrpci_load_game`, `mrpci_upsert_room`, `mrpci_upsert_script`, +`mrpci_save_room`, `mrpci_command` (escape hatch). + +`mrpci_render_frame` / `mrpci_render_screen` return real PNGs, so a model +can look at the frame — or the invisible screens — it just authored. + +## The rhai scripting surface + +Hooks a room script (or `main.rhai`, merged underneath) may define: + +```rhai +fn on_enter() // room became live (also after death-rewind!) +fn on_exit() // leaving the room +fn on_verb(verb, noun) // FIRST refusal on every verb; return true = handled + // verb is "look"/"do"/"talk"/"take"/"use:"/... +fn on_trigger(name) // ego stepped into a trigger hotspot +fn on_tick() // every cycle (keep it light) +fn any_name() // timer target for after(ticks, "any_name") +``` + +API: `say(t)`, `say_by(who,t)`, `flag(n)`, `set_flag(n,v)`, `val(n)`, +`set_val(n,v)`, `has(item)`, `give(item)`, `remove_item(item)`, `room()`, +`ego_x()`, `ego_y()`, `goto_room(n)`, `place_ego(x,y)`, `walk_ego(x,y)`, +`freeze_ego(b)`, `npc_walk(name,x,y)`, `npc_place(name,x,y)`, +`npc_freeze(name,b)`, `play(cue)`, `music(mood)`, `points(n)`, `win()`, +`die(text)`, `after(ticks, "fn")`, `pal_cycle(i, on)`, `rand(n)`. + +Reads see a snapshot; writes queue as effects applied after the hook +returns. A script can print nonsense but cannot corrupt the sim, and a +runaway loop is killed at 50k ops. `rand` is the engine's seeded xorshift — +scripts stay replay-deterministic. diff --git a/games/neon-precinct/game.json b/games/neon-precinct/game.json new file mode 100644 index 0000000..8ab2012 --- /dev/null +++ b/games/neon-precinct/game.json @@ -0,0 +1,15 @@ +{ + "name": "Neon Precinct", + "start_room": 0, + "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.)", + "verbs": {}, + "flags": {}, + "max_score": 25, + "ego_sprite": "", + "defaults": { + "listen": "The city hums in B-flat.", + "smell": "Ozone, rain, and yesterday's synth-noodles.", + "do": "Your servos find no purchase on that.", + "look": "Rain-slick chrome and old neon. Nothing more." + } +} \ No newline at end of file diff --git a/games/neon-precinct/rooms/room0.json b/games/neon-precinct/rooms/room0.json new file mode 100644 index 0000000..dc4e601 --- /dev/null +++ b/games/neon-precinct/rooms/room0.json @@ -0,0 +1,1149 @@ +{ + "name": "Neon Row", + "enter_text": "Neon Row at 2 AM. The EAT sign crawls. The precinct door glows across the street.", + "background": "", + "ops": [ + { + "VGradient": { + "x": 0, + "y": 0, + "w": 320, + "h": 84, + "ramp": [ + 33, + 34, + 70, + 77 + ], + "ink": { + "color": 0, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 0, + "y": 30, + "w": 90, + "h": 78, + "ink": { + "color": 76, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 90, + "y": 44, + "w": 70, + "h": 64, + "ink": { + "color": 112, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 160, + "y": 24, + "w": 100, + "h": 84, + "ink": { + "color": 77, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 260, + "y": 50, + "w": 60, + "h": 58, + "ink": { + "color": 119, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 6, + "y": 38, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 32, + "y": 38, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 45, + "y": 38, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 71, + "y": 38, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 6, + "y": 52, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 32, + "y": 52, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 45, + "y": 52, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 71, + "y": 52, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 6, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 32, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 45, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 71, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 6, + "y": 80, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 32, + "y": 80, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 45, + "y": 80, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 71, + "y": 80, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 96, + "y": 52, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 122, + "y": 52, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 135, + "y": 52, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 96, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 109, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 135, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 148, + "y": 66, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 109, + "y": 80, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 122, + "y": 80, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 148, + "y": 80, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 268, + "y": 58, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 294, + "y": 58, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 307, + "y": 58, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 281, + "y": 72, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 294, + "y": 72, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 268, + "y": 86, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 281, + "y": 86, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 307, + "y": 86, + "w": 6, + "h": 8, + "ink": { + "color": 195, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 204, + "y": 60, + "w": 38, + "h": 48, + "ink": { + "color": 120, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 210, + "y": 68, + "w": 26, + "h": 40, + "ink": { + "color": 76, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 214, + "y": 56, + "w": 18, + "h": 8, + "ink": { + "color": 244, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 96, + "y": 22, + "w": 4, + "h": 18, + "ink": { + "color": 248, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 100, + "y": 22, + "w": 8, + "h": 4, + "ink": { + "color": 249, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 100, + "y": 30, + "w": 6, + "h": 4, + "ink": { + "color": 250, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 114, + "y": 22, + "w": 12, + "h": 4, + "ink": { + "color": 251, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 114, + "y": 30, + "w": 4, + "h": 10, + "ink": { + "color": 252, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 124, + "y": 30, + "w": 4, + "h": 10, + "ink": { + "color": 253, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 116, + "y": 34, + "w": 8, + "h": 4, + "ink": { + "color": 254, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 134, + "y": 22, + "w": 12, + "h": 4, + "ink": { + "color": 255, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 138, + "y": 26, + "w": 4, + "h": 14, + "ink": { + "color": 248, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "VGradient": { + "x": 0, + "y": 108, + "w": 320, + "h": 42, + "ramp": [ + 22, + 21, + 20 + ], + "ink": { + "color": 0, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 0, + "y": 150, + "w": 320, + "h": 3, + "ink": { + "color": 24, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "VGradient": { + "x": 0, + "y": 153, + "w": 320, + "h": 37, + "ramp": [ + 19, + 18 + ], + "ink": { + "color": 0, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Ellipse": { + "cx": 60, + "cy": 166, + "rx": 26, + "ry": 6, + "ink": { + "color": 77, + "pri": "Keep", + "ctl": { + "Set": 2 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 74, + "y": 92, + "w": 4, + "h": 54, + "ink": { + "color": 26, + "pri": { + "Set": 12 + }, + "ctl": "Keep", + "hot": null + } + } + }, + { + "Rect": { + "x": 68, + "y": 86, + "w": 16, + "h": 6, + "ink": { + "color": 245, + "pri": { + "Set": 12 + }, + "ctl": "Keep", + "hot": null + } + } + }, + { + "Rect": { + "x": 74, + "y": 143, + "w": 4, + "h": 3, + "ink": { + "color": null, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + } + ], + "palette": null, + "cycles": [ + { + "start": 248, + "len": 8, + "period": 3, + "reverse": false, + "active": true + } + ], + "scale": { + "horizon_y": 100, + "min_scale": 0.55, + "full_y": 185 + }, + "spawn": [ + 104, + 168 + ], + "exits": [ + null, + 2, + null, + null + ], + "exit_flags": [ + "", + "", + "", + "" + ], + "exit_blocked": [ + "", + "", + "", + "" + ], + "hotspots": [ + { + "name": "precinct door", + "rect": [ + 206, + 104, + 34, + 12 + ], + "poly": [], + "msgs": { + "look": "The 41st Precinct. Your house." + }, + "exit_to": 1, + "arrive": [ + 160, + 176 + ], + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + }, + { + "name": "neon sign", + "rect": [ + 92, + 18, + 54, + 34 + ], + "poly": [], + "msgs": { + "do": "It's four stories up. Even your warrant doesn't reach.", + "look": "EAT, insists the neon, in colors that never sit still." + }, + "exit_to": null, + "arrive": null, + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + }, + { + "name": "puddle", + "rect": [ + 34, + 160, + 52, + 13 + ], + "poly": [], + "msgs": { + "do": "You are 900 pounds of municipal robot. The puddle wins.", + "look": "Neon drowns in the puddle. Your chassis manual is very clear about puddles." + }, + "exit_to": null, + "arrive": null, + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + }, + { + "name": "tripwire", + "rect": [ + 120, + 108, + 24, + 82 + ], + "poly": [], + "msgs": {}, + "exit_to": null, + "arrive": null, + "requires_flag": "", + "blocked_text": "", + "trigger": true, + "blocks": false + } + ], + "props": [ + { + "name": "dumpster", + "sprite": "dumpster", + "x": 288, + "y": 148, + "msgs": { + "look": "A city dumpster. Something inside is composting on a geological timescale.", + "smell": "Your olfactory sensor files a grievance.", + "do": "You rummage. Old circuit boards, a single roller skate, regret." + }, + "takeable": false, + "synonyms": "", + "use_text": "", + "needs": "", + "wins": false, + "requires_flag": "", + "sets_flag": "", + "dialogue": [], + "points": 0, + "consumes": false, + "kills": false, + "visible_flag": "", + "hidden_by_flag": "", + "solid": true, + "fixed_priority": null, + "fixed_scale": null + } + ], + "npcs": [ + { + "name": "vend-bot", + "sprite": "vendbot", + "x": 150, + "y": 132, + "msgs": { + "look": "A vending robot on tired treads, hawking hot oil." + }, + "dialogue": [ + { + "says": "Hot oil! Fresh volts! You look like a cop with questions.", + "choices": [ + { + "text": "Seen anything shady tonight?", + "goto": 1, + "requires_flag": "", + "sets_flag": "", + "points": 0, + "kills": false + }, + { + "text": "Just passing through.", + "goto": -1, + "requires_flag": "", + "sets_flag": "", + "points": 0, + "kills": false + } + ] + }, + { + "says": "Somebody ditched a keycard in the alley east of here. Behind the crates. Didn't touch it — bad for business.", + "choices": [ + { + "text": "Appreciated, citizen.", + "goto": -1, + "requires_flag": "", + "sets_flag": "", + "points": 0, + "kills": false + } + ] + } + ], + "wander": 34, + "visible_flag": "", + "hidden_by_flag": "", + "fixed_scale": null + } + ], + "music": 6, + "defaults": {}, + "script": "room0.rhai" +} \ No newline at end of file diff --git a/games/neon-precinct/rooms/room1.json b/games/neon-precinct/rooms/room1.json new file mode 100644 index 0000000..4a58abb --- /dev/null +++ b/games/neon-precinct/rooms/room1.json @@ -0,0 +1,842 @@ +{ + "name": "41st Precinct Lobby", + "enter_text": "Fluorescents flicker over the duty desk. The evidence locker waits along the east wall.", + "background": "", + "ops": [ + { + "VGradient": { + "x": 0, + "y": 0, + "w": 320, + "h": 96, + "ramp": [ + 119, + 76 + ], + "ink": { + "color": 0, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 0, + "y": 96, + "w": 320, + "h": 4, + "ink": { + "color": 153, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "VGradient": { + "x": 0, + "y": 100, + "w": 320, + "h": 90, + "ramp": [ + 23, + 21 + ], + "ink": { + "color": 0, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 0, + "y": 100, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 64, + "y": 100, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 128, + "y": 100, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 192, + "y": 100, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 256, + "y": 100, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 32, + "y": 115, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 96, + "y": 115, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 160, + "y": 115, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 224, + "y": 115, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 288, + "y": 115, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 0, + "y": 130, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 64, + "y": 130, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 128, + "y": 130, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 192, + "y": 130, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 256, + "y": 130, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 32, + "y": 145, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 96, + "y": 145, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 160, + "y": 145, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 224, + "y": 145, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 288, + "y": 145, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 0, + "y": 160, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 64, + "y": 160, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 128, + "y": 160, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 192, + "y": 160, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 256, + "y": 160, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 32, + "y": 175, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 96, + "y": 175, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 160, + "y": 175, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 224, + "y": 175, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 288, + "y": 175, + "w": 32, + "h": 15, + "ink": { + "color": 118, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 118, + "y": 96, + "w": 90, + "h": 26, + "ink": { + "color": 110, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 118, + "y": 96, + "w": 90, + "h": 4, + "ink": { + "color": 153, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 30, + "y": 30, + "w": 20, + "h": 26, + "ink": { + "color": 196, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 56, + "y": 30, + "w": 20, + "h": 26, + "ink": { + "color": 167, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 140, + "y": 182, + "w": 40, + "h": 8, + "ink": { + "color": 147, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + } + ], + "palette": null, + "cycles": [], + "scale": { + "horizon_y": 88, + "min_scale": 0.7, + "full_y": 185 + }, + "spawn": [ + 160, + 176 + ], + "exits": [ + null, + null, + null, + null + ], + "exit_flags": [ + "", + "", + "", + "" + ], + "exit_blocked": [ + "", + "", + "", + "" + ], + "hotspots": [ + { + "name": "street door", + "rect": [ + 136, + 184, + 48, + 6 + ], + "poly": [], + "msgs": { + "look": "Back out to Neon Row." + }, + "exit_to": 0, + "arrive": [ + 226, + 120 + ], + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + }, + { + "name": "duty desk", + "rect": [ + 118, + 96, + 90, + 30 + ], + "poly": [], + "msgs": { + "look": "The duty desk. Coffee rings dating back three administrations.", + "do": "You straighten a stack of incident forms. Order is restored, briefly." + }, + "exit_to": null, + "arrive": null, + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + }, + { + "name": "wanted posters", + "rect": [ + 28, + 28, + 52, + 30 + ], + "poly": [], + "msgs": { + "look": "WANTED: the Compost Bandit. Last seen fleeing Sickbay with a datacard." + }, + "exit_to": null, + "arrive": null, + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + } + ], + "props": [ + { + "name": "locker", + "sprite": "locker", + "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." + }, + "takeable": false, + "synonyms": "", + "use_text": "The keycard chirps. Locker 47 clunks open: one data-slate, sealed in an evidence bag.", + "needs": "keycard", + "wins": false, + "requires_flag": "", + "sets_flag": "locker_open", + "dialogue": [], + "points": 10, + "consumes": false, + "kills": false, + "visible_flag": "", + "hidden_by_flag": "", + "solid": true, + "fixed_priority": null, + "fixed_scale": null + }, + { + "name": "terminal", + "sprite": "terminal", + "x": 36, + "y": 130, + "msgs": { + "look": "The case terminal. Slot: DATA-SLATE. It has been hungry for weeks.", + "do": "It wants the evidence, not your fingerprints." + }, + "takeable": false, + "synonyms": "", + "use_text": "The slate slides home. Records unspool. Warrants bloom. Somewhere, the Compost Bandit sneezes.", + "needs": "evidence", + "wins": true, + "requires_flag": "", + "sets_flag": "", + "dialogue": [], + "points": 10, + "consumes": true, + "kills": false, + "visible_flag": "", + "hidden_by_flag": "", + "solid": true, + "fixed_priority": null, + "fixed_scale": null + } + ], + "npcs": [ + { + "name": "sergeant", + "sprite": "sergeant", + "x": 162, + "y": 132, + "msgs": { + "look": "Sergeant Brasso. Chrome polished, patience not." + }, + "dialogue": [ + { + "says": "Morp-9. The data-slate case won't close itself.", + "choices": [ + { + "text": "Where do I start?", + "goto": 1, + "requires_flag": "", + "sets_flag": "", + "points": 0, + "kills": false + }, + { + "text": "What do I do with evidence?", + "goto": 2, + "requires_flag": "locker_open", + "sets_flag": "", + "points": 0, + "kills": false + }, + { + "text": "On it, Sarge.", + "goto": -1, + "requires_flag": "", + "sets_flag": "", + "points": 0, + "kills": false + } + ] + }, + { + "says": "Perp dumped a keycard in Rain Alley, east off Neon Row. It opens evidence locker 47.", + "choices": [ + { + "text": "Got it.", + "goto": 0, + "requires_flag": "", + "sets_flag": "", + "points": 0, + "kills": false + } + ] + }, + { + "says": "Slot the slate into the case terminal, west wall. Then we both go home.", + "choices": [ + { + "text": "Copy.", + "goto": 0, + "requires_flag": "", + "sets_flag": "", + "points": 0, + "kills": false + } + ] + } + ], + "wander": 0, + "visible_flag": "", + "hidden_by_flag": "", + "fixed_scale": null + } + ], + "music": 1, + "defaults": {}, + "script": "room1.rhai" +} \ No newline at end of file diff --git a/games/neon-precinct/rooms/room2.json b/games/neon-precinct/rooms/room2.json new file mode 100644 index 0000000..17aad03 --- /dev/null +++ b/games/neon-precinct/rooms/room2.json @@ -0,0 +1,684 @@ +{ + "name": "Rain Alley", + "enter_text": "The alley smells like rust and secrets. Crates wall off the far corner. Something glints behind them.", + "background": "", + "ops": [ + { + "VGradient": { + "x": 0, + "y": 0, + "w": 320, + "h": 100, + "ramp": [ + 33, + 76 + ], + "ink": { + "color": 0, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Polygon": { + "pts": [ + [ + 0, + 0 + ], + [ + 110, + 0 + ], + [ + 86, + 100 + ], + [ + 0, + 100 + ] + ], + "ink": { + "color": 75, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Polygon": { + "pts": [ + [ + 210, + 0 + ], + [ + 320, + 0 + ], + [ + 320, + 100 + ], + [ + 234, + 100 + ] + ], + "ink": { + "color": 111, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 0, + 10 + ], + [ + 101, + 10 + ] + ], + "ink": { + "color": 33, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 217, + 10 + ], + [ + 320, + 10 + ] + ], + "ink": { + "color": 68, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 0, + 24 + ], + [ + 98, + 24 + ] + ], + "ink": { + "color": 33, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 220, + 24 + ], + [ + 320, + 24 + ] + ], + "ink": { + "color": 68, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 0, + 38 + ], + [ + 96, + 38 + ] + ], + "ink": { + "color": 33, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 222, + 38 + ], + [ + 320, + 38 + ] + ], + "ink": { + "color": 68, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 0, + 52 + ], + [ + 94, + 52 + ] + ], + "ink": { + "color": 33, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 224, + 52 + ], + [ + 320, + 52 + ] + ], + "ink": { + "color": 68, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 0, + 66 + ], + [ + 91, + 66 + ] + ], + "ink": { + "color": 33, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 227, + 66 + ], + [ + 320, + 66 + ] + ], + "ink": { + "color": 68, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 0, + 80 + ], + [ + 89, + 80 + ] + ], + "ink": { + "color": 33, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 229, + 80 + ], + [ + 320, + 80 + ] + ], + "ink": { + "color": 68, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 0, + 94 + ], + [ + 87, + 94 + ] + ], + "ink": { + "color": 33, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Line": { + "pts": [ + [ + 231, + 94 + ], + [ + 320, + 94 + ] + ], + "ink": { + "color": 68, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, + { + "Rect": { + "x": 250, + "y": 30, + "w": 4, + "h": 16, + "ink": { + "color": 248, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 254, + "y": 30, + "w": 6, + "h": 4, + "ink": { + "color": 250, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 254, + "y": 36, + "w": 6, + "h": 4, + "ink": { + "color": 252, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 254, + "y": 42, + "w": 6, + "h": 4, + "ink": { + "color": 254, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "VGradient": { + "x": 0, + "y": 100, + "w": 320, + "h": 90, + "ramp": [ + 21, + 19, + 18 + ], + "ink": { + "color": 0, + "pri": "Band", + "ctl": { + "Set": 0 + }, + "hot": null + } + } + }, + { + "Ellipse": { + "cx": 150, + "cy": 152, + "rx": 44, + "ry": 8, + "ink": { + "color": 77, + "pri": "Keep", + "ctl": { + "Set": 2 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 60, + "y": 20, + "w": 6, + "h": 66, + "ink": { + "color": 24, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + }, + { + "Rect": { + "x": 60, + "y": 86, + "w": 6, + "h": 4, + "ink": { + "color": 26, + "pri": "Keep", + "ctl": { + "Set": 1 + }, + "hot": null + } + } + } + ], + "palette": null, + "cycles": [ + { + "start": 248, + "len": 8, + "period": 4, + "reverse": true, + "active": true + } + ], + "scale": { + "horizon_y": 95, + "min_scale": 0.6, + "full_y": 185 + }, + "spawn": [ + 24, + 160 + ], + "exits": [ + null, + null, + null, + 0 + ], + "exit_flags": [ + "", + "", + "", + "" + ], + "exit_blocked": [ + "", + "", + "", + "" + ], + "hotspots": [ + { + "name": "pipe", + "rect": [ + 58, + 18, + 10, + 74 + ], + "poly": [], + "msgs": { + "listen": "Drip. Drip. Drip. It's in 7/8 time, somehow.", + "look": "A drainage pipe keeping its own beat." + }, + "exit_to": null, + "arrive": null, + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + }, + { + "name": "bar sign", + "rect": [ + 246, + 26, + 20, + 24 + ], + "poly": [], + "msgs": { + "look": "BAR, says the little neon, economically." + }, + "exit_to": null, + "arrive": null, + "requires_flag": "", + "blocked_text": "", + "trigger": false, + "blocks": false + } + ], + "props": [ + { + "name": "crate stack", + "sprite": "crate", + "x": 226, + "y": 138, + "msgs": { + "look": "Shipping crates. Stenciled: PROPERTY OF NOBODY, HONEST." + }, + "takeable": false, + "synonyms": "crates boxes", + "use_text": "", + "needs": "", + "wins": false, + "requires_flag": "", + "sets_flag": "", + "dialogue": [], + "points": 0, + "consumes": false, + "kills": false, + "visible_flag": "", + "hidden_by_flag": "", + "solid": true, + "fixed_priority": null, + "fixed_scale": null + }, + { + "name": "more crates", + "sprite": "crate", + "x": 258, + "y": 130, + "msgs": { + "look": "More crates. The alley is basically municipal Tetris." + }, + "takeable": false, + "synonyms": "", + "use_text": "", + "needs": "", + "wins": false, + "requires_flag": "", + "sets_flag": "", + "dialogue": [], + "points": 0, + "consumes": false, + "kills": false, + "visible_flag": "", + "hidden_by_flag": "", + "solid": true, + "fixed_priority": null, + "fixed_scale": null + }, + { + "name": "keycard", + "sprite": "keycard", + "x": 268, + "y": 112, + "msgs": { + "look": "A precinct keycard, dropped in a hurry. Evidence locker 47, if you had to guess." + }, + "takeable": true, + "synonyms": "", + "use_text": "", + "needs": "", + "wins": false, + "requires_flag": "", + "sets_flag": "", + "dialogue": [], + "points": 5, + "consumes": false, + "kills": false, + "visible_flag": "", + "hidden_by_flag": "", + "solid": false, + "fixed_priority": null, + "fixed_scale": null + }, + { + "name": "fusebox", + "sprite": "fusebox", + "x": 96, + "y": 96, + "msgs": { + "look": "A junction box, sparking gently. Every instinct you have files a hazard report." + }, + "takeable": false, + "synonyms": "", + "use_text": "You touch the sparking fusebox. For 0.4 glorious seconds you are the brightest thing on Neon Row.", + "needs": "", + "wins": false, + "requires_flag": "", + "sets_flag": "", + "dialogue": [], + "points": 0, + "consumes": false, + "kills": true, + "visible_flag": "", + "hidden_by_flag": "", + "solid": false, + "fixed_priority": 15, + "fixed_scale": null + } + ], + "npcs": [], + "music": 2, + "defaults": {}, + "script": "room2.rhai" +} \ No newline at end of file diff --git a/games/neon-precinct/scripts/main.rhai b/games/neon-precinct/scripts/main.rhai new file mode 100644 index 0000000..8299580 --- /dev/null +++ b/games/neon-precinct/scripts/main.rhai @@ -0,0 +1,8 @@ +// Neon Precinct — global helpers. Merged under every room script. + +// One-shot latch: true the first time, false forever after. +fn once(name) { + if flag(name) { return false; } + set_flag(name, true); + true +} diff --git a/games/neon-precinct/scripts/room0.rhai b/games/neon-precinct/scripts/room0.rhai new file mode 100644 index 0000000..826c5b1 --- /dev/null +++ b/games/neon-precinct/scripts/room0.rhai @@ -0,0 +1,14 @@ +// Neon Row. + +fn on_enter() { + if once("seen_street") { + say("Your shift started four hours ago. The rain never clocked out."); + } +} + +fn on_trigger(name) { + if name == "tripwire" && once("rat_scare") { + play("scan"); + say("A rat the size of a toaster bolts from under the EAT sign, swearing in ultrasonic."); + } +} diff --git a/games/neon-precinct/scripts/room1.rhai b/games/neon-precinct/scripts/room1.rhai new file mode 100644 index 0000000..009c87f --- /dev/null +++ b/games/neon-precinct/scripts/room1.rhai @@ -0,0 +1,10 @@ +// Precinct lobby: the locker gives up its evidence once it's open. + +fn on_verb(verb, noun) { + if noun == "locker" && flag("locker_open") && !has("evidence") && (verb == "do" || verb == "look") { + give("evidence"); + say("You bag the data-slate. Chain of custody: immaculate."); + return true; + } + false +} diff --git a/games/neon-precinct/scripts/room2.rhai b/games/neon-precinct/scripts/room2.rhai new file mode 100644 index 0000000..8b51f47 --- /dev/null +++ b/games/neon-precinct/scripts/room2.rhai @@ -0,0 +1,18 @@ +// Rain Alley: an after() timer keeps the pipe dripping — on the game's +// deterministic 30Hz clock, never the CPU's. + +fn on_enter() { + if val("drip_armed") == 0 { + set_val("drip_armed", 1); + after(240, "drip"); + } +} + +fn drip() { + if room() == 2 { + say("The pipe drips, once, with great ceremony."); + after(300, "drip"); + } else { + set_val("drip_armed", 0); + } +} diff --git a/games/neon-precinct/sprites/crate.png b/games/neon-precinct/sprites/crate.png new file mode 100644 index 0000000000000000000000000000000000000000..b419317c2eb3ec2632da78313df3657765047a41 GIT binary patch literal 240 zcmeAS@N?(olHy`uVBq!ia0vp^Qa~)i!3HEJJLv8MQrkUU978G?-yUFIpb|Ol*vJ3n z?|M(1SzhFwye{|lHYm#MnK#XA{X8RMrFTEewr5o>Yq!q6y8hn__5JI9hX44xe=l=G zc>T%QKf)J$if^vEZM*wV^qsQvdjGjX{$&UJT<`dFKiky*Qc%v`xBHm?>E`Wz-%!u0 z_1|>G_L$8__aDpsF3V7G+BEu)HiibE?yuW^^ZnSq1Ef=Q#h>dBYq!q6y8e@o{%^(njDPGuPv`#% zWeoY3zW9^9^wj@1SNu8e_O#yY%d7c+uCZC{Dmn6BMf$iI!wQ?r$2Ri*V_yVR_4Q8f z^B?j(r~c<)Xw&+?Iq>KFUQ{WY>5K30?|z>C_-g(G`%4$!-#_|!^T$`_9~cnYf4FBJ W6mnbmqv<}-TMVAAelF{r5}E)c6@^m( literal 0 HcmV?d00001 diff --git a/games/neon-precinct/sprites/fusebox.png b/games/neon-precinct/sprites/fusebox.png new file mode 100644 index 0000000000000000000000000000000000000000..8502633bcd54b13062980ff8222716331c897f78 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0vp^d_XM3!3HF=W8NDAsokC~jv*C{Zx1jpP>Gy&?BoCP zcfBXhEHCm-UYC1&8x&>s%$w%5ex8xB(z~B!+q0^cwOeOjU0?U)c-^j_*B5`vcYZoE zZ}(fiIFaplii8=a)=O*sHw%etnE&e*W5f2JC+0nGKDD10Dm?GB|K6X$JKluve6#%C zoYVK7J(oYn9Po4d!cQ?EHs1mN+P4e`Zr7xo|Lpkme%mST4`%W=KRba{fGyhjRPCSW eia)nM^q*fQG~a*m25q1>7(8A5T-G@yGywoPUwEwm literal 0 HcmV?d00001 diff --git a/games/neon-precinct/sprites/keycard.png b/games/neon-precinct/sprites/keycard.png new file mode 100644 index 0000000000000000000000000000000000000000..cd3d773614c0e442aacfdd770684ffb4b2b3a6ac GIT binary patch literal 169 zcmeAS@N?(olHy`uVBq!ia0vp^JV4CB!3HGHK9TzfqzXJ;978G?-yUFIpb|Ol*vJ3n z?|M(1SzhFwye{|lHYm#MnK#XA{X8RMrFTEewr5o>Yq!q6y8hnRCN6fZflKuTFwhu0UHx3vIVCg!0M}Tz(f|Me literal 0 HcmV?d00001 diff --git a/games/neon-precinct/sprites/sergeant.png b/games/neon-precinct/sprites/sergeant.png new file mode 100644 index 0000000000000000000000000000000000000000..0ecd420bc191eb3ebc39cebf9782002192e1e2a1 GIT binary patch literal 331 zcmeAS@N?(olHy`uVBq!ia0vp^LO`s-!3HE--ex^#U|9>%ZZOJq-K*Z+g#gVD;L9s*z+s?pTv3gssANH;u^yLpO$~z824Ad zzDK<2R6P$!&!6KCHU}2R|2=(n{~_VJ|BsJ~hWtwosAR1Be7b(t^XH}Z|JGL80aY#d zB;S0B`@=5z|JVL#2mI`Jd}_}IG8L#88;AYY-$1z+ob~^gKVp>o`)GZ}JbQ?3U~^zr cV{qzO4qum^5y5E23JfF$Pgg&ebxsLQ0Cq^C%>V!Z literal 0 HcmV?d00001 diff --git a/games/neon-precinct/sprites/terminal.png b/games/neon-precinct/sprites/terminal.png new file mode 100644 index 0000000000000000000000000000000000000000..99d138c5f2f2e6d6c2b25dfd2368c3de9a82ea5b GIT binary patch literal 314 zcmeAS@N?(olHy`uVBq!ia0vp^azL!W!3HE*9Zu9UFfcNDx;TbZFupy&yg(&#+Od!S z%ir~$IJ3OSJ9%C1?QKw$*)wmN*ZO%z#!BygmTk|fTGnozeRci5uebMa|GD1%>3;U9 z|K&pD9(;fQGx?wK-e0Bb&lm^(eDC=5XT@Rp+WLRCbzf!o&#h+!shZaxUwb_NxxFNW zWB%vj_vi7C!|#8*{WE;gr~A#PK7U;O{>R%t>Wd+qHv3=y{<#0RTK=ajjzR0c?Ft+D z{lEI|pQ|?lasGT?08+-h2V`fxAd*l${}G@|viB|gssG{k{@<&aU~236QB?gC-2PWW V@QC>uKVX^*=1ucjKhMZm>D|w=?O9dJ+O4y%uK(m?|Nqtcynnp+{(P^G zXPEk5BII9kK&4*(x4ZdgyB~?)`@Ou9LF>Qaia*B}e3EZIRnG(B*i4VFct8Ky{RTL{ z-@g37-+h0+h{@N#YxrIJ^QErczdMZIe}B3hZ6gdZRbk%C=kb59p51?N_WytHxxvO2 z|GaSf-6PkJz1!~`U=H}%@A%Z7ZR+z+AntxfkhXa*yOW#i+2Jl=zFTzK`Rsmqs6&7D cAGBwZyDR;D$A?l+U`Q}{y85}Sb4q9e0E48Sv;Y7A literal 0 HcmV?d00001 diff --git a/mrpci-core/Cargo.toml b/mrpci-core/Cargo.toml new file mode 100644 index 0000000..2faf660 --- /dev/null +++ b/mrpci-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mrpci-core" +version = "0.1.0" +edition = "2021" +description = "MRPCI headless engine core — the SCI-generation sim: 256-color rooms, walkbox pathfinding, rhai scripts, and the command/event bus. Zero windowing dependencies: a compile error is the guardrail." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +image = { version = "0.24", default-features = false, features = ["png"] } # PNG-only: lean builds +# Room logic. "sync" keeps the engine Send+Sync (the HTTP surface shares +# GameState across threads); "no_time" bans wall-clock from scripts, so a +# replayed script is bit-identical — the whole CPU-speed-bug class, deleted. +rhai = { version = "1", features = ["sync", "no_time"] } + +# Native-only: the HTTP surface doesn't exist in browser builds. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tiny_http = "0.12" # the --serve control surface + +[[bin]] +name = "mrpci-headless" +path = "src/bin/mrpci-headless/main.rs" diff --git a/mrpci-core/src/actor.rs b/mrpci-core/src/actor.rs new file mode 100644 index 0000000..2dc5c43 --- /dev/null +++ b/mrpci-core/src/actor.rs @@ -0,0 +1,302 @@ +//! Actors — the ego and every NPC, one struct, no special cases. +//! +//! An actor's position is the bottom-center of its current cel — its "feet". +//! That single point drives depth (which priority band it stands in), +//! collision (what control flags it's about to step on) and **scale**: SCI1's +//! trick of shrinking an actor as it walks toward the horizon, computed from +//! the room's scale table so authors get perspective for free. + +use crate::path::{find_path, walk_line, Passable}; +use crate::screens::{band, Screens, PIC_H, PIC_W}; +use crate::view::View; +use serde::{Deserialize, Serialize}; + +/// 8-way direction → unit step. Index 0 = stopped. +/// 1=N 2=NE 3=E 4=SE 5=S 6=SW 7=W 8=NW (AGI ordering, kept engine-wide). +pub const DIRV: [(i32, i32); 9] = [ + (0, 0), + (0, -1), + (1, -1), + (1, 0), + (1, 1), + (0, 1), + (-1, 1), + (-1, 0), + (-1, -1), +]; + +/// The room's perspective model: actors at `horizon_y` render at +/// `min_scale`, growing linearly to full size at `full_y`. One struct is the +/// entire "walk into the distance and shrink" feature. +#[derive(Clone, Copy, Serialize, Deserialize)] +pub struct ScaleTable { + pub horizon_y: i32, + pub min_scale: f32, + pub full_y: i32, +} + +impl Default for ScaleTable { + fn default() -> Self { + // Flat rooms by default: no scaling until an author asks for it. + ScaleTable { horizon_y: 0, min_scale: 1.0, full_y: PIC_H as i32 } + } +} + +impl ScaleTable { + pub fn scale_at(&self, y: i32) -> f32 { + if (self.min_scale - 1.0).abs() < f32::EPSILON || self.full_y <= self.horizon_y { + return 1.0; + } + let t = (y - self.horizon_y) as f32 / (self.full_y - self.horizon_y) as f32; + (self.min_scale + t.clamp(0.0, 1.0) * (1.0 - self.min_scale)).clamp(0.1, 2.0) + } +} + +pub struct Actor { + pub name: String, + pub x: i32, + pub y: i32, + pub view: View, + pub cur_loop: usize, + pub mirrored: bool, + pub cur_cel: usize, + pub dir: u8, + pub step_size: i32, + pub cycle_div: u32, + cycle_count: u32, + pub fixed_priority: Option, + /// Overrides the room scale table (billboards, giants, pickups). + pub fixed_scale: Option, + /// The A* waypoint list currently being walked (last entry = destination). + pub path: Vec<(i32, i32)>, + pub visible: bool, + /// Scripts can freeze an actor mid-scene (cutscenes, dialogue). + pub frozen: bool, + pub water_ok: bool, + /// Base-point clamp box for this room. + pub min_x: i32, + pub max_x: i32, + pub min_y: i32, + pub max_y: i32, + /// Set for one cycle when a walk finishes — scripts poll `arrived()`. + pub arrived: bool, + /// Remaining mid-walk re-routes before giving up (dynamic obstacles). + replans: u8, +} + +impl Actor { + pub fn new(name: impl Into, view: View, x: i32, y: i32) -> Self { + Actor { + name: name.into(), + x, + y, + view, + cur_loop: 0, + mirrored: false, + cur_cel: 0, + dir: 0, + step_size: 3, + cycle_div: 3, + cycle_count: 0, + fixed_priority: None, + fixed_scale: None, + path: Vec::new(), + visible: true, + frozen: false, + water_ok: false, + min_x: 4, + max_x: PIC_W as i32 - 4, + min_y: 8, + max_y: PIC_H as i32 - 2, + arrived: false, + replans: 0, + } + } + + /// Depth priority: fixed if set, otherwise the band under its feet. + pub fn priority(&self) -> u8 { + self.fixed_priority.unwrap_or_else(|| band(self.y.clamp(0, PIC_H as i32 - 1) as usize)) + } + + pub fn scale(&self, table: &ScaleTable) -> f32 { + self.fixed_scale.unwrap_or_else(|| table.scale_at(self.y)) + } + + /// Footprint half-width at current scale (used by collision + A*). + pub fn half_w(&self, table: &ScaleTable) -> i32 { + let cel = self.view.cel(self.cur_loop, self.cur_cel); + ((cel.w as f32 * self.scale(table)) as i32 / 3).max(2) + } + + fn passable<'a>(&self, s: &'a Screens, table: &ScaleTable) -> Passable<'a> { + Passable { + s, + half_w: self.half_w(table), + water_ok: self.water_ok, + min: (self.min_x, self.min_y), + max: (self.max_x, self.max_y), + } + } + + /// Route to a target through A* — clicks, scripts and NPC AI all land here. + pub fn walk_to(&mut self, s: &Screens, table: &ScaleTable, tx: i32, ty: i32) { + self.path = find_path(&self.passable(s, table), (self.x, self.y), (tx, ty)); + self.arrived = false; + self.replans = 3; + } + + /// Set free-walk direction (keyboard). Cancels any path. + pub fn set_dir(&mut self, dir: u8) { + if dir != 0 { + self.path.clear(); + } + self.dir = dir.min(8); + if dir != 0 { + self.face(dir); + } + } + + fn face(&mut self, dir: u8) { + let (l, m) = self.view.loop_for_dir(dir); + self.cur_loop = l; + self.mirrored = m; + } + + pub fn walking(&self) -> bool { + self.dir != 0 || !self.path.is_empty() + } + + /// Advance one game cycle: follow the path (or held direction), collide + /// against the control screen, run the walk-cycle animation. + pub fn update(&mut self, s: &Screens, table: &ScaleTable) { + self.arrived = false; + if self.frozen || !self.visible { + return; + } + + // Perspective-correct speed: a distant (small) actor covers fewer + // pixels per step, so walking "into" the room looks right. + let step = ((self.step_size as f32) * self.scale(table)).round().max(1.0) as i32; + + // Path following: walk pixels along the exact line the pathfinder + // verified — no direction quantization, so a legal path can't clip. + if let Some(&(wx, wy)) = self.path.first() { + let pass = self.passable(s, table); + let stuck = !pass.ok(self.x, self.y); + let ((nx, ny), blocked) = walk_line(&pass, (self.x, self.y), (wx, wy), step, stuck); + if nx != self.x || ny != self.y { + self.dir = dir_from_delta(nx - self.x, ny - self.y); + self.face(self.dir); + self.x = nx; + self.y = ny; + self.animate(); + } + if (self.x, self.y) == (wx, wy) { + self.path.remove(0); + if self.path.is_empty() { + self.dir = 0; + self.cur_cel = 0; + self.arrived = true; + } + } else if blocked { + // Something moved into the way (or a pinch survived the + // planner). Re-route toward the final destination a few + // times, then concede. + let dest = *self.path.last().unwrap(); + if self.replans > 0 { + self.replans -= 1; + self.path = find_path(&pass, (self.x, self.y), dest); + if self.path.is_empty() { + self.stop(); + } + } else { + self.stop(); + } + } + return; + } + + if self.dir == 0 { + self.cur_cel = 0; + return; + } + + // Free walking (keyboard): 8-way steps with wall-slide + unstick. + let (sx, sy) = DIRV[self.dir as usize]; + let nx = (self.x + sx * step).clamp(self.min_x, self.max_x); + let ny = (self.y + sy * step).clamp(self.min_y, self.max_y); + + let pass = self.passable(s, table); + // Unstick rule: if the actor is ALREADY standing somewhere illegal + // (bad spawn, geometry changed under it), any move is allowed — + // otherwise it would be frozen there forever. Sierra shipped that + // exact freeze more than once; we decline to. + let stuck = !pass.ok(self.x, self.y); + if !stuck && !pass.ok(nx, ny) { + // Try sliding along one axis before giving up — hugging a wall + // diagonally shouldn't glue the ego in place. + if pass.ok(nx, self.y) && nx != self.x { + self.x = nx; + } else if pass.ok(self.x, ny) && ny != self.y { + self.y = ny; + } else { + self.stop(); + return; + } + } else { + if nx == self.x && ny == self.y { + self.stop(); + return; + } + self.x = nx; + self.y = ny; + } + self.animate(); + } + + fn animate(&mut self) { + self.cycle_count += 1; + if self.cycle_count >= self.cycle_div { + self.cycle_count = 0; + let n = self.view.loops[self.cur_loop.min(self.view.loops.len() - 1)].cels.len(); + if n > 0 { + self.cur_cel = (self.cur_cel + 1) % n; + } + } + } + + pub fn stop(&mut self) { + self.dir = 0; + self.path.clear(); + self.cur_cel = 0; + self.cycle_count = 0; + } +} + +pub fn dir_from_delta(dx: i32, dy: i32) -> u8 { + // Bias to the dominant axis so shallow angles read as horizontal walks. + let (h, v) = (dx.abs(), dy.abs()); + let sx = dx.signum(); + let sy = dy.signum(); + if h * 2 >= v && v * 2 >= h && sx != 0 && sy != 0 { + // genuinely diagonal + match (sx, sy) { + (1, -1) => 2, + (1, 1) => 4, + (-1, 1) => 6, + _ => 8, + } + } else if h >= v { + if sx > 0 { + 3 + } else if sx < 0 { + 7 + } else { + 0 + } + } else if sy > 0 { + 5 + } else { + 1 + } +} diff --git a/mrpci-core/src/assets.rs b/mrpci-core/src/assets.rs new file mode 100644 index 0000000..24f4057 --- /dev/null +++ b/mrpci-core/src/assets.rs @@ -0,0 +1,189 @@ +//! The asset pipeline — where modern art becomes 256-color game fabric. +//! +//! Images don't sit *on top* of an MRPCI room, they're folded *into* it: a +//! background PNG is **median-cut quantized to its own adaptive palette** +//! (written into the room palette's middle range, leaving EGA, the gray ramp +//! and the ember/cycle range stable), so from that moment it depth-sorts, +//! occludes and palette-cycles like everything else. Companion masks paint +//! the invisible screens: `*-pri.png` (grayscale → depth bands) and +//! `*-ctl.png` (black → wall, blue → water). + +use crate::palette::Palette; +use crate::screens::{Screens, CTL_BLOCK, CTL_WATER, PIC_H, PIC_W}; +use crate::view::{Cel, SpriteSrc}; +use std::path::Path; + +/// Where quantized background colors live in a room palette. Everything +/// outside this window (EGA 0..16, grays 16..32, embers 248..256) survives +/// quantization, so ASCII sprites and cycle ramps never shift under art. +pub const QUANT_LO: usize = 32; +pub const QUANT_HI: usize = 248; + +/// Load any PNG as raw RGBA, nearest-neighbor scaled to (w, h). +pub fn load_rgba_scaled(path: &Path, w: usize, h: usize) -> Option> { + let img = image::open(path).ok()?.to_rgba8(); + let (sw, sh) = (img.width() as usize, img.height() as usize); + if sw == 0 || sh == 0 { + return None; + } + let mut out = vec![0u8; w * h * 4]; + for y in 0..h { + let sy = y * sh / h; + for x in 0..w { + let sx = x * sw / w; + let p = img.get_pixel(sx as u32, sy as u32).0; + out[(y * w + x) * 4..(y * w + x) * 4 + 4].copy_from_slice(&p); + } + } + Some(out) +} + +/// Median-cut: reduce a pixel cloud to at most `n` representative colors. +/// The classic algorithm, compact: split the box with the widest channel +/// range at its median until we have `n` boxes, then average each. +pub fn median_cut(pixels: &[(u8, u8, u8)], n: usize) -> Vec<(u8, u8, u8)> { + if pixels.is_empty() || n == 0 { + return Vec::new(); + } + let mut boxes: Vec> = vec![pixels.to_vec()]; + while boxes.len() < n { + // Split the box with the widest single-channel range. + let (bi, ch) = boxes + .iter() + .enumerate() + .filter(|(_, b)| b.len() > 1) + .map(|(i, b)| { + let (mut lo, mut hi) = ([255u8; 3], [0u8; 3]); + for &(r, g, bl) in b.iter() { + for (k, v) in [r, g, bl].into_iter().enumerate() { + lo[k] = lo[k].min(v); + hi[k] = hi[k].max(v); + } + } + let ranges = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]; + let ch = (0..3).max_by_key(|&k| ranges[k]).unwrap(); + (i, ch, ranges[ch]) + }) + .max_by_key(|&(_, _, range)| range) + .map(|(i, ch, _)| (i, ch)) + .unwrap_or((usize::MAX, 0)); + if bi == usize::MAX { + break; // every box is a single color — done early + } + let mut b = boxes.swap_remove(bi); + b.sort_unstable_by_key(|&(r, g, bl)| [r, g, bl][ch]); + let mid = b.len() / 2; + let hi_half = b.split_off(mid); + boxes.push(b); + boxes.push(hi_half); + } + boxes + .into_iter() + .filter(|b| !b.is_empty()) + .map(|b| { + let n = b.len() as u32; + let (mut r, mut g, mut bl) = (0u32, 0u32, 0u32); + for &(pr, pg, pb) in &b { + r += pr as u32; + g += pg as u32; + bl += pb as u32; + } + ((r / n) as u8, (g / n) as u8, (bl / n) as u8) + }) + .collect() +} + +/// Quantize a full-screen background into the room: adaptive colors are +/// written into `palette[QUANT_LO..QUANT_HI]` and the visual screen filled +/// with nearest-match indices. Priority/control screens are untouched — +/// that's what the masks (or PicOps) are for. +pub fn bake_background(rgba: &[u8], palette: &mut Palette, s: &mut Screens) { + // Sample the pixel cloud (every 2nd pixel is plenty for median cut). + let mut cloud: Vec<(u8, u8, u8)> = Vec::with_capacity(PIC_W * PIC_H / 4); + for i in (0..PIC_W * PIC_H).step_by(2) { + cloud.push((rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2])); + } + let colors = median_cut(&cloud, QUANT_HI - QUANT_LO); + for (k, &(r, g, b)) in colors.iter().enumerate() { + palette.0[QUANT_LO + k] = (r, g, b); + } + // Map every pixel to its nearest palette entry (the whole palette, so a + // pure-EGA pixel can still land on its exact EGA slot). + for i in 0..PIC_W * PIC_H { + s.visual[i] = palette.nearest(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]); + } +} + +/// Grayscale priority mask → depth bands: band = luma / 16. White = nearest +/// the camera (band 15), black = deepest. Paint your walls-you-walk-behind +/// as light strokes over a dark floor gradient and it Just Works. +pub fn bake_pri_mask(rgba: &[u8], s: &mut Screens) { + for i in 0..PIC_W * PIC_H { + let (r, g, b) = (rgba[i * 4] as u32, rgba[i * 4 + 1] as u32, rgba[i * 4 + 2] as u32); + let luma = (r * 30 + g * 59 + b * 11) / 100; + s.priority[i] = (luma / 16).min(15) as u8; + } +} + +/// Control mask: near-black = wall, blue-dominant = water, all else open. +pub fn bake_ctl_mask(rgba: &[u8], s: &mut Screens) { + for i in 0..PIC_W * PIC_H { + let (r, g, b) = (rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]); + s.control[i] = if (r as u16 + g as u16 + b as u16) < 90 { + CTL_BLOCK + } else if b > 128 && b / 2 > r && b / 2 > g { + CTL_WATER + } else { + 0 + }; + } +} + +/// Load a sprite PNG as a raw RGBA source (quantized per-room at bake time, +/// so sprites always match the palette the room actually ended up with). +pub fn load_sprite(path: &Path) -> Option { + let img = image::open(path).ok()?.to_rgba8(); + Some(SpriteSrc { w: img.width() as usize, h: img.height() as usize, rgba: img.into_raw() }) +} + +/// Every PNG in a directory, keyed by file stem. Missing directory = empty +/// list, never an error — games without art are still games. +pub fn load_sprite_dir(dir: &Path) -> Vec<(String, SpriteSrc)> { + let mut out = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { + return out; + }; + let mut entries: Vec<_> = rd.flatten().collect(); + entries.sort_by_key(|e| e.file_name()); + for e in entries { + let path = e.path(); + if path.extension().and_then(|s| s.to_str()) != Some("png") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if stem.ends_with("-pri") || stem.ends_with("-ctl") { + continue; // masks are for pics, not sprites + } + if let Some(src) = load_sprite(&path) { + out.push((stem.to_string(), src)); + } + } + out +} + +/// Quantize a sprite source against a palette: alpha < 128 is transparent, +/// everything else snaps to its nearest entry. +pub fn sprite_to_cel(src: &SpriteSrc, palette: &Palette) -> Cel { + let mut pix = vec![0u8; src.w * src.h]; + let mut solid = vec![false; src.w * src.h]; + for i in 0..src.w * src.h { + let a = src.rgba[i * 4 + 3]; + if a >= 128 { + pix[i] = palette.nearest(src.rgba[i * 4], src.rgba[i * 4 + 1], src.rgba[i * 4 + 2]); + solid[i] = true; + } + } + Cel { w: src.w, h: src.h, pix, solid } +} diff --git a/mrpci-core/src/audio.rs b/mrpci-core/src/audio.rs new file mode 100644 index 0000000..335c8b6 --- /dev/null +++ b/mrpci-core/src/audio.rs @@ -0,0 +1,225 @@ +//! Audio *content*, not audio *output*. The core never touches a sound +//! device — it emits [`AudioCue`]/music events on the bus and synthesizes +//! WAV bytes on request. Front-ends decide how (and whether) to play them. +//! +//! The SCI-generation upgrade over MRPGI's single square wave: a little +//! **multi-voice chip** — square, pulse, triangle and noise voices mixed +//! down together — so cues have body and music moods carry a bassline. + +use serde::{Deserialize, Serialize}; + +const SR: u32 = 22050; + +#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Wave { + Square, + /// 25% duty pulse — thinner, NES-lead flavored. + Pulse, + Triangle, + /// LFSR noise; `freq` sets the shift rate (percussion, static). + Noise, +} + +/// One voice's note list: (frequency Hz, duration ms). Frequency 0 = rest. +pub type Notes = Vec<(f32, u32)>; + +#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AudioCue { + Blip, + Pickup, + Confirm, + Error, + Door, + Win, + Scan, + Alert, +} + +impl AudioCue { + pub const ALL: [AudioCue; 8] = [ + AudioCue::Blip, + AudioCue::Pickup, + AudioCue::Confirm, + AudioCue::Error, + AudioCue::Door, + AudioCue::Win, + AudioCue::Scan, + AudioCue::Alert, + ]; + + pub fn name(self) -> &'static str { + match self { + AudioCue::Blip => "blip", + AudioCue::Pickup => "pickup", + AudioCue::Confirm => "confirm", + AudioCue::Error => "error", + AudioCue::Door => "door", + AudioCue::Win => "win", + AudioCue::Scan => "scan", + AudioCue::Alert => "alert", + } + } + + /// The built-in chiptune rendering of this cue, as WAV bytes. + pub fn wav(self) -> Vec { + match self { + AudioCue::Blip => mix(&[(Wave::Pulse, vec![(660.0, 18)], 0.18)]), + AudioCue::Pickup => mix(&[ + (Wave::Pulse, vec![(660.0, 55), (988.0, 80)], 0.16), + (Wave::Triangle, vec![(330.0, 55), (494.0, 80)], 0.14), + ]), + AudioCue::Confirm => mix(&[ + (Wave::Square, vec![(523.0, 55), (784.0, 85)], 0.14), + (Wave::Triangle, vec![(262.0, 140)], 0.12), + ]), + AudioCue::Error => mix(&[ + (Wave::Square, vec![(196.0, 90), (147.0, 150)], 0.16), + (Wave::Noise, vec![(2000.0, 40)], 0.05), + ]), + AudioCue::Door => mix(&[ + (Wave::Square, vec![(392.0, 60), (294.0, 60), (196.0, 120)], 0.14), + (Wave::Noise, vec![(0.0, 120), (900.0, 60)], 0.06), + ]), + AudioCue::Win => mix(&[ + (Wave::Pulse, vec![(523.0, 90), (659.0, 90), (784.0, 90), (1047.0, 240)], 0.15), + (Wave::Triangle, vec![(262.0, 180), (330.0, 90), (523.0, 240)], 0.13), + ]), + AudioCue::Scan => mix(&[(Wave::Pulse, vec![(880.0, 30), (1100.0, 30), (1320.0, 60)], 0.12)]), + AudioCue::Alert => mix(&[ + (Wave::Square, vec![(880.0, 120), (660.0, 120), (880.0, 120), (660.0, 120)], 0.15), + ]), + } + } +} + +/// Mood names matching the room picker: 0 = off, 1..=6 below. +pub const MOODS: [&str; 7] = ["off", "calm", "eerie", "tense", "jolly", "spooky", "noir"]; + +/// The built-in looping ambient track for a mood (None = silence / unknown). +/// Every mood is lead + bass now; a couple add a noise hat. +pub fn music_wav(mood: u8) -> Option> { + let track: Vec<(Wave, Notes, f32)> = match mood { + 1 => vec![ + (Wave::Pulse, vec![(262.0, 300), (0.0, 150), (330.0, 300), (0.0, 150), (392.0, 420), (0.0, 280), (330.0, 280), (0.0, 160), (294.0, 320), (0.0, 700)], 0.11), + (Wave::Triangle, vec![(131.0, 900), (98.0, 800), (110.0, 900), (98.0, 760)], 0.10), + ], + 2 => vec![ + (Wave::Square, vec![(220.0, 420), (0.0, 320), (311.0, 420), (0.0, 520), (233.0, 420), (0.0, 900)], 0.10), + (Wave::Triangle, vec![(55.0, 1500), (58.3, 1500)], 0.11), + ], + 3 => vec![ + (Wave::Pulse, vec![(147.0, 170), (0.0, 110), (147.0, 170), (0.0, 110), (165.0, 170), (0.0, 110), (147.0, 170), (0.0, 520)], 0.12), + (Wave::Noise, vec![(0.0, 140), (3000.0, 40), (0.0, 240), (3000.0, 40), (0.0, 240), (3000.0, 40), (0.0, 640)], 0.04), + ], + 4 => vec![ + (Wave::Pulse, vec![(523.0, 150), (659.0, 150), (784.0, 150), (659.0, 150), (523.0, 150), (0.0, 280), (587.0, 150), (784.0, 150), (0.0, 460)], 0.10), + (Wave::Triangle, vec![(131.0, 300), (165.0, 300), (196.0, 300), (165.0, 300), (131.0, 450), (147.0, 450)], 0.10), + ], + 5 => vec![ + (Wave::Square, vec![(165.0, 520), (0.0, 700), (196.0, 420), (0.0, 520), (147.0, 620), (0.0, 1100)], 0.10), + (Wave::Triangle, vec![(41.2, 1900), (49.0, 1980)], 0.10), + ], + 6 => vec![ + // noir: brushed hats over a slow walking bass, lead sits out mostly + (Wave::Triangle, vec![(98.0, 600), (110.0, 600), (117.0, 600), (110.0, 600), (98.0, 620), (87.3, 620), (92.5, 640), (98.0, 640)], 0.12), + (Wave::Pulse, vec![(0.0, 1200), (294.0, 260), (0.0, 340), (277.0, 300), (0.0, 2000)], 0.07), + (Wave::Noise, vec![(0.0, 300), (2400.0, 25), (0.0, 275), (2400.0, 25), (0.0, 275), (2400.0, 25), (0.0, 275), (2400.0, 25)], 0.025), + ], + _ => return None, + }; + Some(mix(&track)) +} + +/// Render one voice into a float buffer at its offset. +fn render_voice(buf: &mut Vec, wave: Wave, notes: &Notes, vol: f32) { + let mut pos = 0usize; + let mut lfsr: u32 = 0xACE1; + for &(freq, ms) in notes { + let n = (SR as u64 * ms as u64 / 1000) as usize; + if buf.len() < pos + n { + buf.resize(pos + n, 0.0); + } + if freq <= 0.0 { + pos += n; + continue; + } + let period = (SR as f32 / freq).max(2.0); + let mut noise_val = 1.0f32; + let mut noise_count = 0f32; + for i in 0..n { + let phase = (i as f32 % period) / period; + let raw = match wave { + Wave::Square => { + if phase < 0.5 { + 1.0 + } else { + -1.0 + } + } + Wave::Pulse => { + if phase < 0.25 { + 1.0 + } else { + -1.0 + } + } + Wave::Triangle => 4.0 * (phase - 0.5).abs() - 1.0, + Wave::Noise => { + noise_count += freq / SR as f32; + if noise_count >= 1.0 { + noise_count -= 1.0; + let bit = ((lfsr >> 0) ^ (lfsr >> 2) ^ (lfsr >> 3) ^ (lfsr >> 5)) & 1; + lfsr = (lfsr >> 1) | (bit << 15); + noise_val = if lfsr & 1 == 1 { 1.0 } else { -1.0 }; + } + noise_val + } + }; + // quick attack, linear decay — keeps it from clicking + let t = i as f32 / n.max(1) as f32; + let env = (t * 10.0).min(1.0) * (1.0 - t); + buf[pos + i] += raw * vol * env * 0.7; + } + pos += n; + } +} + +/// Mix voices → mono 16-bit WAV bytes, with a soft clip so stacked voices +/// never wrap around into crackle. +pub fn mix(voices: &[(Wave, Notes, f32)]) -> Vec { + let mut buf: Vec = Vec::new(); + for (w, notes, vol) in voices { + render_voice(&mut buf, *w, notes, *vol); + } + let samples: Vec = buf.iter().map(|&v| (v.tanh() * i16::MAX as f32) as i16).collect(); + wav_bytes(&samples) +} + +/// Single-voice convenience, MRPGI-compatible shape. +pub fn seq(notes: &[(f32, u32)], vol: f32) -> Vec { + mix(&[(Wave::Square, notes.to_vec(), vol)]) +} + +pub fn wav_bytes(samples: &[i16]) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut b = Vec::with_capacity(44 + data_len as usize); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data_len).to_le_bytes()); + b.extend_from_slice(b"WAVE"); + b.extend_from_slice(b"fmt "); + b.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size + b.extend_from_slice(&1u16.to_le_bytes()); // format = PCM + b.extend_from_slice(&1u16.to_le_bytes()); // channels = mono + b.extend_from_slice(&SR.to_le_bytes()); // sample rate + b.extend_from_slice(&(SR * 2).to_le_bytes()); // byte rate + b.extend_from_slice(&2u16.to_le_bytes()); // block align + b.extend_from_slice(&16u16.to_le_bytes()); // bits per sample + b.extend_from_slice(b"data"); + b.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + b.extend_from_slice(&s.to_le_bytes()); + } + b +} diff --git a/mrpci-core/src/bin/mrpci-headless/http.rs b/mrpci-core/src/bin/mrpci-headless/http.rs new file mode 100644 index 0000000..3f75856 --- /dev/null +++ b/mrpci-core/src/bin/mrpci-headless/http.rs @@ -0,0 +1,143 @@ +//! The HTTP control surface — a thin adapter over the bus for curl, Python, +//! browsers, anything. Single-threaded on purpose: one request at a time +//! keeps engine access lock-free and command ordering deterministic. +//! +//! POST /command body = one Command JSON → JSON array of Events +//! GET /state → StateSnapshot JSON +//! GET /frame.png → current frame, headless-rendered +//! GET /screen/{which}.png → priority|control|hotspot debug composite +//! GET /events?since=N → {"next":M,"events":[...]} (cursor polling) +//! POST /rooms/{n}[?persist] body = RoomDoc JSON → events (lore drop!) + +use mrpci_core::state::{Command, Event}; +use mrpci_core::GameState; +use tiny_http::{Header, Method, Response, Server}; + +struct Log { + base: usize, + events: Vec, +} + +impl Log { + fn push_all(&mut self, evs: &[Event]) { + for e in evs { + if let Ok(v) = serde_json::to_value(e) { + self.events.push(v); + } + } + if self.events.len() > 10_000 { + let drop = self.events.len() - 10_000; + self.events.drain(..drop); + self.base += drop; + } + } + + fn since(&self, n: usize) -> (usize, &[serde_json::Value]) { + let start = n.max(self.base) - self.base; + let start = start.min(self.events.len()); + (self.base + self.events.len(), &self.events[start..]) + } +} + +fn read_body(req: &mut tiny_http::Request) -> String { + let mut body = String::new(); + let _ = std::io::Read::read_to_string(&mut std::io::Read::take(req.as_reader(), 8 * 1024 * 1024), &mut body); + body +} + +pub fn serve(mut gs: GameState, port: u16) { + let addr = format!("127.0.0.1:{}", port); + let server = Server::http(&addr).unwrap_or_else(|e| panic!("can't bind {}: {}", addr, e)); + let mut log = Log { base: 0, events: Vec::new() }; + eprintln!("mrpci http listening on http://{} (POST /command, GET /state /frame.png /events)", addr); + + for mut req in server.incoming_requests() { + let url = req.url().to_string(); + let path = url.split('?').next().unwrap_or("").to_string(); + let query = url.split('?').nth(1).unwrap_or("").to_string(); + let method = req.method().clone(); + + let respond = |req: tiny_http::Request, code: u16, body: Vec, ctype: &str| { + let mut resp = Response::from_data(body).with_status_code(code); + resp.add_header(Header::from_bytes("Content-Type", ctype).unwrap()); + resp.add_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()); + let _ = req.respond(resp); + }; + let json = |req: tiny_http::Request, code: u16, v: serde_json::Value| { + respond(req, code, v.to_string().into_bytes(), "application/json"); + }; + + match (method, path.as_str()) { + (Method::Options, _) => { + let mut resp = Response::empty(204); + resp.add_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()); + resp.add_header(Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap()); + resp.add_header(Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap()); + let _ = req.respond(resp); + } + (Method::Post, "/command") => { + let body = read_body(&mut req); + match serde_json::from_str::(&body) { + Ok(cmd) => { + let evs = gs.apply(cmd); + log.push_all(&evs); + json(req, 200, serde_json::to_value(&evs).unwrap_or_default()); + } + Err(e) => json(req, 400, serde_json::json!({"error": format!("bad command: {}", e)})), + } + } + (Method::Get, "/state") => { + json(req, 200, serde_json::to_value(gs.snapshot()).unwrap_or_default()); + } + (Method::Get, "/frame.png") => { + respond(req, 200, gs.render_png(true), "image/png"); + } + (Method::Get, p) if p.starts_with("/screen/") && p.ends_with(".png") => { + let which = p.trim_start_matches("/screen/").trim_end_matches(".png"); + let rgba = mrpci_core::render::debug_screen(&gs.world.screens, which); + respond(req, 200, mrpci_core::render::encode_png(&rgba), "image/png"); + } + (Method::Get, "/events") => { + let since: usize = query + .split('&') + .find_map(|kv| kv.strip_prefix("since=")) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let (next, events) = log.since(since); + json(req, 200, serde_json::json!({ "next": next, "events": events })); + } + (Method::Post, p) if p.starts_with("/rooms/") => { + let n: Option = p.trim_start_matches("/rooms/").parse().ok(); + let body = read_body(&mut req); + match (n, serde_json::from_str::(&body)) { + (Some(n), Ok(doc)) => { + let persist = query.split('&').any(|kv| kv == "persist" || kv == "persist=1" || kv == "persist=true"); + let evs = gs.apply(Command::UpsertRoom { n, doc, persist }); + log.push_all(&evs); + json(req, 200, serde_json::to_value(&evs).unwrap_or_default()); + } + (None, _) => json(req, 400, serde_json::json!({"error": "room number must be an integer"})), + (_, Err(e)) => json(req, 400, serde_json::json!({"error": format!("bad RoomDoc: {}", e)})), + } + } + (Method::Get, "/") => { + respond(req, 200, INDEX.as_bytes().to_vec(), "text/plain; charset=utf-8"); + } + _ => json(req, 404, serde_json::json!({"error": "no such route"})), + } + } +} + +const INDEX: &str = "\ +MRPCI headless HTTP surface. + +POST /command one Command JSON, e.g. {\"cmd\":\"verb_at\",\"verb\":\"look\",\"x\":100,\"y\":120} +GET /state full state snapshot +GET /frame.png current frame (rendered with no window) +GET /screen/priority.png |control|hotspot — the invisible screens, visualized +GET /events?since=N event log from cursor N; response carries the next cursor +POST /rooms/{n} body = RoomDoc JSON (add ?persist to write to disk) + +The engine only advances when told: send {\"cmd\":\"tick\",\"n\":30} to run a second +of game time, or use walk_to which ticks until arrival. +"; diff --git a/mrpci-core/src/bin/mrpci-headless/main.rs b/mrpci-core/src/bin/mrpci-headless/main.rs new file mode 100644 index 0000000..92f9212 --- /dev/null +++ b/mrpci-core/src/bin/mrpci-headless/main.rs @@ -0,0 +1,129 @@ +//! mrpci-headless — every control surface for the MRPCI engine in one +//! binary: JSONL stdio (the substrate), --serve HTTP, --mcp for Claude, +//! --script replay for golden tests, --render-room for LLM eyes, --sample +//! to write the demo game. + +mod http; +mod mcp; +mod sample; + +use mrpci_core::{Command, Event, GameState, World}; +use std::io::{BufRead, Write}; + +fn main() { + let args: Vec = std::env::args().collect(); + let get = |flag: &str| -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned()) + }; + let has = |flag: &str| args.iter().any(|a| a == flag); + + if has("--help") || has("-h") { + println!( + "mrpci-headless — the MRPCI engine, no window\n\n\ + USAGE:\n mrpci-headless [--game DIR] [MODE]\n\n\ + MODES (default: JSONL stdio — one JSON command per line):\n\ + --serve [PORT] HTTP surface (default 8093)\n\ + --mcp MCP server on stdio (for Claude)\n\ + --script FILE replay a JSONL command log, print events\n\ + --render-room FILE render the current room to a PNG and exit\n\ + --screens DIR dump visual/priority/control/hotspot PNGs\n\ + --sample write the demo game into games/neon-precinct\n\ + --seed N seed the deterministic RNG\n\ + --room N jump to room N at boot (with --render-room etc)\n\ + --ticks N advance N cycles at boot (palette cycling, NPCs)\n" + ); + return; + } + + if has("--sample") { + let dir = get("--sample-dir").unwrap_or_else(|| "games/neon-precinct".into()); + match sample::write_sample(&dir) { + Ok(()) => println!("sample game written to {}", dir), + Err(e) => { + eprintln!("sample write failed: {}", e); + std::process::exit(1); + } + } + return; + } + + let game_dir = get("--game").unwrap_or_else(|| ".".into()); + let world = World::load_game(&game_dir); + let mut gs = GameState::new(world); + if let Some(seed) = get("--seed").and_then(|s| s.parse::().ok()) { + gs.apply(Command::SetSeed { seed }); + } + if let Some(n) = get("--room").and_then(|s| s.parse::().ok()) { + gs.apply(Command::GotoRoom { n }); + } + if let Some(n) = get("--ticks").and_then(|s| s.parse::().ok()) { + gs.apply(Command::Tick { n }); + } + + if let Some(path) = get("--render-room") { + let png = gs.render_png(true); + std::fs::write(&path, png).expect("write png"); + println!("rendered {} ({} room {})", path, gs.world.manifest.name, gs.world.current); + return; + } + + if let Some(dir) = get("--screens") { + std::fs::create_dir_all(&dir).expect("mkdir"); + std::fs::write(format!("{}/visual.png", dir), gs.render_png(true)).unwrap(); + for which in ["priority", "control", "hotspot"] { + let rgba = mrpci_core::render::debug_screen(&gs.world.screens, which); + std::fs::write(format!("{}/{}.png", dir, which), mrpci_core::render::encode_png(&rgba)).unwrap(); + } + println!("screens dumped to {}", dir); + return; + } + + if has("--mcp") { + mcp::serve(gs); + return; + } + + if has("--serve") { + let port: u16 = get("--serve").and_then(|p| p.parse().ok()).unwrap_or(8093); + http::serve(gs, port); + return; + } + + if let Some(path) = get("--script") { + let f = std::fs::File::open(&path).unwrap_or_else(|e| { + eprintln!("can't open {}: {}", path, e); + std::process::exit(1); + }); + for line in std::io::BufReader::new(f).lines().map_while(Result::ok) { + run_line(&mut gs, &line); + } + return; + } + + // The substrate: JSONL on stdio. + let stdin = std::io::stdin(); + for line in stdin.lock().lines().map_while(Result::ok) { + run_line(&mut gs, &line); + } +} + +fn run_line(gs: &mut GameState, line: &str) { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return; + } + let out = std::io::stdout(); + let mut out = out.lock(); + match serde_json::from_str::(line) { + Ok(cmd) => { + for ev in gs.apply(cmd) { + let _ = writeln!(out, "{}", serde_json::to_string(&ev).unwrap_or_default()); + } + } + Err(e) => { + let ev = Event::Error { message: format!("bad command: {}", e) }; + let _ = writeln!(out, "{}", serde_json::to_string(&ev).unwrap_or_default()); + } + } + let _ = out.flush(); +} diff --git a/mrpci-core/src/bin/mrpci-headless/mcp.rs b/mrpci-core/src/bin/mrpci-headless/mcp.rs new file mode 100644 index 0000000..d110d29 --- /dev/null +++ b/mrpci-core/src/bin/mrpci-headless/mcp.rs @@ -0,0 +1,185 @@ +//! The MCP control surface — newline-delimited JSON-RPC 2.0 on stdio, the +//! Model Context Protocol's stdio transport. This is how Claude both AUTHORS +//! a game (upsert rooms/scripts) and PLAYS it (verbs, walks, ticks), with +//! `mrpci_render_frame` returning a real PNG so the model can *see* the +//! frame it just produced — including the invisible priority/control/hotspot +//! screens. +//! +//! Register with e.g.: +//! claude mcp add mrpci -- /path/to/mrpci-headless --mcp --game games/neon-precinct + +use mrpci_core::state::{Command, Event}; +use mrpci_core::GameState; +use serde_json::{json, Value}; +use std::io::{BufRead, Write}; + +pub fn serve(mut gs: GameState) { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let Some(id) = id else { continue }; + + let result = match method { + "initialize" => Ok(json!({ + "protocolVersion": msg["params"]["protocolVersion"].as_str().unwrap_or("2024-11-05"), + "capabilities": { "tools": {} }, + "serverInfo": { "name": "mrpci", "version": env!("CARGO_PKG_VERSION") } + })), + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({ "tools": tool_list() })), + "tools/call" => { + let name = msg["params"]["name"].as_str().unwrap_or(""); + let args = msg["params"]["arguments"].clone(); + call_tool(&mut gs, name, args) + } + _ => Err((-32601, format!("method not found: {}", method))), + }; + + let reply = match result { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }), + }; + let _ = writeln!(out, "{}", reply); + let _ = out.flush(); + } +} + +fn tool(name: &str, desc: &str, props: Value, required: &[&str]) -> Value { + json!({ + "name": name, + "description": desc, + "inputSchema": { "type": "object", "properties": props, "required": required } + }) +} + +fn tool_list() -> Vec { + vec![ + tool("mrpci_verb", "Point-and-click: apply a verb (walk/look/do/talk) at a pixel (x 0-319, y 0-189). Distant do/talk/take targets are walked to first.", + json!({ "verb": { "type": "string" }, "x": { "type": "integer" }, "y": { "type": "integer" } }), &["verb", "x", "y"]), + tool("mrpci_use_item", "Apply an inventory item at a pixel (the 'use keycard on scanner' click).", + json!({ "item": { "type": "string" }, "x": { "type": "integer" }, "y": { "type": "integer" } }), &["item", "x", "y"]), + tool("mrpci_parse", "Type a player command into the running game (e.g. 'look', 'take keycard', 'use keycard on locker', 'talk sergeant').", + json!({ "text": { "type": "string" } }), &["text"]), + tool("mrpci_walk_to", "A*-walk the ego toward a point and run cycles until arrival or blocked. Doors and edge exits fire automatically.", + json!({ "x": { "type": "integer" }, "y": { "type": "integer" } }), &["x", "y"]), + tool("mrpci_choose", "Pick a numbered dialogue choice while a conversation is open.", + json!({ "n": { "type": "integer" } }), &["n"]), + tool("mrpci_tick", "Advance n fixed game cycles (30 per second of game time) — timers, NPCs, triggers.", + json!({ "n": { "type": "integer" } }), &["n"]), + tool("mrpci_state", "Full state snapshot: room, ego, inventory, flags, vars, props, NPCs, hotspots, exits, transcript tail.", + json!({}), &[]), + tool("mrpci_render_frame", "Render the current frame headlessly (256-color, palette cycles applied) and return a PNG.", + json!({ "with_actors": { "type": "boolean" } }), &[]), + tool("mrpci_render_screen", "Render one of the invisible screens as a PNG: 'priority' (depth bands), 'control' (walls red / water blue), 'hotspot' (id map).", + json!({ "which": { "type": "string" } }), &["which"]), + tool("mrpci_save", "Save the full game state to a named slot (save-anywhere).", + json!({ "slot": { "type": "string" } }), &["slot"]), + tool("mrpci_restore", "Restore a named save slot.", + json!({ "slot": { "type": "string" } }), &["slot"]), + tool("mrpci_new_game", "Reset the session and start at the manifest's start room (or a specific room).", + json!({ "room": { "type": "integer" } }), &[]), + tool("mrpci_load_game", "Load a game folder (game.json + rooms/ + pics/ + sprites/ + scripts/) and start it.", + json!({ "dir": { "type": "string" } }), &["dir"]), + tool("mrpci_upsert_room", "Create or replace a whole room from RoomDoc JSON: ops (paint), background, spawn, exits, hotspots, props, npcs, cycles, scale, music, script. Validated before commit.", + json!({ "room": { "type": "integer" }, "doc": { "type": "object" }, "persist": { "type": "boolean" } }), &["room", "doc"]), + tool("mrpci_upsert_script", "Insert or replace a rhai script by file name (e.g. 'room3.rhai'). Hooks: on_enter, on_exit, on_verb(verb,noun)->bool, on_trigger(name), on_tick, plus after(ticks,'fn') timers.", + json!({ "file": { "type": "string" }, "source": { "type": "string" } }), &["file", "source"]), + tool("mrpci_save_room", "Write a room (default: current) to disk as rooms/roomN.json.", + json!({ "room": { "type": "integer" } }), &[]), + tool("mrpci_command", "Escape hatch: send any raw engine Command JSON (see docs/CONTROL.md).", + json!({ "command": { "type": "object" } }), &["command"]), + ] +} + +fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result { + let cmd = match name { + "mrpci_verb" => Command::VerbAt { + verb: args["verb"].as_str().unwrap_or("look").to_string(), + x: args["x"].as_i64().unwrap_or(160) as i32, + y: args["y"].as_i64().unwrap_or(150) as i32, + }, + "mrpci_use_item" => Command::UseItemAt { + item: args["item"].as_str().unwrap_or("").to_string(), + x: args["x"].as_i64().unwrap_or(160) as i32, + y: args["y"].as_i64().unwrap_or(150) as i32, + }, + "mrpci_parse" => Command::Parse { text: args["text"].as_str().unwrap_or("").to_string() }, + "mrpci_walk_to" => Command::WalkTo { x: args["x"].as_i64().unwrap_or(160) as i32, y: args["y"].as_i64().unwrap_or(150) as i32 }, + "mrpci_choose" => Command::Choose { n: args["n"].as_u64().unwrap_or(1) as usize }, + "mrpci_tick" => Command::Tick { n: args["n"].as_u64().unwrap_or(30) as u32 }, + "mrpci_state" => Command::Query, + "mrpci_save" => Command::SaveGame { slot: args["slot"].as_str().unwrap_or("quick").to_string() }, + "mrpci_restore" => Command::RestoreGame { slot: args["slot"].as_str().unwrap_or("quick").to_string() }, + "mrpci_new_game" => Command::NewGame { room: args["room"].as_u64().map(|n| n as u32) }, + "mrpci_load_game" => Command::LoadGame { dir: args["dir"].as_str().unwrap_or(".").to_string() }, + "mrpci_save_room" => Command::SaveRoom { n: args["room"].as_u64().map(|n| n as u32) }, + "mrpci_render_frame" => { + let png = gs.render_png(args["with_actors"].as_bool().unwrap_or(true)); + return Ok(json!({ + "content": [{ "type": "image", "data": b64(&png), "mimeType": "image/png" }] + })); + } + "mrpci_render_screen" => { + let which = args["which"].as_str().unwrap_or("priority"); + let rgba = mrpci_core::render::debug_screen(&gs.world.screens, which); + let png = mrpci_core::render::encode_png(&rgba); + return Ok(json!({ + "content": [{ "type": "image", "data": b64(&png), "mimeType": "image/png" }] + })); + } + "mrpci_upsert_room" => { + let doc = serde_json::from_value(args["doc"].clone()).map_err(|e| (-32602i64, format!("bad RoomDoc: {}", e)))?; + Command::UpsertRoom { n: args["room"].as_u64().unwrap_or(0) as u32, doc, persist: args["persist"].as_bool().unwrap_or(false) } + } + "mrpci_upsert_script" => Command::UpsertScript { + file: args["file"].as_str().unwrap_or("").to_string(), + source: args["source"].as_str().unwrap_or("").to_string(), + }, + "mrpci_command" => serde_json::from_value(args["command"].clone()).map_err(|e| (-32602i64, format!("bad Command: {}", e)))?, + _ => return Err((-32602, format!("unknown tool: {}", name))), + }; + + let events = gs.apply(cmd); + let is_error = events.iter().any(|e| matches!(e, Event::Error { .. })); + let text = serde_json::to_string_pretty(&events).unwrap_or_default(); + Ok(json!({ + "content": [{ "type": "text", "text": text }], + "isError": is_error + })) +} + +/// Standard base64 (RFC 4648 with padding). Hand-rolled to stay dependency-free. +fn b64(data: &[u8]) -> String { + const TBL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + out.push(TBL[(n >> 18) as usize & 63] as char); + out.push(TBL[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { TBL[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { TBL[n as usize & 63] as char } else { '=' }); + } + out +} + +#[cfg(test)] +mod tests { + #[test] + fn b64_matches_known_vectors() { + assert_eq!(super::b64(b""), ""); + assert_eq!(super::b64(b"f"), "Zg=="); + assert_eq!(super::b64(b"fo"), "Zm8="); + assert_eq!(super::b64(b"foo"), "Zm9v"); + assert_eq!(super::b64(b"foobar"), "Zm9vYmFy"); + } +} diff --git a/mrpci-core/src/bin/mrpci-headless/sample.rs b/mrpci-core/src/bin/mrpci-headless/sample.rs new file mode 100644 index 0000000..8227139 --- /dev/null +++ b/mrpci-core/src/bin/mrpci-headless/sample.rs @@ -0,0 +1,658 @@ +//! `--sample`: writes the demo game, **Neon Precinct** — a three-room noir +//! that exercises every engine feature on purpose: +//! +//! * palette-cycling neon signage (the ember ramp, rotating) +//! * perspective scale tables (walk up the street, shrink into the night) +//! * walk-behind priority (the lamppost), A\* around solid props (crates) +//! * hotspot doors, an invisible trigger, water the robot won't ford +//! * a lock-and-key chain (keycard → locker → evidence → terminal → win) +//! * NPC dialogue trees + a wandering vend-bot +//! * rhai room scripts: on_enter/on_trigger/on_verb hooks and after() timers +//! * a lethal fusebox to demonstrate the death-rewind checkpoint +//! +//! Rooms are authored as Rust structs and serialized — the sample doubles as +//! reference documentation for the RoomDoc format. + +use image::{Rgba, RgbaImage}; +use mrpci_core::actor::ScaleTable; +use mrpci_core::dialogue::{DlgChoice, DlgNode}; +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::path::Path; + +/// Index into the base palette's 6x6x6 cube (components 0..=5). +fn cube(r: u8, g: u8, b: u8) -> u8 { + 32 + r * 36 + g * 6 + b +} +/// Index into the 16-step gray ramp. +fn gray(i: u8) -> u8 { + 16 + i.min(15) +} + +fn ink_color(c: u8) -> Ink { + Ink { color: Some(c), pri: PriInk::Keep, ctl: CtlInk::Keep, hot: None } +} +fn ink_floor(c: u8) -> Ink { + Ink { color: Some(c), pri: PriInk::Band, ctl: CtlInk::Set(0), hot: None } +} +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 { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() +} + +pub fn write_sample(dir: &str) -> std::io::Result<()> { + let base = Path::new(dir); + for sub in ["rooms", "sprites", "scripts", "pics"] { + std::fs::create_dir_all(base.join(sub))?; + } + + write_sprites(&base.join("sprites"))?; + write_scripts(&base.join("scripts"))?; + + let manifest = GameManifest { + name: "Neon Precinct".into(), + start_room: 0, + 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(), + max_score: 25, + ego_sprite: String::new(), + defaults: msgs(&[ + ("look", "Rain-slick chrome and old neon. Nothing more."), + ("do", "Your servos find no purchase on that."), + ("smell", "Ozone, rain, and yesterday's synth-noodles."), + ("listen", "The city hums in B-flat."), + ]), + }; + std::fs::write(base.join("game.json"), serde_json::to_string_pretty(&manifest)?)?; + + std::fs::write(base.join("rooms/room0.json"), serde_json::to_string_pretty(&room0())?)?; + std::fs::write(base.join("rooms/room1.json"), serde_json::to_string_pretty(&room1())?)?; + std::fs::write(base.join("rooms/room2.json"), serde_json::to_string_pretty(&room2())?)?; + Ok(()) +} + +// --- room 0: Neon Row (the street) ------------------------------------------ + +fn room0() -> RoomDoc { + let mut ops: Vec = Vec::new(); + // Night sky, dithered down to the rooftops. + ops.push(PicOp::VGradient { + x: 0, + y: 0, + w: 320, + h: 84, + ramp: vec![cube(0, 0, 1), cube(0, 0, 2), cube(1, 0, 2), cube(1, 1, 3)], + ink: ink_wall(0), + }); + // Building slabs. + ops.push(PicOp::Rect { x: 0, y: 30, w: 90, h: 78, ink: ink_wall(cube(1, 1, 2)) }); + ops.push(PicOp::Rect { x: 90, y: 44, w: 70, h: 64, ink: ink_wall(cube(2, 1, 2)) }); + ops.push(PicOp::Rect { x: 160, y: 24, w: 100, h: 84, ink: ink_wall(cube(1, 1, 3)) }); + ops.push(PicOp::Rect { x: 260, y: 50, w: 60, h: 58, ink: ink_wall(cube(2, 2, 3)) }); + // Lit windows (regular grid, skipping some for life). + for (bx, by, cols, rows) in [(6, 38, 6, 4), (96, 52, 5, 3), (268, 58, 4, 3)] { + for r in 0..rows { + for c in 0..cols { + if (r * cols + c) % 3 == 1 { + continue; // dark apartment + } + ops.push(PicOp::Rect { + x: bx + c * 13, + y: by + r * 14, + w: 6, + h: 8, + ink: ink_wall(cube(4, 3, 1)), + }); + } + } + } + // The precinct facade: door arch + light. + ops.push(PicOp::Rect { x: 204, y: 60, w: 38, h: 48, ink: ink_wall(cube(2, 2, 4)) }); + ops.push(PicOp::Rect { x: 210, y: 68, w: 26, h: 40, ink: ink_wall(cube(1, 1, 2)) }); + ops.push(PicOp::Rect { x: 214, y: 56, w: 18, h: 8, ink: ink_wall(cube(5, 5, 2)) }); + // Neon sign: EAT — every stripe a different ember index; the palette + // cycle rotates them and the sign crawls. + for (i, seg) in [(96i32, 30i32, 4i32, 18i32), (100, 30, 8, 4), (100, 38, 6, 4), (114, 30, 12, 4), (114, 38, 4, 10), (124, 38, 4, 10), (116, 42, 8, 4), (134, 30, 12, 4), (138, 34, 4, 14)] + .iter() + .enumerate() + { + ops.push(PicOp::Rect { x: seg.0, y: seg.1 - 8, w: seg.2, h: seg.3, ink: ink_wall(248 + (i % 8) as u8) }); + } + // Sidewalk (walkable), street (walkable), curb. + ops.push(PicOp::VGradient { x: 0, y: 108, w: 320, h: 42, ramp: vec![gray(6), gray(5), gray(4)], ink: ink_floor(0) }); + ops.push(PicOp::Rect { x: 0, y: 150, w: 320, h: 3, ink: ink_color(gray(8)) }); + ops.push(PicOp::VGradient { x: 0, y: 153, w: 320, h: 37, ramp: vec![gray(3), gray(2)], ink: ink_floor(0) }); + // A rain puddle the robot won't ford (CTL_WATER). + ops.push(PicOp::Ellipse { + cx: 60, + cy: 166, + rx: 26, + ry: 6, + ink: Ink { color: Some(cube(1, 1, 3)), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_WATER), hot: None }, + }); + // Lamppost: drawn at the priority band of its BASE, so you walk behind + // the pole when you're above its foot and in front when below. + let post_base = 146usize; + ops.push(PicOp::Rect { + x: 74, + y: 92, + w: 4, + h: 54, + ink: Ink { color: Some(gray(10)), pri: PriInk::Set(band(post_base)), ctl: CtlInk::Keep, hot: None }, + }); + ops.push(PicOp::Rect { + x: 68, + y: 86, + w: 16, + h: 6, + ink: Ink { color: Some(cube(5, 5, 3)), pri: PriInk::Set(band(post_base)), ctl: CtlInk::Keep, hot: None }, + }); + // The 3px of pole that actually occupies the floor blocks walking. + ops.push(PicOp::Rect { x: 74, y: 143, w: 4, h: 3, ink: Ink { color: None, pri: PriInk::Keep, ctl: CtlInk::Set(CTL_BLOCK), hot: None } }); + + RoomDoc { + name: "Neon Row".into(), + enter_text: "Neon Row at 2 AM. The EAT sign crawls. The precinct door glows across the street.".into(), + ops, + cycles: vec![PalCycle { start: 248, len: 8, period: 3, reverse: false, active: true }], + scale: ScaleTable { horizon_y: 100, min_scale: 0.55, full_y: 185 }, + spawn: (104, 168), + exits: [None, Some(2), None, None], // E → Rain Alley + hotspots: vec![ + Hotspot { + name: "precinct door".into(), + rect: Some([206, 104, 34, 12]), + msgs: msgs(&[("look", "The 41st Precinct. Your house.")]), + exit_to: Some(1), + arrive: Some((160, 176)), + ..default_hotspot() + }, + Hotspot { + name: "neon sign".into(), + rect: Some([92, 18, 54, 34]), + msgs: msgs(&[ + ("look", "EAT, insists the neon, in colors that never sit still."), + ("do", "It's four stories up. Even your warrant doesn't reach."), + ]), + ..default_hotspot() + }, + Hotspot { + name: "puddle".into(), + rect: Some([34, 160, 52, 13]), + msgs: msgs(&[ + ("look", "Neon drowns in the puddle. Your chassis manual is very clear about puddles."), + ("do", "You are 900 pounds of municipal robot. The puddle wins."), + ]), + ..default_hotspot() + }, + Hotspot { + name: "tripwire".into(), + rect: Some([120, 108, 24, 82]), + trigger: true, + ..default_hotspot() + }, + ], + props: vec![PropDef { + name: "dumpster".into(), + sprite: "dumpster".into(), + x: 288, + y: 148, + msgs: msgs(&[ + ("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."), + ]), + solid: true, + ..Default::default() + }], + npcs: vec![NpcDef { + name: "vend-bot".into(), + sprite: "vendbot".into(), + x: 150, + y: 132, + wander: 34, + msgs: msgs(&[("look", "A vending robot on tired treads, hawking hot oil.")]), + dialogue: vec![ + DlgNode { + says: "Hot oil! Fresh volts! You look like a cop with questions.".into(), + choices: vec![ + DlgChoice { text: "Seen anything shady tonight?".into(), goto: 1, ..Default::default() }, + DlgChoice { text: "Just passing through.".into(), goto: -1, ..Default::default() }, + ], + }, + DlgNode { + says: "Somebody ditched a keycard in the alley east of here. Behind the crates. \ + Didn't touch it — bad for business." + .into(), + choices: vec![DlgChoice { text: "Appreciated, citizen.".into(), goto: -1, ..Default::default() }], + }, + ], + ..Default::default() + }], + music: 6, // noir + script: "room0.rhai".into(), + ..Default::default() + } +} + +// --- room 1: the precinct lobby --------------------------------------------- + +fn room1() -> RoomDoc { + let mut ops: Vec = Vec::new(); + // Back wall + trim. + ops.push(PicOp::VGradient { x: 0, y: 0, w: 320, h: 96, ramp: vec![cube(2, 2, 3), cube(1, 1, 2)], ink: ink_wall(0) }); + ops.push(PicOp::Rect { x: 0, y: 96, w: 320, h: 4, ink: ink_wall(cube(3, 2, 1)) }); + // Checkerboard floor. + ops.push(PicOp::VGradient { x: 0, y: 100, w: 320, h: 90, ramp: vec![gray(7), gray(5)], ink: ink_floor(0) }); + for ty in 0..6 { + for tx in 0..10 { + if (tx + ty) % 2 == 0 { + ops.push(PicOp::Rect { + x: tx * 32, + y: 100 + ty * 15, + w: 32, + h: 15, + ink: Ink { color: Some(cube(2, 2, 2)), pri: PriInk::Band, ctl: CtlInk::Set(0), hot: None }, + }); + } + } + } + // The duty desk: a solid slab you can't walk through but can look at. + ops.push(PicOp::Rect { x: 118, y: 96, w: 90, h: 26, ink: ink_wall(cube(2, 1, 0)) }); + ops.push(PicOp::Rect { x: 118, y: 96, w: 90, h: 4, ink: ink_wall(cube(3, 2, 1)) }); + // Wanted posters. + for (x, c) in [(30, cube(4, 3, 2)), (56, cube(3, 4, 3))] { + ops.push(PicOp::Rect { x, y: 30, w: 20, h: 26, ink: ink_wall(c) }); + } + // Door mat back to the street. + ops.push(PicOp::Rect { x: 140, y: 182, w: 40, h: 8, ink: ink_color(cube(3, 1, 1)) }); + + RoomDoc { + name: "41st Precinct Lobby".into(), + enter_text: "Fluorescents flicker over the duty desk. The evidence locker waits along the east wall.".into(), + ops, + scale: ScaleTable { horizon_y: 88, min_scale: 0.7, full_y: 185 }, + spawn: (160, 176), + hotspots: vec![ + Hotspot { + name: "street door".into(), + rect: Some([136, 184, 48, 6]), + msgs: msgs(&[("look", "Back out to Neon Row.")]), + exit_to: Some(0), + arrive: Some((226, 120)), + ..default_hotspot() + }, + Hotspot { + name: "duty desk".into(), + rect: Some([118, 96, 90, 30]), + msgs: msgs(&[ + ("look", "The duty desk. Coffee rings dating back three administrations."), + ("do", "You straighten a stack of incident forms. Order is restored, briefly."), + ]), + ..default_hotspot() + }, + Hotspot { + name: "wanted posters".into(), + rect: Some([28, 28, 52, 30]), + msgs: msgs(&[("look", "WANTED: the Compost Bandit. Last seen fleeing Sickbay with a datacard.")]), + ..default_hotspot() + }, + ], + props: vec![ + PropDef { + name: "locker".into(), + sprite: "locker".into(), + x: 286, + 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."), + ]), + needs: "keycard".into(), + use_text: "The keycard chirps. Locker 47 clunks open: one data-slate, sealed in an evidence bag.".into(), + sets_flag: "locker_open".into(), + points: 10, + solid: true, + ..Default::default() + }, + PropDef { + name: "terminal".into(), + sprite: "terminal".into(), + x: 36, + y: 130, + msgs: msgs(&[ + ("look", "The case terminal. Slot: DATA-SLATE. It has been hungry for weeks."), + ("do", "It wants the evidence, not your fingerprints."), + ]), + needs: "evidence".into(), + use_text: "The slate slides home. Records unspool. Warrants bloom. Somewhere, the Compost Bandit sneezes.".into(), + wins: true, + points: 10, + consumes: true, + solid: true, + ..Default::default() + }, + ], + npcs: vec![NpcDef { + name: "sergeant".into(), + sprite: "sergeant".into(), + x: 162, + y: 132, + wander: 0, + msgs: msgs(&[("look", "Sergeant Brasso. Chrome polished, patience not.")]), + dialogue: vec![ + DlgNode { + says: "Morp-9. The data-slate case won't close itself.".into(), + choices: vec![ + DlgChoice { text: "Where do I start?".into(), goto: 1, ..Default::default() }, + DlgChoice { text: "What do I do with evidence?".into(), goto: 2, requires_flag: "locker_open".into(), ..Default::default() }, + DlgChoice { text: "On it, Sarge.".into(), goto: -1, ..Default::default() }, + ], + }, + 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() }], + }, + 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() }], + }, + ], + ..Default::default() + }], + music: 1, // calm + script: "room1.rhai".into(), + ..Default::default() + } +} + +// --- room 2: Rain Alley ------------------------------------------------------- + +fn room2() -> RoomDoc { + let mut ops: Vec = Vec::new(); + // Alley walls closing in. + ops.push(PicOp::VGradient { x: 0, y: 0, w: 320, h: 100, ramp: vec![cube(0, 0, 1), cube(1, 1, 2)], ink: ink_wall(0) }); + ops.push(PicOp::Polygon { + pts: vec![(0, 0), (110, 0), (86, 100), (0, 100)], + ink: ink_wall(cube(1, 1, 1)), + }); + ops.push(PicOp::Polygon { + pts: vec![(210, 0), (320, 0), (320, 100), (234, 100)], + ink: ink_wall(cube(2, 1, 1)), + }); + // Brick hints. + for y in (10..96).step_by(14) { + ops.push(PicOp::Line { pts: vec![(0, y), (86 + (100 - y) / 6, y)], ink: ink_color(cube(0, 0, 1)) }); + ops.push(PicOp::Line { pts: vec![(232 - (100 - y) / 6, y), (320, y)], ink: ink_color(cube(1, 0, 0)) }); + } + // Small BAR neon on the right wall (same ember cycle as Neon Row). + for (i, seg) in [(250i32, 30i32, 4i32, 16i32), (254, 30, 6, 4), (254, 36, 6, 4), (254, 42, 6, 4)].iter().enumerate() { + ops.push(PicOp::Rect { x: seg.0, y: seg.1, w: seg.2, h: seg.3, ink: ink_wall(248 + (i * 2 % 8) as u8) }); + } + // Wet cobbles. + ops.push(PicOp::VGradient { x: 0, y: 100, w: 320, h: 90, ramp: vec![gray(5), gray(3), gray(2)], ink: ink_floor(0) }); + // A long puddle down the middle — water, walk around it. + ops.push(PicOp::Ellipse { + cx: 150, + cy: 152, + rx: 44, + ry: 8, + ink: Ink { color: Some(cube(1, 1, 3)), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_WATER), hot: None }, + }); + // Dripping pipe on the left wall. + ops.push(PicOp::Rect { x: 60, y: 20, w: 6, h: 66, ink: ink_wall(gray(8)) }); + ops.push(PicOp::Rect { x: 60, y: 86, w: 6, h: 4, ink: ink_wall(gray(10)) }); + + RoomDoc { + name: "Rain Alley".into(), + enter_text: "The alley smells like rust and secrets. Crates wall off the far corner. Something glints behind them.".into(), + ops, + cycles: vec![PalCycle { start: 248, len: 8, period: 4, reverse: true, active: true }], + scale: ScaleTable { horizon_y: 95, min_scale: 0.6, full_y: 185 }, + spawn: (24, 160), + exits: [None, None, None, Some(0)], // W → Neon Row + hotspots: vec![ + Hotspot { + name: "pipe".into(), + rect: Some([58, 18, 10, 74]), + msgs: msgs(&[ + ("look", "A drainage pipe keeping its own beat."), + ("listen", "Drip. Drip. Drip. It's in 7/8 time, somehow."), + ]), + ..default_hotspot() + }, + Hotspot { + name: "bar sign".into(), + rect: Some([246, 26, 20, 24]), + msgs: msgs(&[("look", "BAR, says the little neon, economically.")]), + ..default_hotspot() + }, + ], + props: vec![ + PropDef { + name: "crate stack".into(), + sprite: "crate".into(), + x: 226, + y: 138, + msgs: msgs(&[("look", "Shipping crates. Stenciled: PROPERTY OF NOBODY, HONEST.")]), + solid: true, + synonyms: "crates boxes".into(), + ..Default::default() + }, + PropDef { + name: "more crates".into(), + sprite: "crate".into(), + x: 258, + y: 130, + msgs: msgs(&[("look", "More crates. The alley is basically municipal Tetris.")]), + solid: true, + ..Default::default() + }, + PropDef { + name: "keycard".into(), + sprite: "keycard".into(), + x: 268, + y: 112, + msgs: msgs(&[("look", "A precinct keycard, dropped in a hurry. Evidence locker 47, if you had to guess.")]), + takeable: true, + points: 5, + ..Default::default() + }, + PropDef { + name: "fusebox".into(), + sprite: "fusebox".into(), + x: 96, + y: 96, + msgs: msgs(&[("look", "A junction box, sparking gently. Every instinct you have files a hazard report.")]), + use_text: "You touch the sparking fusebox. For 0.4 glorious seconds you are the brightest thing on Neon Row.".into(), + kills: true, + fixed_priority: Some(15), + ..Default::default() + }, + ], + music: 2, // eerie + script: "room2.rhai".into(), + ..Default::default() + } +} + +fn default_hotspot() -> Hotspot { + Hotspot { + name: String::new(), + rect: None, + poly: Vec::new(), + msgs: HashMap::new(), + exit_to: None, + arrive: None, + requires_flag: String::new(), + blocked_text: String::new(), + trigger: false, + blocks: false, + } +} + +// --- scripts --------------------------------------------------------------- + +fn write_scripts(dir: &Path) -> std::io::Result<()> { + std::fs::write( + dir.join("main.rhai"), + r#"// Neon Precinct — global helpers. Merged under every room script. + +// One-shot latch: true the first time, false forever after. +fn once(name) { + if flag(name) { return false; } + set_flag(name, true); + true +} +"#, + )?; + std::fs::write( + dir.join("room0.rhai"), + r#"// Neon Row. + +fn on_enter() { + if once("seen_street") { + say("Your shift started four hours ago. The rain never clocked out."); + } +} + +fn on_trigger(name) { + if name == "tripwire" && once("rat_scare") { + play("scan"); + say("A rat the size of a toaster bolts from under the EAT sign, swearing in ultrasonic."); + } +} +"#, + )?; + std::fs::write( + dir.join("room1.rhai"), + r#"// Precinct lobby: the locker gives up its evidence once it's open. + +fn on_verb(verb, noun) { + if noun == "locker" && flag("locker_open") && !has("evidence") && (verb == "do" || verb == "look") { + give("evidence"); + say("You bag the data-slate. Chain of custody: immaculate."); + return true; + } + false +} +"#, + )?; + std::fs::write( + dir.join("room2.rhai"), + r#"// Rain Alley: an after() timer keeps the pipe dripping — on the game's +// deterministic 30Hz clock, never the CPU's. + +fn on_enter() { + if val("drip_armed") == 0 { + set_val("drip_armed", 1); + after(240, "drip"); + } +} + +fn drip() { + if room() == 2 { + say("The pipe drips, once, with great ceremony."); + after(300, "drip"); + } else { + set_val("drip_armed", 0); + } +} +"#, + )?; + Ok(()) +} + +// --- sprites (tiny generated pixel art) --------------------------------------- + +fn write_sprites(dir: &Path) -> std::io::Result<()> { + let save = |name: &str, img: &RgbaImage| -> std::io::Result<()> { + img.save(dir.join(format!("{}.png", name))).map_err(std::io::Error::other) + }; + + let mut keycard = RgbaImage::new(12, 8); + fill(&mut keycard, 0, 0, 12, 8, [230, 200, 60]); + fill(&mut keycard, 0, 2, 12, 2, [40, 40, 48]); + fill(&mut keycard, 8, 5, 3, 2, [240, 240, 240]); + save("keycard", &keycard)?; + + let mut dumpster = RgbaImage::new(40, 26); + fill(&mut dumpster, 0, 6, 40, 20, [40, 90, 60]); + fill(&mut dumpster, 0, 6, 40, 3, [60, 120, 80]); + fill(&mut dumpster, 2, 0, 36, 7, [50, 105, 70]); + fill(&mut dumpster, 4, 22, 6, 4, [30, 30, 34]); + fill(&mut dumpster, 30, 22, 6, 4, [30, 30, 34]); + save("dumpster", &dumpster)?; + + let mut crate_ = RgbaImage::new(26, 20); + fill(&mut crate_, 0, 0, 26, 20, [140, 96, 50]); + fill(&mut crate_, 0, 0, 26, 2, [170, 120, 66]); + fill(&mut crate_, 0, 9, 26, 2, [110, 74, 38]); + fill(&mut crate_, 0, 18, 26, 2, [110, 74, 38]); + fill(&mut crate_, 12, 0, 2, 20, [110, 74, 38]); + save("crate", &crate_)?; + + let mut locker = RgbaImage::new(28, 46); + fill(&mut locker, 0, 0, 28, 46, [90, 100, 130]); + fill(&mut locker, 2, 2, 24, 42, [110, 122, 155]); + fill(&mut locker, 13, 2, 2, 42, [70, 78, 104]); + fill(&mut locker, 18, 20, 6, 4, [230, 200, 60]); // the reader slot + fill(&mut locker, 5, 20, 4, 4, [70, 78, 104]); + save("locker", &locker)?; + + let mut terminal = RgbaImage::new(30, 32); + fill(&mut terminal, 2, 24, 26, 8, [60, 62, 70]); + fill(&mut terminal, 0, 0, 30, 24, [80, 84, 96]); + fill(&mut terminal, 3, 3, 24, 16, [20, 60, 40]); + fill(&mut terminal, 5, 5, 16, 2, [90, 230, 140]); + fill(&mut terminal, 5, 9, 12, 2, [90, 230, 140]); + fill(&mut terminal, 5, 13, 18, 2, [90, 230, 140]); + save("terminal", &terminal)?; + + let mut sergeant = RgbaImage::new(18, 36); + fill(&mut sergeant, 5, 0, 8, 8, [140, 150, 170]); // head + fill(&mut sergeant, 6, 2, 2, 2, [80, 200, 255]); // eye + fill(&mut sergeant, 10, 2, 2, 2, [80, 200, 255]); + fill(&mut sergeant, 3, 8, 12, 16, [40, 60, 140]); // blue torso + fill(&mut sergeant, 6, 10, 6, 3, [230, 200, 60]); // badge + fill(&mut sergeant, 3, 24, 4, 12, [40, 44, 54]); // legs + 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)?; + + let mut fusebox = RgbaImage::new(14, 18); + fill(&mut fusebox, 0, 0, 14, 18, [110, 114, 124]); + fill(&mut fusebox, 2, 2, 10, 14, [80, 84, 96]); + fill(&mut fusebox, 6, 5, 2, 8, [255, 230, 90]); // the spark + fill(&mut fusebox, 4, 9, 6, 2, [255, 160, 60]); + save("fusebox", &fusebox)?; + + Ok(()) +} + +fn fill(img: &mut RgbaImage, x: u32, y: u32, w: u32, h: u32, c: [u8; 3]) { + for yy in y..(y + h).min(img.height()) { + for xx in x..(x + w).min(img.width()) { + img.put_pixel(xx, yy, Rgba([c[0], c[1], c[2], 255])); + } + } +} diff --git a/mrpci-core/src/dialogue.rs b/mrpci-core/src/dialogue.rs new file mode 100644 index 0000000..68ad4a4 --- /dev/null +++ b/mrpci-core/src/dialogue.rs @@ -0,0 +1,103 @@ +//! Branching conversations — MRPGI's dialogue trees, carried forward whole. +//! A dialogue is a list of nodes; each node says something and offers +//! choices; choices can gate on flags, set flags, award points, or kill you +//! (Sierra tradition demands the option). + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct DlgChoice { + pub text: String, + pub goto: i32, // next node index, or -1 to end the conversation + /// Only offered while this flag is true (empty = always offered). + #[serde(default)] + pub requires_flag: String, + /// Picking this choice sets this flag true (empty = none). + #[serde(default)] + pub sets_flag: String, + /// Points awarded when this choice flips its `sets_flag` false → true + /// (the flag latch is what makes the award once-only). + #[serde(default)] + pub points: u32, + /// Picking this choice kills the player, Sierra style. The `goto` node's + /// `says` is printed as the death text, then the day restarts. + #[serde(default)] + pub kills: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct DlgNode { + pub says: String, + #[serde(default)] + pub choices: Vec, +} + +/// A live conversation: the nodes plus where we are in them. +#[derive(Clone)] +pub struct Dlg { + pub nodes: Vec, + pub node: usize, + /// Who we're talking to (prefixes the says lines). + pub with: String, +} + +impl Dlg { + fn offered<'a>(&'a self, flags: &HashMap) -> Vec<&'a DlgChoice> { + self.nodes[self.node] + .choices + .iter() + .filter(|c| c.requires_flag.is_empty() || flags.get(&c.requires_flag).copied().unwrap_or(false)) + .collect() + } + + /// The current node rendered as transcript lines: what they say, then + /// the numbered choices. + pub fn lines(&self, flags: &HashMap) -> Vec { + let mut out = Vec::new(); + if let Some(n) = self.nodes.get(self.node) { + out.push(format!("{}: \u{201C}{}\u{201D}", self.with, n.says)); + for (i, c) in self.offered(flags).iter().enumerate() { + out.push(format!(" {}. {}", i + 1, c.text)); + } + } + out + } + + /// No live choices → the conversation is a one-liner and ends itself. + pub fn terminal(&self, flags: &HashMap) -> bool { + self.offered(flags).is_empty() + } + + /// Pick choice `n` (1-based among *offered* choices). + /// Returns (lines to print, conversation ended, player died). + pub fn choose( + &mut self, + n: usize, + flags: &mut HashMap, + score: &mut u32, + ) -> (Vec, bool, bool) { + let offered = self.offered(flags); + let Some(&c) = offered.get(n.wrapping_sub(1)) else { + return (vec!["(that's not one of the choices.)".into()], false, false); + }; + let c = c.clone(); + if !c.sets_flag.is_empty() && !flags.get(&c.sets_flag).copied().unwrap_or(false) { + flags.insert(c.sets_flag.clone(), true); + *score += c.points; + } + if c.kills { + let death = self + .nodes + .get(c.goto.max(0) as usize) + .map(|n| n.says.clone()) + .unwrap_or_else(|| "That was a fatal thing to say.".into()); + return (vec![death], true, true); + } + if c.goto < 0 || c.goto as usize >= self.nodes.len() { + return (Vec::new(), true, false); + } + self.node = c.goto as usize; + (self.lines(flags), self.terminal(flags), false) + } +} diff --git a/mrpci-core/src/lib.rs b/mrpci-core/src/lib.rs new file mode 100644 index 0000000..bd8c4e0 --- /dev/null +++ b/mrpci-core/src/lib.rs @@ -0,0 +1,34 @@ +//! mrpci-core — the headless MRPCI engine. +//! +//! MRPCI is the SCI generation of the Monster Robot Party family: where +//! MRPGI reimagined Sierra's 1980s AGI (16 colors, one merged +//! priority/control buffer, greedy steering), MRPCI reimagines the tech of +//! *King's Quest V* and *Space Quest IV* — 256-color palettes that cycle, +//! four separate screens (visual / priority / control / hotspot), A\* +//! pathfinding, perspective-scaled actors, point-and-click verbs *and* a +//! text parser, and rhai room scripts with deterministic tick timers. +//! +//! The whole sim lives here with zero windowing dependencies (that's the +//! guardrail: this crate must never depend on macroquad). One insight powers +//! every control surface: "CLI or endpoints or websockets or MCP or Python" +//! is one feature, not seven — a [`state::GameState`] behind a typed +//! command/event bus. Every surface is a thin adapter that pushes +//! [`Command`]s in and reads [`Event`]s out. + +pub mod actor; +pub mod assets; +pub mod audio; +pub mod dialogue; +pub mod palette; +pub mod parser; +pub mod path; +pub mod pic; +pub mod render; +pub mod room; +pub mod screens; +pub mod script; +pub mod state; +pub mod view; + +pub use room::{GameManifest, Hotspot, NpcDef, PropDef, RoomDoc, World, WorldBundle}; +pub use state::{Command, Event, GameState, SaveData, StateSnapshot, CYCLE_DT}; diff --git a/mrpci-core/src/palette.rs b/mrpci-core/src/palette.rs new file mode 100644 index 0000000..4efebe8 --- /dev/null +++ b/mrpci-core/src/palette.rs @@ -0,0 +1,173 @@ +//! 256-color palettes — the VGA leap that made SCI1 look like SCI1. +//! +//! Everything in the engine works in palette *indices* (0..=255); we only +//! convert to real RGBA at the very last moment, when pixels leave the core. +//! Unlike AGI's one fixed EGA table, a palette here is *data*: every room can +//! carry its own (usually quantized straight from its background art), and +//! ranges of it can **cycle** — the classic Sierra waterfall/neon-sign trick, +//! done the honest way: by rotating palette entries, not by re-drawing. + +use serde::{Deserialize, Serialize}; + +/// The first 16 entries of every default palette — the IBM EGA table, so +/// MRPGI-style ASCII sprites (hex digits 0-f) mean the same colors here. +pub const EGA: [(u8, u8, u8); 16] = [ + (0x00, 0x00, 0x00), // 0 black + (0x00, 0x00, 0xAA), // 1 blue + (0x00, 0xAA, 0x00), // 2 green + (0x00, 0xAA, 0xAA), // 3 cyan + (0xAA, 0x00, 0x00), // 4 red + (0xAA, 0x00, 0xAA), // 5 magenta + (0xAA, 0x55, 0x00), // 6 brown + (0xAA, 0xAA, 0xAA), // 7 light gray + (0x55, 0x55, 0x55), // 8 dark gray + (0x55, 0x55, 0xFF), // 9 light blue + (0x55, 0xFF, 0x55), // 10 light green + (0x55, 0xFF, 0xFF), // 11 light cyan + (0xFF, 0x55, 0x55), // 12 light red + (0xFF, 0x55, 0xFF), // 13 light magenta + (0xFF, 0xFF, 0x55), // 14 yellow + (0xFF, 0xFF, 0xFF), // 15 white +]; + +/// A full 256-entry palette. Serializes as a flat `[r,g,b, r,g,b, ...]` list +/// so room JSON stays diffable and an LLM can emit one without ceremony. +#[derive(Clone, PartialEq)] +pub struct Palette(pub [(u8, u8, u8); 256]); + +impl Palette { + /// The default palette: EGA (0..16), a 16-step gray ramp (16..32), the + /// 6x6x6 color cube (32..248), and a warm ember ramp (248..256). Enough + /// range that quantized art looks good even before a room brings its own. + pub fn base() -> Self { + let mut p = [(0u8, 0u8, 0u8); 256]; + p[..16].copy_from_slice(&EGA); + for i in 0..16 { + let v = (i * 17) as u8; + p[16 + i] = (v, v, v); + } + let mut n = 32; + for r in 0..6 { + for g in 0..6 { + for b in 0..6 { + p[n] = (r * 51, g * 51, b * 51); + n += 1; + } + } + } + // 248..256: embers — a hand-picked hot ramp for fire/neon cycles. + let embers = [ + (0x40, 0x00, 0x00), + (0x80, 0x10, 0x00), + (0xC0, 0x30, 0x00), + (0xFF, 0x60, 0x00), + (0xFF, 0x90, 0x10), + (0xFF, 0xC0, 0x30), + (0xFF, 0xE8, 0x80), + (0xFF, 0xFF, 0xC0), + ]; + p[248..].copy_from_slice(&embers); + Palette(p) + } + + /// Raw RGBA bytes for a palette index. + #[inline] + pub fn rgba(&self, idx: u8) -> [u8; 4] { + let (r, g, b) = self.0[idx as usize]; + [r, g, b, 255] + } + + /// Nearest palette index to an RGB color (perceptually weighted distance — + /// green counts double, which is most of why quantized skin tones and + /// foliage stop looking radioactive). + pub fn nearest(&self, r: u8, g: u8, b: u8) -> u8 { + let mut best = 0usize; + let mut best_d = u32::MAX; + for (i, &(pr, pg, pb)) in self.0.iter().enumerate() { + let dr = pr as i32 - r as i32; + let dg = pg as i32 - g as i32; + let db = pb as i32 - b as i32; + let d = (2 * dr * dr + 4 * dg * dg + 3 * db * db) as u32; + if d < best_d { + best_d = d; + best = i; + } + } + best as u8 + } +} + +impl Default for Palette { + fn default() -> Self { + Self::base() + } +} + +// Serde as a flat byte list: [r,g,b, r,g,b, ...] * 256. +impl Serialize for Palette { + fn serialize(&self, s: S) -> Result { + let flat: Vec = self.0.iter().flat_map(|&(r, g, b)| [r, g, b]).collect(); + flat.serialize(s) + } +} + +impl<'de> Deserialize<'de> for Palette { + fn deserialize>(d: D) -> Result { + let flat = Vec::::deserialize(d)?; + let mut p = Palette::base(); + for (i, ch) in flat.chunks(3).take(256).enumerate() { + if ch.len() == 3 { + p.0[i] = (ch[0], ch[1], ch[2]); + } + } + Ok(p) + } +} + +/// One cycling range: every `period` ticks the entries `start..start+len` +/// rotate one step. Waterfalls, neon, lava — all of them are this struct. +#[derive(Clone, Serialize, Deserialize)] +pub struct PalCycle { + pub start: u8, + pub len: u8, + /// Game ticks per rotation step (30 ticks = 1 second). + #[serde(default = "default_period")] + pub period: u32, + #[serde(default)] + pub reverse: bool, + /// Cycles can start paused and be switched on by script (`pal_cycle`). + #[serde(default = "default_true")] + pub active: bool, +} + +fn default_period() -> u32 { + 4 +} +fn default_true() -> bool { + true +} + +/// Build the index-remap LUT for a set of cycles at a given tick count. +/// Identity everywhere except inside active cycle ranges. +pub fn cycle_lut(cycles: &[PalCycle], ticks: u64) -> [u8; 256] { + let mut lut = [0u8; 256]; + for (i, l) in lut.iter_mut().enumerate() { + *l = i as u8; + } + for c in cycles { + let len = c.len as u64; + if !c.active || len < 2 || c.period == 0 { + continue; + } + let step = (ticks / c.period as u64) % len; + let step = if c.reverse { len - step } else { step } % len; + for k in 0..len { + let from = c.start as u64 + k; + let to = c.start as u64 + (k + step) % len; + if from < 256 && to < 256 { + lut[from as usize] = to as u8; + } + } + } + lut +} diff --git a/mrpci-core/src/parser.rs b/mrpci-core/src/parser.rs new file mode 100644 index 0000000..a19859c --- /dev/null +++ b/mrpci-core/src/parser.rs @@ -0,0 +1,154 @@ +//! The text parser — SCI0's typing soul, kept alive alongside the icon bar. +//! +//! MRPCI is point-and-click first, but every game also accepts typed +//! commands; the parser and the mouse converge on the same verb pipeline in +//! the state layer, so authors write one set of responses and get both +//! interfaces free. Parsing is deliberately dumb-simple: verb word + optional +//! preposition split ("use badge on scanner"), synonyms folded to canonical +//! verbs. The clever part — resolving nouns against the live room — happens +//! where the live room actually is (state.rs). + +use std::collections::HashMap; + +/// Canonical verbs the engine acts on. Authors may key `msgs` with any verb +/// string; these are the ones with built-in behavior. +pub const V_LOOK: &str = "look"; +pub const V_DO: &str = "do"; +pub const V_TALK: &str = "talk"; +pub const V_TAKE: &str = "take"; +pub const V_WALK: &str = "walk"; + +/// A parsed player line, before noun resolution. +#[derive(Clone, Debug, PartialEq)] +pub enum Parsed { + /// verb + the words that should name a target ("" = the room itself). + Verb { verb: String, noun: String }, + /// "use on " / "give X to Y". + UseOn { item: String, noun: String }, + Inventory, + Help, + /// Meta commands surfaced to the driver (save/restore/quit). + Meta(String), + Empty, + /// No verb we know. Carries the original line for the transcript. + Unknown(String), +} + +/// canonical verb → space-separated synonyms. Game manifests merge over this. +pub fn builtin_verbs() -> HashMap { + let mut m = HashMap::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()); + m.insert(V_TALK.into(), "speak ask tell greet question interview".into()); + m.insert(V_WALK.into(), "go approach".into()); + m.insert("smell".into(), "sniff".into()); + m.insert("listen".into(), "hear".into()); + m +} + +/// Build the reverse map word → canonical verb. +pub fn verb_index(extra: &HashMap) -> HashMap { + let mut idx = HashMap::new(); + let mut fold = |canon: &str, syns: &str| { + idx.insert(canon.to_string(), canon.to_string()); + for w in syns.split_whitespace() { + idx.insert(w.to_string(), canon.to_string()); + } + }; + for (c, s) in builtin_verbs() { + fold(&c, &s); + } + for (c, s) in extra { + fold(c, s); + } + idx +} + +const STOP_WORDS: &[&str] = &[ + "the", "a", "an", "at", "to", "on", "in", "up", "with", "that", "this", "my", "some", "again", "please", +]; +const PREPOSITIONS: &[&str] = &["on", "to", "with", "in", "into", "onto", "using"]; + +fn strip_noise(words: &[&str]) -> String { + words + .iter() + .filter(|w| !STOP_WORDS.contains(w)) + .cloned() + .collect::>() + .join(" ") +} + +/// Parse one typed line. `verbs` is the word → canonical index. +pub fn parse(line: &str, verbs: &HashMap) -> Parsed { + let lower = line.to_lowercase(); + let words: Vec<&str> = lower.split_whitespace().collect(); + let Some(&first) = words.first() else { + return Parsed::Empty; + }; + + match first { + "i" | "inv" | "inventory" => return Parsed::Inventory, + "help" | "?" | "verbs" => return Parsed::Help, + "save" | "restore" | "load" | "quit" | "restart" | "score" => { + return Parsed::Meta(first.to_string()) + } + _ => {} + } + + // "use X on Y" / "give X to Y" / "show X to Y" — item application. + if ["use", "give", "show", "put", "apply"].contains(&first) { + if let Some(p) = words.iter().position(|w| PREPOSITIONS.contains(w)) { + let item = strip_noise(&words[1..p]); + let noun = strip_noise(&words[p + 1..]); + if !item.is_empty() && !noun.is_empty() { + return Parsed::UseOn { item, noun }; + } + } + } + + if let Some(canon) = verbs.get(first) { + // "pick up X" — swallow the particle. + let mut rest = &words[1..]; + if canon == V_TAKE && rest.first() == Some(&"up") { + rest = &rest[1..]; + } + // "look at X" handled by stop-word stripping ("at" is noise). + return Parsed::Verb { verb: canon.clone(), noun: strip_noise(rest) }; + } + + Parsed::Unknown(line.to_string()) +} + +/// The verbs an AI lane would be allowed to emit — everything canonical. +pub fn verb_whitelist(extra: &HashMap) -> Vec { + let mut v: Vec = builtin_verbs().keys().cloned().collect(); + v.extend(extra.keys().cloned()); + v.sort(); + v.dedup(); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_classics() { + let idx = verb_index(&HashMap::new()); + assert_eq!( + parse("look at the neon sign", &idx), + Parsed::Verb { verb: "look".into(), noun: "neon sign".into() } + ); + assert_eq!( + parse("pick up the keycard", &idx), + Parsed::Verb { verb: "take".into(), noun: "keycard".into() } + ); + assert_eq!( + parse("use badge on scanner", &idx), + Parsed::UseOn { item: "badge".into(), noun: "scanner".into() } + ); + assert_eq!(parse("i", &idx), Parsed::Inventory); + assert!(matches!(parse("xyzzy plugh", &idx), Parsed::Unknown(_))); + } +} diff --git a/mrpci-core/src/path.rs b/mrpci-core/src/path.rs new file mode 100644 index 0000000..f0de382 --- /dev/null +++ b/mrpci-core/src/path.rs @@ -0,0 +1,257 @@ +//! Real pathfinding — the upgrade Sierra's egos never got. +//! +//! AGI (and MRPGI v0.1) steered greedily at the target and simply stopped at +//! the first wall. SCI added polygon avoidance, but you could still wedge an +//! ego into a corner. MRPCI runs **A\*** over the control screen, then +//! string-pulls the result with line-of-sight smoothing, so one click walks +//! the actor *around* the desk, through the doorway, to the point you meant. +//! If the exact target is blocked, the walk goes to the nearest standable +//! pixel instead of refusing — clicks always mean something. + +use crate::screens::{Screens, PIC_H, PIC_W}; +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +/// A passability oracle: (x, y) → can this actor stand here? Wraps the +/// control screen plus the actor's clamp box and footprint half-width. +pub struct Passable<'a> { + pub s: &'a Screens, + pub half_w: i32, + pub water_ok: bool, + pub min: (i32, i32), + pub max: (i32, i32), +} + +impl Passable<'_> { + #[inline] + pub fn ok(&self, x: i32, y: i32) -> bool { + if x < self.min.0 || x > self.max.0 || y < self.min.1 || y > self.max.1 { + return false; + } + // Feet are a horizontal span, not a point — this is what stops a + // 14px-wide robot slipping through a 2px gap between desk and wall. + for dx in [-self.half_w, 0, self.half_w] { + let c = self.s.control_at(x + dx, y); + if c & crate::screens::CTL_BLOCK != 0 { + return false; + } + if !self.water_ok && c & crate::screens::CTL_WATER != 0 { + return false; + } + } + true + } +} + +/// Straight-line walkability between two points (Bresenham over `ok`). +pub fn line_of_sight(p: &Passable, a: (i32, i32), b: (i32, i32)) -> bool { + let (mut x0, mut y0) = a; + let (x1, y1) = b; + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + if !p.ok(x0, y0) { + return false; + } + if x0 == x1 && y0 == y1 { + return true; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } +} + +/// Advance up to `budget` pixels along the Bresenham line from `a` toward +/// `b`, stopping at the first impassable pixel (unless `ignore_collision` — +/// the unstick case). Returns the reached point and whether the walk was +/// blocked early. This is how actors *move*: on the exact line the +/// pathfinder verified, so a legal path can never be clipped mid-walk. +pub fn walk_line( + p: &Passable, + a: (i32, i32), + b: (i32, i32), + budget: i32, + ignore_collision: bool, +) -> ((i32, i32), bool) { + let (mut x0, mut y0) = a; + let (x1, y1) = b; + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + let mut cur = a; + for _ in 0..budget.max(1) { + if (x0, y0) == (x1, y1) { + return (cur, false); + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + if !ignore_collision && !p.ok(x0, y0) { + return (cur, true); + } + cur = (x0, y0); + } + (cur, false) +} + +/// Nearest standable pixel to `target` (ring search, radius-capped). +/// Clicking on the desk walks you to the desk's edge, not into an error. +pub fn nearest_open(p: &Passable, target: (i32, i32)) -> Option<(i32, i32)> { + if p.ok(target.0, target.1) { + return Some(target); + } + for r in 1i32..64 { + for dy in -r..=r { + for dx in -r..=r { + if dx.abs() != r && dy.abs() != r { + continue; // ring perimeter only + } + let (x, y) = (target.0 + dx, target.1 + dy); + if p.ok(x, y) { + return Some((x, y)); + } + } + } + } + None +} + +/// A\* from `start` to (the nearest open pixel to) `goal` on a 4px grid, +/// refined and string-pulled down to a short waypoint list. Returns waypoints +/// *excluding* start; empty means "no route" (or already there). +pub fn find_path(p: &Passable, start: (i32, i32), goal: (i32, i32)) -> Vec<(i32, i32)> { + let Some(goal) = nearest_open(p, goal) else { + return Vec::new(); + }; + let start = match nearest_open(p, start) { + Some(s) => s, + None => return Vec::new(), // actor is inside a wall; scripts fix that + }; + if start == goal { + return vec![goal]; + } + if line_of_sight(p, start, goal) { + return vec![goal]; // the common case: nothing in the way + } + + // Coarse grid: 4px cells keep the search tiny (80x48 nodes) while the + // footprint check in `ok` keeps corridors honest. + const CELL: i32 = 4; + let gw = (PIC_W as i32 / CELL) + 1; + let gh = (PIC_H as i32 / CELL) + 1; + let to_cell = |(x, y): (i32, i32)| ((x / CELL), (y / CELL)); + let cell_idx = |(cx, cy): (i32, i32)| (cy * gw + cx) as usize; + let cell_center = |(cx, cy): (i32, i32)| (cx * CELL + CELL / 2, cy * CELL + CELL / 2); + + let sc = to_cell(start); + let gc = to_cell(goal); + let n = (gw * gh) as usize; + let mut g_cost = vec![u32::MAX; n]; + let mut came: Vec = vec![-1; n]; + let mut heap: BinaryHeap> = BinaryHeap::new(); + let h = |c: (i32, i32)| { + let (dx, dy) = ((c.0 - gc.0).abs() as u32, (c.1 - gc.1).abs() as u32); + // octile distance, x10 to stay integral + 14 * dx.min(dy) + 10 * (dx.max(dy) - dx.min(dy)) + }; + g_cost[cell_idx(sc)] = 0; + heap.push(Reverse((h(sc), sc.0, sc.1))); + + const DIRS: [(i32, i32, u32); 8] = [ + (0, -1, 10), + (1, 0, 10), + (0, 1, 10), + (-1, 0, 10), + (1, -1, 14), + (1, 1, 14), + (-1, 1, 14), + (-1, -1, 14), + ]; + + let mut found = false; + while let Some(Reverse((_, cx, cy))) = heap.pop() { + let c = (cx, cy); + if c == gc { + found = true; + break; + } + let ci = cell_idx(c); + for &(dx, dy, cost) in &DIRS { + let nc = (cx + dx, cy + dy); + if nc.0 < 0 || nc.1 < 0 || nc.0 >= gw || nc.1 >= gh { + continue; + } + let (px_, py_) = cell_center(nc); + // Edges must be *continuously* walkable, not just open at the + // sampled centers — otherwise a 1px pinch between two valid + // cells becomes a path the actor can't actually walk. The goal + // cell is exempt (goal itself is open by nearest_open); edges out + // of the start cell only need an open far end, since the start + // center may sit off-grid from the actor's true position. + if nc != gc { + if !p.ok(px_, py_) { + continue; + } + if c != sc && !line_of_sight(p, cell_center(c), (px_, py_)) { + continue; + } + } + let ni = cell_idx(nc); + let ng = g_cost[ci].saturating_add(cost); + if ng < g_cost[ni] { + g_cost[ni] = ng; + came[ni] = ci as i32; + heap.push(Reverse((ng + h(nc), nc.0, nc.1))); + } + } + } + if !found { + return Vec::new(); + } + + // Reconstruct cell path → pixel waypoints (start .. goal). + let mut cells = vec![gc]; + let mut cur = cell_idx(gc); + while came[cur] >= 0 { + cur = came[cur] as usize; + cells.push((cur as i32 % gw, cur as i32 / gw)); + } + cells.reverse(); + let mut pts: Vec<(i32, i32)> = cells.into_iter().map(cell_center).collect(); + pts[0] = start; + *pts.last_mut().unwrap() = goal; + + // String-pull: drop every waypoint the actor can already see past. + let mut out = Vec::new(); + let mut anchor = 0usize; + while anchor + 1 < pts.len() { + let mut far = anchor + 1; + for j in (anchor + 1..pts.len()).rev() { + if line_of_sight(p, pts[anchor], pts[j]) { + far = j; + break; + } + } + out.push(pts[far]); + anchor = far; + } + out +} diff --git a/mrpci-core/src/pic.rs b/mrpci-core/src/pic.rs new file mode 100644 index 0000000..92531b3 --- /dev/null +++ b/mrpci-core/src/pic.rs @@ -0,0 +1,290 @@ +//! PIC painting: vector operations that write any subset of the four screens +//! in one pass. This is the modern echo of SCI's PICTURE opcode streams — +//! a room's look, depth, walls and hotspots are one replayable list of ops. + +use crate::screens::{band, Screens, PIC_H}; +use serde::{Deserialize, Serialize}; + +/// What a paint op writes into the priority screen. +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum PriInk { + /// Leave depth alone (pure color / control edits). + Keep, + /// Restore the default row band (floors). + Band, + /// Force a band 0..=15 (a tree trunk that should occlude, a raised deck). + Set(u8), +} + +/// What a paint op writes into the control screen. +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum CtlInk { + Keep, + /// Overwrite flags entirely (0 = open floor). + Set(u8), +} + +/// A combined "ink": which screens this op touches, and with what. +/// `None`/`Keep` everywhere means the op is a no-op — inks compose freely, +/// so one polygon can paint color + depth + wall + hotspot at once. +#[derive(Clone, Copy, Serialize, Deserialize)] +pub struct Ink { + #[serde(default)] + pub color: Option, + #[serde(default = "keep_pri")] + pub pri: PriInk, + #[serde(default = "keep_ctl")] + pub ctl: CtlInk, + #[serde(default)] + pub hot: Option, +} + +fn keep_pri() -> PriInk { + PriInk::Keep +} +fn keep_ctl() -> CtlInk { + CtlInk::Keep +} + +impl Ink { + pub fn color(c: u8) -> Self { + Ink { color: Some(c), pri: PriInk::Keep, ctl: CtlInk::Keep, hot: None } + } + pub fn floor(c: u8) -> Self { + Ink { color: Some(c), pri: PriInk::Band, ctl: CtlInk::Set(0), hot: None } + } + pub fn wall(c: u8) -> Self { + Ink { color: Some(c), pri: PriInk::Keep, ctl: CtlInk::Set(crate::screens::CTL_BLOCK), hot: None } + } + pub fn with_pri(mut self, p: u8) -> Self { + self.pri = PriInk::Set(p); + self + } + pub fn with_ctl(mut self, c: u8) -> Self { + self.ctl = CtlInk::Set(c); + self + } + pub fn with_hot(mut self, h: u8) -> Self { + self.hot = Some(h); + self + } +} + +/// Paint one pixel through an ink. +#[inline] +pub fn px(s: &mut Screens, x: i32, y: i32, ink: Ink) { + if !Screens::in_bounds(x, y) { + return; + } + let i = Screens::idx(x, y); + if let Some(c) = ink.color { + s.visual[i] = c; + } + match ink.pri { + PriInk::Keep => {} + PriInk::Band => s.priority[i] = band(y as usize), + PriInk::Set(p) => s.priority[i] = p.min(15), + } + if let CtlInk::Set(c) = ink.ctl { + s.control[i] = c; + } + if let Some(h) = ink.hot { + s.hotspot[i] = h; + } +} + +pub fn line(s: &mut Screens, a: (i32, i32), b: (i32, i32), ink: Ink) { + let (mut x0, mut y0) = a; + let (x1, y1) = b; + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + px(s, x0, y0, ink); + if x0 == x1 && y0 == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } +} + +pub fn rect(s: &mut Screens, x: i32, y: i32, w: i32, h: i32, ink: Ink) { + for yy in y..y + h { + for xx in x..x + w { + px(s, xx, yy, ink); + } + } +} + +pub fn ellipse(s: &mut Screens, cx: i32, cy: i32, rx: i32, ry: i32, ink: Ink) { + if rx <= 0 || ry <= 0 { + return; + } + for yy in cy - ry..=cy + ry { + for xx in cx - rx..=cx + rx { + let nx = (xx - cx) as f32 / rx as f32; + let ny = (yy - cy) as f32 / ry as f32; + if nx * nx + ny * ny <= 1.0 { + px(s, xx, yy, ink); + } + } + } +} + +/// Filled polygon (even-odd scanline). Walkboxes, rooftops, rivers — SCI +/// thought in polygons and so does MRPCI. +pub fn polygon(s: &mut Screens, pts: &[(i32, i32)], ink: Ink) { + if pts.len() < 3 { + return; + } + let y_min = pts.iter().map(|p| p.1).min().unwrap().max(0); + let y_max = pts.iter().map(|p| p.1).max().unwrap().min(PIC_H as i32 - 1); + for y in y_min..=y_max { + let mut xs: Vec = Vec::new(); + let n = pts.len(); + for i in 0..n { + let (x0, y0) = pts[i]; + let (x1, y1) = pts[(i + 1) % n]; + if (y0 <= y && y1 > y) || (y1 <= y && y0 > y) { + let t = (y - y0) as f32 / (y1 - y0) as f32; + xs.push(x0 + (t * (x1 - x0) as f32) as i32); + } + } + xs.sort_unstable(); + for pair in xs.chunks(2) { + if let [a, b] = pair { + for x in *a..=*b { + px(s, x, y, ink); + } + } + } + } +} + +/// Flood fill from a seed, matching on the *visual* color under the seed. +pub fn flood(s: &mut Screens, x: i32, y: i32, ink: Ink) { + if !Screens::in_bounds(x, y) { + return; + } + let target = s.visual[Screens::idx(x, y)]; + if ink.color == Some(target) { + return; + } + let mut stack = vec![(x, y)]; + while let Some((cx, cy)) = stack.pop() { + if !Screens::in_bounds(cx, cy) { + continue; + } + let i = Screens::idx(cx, cy); + if s.visual[i] != target { + continue; + } + px(s, cx, cy, ink); + // px always writes color when ink.color is Some; if it's None we'd + // loop forever, so color-less floods paint the seed color back. + if ink.color.is_none() { + s.visual[i] = target; + // mark by control/pri/hot writes only; use a visited trick: + // color-less flood is only sane with a bounded region — bail. + return; + } + stack.push((cx + 1, cy)); + stack.push((cx - 1, cy)); + stack.push((cx, cy + 1)); + stack.push((cx, cy - 1)); + } +} + +/// 4x4 Bayer matrix — ordered dithering thresholds 0..16. +const BAYER: [[u8; 4]; 4] = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; + +/// Vertical gradient across a rect, dithered through a ramp of palette +/// indices. This is how a 256-color sky stops banding — the SCI1 look. +pub fn vgradient(s: &mut Screens, x: i32, y: i32, w: i32, h: i32, ramp: &[u8], ink: Ink) { + if ramp.is_empty() || h <= 0 { + return; + } + for yy in y..y + h { + let t = (yy - y) as f32 / h as f32 * (ramp.len() as f32 - 1.0); + let lo = t.floor() as usize; + let hi = (lo + 1).min(ramp.len() - 1); + let frac = t - lo as f32; + for xx in x..x + w { + let threshold = BAYER[(yy & 3) as usize][(xx & 3) as usize] as f32 / 16.0; + let c = if frac > threshold { ramp[hi] } else { ramp[lo] }; + let mut k = ink; + k.color = Some(c); + px(s, xx, yy, k); + } + } +} + +/// Horizontal gradient twin of [`vgradient`]. +pub fn hgradient(s: &mut Screens, x: i32, y: i32, w: i32, h: i32, ramp: &[u8], ink: Ink) { + if ramp.is_empty() || w <= 0 { + return; + } + for xx in x..x + w { + let t = (xx - x) as f32 / w as f32 * (ramp.len() as f32 - 1.0); + let lo = t.floor() as usize; + let hi = (lo + 1).min(ramp.len() - 1); + let frac = t - lo as f32; + for yy in y..y + h { + let threshold = BAYER[(yy & 3) as usize][(xx & 3) as usize] as f32 / 16.0; + let c = if frac > threshold { ramp[hi] } else { ramp[lo] }; + let mut k = ink; + k.color = Some(c); + px(s, xx, yy, k); + } + } +} + +/// A room-authoring paint op — the serde face of the functions above. +/// A room's procedural look is `Vec`, replayed in order onto blank +/// screens (then the background PNG, masks and hotspot shapes layer in). +#[derive(Clone, Serialize, Deserialize)] +pub enum PicOp { + Px { x: i32, y: i32, ink: Ink }, + Line { pts: Vec<(i32, i32)>, ink: Ink }, + Rect { x: i32, y: i32, w: i32, h: i32, ink: Ink }, + Ellipse { cx: i32, cy: i32, rx: i32, ry: i32, ink: Ink }, + Polygon { pts: Vec<(i32, i32)>, ink: Ink }, + Flood { x: i32, y: i32, ink: Ink }, + VGradient { x: i32, y: i32, w: i32, h: i32, ramp: Vec, ink: Ink }, + HGradient { x: i32, y: i32, w: i32, h: i32, ramp: Vec, ink: Ink }, +} + +pub fn apply_op(s: &mut Screens, op: &PicOp) { + match op { + PicOp::Px { x, y, ink } => px(s, *x, *y, *ink), + PicOp::Line { pts, ink } => { + for w in pts.windows(2) { + line(s, w[0], w[1], *ink); + } + } + PicOp::Rect { x, y, w, h, ink } => rect(s, *x, *y, *w, *h, *ink), + PicOp::Ellipse { cx, cy, rx, ry, ink } => ellipse(s, *cx, *cy, *rx, *ry, *ink), + PicOp::Polygon { pts, ink } => polygon(s, pts, *ink), + PicOp::Flood { x, y, ink } => flood(s, *x, *y, *ink), + PicOp::VGradient { x, y, w, h, ramp, ink } => vgradient(s, *x, *y, *w, *h, ramp, *ink), + PicOp::HGradient { x, y, w, h, ramp, ink } => hgradient(s, *x, *y, *w, *h, ramp, *ink), + } +} + +/// Guard against the pathological op lists an LLM might emit: PIC_W/PIC_H +/// bounds are enforced per-pixel, so the worst case is wasted time, not UB. +pub fn apply_ops(s: &mut Screens, ops: &[PicOp]) { + for op in ops { + apply_op(s, op); + } +} diff --git a/mrpci-core/src/render.rs b/mrpci-core/src/render.rs new file mode 100644 index 0000000..a4c9e72 --- /dev/null +++ b/mrpci-core/src/render.rs @@ -0,0 +1,99 @@ +//! Headless compositing: the four screens + depth-sorted, perspective-scaled +//! actors → RGBA pixels → optionally a PNG. No window required — this is +//! what lets an LLM *see* the frame it just authored. + +use crate::palette::Palette; +use crate::screens::{Screens, PIC_H, PIC_W}; +use crate::view::Cel; + +/// One thing to draw this frame: a cel standing at its feet point. +pub struct DrawObj<'a> { + pub cel: &'a Cel, + pub x: i32, + pub y: i32, + pub pri: u8, + pub scale: f32, + pub mirrored: bool, +} + +/// The SCI trick, kept from AGI days: draw objects back-to-front, but test +/// every pixel against the room's priority screen, so scenery painted nearer +/// still occludes a sprite standing behind it. New here: per-object scale +/// (nearest-neighbor, crisp) and the palette-cycling LUT applied at the exit. +pub fn compose(room: &Screens, palette: &Palette, lut: &[u8; 256], draws: &mut Vec) -> Vec { + let mut vis = room.visual.clone(); + draws.sort_by_key(|d| d.pri); + + for d in draws.iter() { + let sw = ((d.cel.w as f32) * d.scale).round().max(1.0) as i32; + let sh = ((d.cel.h as f32) * d.scale).round().max(1.0) as i32; + let ox = d.x - sw / 2; + let oy = d.y - sh; + for sy in 0..sh { + let py = oy + sy; + if py < 0 || py as usize >= PIC_H { + continue; + } + let cy = (sy * d.cel.h as i32 / sh) as usize; + for sx in 0..sw { + let px = ox + sx; + if px < 0 || px as usize >= PIC_W { + continue; + } + let cx = (sx * d.cel.w as i32 / sw) as usize; + let c = if d.mirrored { d.cel.at_mirrored(cx, cy) } else { d.cel.at(cx, cy) }; + let Some(c) = c else { continue }; + let i = py as usize * PIC_W + px as usize; + if d.pri >= room.priority[i] { + vis[i] = c; + } + } + } + } + + let mut rgba = Vec::with_capacity(PIC_W * PIC_H * 4); + for &v in &vis { + rgba.extend_from_slice(&palette.rgba(lut[v as usize])); + } + rgba +} + +/// Debug composites: see the invisible screens the way the engine does. +pub fn debug_screen(room: &Screens, which: &str) -> Vec { + let mut rgba = Vec::with_capacity(PIC_W * PIC_H * 4); + for i in 0..PIC_W * PIC_H { + let px: [u8; 4] = match which { + "priority" => { + let v = room.priority[i] * 17; + [v, v, v, 255] + } + "control" => { + let c = room.control[i]; + if c & crate::screens::CTL_BLOCK != 0 { + [220, 40, 40, 255] + } else if c & crate::screens::CTL_WATER != 0 { + [40, 80, 220, 255] + } else { + [20, 20, 20, 255] + } + } + "hotspot" => { + let h = room.hotspot[i]; + // hash the id into a repeatable color + [h.wrapping_mul(97), h.wrapping_mul(57).wrapping_add(60), h.wrapping_mul(31).wrapping_add(120), 255] + } + _ => [0, 0, 0, 255], + }; + rgba.extend_from_slice(&px); + } + rgba +} + +/// Encode raw RGBA (PIC_W x PIC_H) to PNG bytes. +pub fn encode_png(rgba: &[u8]) -> Vec { + let img = image::RgbaImage::from_raw(PIC_W as u32, PIC_H as u32, rgba.to_vec()) + .expect("buffer is always PIC_W*PIC_H*4"); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageOutputFormat::Png).expect("png encode of a valid image"); + out.into_inner() +} diff --git a/mrpci-core/src/room.rs b/mrpci-core/src/room.rs new file mode 100644 index 0000000..c215dea --- /dev/null +++ b/mrpci-core/src/room.rs @@ -0,0 +1,599 @@ +//! The world data model — rooms, hotspots, props, NPCs — and [`World`], the +//! single source of truth for "what game is loaded and which room is baked". +//! Everything is serde: a game is a folder of JSON + PNGs + rhai scripts, +//! and an LLM expansion is just JSON that deserializes. + +use crate::actor::ScaleTable; +use crate::assets; +use crate::dialogue::DlgNode; +use crate::palette::{PalCycle, Palette}; +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::path::PathBuf; + +/// A clickable/steppable region. Painted into the hotspot screen by id, so +/// hit-testing any pixel is one array read — SCI's control-screen trick, +/// given its own screen. +#[derive(Clone, Serialize, Deserialize)] +pub struct Hotspot { + pub name: String, + /// Rect [x, y, w, h] or a polygon of points — one of the two. + #[serde(default)] + pub rect: Option<[i32; 4]>, + #[serde(default)] + pub poly: Vec<(i32, i32)>, + /// Verb → response text. Keys are canonical verbs ("look", "do", + /// "talk", ...) plus "use:" for inventory-on-hotspot. + #[serde(default)] + pub msgs: HashMap, + /// Walking into this region changes rooms (doors!). The ego arrives at + /// `arrive` in the target room (or that room's spawn). + #[serde(default)] + pub exit_to: Option, + #[serde(default)] + pub arrive: Option<(i32, i32)>, + /// Exit/trigger only fires while this flag is true (empty = always). + #[serde(default)] + pub requires_flag: String, + /// What to say when the exit refuses (empty = a stock line). + #[serde(default)] + pub blocked_text: String, + /// Stepping in fires the room script's `on_trigger(name)`. + #[serde(default)] + pub trigger: bool, + /// Also paint CTL_BLOCK under the shape (fences you can look at). + #[serde(default)] + pub blocks: bool, +} + +/// A live object: a sprite the player can look at / take / use / talk to. +/// Its base point (x, y) is its feet, like the ego, so it depth-sorts and +/// scales the same way. +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct PropDef { + pub name: String, + pub sprite: String, + pub x: i32, + pub y: i32, + /// Verb → response text (same keys as hotspots). "look" is the classic. + #[serde(default)] + pub msgs: HashMap, + #[serde(default)] + pub takeable: bool, + #[serde(default)] + pub synonyms: String, // space-separated alternate names for the parser + #[serde(default)] + pub use_text: String, // shown on a successful "do"/"use" + #[serde(default)] + pub needs: String, // item required to use it (empty = no lock) + #[serde(default)] + pub wins: bool, // using it (successfully) wins the game + #[serde(default)] + pub requires_flag: String, + #[serde(default)] + pub sets_flag: String, + #[serde(default)] + pub dialogue: Vec, // node 0 = entry; empty = no conversation + /// Points for taking it (takeable) or first successful use (otherwise). + #[serde(default)] + pub points: u32, + /// A successful use removes the `needs` item from inventory. + #[serde(default)] + pub consumes: bool, + /// A successful use kills the player (use_text is the death text). + #[serde(default)] + pub kills: bool, + #[serde(default)] + pub visible_flag: String, + #[serde(default)] + pub hidden_by_flag: String, + /// Solid props stamp a block footprint into the control screen, so the + /// pathfinder walks the ego *around* the desk instead of through it. + #[serde(default)] + pub solid: bool, + #[serde(default)] + pub fixed_priority: Option, + #[serde(default)] + pub fixed_scale: Option, +} + +/// Is this prop currently part of the room, given taken-list and flags? +pub fn prop_visible( + o: &PropDef, + room: u32, + taken: &[(u32, String)], + flags: &HashMap, +) -> bool { + let on = |name: &str| flags.get(name).copied().unwrap_or(false); + !taken.iter().any(|(r, n)| *r == room && *n == o.name) + && (o.visible_flag.is_empty() || on(&o.visible_flag)) + && (o.hidden_by_flag.is_empty() || !on(&o.hidden_by_flag)) +} + +/// A walking NPC: a prop that moves. Wander keeps it strolling a radius +/// around home using the engine's seeded RNG — deterministic every replay. +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct NpcDef { + pub name: String, + pub sprite: String, // sprite name, or "" for the built-in robot view + pub x: i32, + pub y: i32, + #[serde(default)] + pub msgs: HashMap, + #[serde(default)] + pub dialogue: Vec, + /// Wander radius in pixels (0 = stands still). + #[serde(default)] + pub wander: i32, + #[serde(default)] + pub visible_flag: String, + #[serde(default)] + pub hidden_by_flag: String, + #[serde(default)] + pub fixed_scale: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RoomDoc { + /// Display name, shown on entry ("" = just the number). + #[serde(default)] + pub name: String, + /// Prose printed when the ego enters ("" = a stock line). + #[serde(default)] + pub enter_text: String, + /// Background PNG in `pics/` (with optional `-pri.png` and + /// `-ctl.png` companions). Applied before `ops`. + #[serde(default)] + pub background: String, + /// Procedural paint ops (replayed after the background, if any). + #[serde(default)] + pub ops: Vec, + /// A full room palette (usually written by the quantizer). None = base. + #[serde(default)] + pub palette: Option, + /// Palette cycling ranges: waterfalls, neon, lava. + #[serde(default)] + pub cycles: Vec, + /// Perspective: how actors scale as they walk toward the horizon. + #[serde(default)] + pub scale: ScaleTable, + pub spawn: (i32, i32), + /// Edge exits N, E, S, W → target room. + #[serde(default)] + pub exits: [Option; 4], + /// Per-direction flag required before that edge exit works ("" = open). + #[serde(default = "empty4")] + pub exit_flags: [String; 4], + /// What to say when the matching exit refuses ("" = a stock line). + #[serde(default = "empty4")] + pub exit_blocked: [String; 4], + #[serde(default)] + pub hotspots: Vec, + #[serde(default)] + pub props: Vec, + #[serde(default)] + pub npcs: Vec, + #[serde(default)] + pub music: u8, // 0 = silence, 1.. = ambient mood + /// Per-room default verb responses (checked before the global stock lines). + #[serde(default)] + pub defaults: HashMap, + /// Room logic: rhai source file under `scripts/` ("" = `room.rhai` + /// if that file exists, else no script). + #[serde(default)] + pub script: String, +} + +fn empty4() -> [String; 4] { + [String::new(), String::new(), String::new(), String::new()] +} + +impl Default for RoomDoc { + fn default() -> Self { + RoomDoc { + name: String::new(), + enter_text: String::new(), + background: String::new(), + ops: Vec::new(), + palette: None, + cycles: Vec::new(), + scale: ScaleTable::default(), + spawn: (160, 170), + exits: [None; 4], + exit_flags: empty4(), + exit_blocked: empty4(), + hotspots: Vec::new(), + props: Vec::new(), + npcs: Vec::new(), + music: 0, + defaults: HashMap::new(), + script: String::new(), + } + } +} + +/// `game.json` — what makes a folder a game. Every field defaults, so a bare +/// folder of rooms is already a valid game. +#[derive(Clone, Serialize, Deserialize)] +pub struct GameManifest { + #[serde(default = "default_name")] + pub name: String, + #[serde(default)] + pub start_room: u32, + #[serde(default = "default_intro")] + pub intro_text: String, + /// Extra parser verb synonyms: canonical → space-separated synonyms. + #[serde(default)] + pub verbs: HashMap, + /// Initial flag values for a new game. + #[serde(default)] + pub flags: HashMap, + /// Total points on offer; 0 = scoring off. + #[serde(default)] + pub max_score: u32, + /// Sprite name for the ego ("" = the built-in party robot). + #[serde(default)] + pub ego_sprite: String, + /// Global default verb responses (the last resort before stock lines). + #[serde(default)] + pub defaults: HashMap, +} + +fn default_name() -> String { + "untitled".into() +} +fn default_intro() -> String { + "You blink awake in 256 colors.".into() +} + +impl Default for GameManifest { + fn default() -> Self { + GameManifest { + name: default_name(), + start_room: 0, + intro_text: default_intro(), + verbs: HashMap::new(), + flags: HashMap::new(), + max_score: 0, + ego_sprite: String::new(), + defaults: HashMap::new(), + } + } +} + +/// A whole world in one JSON document — what cloud saves store and what +/// `to_bundle`/`apply_bundle` round-trip. +#[derive(Clone, Serialize, Deserialize)] +pub struct WorldBundle { + #[serde(default)] + pub manifest: GameManifest, + #[serde(default)] + pub rooms: HashMap, + /// Script sources by file name (bundles travel with their logic). + #[serde(default)] + pub scripts: HashMap, +} + +/// The loaded game: manifest + sprites + the current room baked into the +/// four screens, plus an in-memory overlay of rooms that differ from disk. +pub struct World { + pub base: PathBuf, + pub manifest: GameManifest, + pub current: u32, + pub doc: RoomDoc, + pub screens: Screens, + /// The room's live palette (room palette or base, quantized art applied). + pub palette: Palette, + /// Raw sprite sources (quantized to cels per room bake). + pub sprites: Vec<(String, SpriteSrc)>, + /// This room's cooked cels, matching `palette`. + pub cels: Vec<(String, Cel)>, + pub overlay: HashMap, + /// In-memory scripts (bundles / upserts); disk is the fallback. + pub script_overlay: HashMap, +} + +impl World { + /// Load a game folder: `game.json` (all-defaults if absent) + `rooms/` + + /// `sprites/` + `pics/` + `scripts/`. + pub fn load_game(dir: impl Into) -> Self { + let base: PathBuf = dir.into(); + let manifest: GameManifest = std::fs::read_to_string(base.join("game.json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let sprites = assets::load_sprite_dir(&base.join("sprites")); + let mut w = World { + base, + current: manifest.start_room, + manifest, + doc: RoomDoc::default(), + screens: Screens::new(), + palette: Palette::base(), + sprites, + cels: Vec::new(), + overlay: HashMap::new(), + script_overlay: HashMap::new(), + }; + let start = w.current; + w.goto_room(start); + w + } + + /// Build a World entirely from memory — no filesystem (browser builds, + /// tests, generated games). + pub fn from_memory( + manifest: GameManifest, + rooms: impl IntoIterator, + scripts: impl IntoIterator, + sprites: Vec<(String, SpriteSrc)>, + ) -> Self { + let mut w = World { + base: PathBuf::from("."), + current: manifest.start_room, + manifest, + doc: RoomDoc::default(), + screens: Screens::new(), + palette: Palette::base(), + sprites, + cels: Vec::new(), + overlay: rooms.into_iter().collect(), + script_overlay: scripts.into_iter().collect(), + }; + let start = w.current; + w.goto_room(start); + w + } + + pub fn room_path(&self, n: u32) -> PathBuf { + self.base.join(format!("rooms/room{}.json", n)) + } + + /// Read a room doc without switching to it: overlay first, then disk. + pub fn peek_room(&self, n: u32) -> Option { + if let Some(doc) = self.overlay.get(&n) { + return Some(doc.clone()); + } + std::fs::read_to_string(self.room_path(n)).ok().and_then(|s| serde_json::from_str(&s).ok()) + } + + /// Switch the working room WITHOUT saving. Overlay wins over disk; a + /// missing room is a fresh blank one (never a crash — no Error 47 here). + pub fn goto_room(&mut self, n: u32) { + self.current = n; + self.doc = self.peek_room(n).unwrap_or_default(); + self.bake(); + } + + /// Rebuild the four screens + palette + cooked cels from the room doc. + /// Order: palette → background PNG (+ masks) → paint ops → hotspot ids → + /// solid prop footprints (via `stamp_solid_props`, called by the state + /// layer which knows which props are alive). + pub fn bake(&mut self) { + self.screens.clear(); + self.palette = self.doc.palette.clone().unwrap_or_else(Palette::base); + if !self.doc.background.is_empty() { + let dir = self.base.join("pics"); + if let Some(rgba) = assets::load_rgba_scaled(&dir.join(format!("{}.png", self.doc.background)), PIC_W, PIC_H) { + assets::bake_background(&rgba, &mut self.palette, &mut self.screens); + } + if let Some(rgba) = assets::load_rgba_scaled(&dir.join(format!("{}-pri.png", self.doc.background)), PIC_W, PIC_H) { + assets::bake_pri_mask(&rgba, &mut self.screens); + } + if let Some(rgba) = assets::load_rgba_scaled(&dir.join(format!("{}-ctl.png", self.doc.background)), PIC_W, PIC_H) { + assets::bake_ctl_mask(&rgba, &mut self.screens); + } + } + pic::apply_ops(&mut self.screens, &self.doc.ops); + // Hotspots paint their id (and optional block) into their screens. + for (i, h) in self.doc.hotspots.iter().enumerate() { + let id = (i + 1).min(255) as u8; + let ink = pic::Ink { + color: None, + pri: pic::PriInk::Keep, + ctl: if h.blocks { pic::CtlInk::Set(CTL_BLOCK) } else { pic::CtlInk::Keep }, + hot: Some(id), + }; + if let Some([x, y, w, hh]) = h.rect { + pic::rect(&mut self.screens, x, y, w, hh, ink); + } + if h.poly.len() >= 3 { + pic::polygon(&mut self.screens, &h.poly, ink); + } + } + // Cook sprites against the final palette. + self.cels = self + .sprites + .iter() + .map(|(n, src)| (n.clone(), assets::sprite_to_cel(src, &self.palette))) + .collect(); + } + + /// Stamp block footprints for the currently-alive solid props (the state + /// layer calls this after bake and after any prop is taken/hidden). + pub fn stamp_solid_props(&mut self, alive: &[String]) { + for p in &self.doc.props { + if !p.solid || !alive.iter().any(|n| n == &p.name) { + continue; + } + let w = self + .cel(&p.sprite) + .map(|c| c.w as i32) + .unwrap_or(16); + let ink = pic::Ink { color: None, pri: pic::PriInk::Keep, ctl: pic::CtlInk::Set(CTL_BLOCK), hot: None }; + pic::ellipse(&mut self.screens, p.x, p.y - 2, (w / 2).max(3), 4, ink); + } + } + + pub fn cel(&self, name: &str) -> Option<&Cel> { + self.cels.iter().find(|(n, _)| n == name).map(|(_, c)| c) + } + + /// Script source by file name: overlay, then `scripts/` on disk. + pub fn script_source(&self, file: &str) -> Option { + if let Some(s) = self.script_overlay.get(file) { + return Some(s.clone()); + } + std::fs::read_to_string(self.base.join("scripts").join(file)).ok() + } + + /// The current room's script file name, if any exists. + pub fn room_script_file(&self) -> Option { + if !self.doc.script.is_empty() { + return Some(self.doc.script.clone()); + } + let default = format!("room{}.rhai", self.current); + if self.script_overlay.contains_key(&default) || self.base.join("scripts").join(&default).exists() { + return Some(default); + } + None + } + + /// Snapshot the whole world as one JSON document. + pub fn to_bundle(&self) -> WorldBundle { + let mut rooms: HashMap = HashMap::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(); + if let Some(n) = name.strip_prefix("room").and_then(|s| s.strip_suffix(".json")).and_then(|s| s.parse().ok()) { + if let Some(doc) = self.peek_room(n) { + rooms.insert(n, doc); + } + } + } + } + for (n, doc) in &self.overlay { + rooms.insert(*n, doc.clone()); + } + rooms.insert(self.current, self.doc.clone()); + let mut scripts = self.script_overlay.clone(); + if let Ok(rd) = std::fs::read_dir(self.base.join("scripts")) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name.ends_with(".rhai") && !scripts.contains_key(&name) { + if let Ok(src) = std::fs::read_to_string(e.path()) { + scripts.insert(name, src); + } + } + } + } + WorldBundle { manifest: self.manifest.clone(), rooms, scripts } + } + + /// Replace the loaded world with a bundle (sprites are kept — bundles + /// carry world data, not art). + pub fn apply_bundle(&mut self, b: WorldBundle) { + self.manifest = b.manifest; + self.overlay = b.rooms.into_iter().collect(); + self.script_overlay = b.scripts.into_iter().collect(); + let start = self.manifest.start_room; + self.goto_room(start); + } + + /// Insert/replace a room. If it's the current room the screens rebake. + /// With `persist` it is also written to disk (and dropped from overlay). + pub fn upsert_room(&mut self, n: u32, doc: RoomDoc, persist: bool) -> std::io::Result<()> { + if persist { + self.write_room(n, &doc)?; + self.overlay.remove(&n); + } else { + self.overlay.insert(n, doc.clone()); + } + if n == self.current { + self.doc = doc; + self.bake(); + } + Ok(()) + } + + pub fn write_room(&self, n: u32, doc: &RoomDoc) -> std::io::Result<()> { + let path = self.room_path(n); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let j = serde_json::to_string_pretty(doc).map_err(std::io::Error::other)?; + std::fs::write(path, j) + } + + pub fn save_current(&mut self) -> std::io::Result<()> { + self.write_room(self.current, &self.doc.clone())?; + self.overlay.remove(&self.current); + Ok(()) + } + + pub fn reload_sprites(&mut self) { + self.sprites = assets::load_sprite_dir(&self.base.join("sprites")); + self.bake(); + } + + /// Sanity-check a room before committing it (the lore-ingestion gate): + /// exits must point at rooms that exist, and there must be somewhere to + /// stand. + pub fn validate_room(&self, n: u32, doc: &RoomDoc) -> Result<(), String> { + let mut probe = World { + base: self.base.clone(), + manifest: self.manifest.clone(), + current: n, + doc: doc.clone(), + screens: Screens::new(), + palette: Palette::base(), + sprites: Vec::new(), // skip art: validation is about geometry + cels: Vec::new(), + overlay: HashMap::new(), + script_overlay: HashMap::new(), + }; + probe.bake(); + let (sx, sy) = find_spawn(&probe.screens, doc.spawn); + if !probe.screens.walkable(sx, sy) { + return Err(format!("room {} has no walkable spawn — it's all wall", n)); + } + let known = |t: u32| t == n || self.overlay.contains_key(&t) || self.room_path(t).exists(); + for (d, t) in doc.exits.iter().enumerate() { + if let Some(t) = t { + if !known(*t) { + return Err(format!( + "room {} exit {} points at room {}, which doesn't exist yet — upsert it first", + n, + ["N", "E", "S", "W"][d], + t + )); + } + } + } + for h in &doc.hotspots { + if let Some(t) = h.exit_to { + if !known(t) { + return Err(format!("room {} hotspot '{}' exits to missing room {}", n, h.name, t)); + } + } + } + Ok(()) + } +} + +/// 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) { + let open = |x: i32, y: i32| s.control_at(x, y) == 0; + if open(prefer.0, prefer.1) { + return prefer; + } + if s.walkable(prefer.0, prefer.1) { + return prefer; + } + for y in (10..PIC_H as i32 - 4).rev() { + for x in 8..PIC_W as i32 - 8 { + if open(x, y) { + return (x, y); + } + } + } + prefer +} + +// Keep constants nearby for consumers that reason about room geometry. +pub const ROOM_W: usize = PIC_W; +pub const ROOM_H: usize = PIC_H; diff --git a/mrpci-core/src/screens.rs b/mrpci-core/src/screens.rs new file mode 100644 index 0000000..ff29400 --- /dev/null +++ b/mrpci-core/src/screens.rs @@ -0,0 +1,124 @@ +//! SCI's core rendering model: every room is FOUR aligned screens. +//! +//! AGI squeezed depth and walls into one buffer (values 0-3 were walls, 4-15 +//! were depth) — which is why AGI rooms can't have a wall you also walk +//! behind. SCI split them, and so do we: +//! +//! * **visual** — palette indices (0..=255), what the player sees +//! * **priority** — depth bands 0..=15 (higher = nearer the camera) +//! * **control** — walkability bitflags (block / water), independent of depth +//! * **hotspot** — a click/step map: each pixel names the hotspot it belongs to +//! +//! The ego is drawn a pixel at a time, only where its priority >= the +//! scenery's — the whole "walk behind the column" trick with zero per-object +//! Z bookkeeping — while walls live on a *different* screen, so a barrier can +//! sit at any depth. + +pub const PIC_W: usize = 320; +pub const PIC_H: usize = 190; + +/// The "open" color the visual buffer clears to. +pub const BG_VISUAL: u8 = 0; + +// --- control bitflags ------------------------------------------------------- +/// Nothing may step here. +pub const CTL_BLOCK: u8 = 1; +/// Water: actors flagged `on_land_only` refuse it; scripts can ask `on_water()`. +pub const CTL_WATER: u8 = 2; + +/// Number of depth bands. +pub const BANDS: u8 = 16; + +/// Map a screen row to its default depth band: 0 at the top of the play area +/// down to 15 at the very bottom. This is what makes a flat floor sort +/// correctly with no extra work from the room author. +#[inline] +pub fn band(y: usize) -> u8 { + ((y * BANDS as usize) / PIC_H).min(15) as u8 +} + +pub struct Screens { + pub visual: Vec, + pub priority: Vec, + pub control: Vec, + pub hotspot: Vec, +} + +impl Screens { + pub fn new() -> Self { + let mut s = Screens { + visual: vec![BG_VISUAL; PIC_W * PIC_H], + priority: vec![0; PIC_W * PIC_H], + control: vec![0; PIC_W * PIC_H], + hotspot: vec![0; PIC_W * PIC_H], + }; + s.clear(); + s + } + + /// Reset to a blank room: black visual, default depth bands, all-open + /// control, no hotspots. + pub fn clear(&mut self) { + for y in 0..PIC_H { + let p = band(y); + let row = y * PIC_W; + for x in 0..PIC_W { + self.visual[row + x] = BG_VISUAL; + self.priority[row + x] = p; + self.control[row + x] = 0; + self.hotspot[row + x] = 0; + } + } + } + + #[inline] + pub fn in_bounds(x: i32, y: i32) -> bool { + x >= 0 && y >= 0 && (x as usize) < PIC_W && (y as usize) < PIC_H + } + + #[inline] + pub fn idx(x: i32, y: i32) -> usize { + y as usize * PIC_W + x as usize + } + + /// Control flags at a point; off-screen reads as a solid wall. + #[inline] + pub fn control_at(&self, x: i32, y: i32) -> u8 { + if Self::in_bounds(x, y) { + self.control[Self::idx(x, y)] + } else { + CTL_BLOCK + } + } + + /// Is this point free to stand on? + #[inline] + pub fn walkable(&self, x: i32, y: i32) -> bool { + self.control_at(x, y) & CTL_BLOCK == 0 + } + + #[inline] + pub fn priority_at(&self, x: i32, y: i32) -> u8 { + if Self::in_bounds(x, y) { + self.priority[Self::idx(x, y)] + } else { + 15 + } + } + + /// Which hotspot (0 = none) owns this pixel? + #[inline] + pub fn hotspot_at(&self, x: i32, y: i32) -> u8 { + if Self::in_bounds(x, y) { + self.hotspot[Self::idx(x, y)] + } else { + 0 + } + } +} + +impl Default for Screens { + fn default() -> Self { + Self::new() + } +} diff --git a/mrpci-core/src/script.rs b/mrpci-core/src/script.rs new file mode 100644 index 0000000..1feaa4f --- /dev/null +++ b/mrpci-core/src/script.rs @@ -0,0 +1,238 @@ +//! The script host — SCI's whole reason to exist, minus its whole reason to +//! crash. Rooms carry [rhai](https://rhai.rs) scripts with hook functions: +//! +//! ```rhai +//! fn on_enter() { say("The precinct hums."); } +//! fn on_verb(verb, noun) { if verb == "do" && noun == "console" { ... return true; } false } +//! fn on_trigger(name) { if name == "tripwire" { die("Zap."); } } +//! fn on_tick() { /* every cycle — keep it light */ } +//! fn my_timer() { say("The kettle boils."); } // via after(90, "my_timer") +//! ``` +//! +//! Scripts never touch the engine directly: reads go through a shared +//! [`ScriptCtx`] snapshot, writes queue as [`Fx`] effects the state layer +//! applies after the call returns. That one-way airlock is why a buggy +//! script can print garbage but can't corrupt the sim — the modern answer +//! to `Error 47: Not an object`. +//! +//! Timers (`after`) count **game ticks**, not wall time, and the only RNG is +//! the engine's seeded xorshift — so a replayed command log is bit-identical +//! on any machine. The entire CPU-speed-bug family, extinct. + +use crate::audio::AudioCue; +use rhai::{Engine, Scope, AST}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// Effects a script can queue. Applied in order by the state layer. +#[derive(Clone, Debug)] +pub enum Fx { + Say(String), + SayBy(String, String), + Give(String), + RemoveItem(String), + GotoRoom(u32), + PlaceEgo(i32, i32), + WalkEgo(i32, i32), + FreezeEgo(bool), + NpcWalk(String, i32, i32), + NpcPlace(String, i32, i32), + NpcFreeze(String, bool), + Play(String), + Music(u8), + AddPoints(u32), + Win, + Die(String), + After(u32, String), + PalCycle(usize, bool), +} + +/// What scripts see and touch. Flags/vars are written directly (so a script +/// reads back its own writes mid-call); everything else queues as Fx. +#[derive(Default)] +pub struct ScriptCtx { + pub flags: HashMap, + pub vars: HashMap, + pub inventory: Vec, + pub ego: (i32, i32), + pub room: u32, + pub rng: u64, + pub fx: Vec, +} + +impl ScriptCtx { + /// xorshift64* — tiny, seedable, identical everywhere. The only RNG any + /// game logic is allowed to have. + pub fn next_rand(&mut self, n: i64) -> i64 { + if n <= 1 { + return 0; + } + let mut x = self.rng.max(1); + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.rng = x; + ((x.wrapping_mul(0x2545F4914F6CDD1D) >> 33) % n as u64) as i64 + } +} + +pub struct ScriptHost { + engine: Engine, + ast: Option, + pub ctx: Arc>, + /// Last compile error, surfaced once as an Event::Error by the caller. + pub compile_error: Option, +} + +impl ScriptHost { + pub fn new() -> Self { + let ctx: Arc> = Arc::new(Mutex::new(ScriptCtx::default())); + let mut engine = Engine::new(); + // Scripts run at most 50k ops per hook call: an accidental + // `while true {}` becomes a skipped hook, not a hung game. + engine.set_max_operations(50_000); + + macro_rules! fx { + ($ctx:expr, $e:expr) => {{ + $ctx.lock().unwrap().fx.push($e); + }}; + } + + let c = ctx.clone(); + engine.register_fn("say", move |s: &str| fx!(c, Fx::Say(s.into()))); + let c = ctx.clone(); + engine.register_fn("say_by", move |who: &str, s: &str| fx!(c, Fx::SayBy(who.into(), s.into()))); + let c = ctx.clone(); + engine.register_fn("flag", move |n: &str| -> bool { c.lock().unwrap().flags.get(n).copied().unwrap_or(false) }); + let c = ctx.clone(); + engine.register_fn("set_flag", move |n: &str, v: bool| { + c.lock().unwrap().flags.insert(n.into(), v); + }); + // ("var" is a rhai reserved word, so numeric state reads as val().) + let c = ctx.clone(); + engine.register_fn("val", move |n: &str| -> i64 { c.lock().unwrap().vars.get(n).copied().unwrap_or(0) }); + let c = ctx.clone(); + engine.register_fn("set_val", move |n: &str, v: i64| { + c.lock().unwrap().vars.insert(n.into(), v); + }); + let c = ctx.clone(); + engine.register_fn("has", move |item: &str| -> bool { c.lock().unwrap().inventory.iter().any(|i| i == item) }); + let c = ctx.clone(); + engine.register_fn("give", move |item: &str| fx!(c, Fx::Give(item.into()))); + let c = ctx.clone(); + engine.register_fn("remove_item", move |item: &str| fx!(c, Fx::RemoveItem(item.into()))); + let c = ctx.clone(); + engine.register_fn("room", move || -> i64 { c.lock().unwrap().room as i64 }); + let c = ctx.clone(); + engine.register_fn("ego_x", move || -> i64 { c.lock().unwrap().ego.0 as i64 }); + let c = ctx.clone(); + engine.register_fn("ego_y", move || -> i64 { c.lock().unwrap().ego.1 as i64 }); + let c = ctx.clone(); + engine.register_fn("goto_room", move |n: i64| fx!(c, Fx::GotoRoom(n.max(0) as u32))); + let c = ctx.clone(); + engine.register_fn("place_ego", move |x: i64, y: i64| fx!(c, Fx::PlaceEgo(x as i32, y as i32))); + let c = ctx.clone(); + engine.register_fn("walk_ego", move |x: i64, y: i64| fx!(c, Fx::WalkEgo(x as i32, y as i32))); + let c = ctx.clone(); + engine.register_fn("freeze_ego", move |v: bool| fx!(c, Fx::FreezeEgo(v))); + let c = ctx.clone(); + engine.register_fn("npc_walk", move |n: &str, x: i64, y: i64| fx!(c, Fx::NpcWalk(n.into(), x as i32, y as i32))); + let c = ctx.clone(); + engine.register_fn("npc_place", move |n: &str, x: i64, y: i64| fx!(c, Fx::NpcPlace(n.into(), x as i32, y as i32))); + let c = ctx.clone(); + engine.register_fn("npc_freeze", move |n: &str, v: bool| fx!(c, Fx::NpcFreeze(n.into(), v))); + let c = ctx.clone(); + engine.register_fn("play", move |cue: &str| fx!(c, Fx::Play(cue.into()))); + let c = ctx.clone(); + engine.register_fn("music", move |mood: i64| fx!(c, Fx::Music(mood.clamp(0, 255) as u8))); + let c = ctx.clone(); + engine.register_fn("points", move |n: i64| fx!(c, Fx::AddPoints(n.max(0) as u32))); + let c = ctx.clone(); + engine.register_fn("win", move || fx!(c, Fx::Win)); + let c = ctx.clone(); + engine.register_fn("die", move |text: &str| fx!(c, Fx::Die(text.into()))); + let c = ctx.clone(); + engine.register_fn("after", move |ticks: i64, f: &str| fx!(c, Fx::After(ticks.max(1) as u32, f.into()))); + let c = ctx.clone(); + engine.register_fn("pal_cycle", move |i: i64, on: bool| fx!(c, Fx::PalCycle(i.max(0) as usize, on))); + let c = ctx.clone(); + engine.register_fn("rand", move |n: i64| -> i64 { c.lock().unwrap().next_rand(n) }); + + ScriptHost { engine, ast: None, ctx, compile_error: None } + } + + /// Compile `main.rhai` (if any) + the room script (if any) into one AST. + /// A compile error disables the host for this room and is reported — + /// the game keeps running on its declarative data. + pub fn load(&mut self, global_src: Option<&str>, room_src: Option<&str>) { + self.ast = None; + self.compile_error = None; + let mut ast: Option = None; + for (label, src) in [("main.rhai", global_src), ("room script", room_src)] { + let Some(src) = src else { continue }; + match self.engine.compile(src) { + Ok(a) => { + ast = Some(match ast { + None => a, + Some(mut base) => { + base += a; + base + } + }); + } + Err(e) => { + self.compile_error = Some(format!("{}: {}", label, e)); + return; + } + } + } + self.ast = ast; + } + + pub fn active(&self) -> bool { + self.ast.is_some() + } + + /// Call a hook that returns nothing. Missing functions are fine (rooms + /// implement only the hooks they care about); real errors are returned. + pub fn call(&self, name: &str, args: impl rhai::FuncArgs) -> Result<(), String> { + let Some(ast) = &self.ast else { + return Ok(()); + }; + let mut scope = Scope::new(); + match self.engine.call_fn::(&mut scope, ast, name, args) { + Ok(_) => Ok(()), + Err(e) => match *e { + rhai::EvalAltResult::ErrorFunctionNotFound(ref f, _) if f.starts_with(name) => Ok(()), + _ => Err(format!("script {}(): {}", name, e)), + }, + } + } + + /// Call `on_verb(verb, noun)` → did the script handle it? + pub fn call_on_verb(&self, verb: &str, noun: &str) -> Result { + let Some(ast) = &self.ast else { + return Ok(false); + }; + let mut scope = Scope::new(); + match self.engine.call_fn::(&mut scope, ast, "on_verb", (verb.to_string(), noun.to_string())) { + Ok(d) => Ok(d.as_bool().unwrap_or(false)), + Err(e) => match *e { + rhai::EvalAltResult::ErrorFunctionNotFound(ref f, _) if f.starts_with("on_verb") => Ok(false), + _ => Err(format!("script on_verb(): {}", e)), + }, + } + } + + /// Map a cue name from scripts to the engine's cue set (unknown = blip — + /// a wrong sound, never a crash). + pub fn cue_by_name(name: &str) -> AudioCue { + AudioCue::ALL.into_iter().find(|c| c.name() == name).unwrap_or(AudioCue::Blip) + } +} + +impl Default for ScriptHost { + fn default() -> Self { + Self::new() + } +} diff --git a/mrpci-core/src/state.rs b/mrpci-core/src/state.rs new file mode 100644 index 0000000..838aeb9 --- /dev/null +++ b/mrpci-core/src/state.rs @@ -0,0 +1,1475 @@ +//! The engine's spine: [`GameState`] owns the whole sim and is driven purely +//! by [`Command`]s (in) and [`Event`]s (out), with a fixed-step `tick`. +//! +//! Every control surface — GUI, JSONL stdio, HTTP, MCP — is a thin adapter +//! that pushes commands and reads events. Nothing in here knows or cares +//! which one is attached. +//! +//! Sierra's sins, answered by construction: +//! * timers/animation run on **fixed 30Hz cycles**, never wall-clock or CPU +//! speed — the QFG4 swamp crash and SQ4 Sequel-Police rushes can't exist; +//! * a **checkpoint autosave** is taken at every room entry, and death +//! restores it — dead-end-proof by default, KQ5's pie unpunishable; +//! * missing resources render placeholders and emit `error` events — the +//! Error 47 hard-crash class is a log line here. + +use crate::actor::Actor; +use crate::audio::AudioCue; +use crate::dialogue::Dlg; +use crate::palette::{cycle_lut, PalCycle}; +use crate::parser::{self, Parsed}; +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 serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; + +/// The SCI heartbeat: 30 game cycles per second, always exactly this long, +/// so scripted replays are bit-identical no matter who is driving. +pub const CYCLE_DT: f32 = 1.0 / 30.0; + +/// How much scrollback the core keeps (front-ends show what they like). +const TRANSCRIPT_CAP: usize = 300; + +/// Clicks farther than this from a touch-target walk the ego over first, +/// then act — the SCI "walk there and do it" feel. +const REACH: i32 = 40; + +/// Everything the engine can be told to do, from any surface. +/// Wire form is newline-JSON like `{"cmd":"verb_at","verb":"do","x":100,"y":120}`. +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum Command { + // --- playing --------------------------------------------------------- + /// Set the ego's direction (AGI ordering: 0 stop, 1 N .. 8 NW). + Walk { dir: u8 }, + /// Click-to-walk: pathfind toward a picture-space point. + MoveTo { x: i32, y: i32 }, + /// Walk toward a point and run cycles until arrival (or stuck). + WalkTo { x: i32, y: i32 }, + /// Point-and-click: apply a verb at a pixel ("look"/"do"/"talk"/"walk"). + VerbAt { verb: String, x: i32, y: i32 }, + /// Apply an inventory item at a pixel. + UseItemAt { item: String, x: i32, y: i32 }, + /// A typed player line — the parser lane. + Parse { text: String }, + /// Pick a numbered dialogue choice (1-based). + Choose { n: usize }, + EndDialogue, + /// Advance `n` fixed game cycles (movement, timers, triggers, NPCs). + Tick { n: u32 }, + /// Reset the session and start at `room` (or the manifest's start room). + NewGame { room: Option }, + GotoRoom { n: u32 }, + /// Emit a full state snapshot. + Query, + SetFlag { name: String, value: bool }, + SetVar { name: String, value: i64 }, + /// Seed the deterministic RNG (replays, tests). + SetSeed { seed: u64 }, + /// Save/restore anywhere — a named slot under `saves/`. + SaveGame { slot: String }, + RestoreGame { slot: String }, + // --- authoring (the same bus an editor would use) ---------------------- + /// Drop a whole room in as JSON — the "lore ingestion" command. + UpsertRoom { n: u32, doc: RoomDoc, #[serde(default)] persist: bool }, + /// Drop a script in (file name → source), compiled on next room entry. + UpsertScript { file: String, source: String }, + SaveRoom { n: Option }, + LoadGame { dir: String }, + ReloadAssets, +} + +/// Everything the engine reports back. Serialized as e.g. +/// `{"event":"transcript","line":"You take the keycard."}`. +#[derive(Clone, Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Transcript { line: String }, + RoomChanged { room: u32, name: String, music: u8 }, + InventoryChanged { items: Vec }, + DialogueOpen { lines: Vec }, + DialogueClosed, + Won, + /// The score changed (only emitted while max_score > 0). + ScoreChanged { score: u32, max: u32 }, + /// The player died. The room-entry checkpoint is restored right after — + /// death is a setback, not a restart. + Died { message: String }, + Audio { cue: AudioCue }, + Music { mood: u8 }, + EgoMoved { x: i32, y: i32, arrived: bool }, + State { state: StateSnapshot }, + SavedGame { slot: String }, + Restored { slot: String }, + RoomUpserted { room: u32, persisted: bool }, + RoomSaved { room: u32, path: String }, + GameLoaded { name: String, room: u32 }, + Error { message: String }, +} + +#[derive(Clone, Serialize)] +pub struct PropSummary { + pub name: String, + pub x: i32, + pub y: i32, + pub takeable: bool, + pub has_dialogue: bool, +} + +#[derive(Clone, Serialize)] +pub struct StateSnapshot { + pub game: String, + pub room: u32, + pub room_name: String, + pub ego: (i32, i32, u8), + pub inventory: Vec, + pub won: bool, + pub score: u32, + pub max_score: u32, + pub flags: BTreeMap, + pub vars: BTreeMap, + pub exits: [Option; 4], + pub music: u8, + pub props: Vec, + pub npcs: Vec, + pub hotspots: Vec, + pub in_dialogue: bool, + pub dialogue: Option>, + pub transcript_tail: Vec, +} + +/// A complete restorable moment — save-anywhere slots and the room-entry +/// checkpoint are both exactly this. +#[derive(Clone, Serialize, Deserialize)] +pub struct SaveData { + pub room: u32, + pub ego: (i32, i32), + pub inventory: Vec, + pub taken: Vec<(u32, String)>, + pub scored: Vec<(u32, String)>, + pub score: u32, + pub won: bool, + pub flags: BTreeMap, + pub vars: BTreeMap, + pub timers: Vec<(u32, String)>, + pub ticks: u64, + pub rng: u64, +} + +/// An NPC at runtime: its actor plus wander behavior. +pub struct Npc { + pub actor: Actor, + pub wander: i32, + pub home: (i32, i32), +} + +/// What a verb can land on. +#[derive(Clone, PartialEq)] +enum Target { + Room, + Prop(usize), + Npc(usize), + Hotspot(usize), + Item(String), +} + +pub struct GameState { + pub world: World, + pub ego: Actor, + pub npcs: Vec, + pub inventory: Vec, + pub taken: Vec<(u32, String)>, + pub won: bool, + pub score: u32, + /// (room, object) pairs that have already paid out their points. + pub scored: Vec<(u32, String)>, + pub dlg: Option, + pub transcript: Vec, + pub flags: HashMap, + pub vars: HashMap, + verb_idx: HashMap, + pub host: ScriptHost, + /// (ticks remaining, script function) — the deterministic `after()` queue. + timers: Vec<(u32, String)>, + pub ticks: u64, + /// Runtime copy of the room's palette cycles (scripts toggle these). + pub cycles: Vec, + acc: f32, + /// Per-direction "already told you it's blocked" latch (N,E,S,W). + blocked_latch: [bool; 4], + /// Hotspot id currently under the ego's feet (edge-triggering). + inside_hotspot: u8, + /// A verb waiting for the ego to arrive: (verb, item, x, y). + pending_verb: Option<(String, String, i32, i32)>, + /// The room-entry autosave that death restores. + checkpoint: Option, +} + +impl GameState { + pub fn new(world: World) -> Self { + let verb_idx = parser::verb_index(&world.manifest.verbs); + let flags = world.manifest.flags.clone(); + let mut gs = GameState { + ego: Actor::new("ego", default_robot_view(), 160, 170), + world, + npcs: Vec::new(), + inventory: Vec::new(), + taken: Vec::new(), + won: false, + score: 0, + scored: Vec::new(), + dlg: None, + transcript: Vec::new(), + flags, + vars: HashMap::new(), + verb_idx, + host: ScriptHost::new(), + timers: Vec::new(), + ticks: 0, + cycles: Vec::new(), + acc: 0.0, + blocked_latch: [false; 4], + inside_hotspot: 0, + pending_verb: None, + checkpoint: None, + }; + let _ = gs.enter_room(None); + gs + } + + // --- the bus ----------------------------------------------------------- + + pub fn apply(&mut self, cmd: Command) -> Vec { + let mut ev = Vec::new(); + match cmd { + Command::Walk { dir } => { + if self.dlg.is_none() && !self.ego.frozen { + self.pending_verb = None; + self.ego.set_dir(dir); + } + } + Command::MoveTo { x, y } => { + 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); + } + } + 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; + } + } + ev.push(Event::EgoMoved { x: self.ego.x, y: self.ego.y, arrived: !self.ego.walking() }); + } + Command::VerbAt { verb, x, y } => { + if self.dlg.is_none() { + let target = self.target_at(x, y); + ev.extend(self.dispatch(&verb, "", target, Some((x, y)))); + } + } + Command::UseItemAt { item, x, y } => { + if self.dlg.is_none() { + if !self.inventory.iter().any(|i| i == &item) { + ev.push(self.line(&format!("You aren't carrying any {}.", item))); + } else { + let target = self.target_at(x, y); + ev.extend(self.dispatch("use", &item, target, Some((x, y)))); + } + } + } + Command::Parse { text } => { + ev.extend(self.run_parse(&text)); + } + Command::Choose { n } => { + if let Some(mut d) = self.dlg.take() { + let score_before = self.score; + let (lines, ended, died) = d.choose(n, &mut self.flags, &mut self.score); + if died { + ev.push(Event::DialogueClosed); + ev.extend(self.die(lines.last().cloned().unwrap_or_default())); + return ev; + } + for l in lines { + ev.push(self.line(&l)); + } + if self.score != score_before && self.world.manifest.max_score > 0 { + ev.push(Event::ScoreChanged { score: self.score, max: self.world.manifest.max_score }); + } + if ended { + ev.push(self.line("(the conversation ends.)")); + ev.push(Event::DialogueClosed); + ev.push(Event::Audio { cue: AudioCue::Confirm }); + } else { + self.dlg = Some(d); + ev.push(Event::Audio { cue: AudioCue::Blip }); + } + } + } + Command::EndDialogue => { + if self.dlg.take().is_some() { + ev.push(self.line("(you end the conversation.)")); + ev.push(Event::DialogueClosed); + } + } + Command::Tick { n } => { + for _ in 0..n.min(10_000) { + ev.extend(self.step_cycle()); + } + } + Command::NewGame { room } => { + ev.extend(self.new_game(room)); + } + Command::GotoRoom { n } => { + ev.extend(self.change_room(n, None)); + } + Command::Query => { + ev.push(Event::State { state: self.snapshot() }); + } + Command::SetFlag { name, value } => { + self.flags.insert(name, value); + } + Command::SetVar { name, value } => { + self.vars.insert(name, value); + } + Command::SetSeed { seed } => { + self.host.ctx.lock().unwrap().rng = seed.max(1); + } + Command::SaveGame { slot } => { + let slot = safe_slot(&slot); + let data = self.save_data(); + let dir = self.world.base.join("saves"); + let res = std::fs::create_dir_all(&dir).and_then(|_| { + let j = serde_json::to_string_pretty(&data).map_err(std::io::Error::other)?; + std::fs::write(dir.join(format!("{}.json", slot)), j) + }); + match res { + Ok(()) => { + ev.push(self.line(&format!("(game saved: {}.)", slot))); + ev.push(Event::SavedGame { slot }); + ev.push(Event::Audio { cue: AudioCue::Confirm }); + } + Err(e) => ev.push(Event::Error { message: format!("save failed: {}", e) }), + } + } + Command::RestoreGame { slot } => { + let slot = safe_slot(&slot); + let path = self.world.base.join("saves").join(format!("{}.json", slot)); + match std::fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str::(&s).ok()) { + Some(data) => { + ev.extend(self.restore_data(data)); + ev.push(self.line(&format!("(game restored: {}.)", slot))); + ev.push(Event::Restored { slot }); + } + None => ev.push(Event::Error { message: format!("no save named '{}'", slot) }), + } + } + Command::UpsertRoom { n, doc, persist } => match self.world.validate_room(n, &doc) { + Ok(()) => { + if let Err(e) = self.world.upsert_room(n, doc, persist) { + ev.push(Event::Error { message: format!("room {} not written: {}", n, e) }); + } else { + if n == self.world.current { + ev.extend(self.enter_room(Some((self.ego.x, self.ego.y)))); + } + ev.push(Event::RoomUpserted { room: n, persisted: persist }); + } + } + Err(msg) => ev.push(Event::Error { message: msg }), + }, + Command::UpsertScript { file, source } => { + self.world.script_overlay.insert(file, source); + // Recompile now so authors hear about errors immediately. + ev.extend(self.load_scripts()); + } + Command::SaveRoom { n } => { + let n = n.unwrap_or(self.world.current); + let res = if n == self.world.current { + self.world.save_current() + } else if let Some(doc) = self.world.peek_room(n) { + self.world.write_room(n, &doc) + } else { + Err(std::io::Error::other("no such room to save")) + }; + match res { + Ok(()) => ev.push(Event::RoomSaved { room: n, path: self.world.room_path(n).to_string_lossy().into_owned() }), + Err(e) => ev.push(Event::Error { message: format!("save failed: {}", e) }), + } + } + Command::LoadGame { dir } => { + self.world = World::load_game(dir); + self.verb_idx = parser::verb_index(&self.world.manifest.verbs); + ev.push(Event::GameLoaded { name: self.world.manifest.name.clone(), room: self.world.current }); + ev.extend(self.new_game(None)); + } + Command::ReloadAssets => { + self.world.reload_sprites(); + ev.extend(self.enter_room(Some((self.ego.x, self.ego.y)))); + } + } + ev + } + + /// Wall-clock driver for real-time front-ends: accumulates `dt` into + /// fixed cycles. Scripted surfaces should send `Command::Tick` instead. + pub fn tick(&mut self, dt: f32) -> Vec { + let mut ev = Vec::new(); + if self.dlg.is_none() { + self.acc += dt; + let mut guard = 0; + while self.acc >= CYCLE_DT && guard < 8 { + ev.extend(self.step_cycle()); + self.acc -= CYCLE_DT; + guard += 1; + } + } + ev + } + + // --- internals ----------------------------------------------------------- + + fn line(&mut self, s: &str) -> Event { + self.transcript.push(s.to_string()); + if self.transcript.len() > TRANSCRIPT_CAP { + self.transcript.remove(0); + } + Event::Transcript { line: s.to_string() } + } + + fn new_game(&mut self, room: Option) -> Vec { + let target = room.unwrap_or(self.world.manifest.start_room); + self.inventory.clear(); + self.taken.clear(); + self.won = false; + self.score = 0; + self.scored.clear(); + self.dlg = None; + self.transcript.clear(); + self.flags = self.world.manifest.flags.clone(); + self.vars.clear(); + self.timers.clear(); + self.ticks = 0; + self.acc = 0.0; + self.world.goto_room(target); + let mut ev = self.enter_room(None); + if self.world.manifest.max_score > 0 { + ev.push(Event::ScoreChanged { score: 0, max: self.world.manifest.max_score }); + } + let intro = self.world.manifest.intro_text.clone(); + ev.push(self.line(&intro)); + ev.extend(self.room_announcement()); + ev + } + + /// Everything that happens when the current room becomes live: position + /// the ego, cook NPCs, load scripts, fire `on_enter`, take the + /// checkpoint. `arrive` overrides the spawn (doors, restores). + fn enter_room(&mut self, arrive: Option<(i32, i32)>) -> Vec { + let mut ev = Vec::new(); + // Solid props block paths — stamp before spawn-finding. + let alive = self.alive_prop_names(); + self.world.stamp_solid_props(&alive); + + let prefer = arrive.unwrap_or(self.world.doc.spawn); + let (sx, sy) = find_spawn(&self.world.screens, prefer); + self.ego.view = self.view_for(&self.world.manifest.ego_sprite.clone()); + self.ego.x = sx; + self.ego.y = sy; + self.ego.stop(); + self.ego.frozen = false; + self.ego.cur_loop = 0; + self.ego.mirrored = false; + self.pending_verb = None; + self.blocked_latch = [false; 4]; + self.cycles = self.world.doc.cycles.clone(); + self.build_npcs(); + self.inside_hotspot = self.world.screens.hotspot_at(sx, sy); + ev.extend(self.load_scripts()); + ev.extend(self.call_hook_ev("on_enter", ())); + // The mercy checkpoint: death rewinds to this exact moment. + self.checkpoint = Some(self.save_data()); + ev.push(Event::RoomChanged { + room: self.world.current, + name: self.world.doc.name.clone(), + music: self.world.doc.music, + }); + ev.push(Event::Music { mood: self.world.doc.music }); + ev + } + + fn load_scripts(&mut self) -> Vec { + let global = self.world.script_source("main.rhai"); + let room = self.world.room_script_file().and_then(|f| self.world.script_source(&f)); + self.host.load(global.as_deref(), room.as_deref()); + if let Some(e) = self.host.compile_error.take() { + return vec![Event::Error { message: e }]; + } + Vec::new() + } + + fn room_announcement(&mut self) -> Vec { + let name = self.world.doc.name.clone(); + let enter = self.world.doc.enter_text.clone(); + let mut ev = Vec::new(); + if !name.is_empty() { + ev.push(self.line(&format!("\u{2014} {} \u{2014}", name))); + } + if !enter.is_empty() { + ev.push(self.line(&enter)); + } + ev + } + + fn alive_prop_names(&self) -> Vec { + let room = self.world.current; + self.world + .doc + .props + .iter() + .filter(|p| prop_visible(p, room, &self.taken, &self.flags)) + .map(|p| p.name.clone()) + .collect() + } + + 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()] }] }; + } + } + default_robot_view() + } + + fn build_npcs(&mut self) { + self.npcs.clear(); + let room = self.world.current; + let defs = self.world.doc.npcs.clone(); + for d in defs { + let on = |name: &str| self.flags.get(name).copied().unwrap_or(false); + let visible = (d.visible_flag.is_empty() || on(&d.visible_flag)) + && (d.hidden_by_flag.is_empty() || !on(&d.hidden_by_flag)); + let mut a = Actor::new(d.name.clone(), self.view_for(&d.sprite), d.x, d.y); + a.visible = visible; + a.fixed_scale = d.fixed_scale; + a.step_size = 2; + let _ = room; + self.npcs.push(Npc { actor: a, wander: d.wander, home: (d.x, d.y) }); + } + } + + /// Sierra death, modernized: report it, then rewind to the room-entry + /// checkpoint. The player loses seconds, not the evening. + fn die(&mut self, cause: String) -> Vec { + let mut ev = vec![ + Event::Died { message: cause.clone() }, + Event::Audio { cue: AudioCue::Error }, + ]; + ev.push(self.line(&format!("\u{2020} {} \u{2020}", cause))); + if let Some(cp) = self.checkpoint.clone() { + ev.extend(self.restore_data(cp)); + ev.push(self.line("(time rewinds to when you entered the room.)")); + } else { + ev.extend(self.new_game(None)); + } + ev + } + + fn save_data(&self) -> SaveData { + SaveData { + room: self.world.current, + ego: (self.ego.x, self.ego.y), + inventory: self.inventory.clone(), + taken: self.taken.clone(), + scored: self.scored.clone(), + score: self.score, + won: self.won, + flags: self.flags.iter().map(|(k, v)| (k.clone(), *v)).collect(), + vars: self.vars.iter().map(|(k, v)| (k.clone(), *v)).collect(), + timers: self.timers.clone(), + ticks: self.ticks, + rng: self.host.ctx.lock().unwrap().rng, + } + } + + fn restore_data(&mut self, d: SaveData) -> Vec { + self.inventory = d.inventory.clone(); + self.taken = d.taken.clone(); + self.scored = d.scored.clone(); + self.score = d.score; + self.won = d.won; + self.flags = d.flags.iter().map(|(k, v)| (k.clone(), *v)).collect(); + self.vars = d.vars.iter().map(|(k, v)| (k.clone(), *v)).collect(); + self.timers = d.timers.clone(); + self.ticks = d.ticks; + self.host.ctx.lock().unwrap().rng = d.rng.max(1); + self.dlg = None; + self.world.goto_room(d.room); + let mut ev = self.enter_room(Some(d.ego)); + // enter_room re-checkpoints (good: repeated deaths rewind to here) + // and re-fires on_enter — acceptable: entry hooks are idempotent by + // convention (guard with flags for one-shots). + ev.push(Event::InventoryChanged { items: self.inventory.clone() }); + if self.world.manifest.max_score > 0 { + ev.push(Event::ScoreChanged { score: self.score, max: self.world.manifest.max_score }); + } + ev + } + + fn change_room(&mut self, n: u32, arrive: Option<(i32, i32)>) -> Vec { + let mut ev = self.call_hook_ev("on_exit", ()); + self.world.goto_room(n); + ev.extend(self.enter_room(arrive)); + ev.push(Event::Audio { cue: AudioCue::Door }); + ev.extend(self.room_announcement()); + ev + } + + // --- the fixed cycle --------------------------------------------------- + + /// One fixed game cycle: timers, ego, NPCs, triggers, exits. + /// Paused entirely while a conversation is open. + fn step_cycle(&mut self) -> Vec { + if self.dlg.is_some() { + return Vec::new(); + } + let mut ev = Vec::new(); + self.ticks += 1; + + // Deterministic timers: count down in ticks, fire script functions. + if !self.timers.is_empty() { + let mut due: Vec = Vec::new(); + for t in self.timers.iter_mut() { + t.0 = t.0.saturating_sub(1); + if t.0 == 0 { + due.push(t.1.clone()); + } + } + self.timers.retain(|t| t.0 > 0); + for f in due { + ev.extend(self.call_hook_ev(&f, ())); + } + } + + ev.extend(self.call_hook_ev_quiet("on_tick")); + + self.ego.update(&self.world.screens, &self.world.doc.scale); + + // NPC wander: deterministic strolls around home. + let scale = self.world.doc.scale; + for i in 0..self.npcs.len() { + let (wander, home) = (self.npcs[i].wander, self.npcs[i].home); + if wander > 0 && !self.npcs[i].actor.walking() && !self.npcs[i].actor.frozen { + let roll = self.host.ctx.lock().unwrap().next_rand(120); + if roll == 0 { + let dx = self.host.ctx.lock().unwrap().next_rand(wander as i64 * 2) as i32 - wander; + let dy = self.host.ctx.lock().unwrap().next_rand(wander as i64) as i32 - wander / 2; + let (tx, ty) = (home.0 + dx, home.1 + dy); + self.npcs[i].actor.walk_to(&self.world.screens, &scale, tx, ty); + } + } + self.npcs[i].actor.update(&self.world.screens, &scale); + } + + // A queued point-and-click action fires when the walk finishes. + if self.ego.arrived { + if let Some((verb, item, x, y)) = self.pending_verb.take() { + let target = self.target_at(x, y); + ev.extend(self.dispatch_now(&verb, &item, target)); + } + } else if self.pending_verb.is_some() && !self.ego.walking() { + // Path failed or was blocked: act from here anyway (better than + // eating the click). + if let Some((verb, item, x, y)) = self.pending_verb.take() { + let target = self.target_at(x, y); + ev.extend(self.dispatch_now(&verb, &item, target)); + } + } + + // Hotspot edges: doors and triggers fire on entry, once. + let here = self.world.screens.hotspot_at(self.ego.x, self.ego.y); + if here != self.inside_hotspot { + self.inside_hotspot = here; + if here > 0 { + let hi = (here - 1) as usize; + if let Some(h) = self.world.doc.hotspots.get(hi).cloned() { + let gate_ok = h.requires_flag.is_empty() + || self.flags.get(&h.requires_flag).copied().unwrap_or(false); + if let Some(target) = h.exit_to { + if gate_ok { + ev.extend(self.change_room(target, h.arrive)); + return ev; + } else { + let msg = if h.blocked_text.is_empty() { + "It won't open.".to_string() + } else { + h.blocked_text.clone() + }; + ev.push(self.line(&msg)); + ev.push(Event::Audio { cue: AudioCue::Error }); + self.ego.stop(); + } + } else if h.trigger && gate_ok { + ev.extend(self.call_hook_ev("on_trigger", (h.name.clone(),))); + } + } + } + } + + ev.extend(self.edge_exits()); + ev + } + + /// Edge-of-screen exits, with the blocked-message latch from MRPGI. + fn edge_exits(&mut self) -> Vec { + let mut ev = Vec::new(); + let exits = self.world.doc.exits; + let trig = 6; + let (w, h) = (PIC_W as i32, PIC_H as i32); + let in_zone = [ + self.ego.y <= self.ego.min_y + trig, + self.ego.x >= w - 4 - trig, + self.ego.y >= h - 2 - trig, + self.ego.x <= 4 + trig, + ]; + for d in 0..4 { + if !in_zone[d] { + self.blocked_latch[d] = false; + } + } + let leave = (0..4).find(|&d| in_zone[d] && exits[d].is_some()); + let Some(from) = leave else { + return ev; + }; + let target = exits[from].unwrap(); + let req = self.world.doc.exit_flags[from].clone(); + if !req.is_empty() && !self.flags.get(&req).copied().unwrap_or(false) { + let inset = trig + 4; + match from { + 0 => self.ego.y = self.ego.min_y + inset, + 1 => self.ego.x = w - 4 - inset, + 2 => self.ego.y = h - 2 - inset, + _ => self.ego.x = 4 + inset, + } + self.ego.stop(); + if !self.blocked_latch[from] { + self.blocked_latch[from] = true; + let msg = self.world.doc.exit_blocked[from].clone(); + let msg = if msg.is_empty() { "Something prevents you going that way.".to_string() } else { msg }; + ev.push(self.line(&msg)); + ev.push(Event::Audio { cue: AudioCue::Error }); + } + return ev; + } + // Arrive just inside the matching edge of the next room. + let inset = 16; + let arrive = match from { + 0 => (self.ego.x, h - 2 - inset), + 1 => (4 + inset, self.ego.y), + 2 => (self.ego.x, self.ego.min_y + inset), + _ => (w - 4 - inset, self.ego.y), + }; + ev.extend(self.change_room(target, Some(arrive))); + ev + } + + // --- verbs ------------------------------------------------------------- + + /// What's under this pixel? Actors first (front-most priority wins), + /// then props, then the hotspot screen, then the room itself. + fn target_at(&self, x: i32, y: i32) -> Target { + let table = &self.world.doc.scale; + let mut best: Option<(u8, Target)> = None; + let mut consider = |pri: u8, t: Target, hit: bool| { + if hit && best.as_ref().map_or(true, |(bp, _)| pri >= *bp) { + best = Some((pri, t)); + } + }; + for (i, n) in self.npcs.iter().enumerate() { + if !n.actor.visible { + 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; + consider(n.actor.priority(), Target::Npc(i), hit); + } + let room = self.world.current; + for (i, p) in self.world.doc.props.iter().enumerate() { + if !prop_visible(p, room, &self.taken, &self.flags) { + 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 (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 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); + } + if let Some((_, t)) = best { + return t; + } + let id = self.world.screens.hotspot_at(x, y); + if id > 0 { + return Target::Hotspot((id - 1) as usize); + } + Target::Room + } + + fn target_name(&self, t: &Target) -> String { + match t { + Target::Room => String::new(), + Target::Prop(i) => self.world.doc.props.get(*i).map(|p| p.name.clone()).unwrap_or_default(), + Target::Npc(i) => self.npcs.get(*i).map(|n| n.actor.name.clone()).unwrap_or_default(), + Target::Hotspot(i) => self.world.doc.hotspots.get(*i).map(|h| h.name.clone()).unwrap_or_default(), + Target::Item(n) => n.clone(), + } + } + + fn target_pos(&self, t: &Target) -> Option<(i32, i32)> { + 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)), + _ => None, + } + } + + /// Verb entry point with the walk-then-act deferral. `item` is set for + /// inventory application ("use"). + fn dispatch(&mut self, verb: &str, item: &str, target: Target, click: Option<(i32, i32)>) -> Vec { + if verb == parser::V_WALK { + if let Some((x, y)) = click { + return self.apply(Command::MoveTo { x, y }); + } + } + // Touch verbs walk over first if the target is far away. + let needs_touch = matches!(verb, "do" | "take" | "talk" | "use"); + if needs_touch && !self.ego.frozen { + if let (Some((px, py)), Some((cx, cy))) = (self.target_pos(&target), click) { + let d = (px - self.ego.x).abs().max((py - self.ego.y).abs()); + if d > REACH { + self.pending_verb = Some((verb.to_string(), item.to_string(), cx, cy)); + self.ego.walk_to(&self.world.screens, &self.world.doc.scale, px, py + 2); + return Vec::new(); + } + } + } + self.dispatch_now(verb, item, target) + } + + /// The verb pipeline proper: script hook first, then built-in behavior, + /// then the message cascade. Clicks and typed commands both land here. + fn dispatch_now(&mut self, verb: &str, item: &str, target: Target) -> Vec { + let mut ev = Vec::new(); + let noun = self.target_name(&target); + let effective_verb = if verb == "use" && !item.is_empty() { format!("use:{}", item) } else { verb.to_string() }; + + // Scripts get first refusal on everything. + self.sync_ctx(); + match self.host.call_on_verb(&effective_verb, &noun) { + Ok(true) => { + ev.extend(self.drain_fx()); + return ev; + } + Ok(false) => { + ev.extend(self.drain_fx()); // an unhandled hook may still have said something + } + Err(e) => ev.push(Event::Error { message: e }), + } + + match (&target, verb) { + (Target::Room, "look") => { + ev.extend(self.room_announcement()); + let things = self.visible_things(); + if !things.is_empty() { + ev.push(self.line(&format!("You notice: {}.", things.join(", ")))); + } + } + (Target::Item(name), _) => { + let name = name.clone(); + ev.push(self.line(&format!("Your {} \u{2014} it's in your pocket.", name))); + } + (Target::Prop(i), _) => ev.extend(self.verb_on_prop(*i, verb, &effective_verb, item)), + (Target::Npc(i), _) => ev.extend(self.verb_on_npc(*i, verb, &effective_verb)), + (Target::Hotspot(i), _) => ev.extend(self.verb_on_hotspot(*i, &effective_verb)), + (Target::Room, _) => { + let msg = self.default_msg(&effective_verb); + ev.push(self.line(&msg)); + } + } + ev + } + + fn visible_things(&self) -> Vec { + let room = self.world.current; + let mut v: Vec = self + .world + .doc + .props + .iter() + .filter(|p| prop_visible(p, room, &self.taken, &self.flags)) + .map(|p| p.name.clone()) + .collect(); + v.extend(self.npcs.iter().filter(|n| n.actor.visible).map(|n| n.actor.name.clone())); + v + } + + /// The message cascade: exact verb → "do" fallback for action verbs → + /// room defaults → manifest defaults → stock line. + fn cascade(&self, msgs: &HashMap, verb: &str) -> Option { + if let Some(m) = msgs.get(verb) { + return Some(m.clone()); + } + if verb != "look" && verb != "talk" && verb != "do" { + if let Some(m) = msgs.get("do") { + return Some(m.clone()); + } + } + None + } + + fn default_msg(&self, verb: &str) -> String { + if let Some(m) = self.world.doc.defaults.get(verb) { + return m.clone(); + } + if let Some(m) = self.world.manifest.defaults.get(verb) { + return m.clone(); + } + match verb { + "look" => "Nothing special about it.".into(), + "talk" => "It has nothing to say.".into(), + "take" => "You can't take that.".into(), + v if v.starts_with("use:") => "That doesn't work here.".into(), + _ => "Nothing happens.".into(), + } + } + + fn verb_on_prop(&mut self, i: usize, verb: &str, effective: &str, item: &str) -> Vec { + let mut ev = Vec::new(); + let Some(p) = self.world.doc.props.get(i).cloned() else { + return vec![self.line("It's gone.")]; + }; + // Author message wins over built-in behavior for look/talk; + // for do/take the built-ins run and messages fill the gaps. + match verb { + "look" | "talk" if self.cascade(&p.msgs, effective).is_some() => { + let m = self.cascade(&p.msgs, effective).unwrap(); + ev.push(self.line(&m)); + if verb == "talk" && !p.dialogue.is_empty() { + ev.extend(self.open_dialogue(&p.name, p.dialogue.clone())); + } + return ev; + } + "talk" if !p.dialogue.is_empty() => { + ev.extend(self.open_dialogue(&p.name, p.dialogue.clone())); + return ev; + } + _ => {} + } + + // take (or do on a takeable prop) + 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); + 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 }); + ev.extend(self.award_points(&p.name, p.points)); + return ev; + } + + // do / use-item: the lock-and-key machine. + if verb == "do" || verb == "use" { + let gate_ok = p.requires_flag.is_empty() || self.flags.get(&p.requires_flag).copied().unwrap_or(false); + let key_ok = p.needs.is_empty() + || (verb == "use" && item == p.needs) + || (verb == "do" && self.inventory.iter().any(|i| i == &p.needs)); + if !gate_ok || !key_ok { + let m = self + .cascade(&p.msgs, effective) + .unwrap_or_else(|| { + if !p.needs.is_empty() { + format!("You need something for the {}.", p.name) + } else { + self.default_msg(effective) + } + }); + ev.push(self.line(&m)); + ev.push(Event::Audio { cue: AudioCue::Error }); + return ev; + } + if p.kills { + let text = if p.use_text.is_empty() { format!("The {} gets you.", p.name) } else { p.use_text.clone() }; + ev.extend(self.die(text)); + return ev; + } + let mut acted = false; + if !p.use_text.is_empty() { + let t = p.use_text.clone(); + ev.push(self.line(&t)); + acted = true; + } else if let Some(m) = self.cascade(&p.msgs, effective) { + ev.push(self.line(&m)); + acted = true; + } + if !p.sets_flag.is_empty() && !self.flags.get(&p.sets_flag).copied().unwrap_or(false) { + self.flags.insert(p.sets_flag.clone(), true); + acted = true; + } + if acted { + // First successful use pays out, once (the scored latch). + ev.extend(self.award_points(&p.name, p.points)); + } + if p.consumes && !p.needs.is_empty() { + self.inventory.retain(|i| i != &p.needs); + ev.push(Event::InventoryChanged { items: self.inventory.clone() }); + } + if p.wins { + self.won = true; + ev.push(Event::Won); + ev.push(Event::Audio { cue: AudioCue::Win }); + return ev; + } + if acted { + ev.push(Event::Audio { cue: AudioCue::Confirm }); + return ev; + } + } + + let m = self.cascade(&p.msgs, effective).unwrap_or_else(|| self.default_msg(effective)); + ev.push(self.line(&m)); + ev + } + + fn verb_on_npc(&mut self, i: usize, verb: &str, effective: &str) -> Vec { + let mut ev = Vec::new(); + let Some(def) = self.world.doc.npcs.iter().find(|d| Some(&d.name) == self.npcs.get(i).map(|n| &n.actor.name)).cloned() else { + return vec![self.line("They're gone.")]; + }; + if verb == "talk" && !def.dialogue.is_empty() { + if let Some(m) = self.cascade(&def.msgs, effective) { + ev.push(self.line(&m)); + } + ev.extend(self.open_dialogue(&def.name, def.dialogue.clone())); + return ev; + } + let m = self + .cascade(&def.msgs, effective) + .unwrap_or_else(|| match verb { + "look" => format!("It's {}.", def.name), + "take" => format!("{} objects strongly.", def.name), + _ => self.default_msg(effective), + }); + ev.push(self.line(&m)); + ev + } + + fn verb_on_hotspot(&mut self, i: usize, effective: &str) -> Vec { + let mut ev = Vec::new(); + let Some(h) = self.world.doc.hotspots.get(i).cloned() else { + return vec![self.line("Nothing there.")]; + }; + let m = self.cascade(&h.msgs, effective).unwrap_or_else(|| { + if effective == "look" && !h.name.is_empty() { + format!("That's the {}.", h.name) + } else { + self.default_msg(effective) + } + }); + ev.push(self.line(&m)); + ev + } + + fn open_dialogue(&mut self, with: &str, nodes: Vec) -> Vec { + let mut ev = Vec::new(); + let d = Dlg { nodes, node: 0, with: with.to_string() }; + let lines = d.lines(&self.flags); + for l in &lines { + ev.push(self.line(l)); + } + if !d.terminal(&self.flags) { + self.dlg = Some(d); + } + ev.push(Event::DialogueOpen { lines }); + ev.push(Event::Audio { cue: AudioCue::Confirm }); + ev + } + + fn award_points(&mut self, key: &str, points: u32) -> Vec { + let mut ev = Vec::new(); + if points == 0 || self.world.manifest.max_score == 0 { + return ev; + } + let k = (self.world.current, key.to_string()); + if !self.scored.contains(&k) { + self.scored.push(k); + self.score += points; + ev.push(Event::ScoreChanged { score: self.score, max: self.world.manifest.max_score }); + } + ev + } + + // --- the parser lane ----------------------------------------------------- + + fn run_parse(&mut self, text: &str) -> Vec { + let mut ev = Vec::new(); + let line = text.trim().to_string(); + if line.is_empty() { + return ev; + } + if self.dlg.is_some() { + if let Ok(n) = line.parse::() { + return self.apply(Command::Choose { n }); + } + ev.push(self.line("(type a number to answer, or end the conversation.)")); + return ev; + } + ev.push(self.line(&format!("> {}", line))); + + match parser::parse(&line, &self.verb_idx) { + Parsed::Empty => {} + Parsed::Inventory => { + let msg = if self.inventory.is_empty() { + "You are carrying nothing.".to_string() + } else { + format!("You are carrying: {}.", self.inventory.join(", ")) + }; + ev.push(self.line(&msg)); + } + Parsed::Help => { + let verbs = parser::verb_whitelist(&self.world.manifest.verbs).join(", "); + ev.push(self.line(&format!("Verbs: {}. Also: inventory, save, restore, score.", verbs))); + } + Parsed::Meta(m) => match m.as_str() { + "save" => ev.extend(self.apply(Command::SaveGame { slot: "quick".into() })), + "restore" | "load" => ev.extend(self.apply(Command::RestoreGame { slot: "quick".into() })), + "restart" => ev.extend(self.apply(Command::NewGame { room: None })), + "score" => { + let msg = format!("Score: {} of {}.", self.score, self.world.manifest.max_score); + ev.push(self.line(&msg)); + } + _ => ev.push(self.line("(the window's close button works too.)")), + }, + Parsed::UseOn { item, noun } => { + let Some(item) = self.match_inventory(&item) else { + ev.push(self.line(&format!("You aren't carrying any {}.", item))); + ev.push(Event::Audio { cue: AudioCue::Error }); + return ev; + }; + match self.resolve_noun(&noun) { + Some(t) => ev.extend(self.dispatch("use", &item, t, None)), + None => { + ev.push(self.line(&format!("You don't see any {} here.", noun))); + ev.push(Event::Audio { cue: AudioCue::Error }); + } + } + } + Parsed::Verb { verb, noun } => { + 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); + ev.extend(self.dispatch(&verb, "", t, click)); + } + None => { + ev.push(self.line(&format!("You don't see any {} here.", noun))); + ev.push(Event::Audio { cue: AudioCue::Error }); + } + } + } + } + Parsed::Unknown(orig) => { + ev.push(self.line(&format!("The parser squints at \u{201C}{}\u{201D} and shrugs.", orig))); + ev.push(Event::Audio { cue: AudioCue::Error }); + } + } + ev + } + + fn match_inventory(&self, words: &str) -> Option { + let w = words.to_lowercase(); + self.inventory + .iter() + .find(|i| { + let n = i.to_lowercase(); + n == w || n.contains(&w) || w.contains(&n) + }) + .cloned() + } + + /// Resolve typed noun words against the live room: props (name + + /// synonyms), NPCs, hotspots, then inventory. Exact beats fuzzy. + fn resolve_noun(&self, words: &str) -> Option { + let w = words.to_lowercase(); + let room = self.world.current; + let matches_name = |name: &str, syns: &str| { + let n = name.to_lowercase(); + if n == w || n.contains(&w) || w.contains(&n) { + return true; + } + syns.to_lowercase().split_whitespace().any(|s| w.split_whitespace().any(|ww| ww == s)) + }; + for (i, p) in self.world.doc.props.iter().enumerate() { + if prop_visible(p, room, &self.taken, &self.flags) && matches_name(&p.name, &p.synonyms) { + return Some(Target::Prop(i)); + } + } + for (i, n) in self.npcs.iter().enumerate() { + if n.actor.visible && matches_name(&n.actor.name, "") { + return Some(Target::Npc(i)); + } + } + for (i, h) in self.world.doc.hotspots.iter().enumerate() { + if matches_name(&h.name, "") { + return Some(Target::Hotspot(i)); + } + } + self.match_inventory(&w).map(Target::Item) + } + + // --- scripts ------------------------------------------------------------ + + fn sync_ctx(&self) { + let mut c = self.host.ctx.lock().unwrap(); + c.flags = self.flags.clone(); + c.vars = self.vars.clone(); + c.inventory = self.inventory.clone(); + c.ego = (self.ego.x, self.ego.y); + c.room = self.world.current; + } + + /// Pull flag/var writes and queued effects out of the script context and + /// make them real. + fn drain_fx(&mut self) -> Vec { + let (flags, vars, fx) = { + let mut c = self.host.ctx.lock().unwrap(); + (c.flags.clone(), c.vars.clone(), std::mem::take(&mut c.fx)) + }; + self.flags = flags; + self.vars = vars; + let mut ev = Vec::new(); + for f in fx { + match f { + Fx::Say(s) => ev.push(self.line(&s)), + Fx::SayBy(who, s) => ev.push(self.line(&format!("{}: \u{201C}{}\u{201D}", who, s))), + Fx::Give(item) => { + if !self.inventory.contains(&item) { + self.inventory.push(item); + ev.push(Event::InventoryChanged { items: self.inventory.clone() }); + ev.push(Event::Audio { cue: AudioCue::Pickup }); + } + } + Fx::RemoveItem(item) => { + let before = self.inventory.len(); + self.inventory.retain(|i| i != &item); + if self.inventory.len() != before { + ev.push(Event::InventoryChanged { items: self.inventory.clone() }); + } + } + Fx::GotoRoom(n) => { + ev.extend(self.change_room(n, None)); + return ev; // room changed: later fx belonged to the old room + } + Fx::PlaceEgo(x, y) => { + self.ego.x = x; + self.ego.y = y; + self.ego.stop(); + } + Fx::WalkEgo(x, y) => self.ego.walk_to(&self.world.screens, &self.world.doc.scale, x, y), + Fx::FreezeEgo(v) => { + self.ego.frozen = v; + if v { + self.ego.stop(); + } + } + Fx::NpcWalk(name, x, y) => { + let scale = self.world.doc.scale; + if let Some(n) = self.npcs.iter_mut().find(|n| n.actor.name == name) { + n.actor.walk_to(&self.world.screens, &scale, x, y); + } + } + Fx::NpcPlace(name, x, y) => { + if let Some(n) = self.npcs.iter_mut().find(|n| n.actor.name == name) { + n.actor.x = x; + n.actor.y = y; + n.actor.stop(); + } + } + Fx::NpcFreeze(name, v) => { + if let Some(n) = self.npcs.iter_mut().find(|n| n.actor.name == name) { + n.actor.frozen = v; + } + } + Fx::Play(cue) => ev.push(Event::Audio { cue: ScriptHost::cue_by_name(&cue) }), + Fx::Music(mood) => ev.push(Event::Music { mood }), + Fx::AddPoints(n) => { + if self.world.manifest.max_score > 0 && n > 0 { + self.score += n; + ev.push(Event::ScoreChanged { score: self.score, max: self.world.manifest.max_score }); + } + } + Fx::Win => { + if !self.won { + self.won = true; + ev.push(Event::Won); + ev.push(Event::Audio { cue: AudioCue::Win }); + } + } + Fx::Die(text) => { + ev.extend(self.die(text)); + return ev; + } + Fx::After(ticks, f) => self.timers.push((ticks, f)), + Fx::PalCycle(i, on) => { + if let Some(c) = self.cycles.get_mut(i) { + c.active = on; + } + } + } + } + ev + } + + fn call_hook_ev(&mut self, name: &str, args: impl rhai::FuncArgs) -> Vec { + if !self.host.active() { + return Vec::new(); + } + self.sync_ctx(); + let mut ev = Vec::new(); + if let Err(e) = self.host.call(name, args) { + ev.push(Event::Error { message: e }); + } + ev.extend(self.drain_fx()); + ev + } + + /// on_tick runs 30x/sec — skip the sync entirely when no script is loaded, + /// and don't report "function not found" style noise. + fn call_hook_ev_quiet(&mut self, name: &str) -> Vec { + if !self.host.active() { + return Vec::new(); + } + self.call_hook_ev(name, ()) + } + + // --- observation ---------------------------------------------------------- + + pub fn snapshot(&self) -> StateSnapshot { + let room = self.world.current; + StateSnapshot { + game: self.world.manifest.name.clone(), + room, + room_name: self.world.doc.name.clone(), + ego: (self.ego.x, self.ego.y, self.ego.dir), + inventory: self.inventory.clone(), + won: self.won, + score: self.score, + max_score: self.world.manifest.max_score, + flags: self.flags.iter().map(|(k, v)| (k.clone(), *v)).collect(), + vars: self.vars.iter().map(|(k, v)| (k.clone(), *v)).collect(), + exits: self.world.doc.exits, + music: self.world.doc.music, + props: self + .world + .doc + .props + .iter() + .filter(|p| prop_visible(p, room, &self.taken, &self.flags)) + .map(|p| PropSummary { + name: p.name.clone(), + x: p.x, + y: p.y, + takeable: p.takeable, + has_dialogue: !p.dialogue.is_empty(), + }) + .collect(), + npcs: self + .npcs + .iter() + .filter(|n| n.actor.visible) + .map(|n| PropSummary { + name: n.actor.name.clone(), + x: n.actor.x, + y: n.actor.y, + takeable: false, + has_dialogue: true, + }) + .collect(), + hotspots: self.world.doc.hotspots.iter().map(|h| h.name.clone()).collect(), + in_dialogue: self.dlg.is_some(), + dialogue: self.dlg.as_ref().map(|d| d.lines(&self.flags)), + transcript_tail: self.transcript.iter().rev().take(8).rev().cloned().collect(), + } + } + + /// Composite the current frame to raw RGBA (no window anywhere). + pub fn render_visual(&self, with_actors: bool) -> Vec { + let lut = cycle_lut(&self.cycles, self.ticks); + let table = &self.world.doc.scale; + let mut draws: Vec = Vec::new(); + if with_actors { + let room = self.world.current; + for p in &self.world.doc.props { + if !prop_visible(p, room, &self.taken, &self.flags) { + continue; + } + if let Some(cel) = self.world.cel(&p.sprite) { + draws.push(DrawObj { + cel, + x: p.x, + y: p.y, + pri: p.fixed_priority.unwrap_or_else(|| crate::screens::band(p.y.clamp(0, PIC_H as i32 - 1) as usize)), + scale: p.fixed_scale.unwrap_or_else(|| table.scale_at(p.y)), + mirrored: false, + }); + } + } + for n in &self.npcs { + if !n.actor.visible { + continue; + } + draws.push(DrawObj { + cel: n.actor.view.cel(n.actor.cur_loop, n.actor.cur_cel), + x: n.actor.x, + y: n.actor.y, + pri: n.actor.priority(), + scale: n.actor.scale(table), + mirrored: n.actor.mirrored, + }); + } + draws.push(DrawObj { + cel: self.ego.view.cel(self.ego.cur_loop, self.ego.cur_cel), + x: self.ego.x, + y: self.ego.y, + pri: self.ego.priority(), + scale: self.ego.scale(table), + mirrored: self.ego.mirrored, + }); + } + render::compose(&self.world.screens, &self.world.palette, &lut, &mut draws) + } + + pub fn render_png(&self, with_actors: bool) -> Vec { + render::encode_png(&self.render_visual(with_actors)) + } +} + +fn safe_slot(slot: &str) -> String { + let s: String = slot.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect(); + if s.is_empty() { + "quick".into() + } else { + s + } +} diff --git a/mrpci-core/src/view.rs b/mrpci-core/src/view.rs new file mode 100644 index 0000000..991086a --- /dev/null +++ b/mrpci-core/src/view.rs @@ -0,0 +1,262 @@ +//! VIEW resources: animated sprites, the SCI way. +//! +//! A view is a set of *loops* (one per facing direction), each a sequence of +//! *cels* (animation frames). Loops follow a fixed convention so any view +//! drops onto any actor: +//! +//! | loop | facing | notes | +//! |------|--------|-----------------------------------------| +//! | 0 | front (S) | required | +//! | 1 | back (N) | falls back to 0 | +//! | 2 | left (W) | falls back to 0 | +//! | 3 | right (E) | falls back to **mirrored loop 2** | +//! +//! Cels are authored two ways: PNG sheets (quantized per room) or the tiny +//! ASCII grids MRPGI proved out — every character a hex palette index +//! (`0`-`9`, `a`-`f` → EGA 0..16, which every room palette preserves), +//! `.`/space transparent. Diff-friendly, hand-editable, LLM-writable. + +use serde::{Deserialize, Serialize}; + +/// A raw RGBA sprite as loaded from disk — cooked into [`Cel`]s per room so +/// quantization always matches the room's actual palette. +#[derive(Clone)] +pub struct SpriteSrc { + pub w: usize, + pub h: usize, + pub rgba: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Cel { + pub w: usize, + pub h: usize, + pub pix: Vec, + /// Opacity mask — in a 256-color world every index is a real color, so + /// transparency is its own channel instead of a stolen sentinel value. + pub solid: Vec, +} + +impl Cel { + /// Palette index at (x, y), or None if transparent / out of bounds. + #[inline] + pub fn at(&self, x: usize, y: usize) -> Option { + if x < self.w && y < self.h && self.solid[y * self.w + x] { + Some(self.pix[y * self.w + x]) + } else { + None + } + } + + /// Same, reading right-to-left (loop mirroring). + #[inline] + pub fn at_mirrored(&self, x: usize, y: usize) -> Option { + if x < self.w { + self.at(self.w - 1 - x, y) + } else { + None + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ViewLoop { + pub cels: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct View { + pub loops: Vec, +} + +impl View { + /// Resolve an 8-way direction (AGI ordering: 0 stop, 1 N .. 8 NW) to + /// (loop index, mirrored?) under the loop convention above. + pub fn loop_for_dir(&self, dir: u8) -> (usize, bool) { + let want = match dir { + 1 => 1, // N → back + 2 | 3 | 4 => 3, // NE/E/SE → right + 6 | 7 | 8 => 2, // SW/W/NW → left + _ => 0, // S, stopped → front + }; + match want { + 1 if self.loops.len() > 1 => (1, false), + 2 if self.loops.len() > 2 => (2, false), + 3 if self.loops.len() > 3 => (3, false), + 3 if self.loops.len() > 2 => (2, true), // mirror left for right + _ => (0, false), + } + } + + pub fn cel(&self, loop_i: usize, cel_i: usize) -> &Cel { + let l = &self.loops[loop_i.min(self.loops.len() - 1)]; + &l.cels[cel_i % l.cels.len().max(1)] + } +} + +/// Build a cel from ASCII rows: hex digit = EGA palette index, `.`/space = +/// transparent. Rows shorter than the widest are right-padded transparent. +pub fn cel_from_ascii(rows: &[&str]) -> Cel { + let h = rows.len(); + let w = rows.iter().map(|r| r.chars().count()).max().unwrap_or(0); + let mut pix = vec![0u8; w * h]; + let mut solid = vec![false; w * h]; + for (y, row) in rows.iter().enumerate() { + for (x, ch) in row.chars().enumerate() { + if let Some(d) = ch.to_digit(16) { + pix[y * w + x] = d as u8; + solid[y * w + x] = true; + } + } + } + Cel { w, h, pix, solid } +} + +/// 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 { + let (w, h) = (cel.w * f, cel.h * f); + let mut pix = vec![0u8; w * h]; + let mut solid = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let i = (y / f) * cel.w + (x / f); + pix[y * w + x] = cel.pix[i]; + solid[y * w + x] = cel.solid[i]; + } + } + Cel { w, h, pix, solid } +} + +/// 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 { + let front0 = cel_from_ascii(&[ + "......cc......", + "......88......", + "....777777....", + "...77777777...", + "...7b7777b7...", + "...77777777...", + "....777777....", + ".....8888.....", + "...77777777...", + "..7777777777..", + "..77777e7777..", + "..7777777777..", + "..7777777777..", + "...77777777...", + "...88....88...", + "...88....88...", + "..888....888..", + "..888....888..", + ]); + let front1 = cel_from_ascii(&[ + "......cc......", + "......88......", + "....777777....", + "...77777777...", + "...7b7777b7...", + "...77777777...", + "....777777....", + ".....8888.....", + "...77777777...", + "..7777777777..", + "..77777e7777..", + "..7777777777..", + "..7777777777..", + "...77777777...", + "...88....88...", + "...88....88...", + "...888..888...", + "...888..888...", + ]); + let back0 = cel_from_ascii(&[ + "......cc......", + "......88......", + "....777777....", + "...77777777...", + "...77777777...", + "...77777777...", + "....777777....", + ".....8888.....", + "...77777777...", + "..7777777777..", + "..7788888877..", + "..7777777777..", + "..7777777777..", + "...77777777...", + "...88....88...", + "...88....88...", + "..888....888..", + "..888....888..", + ]); + let back1 = cel_from_ascii(&[ + "......cc......", + "......88......", + "....777777....", + "...77777777...", + "...77777777...", + "...77777777...", + "....777777....", + ".....8888.....", + "...77777777...", + "..7777777777..", + "..7788888877..", + "..7777777777..", + "..7777777777..", + "...77777777...", + "...88....88...", + "...88....88...", + "...888..888...", + "...888..888...", + ]); + let side0 = cel_from_ascii(&[ + ".....cc.....", + ".....88.....", + "...77777....", + "..7777777...", + "..777b777...", + "..7777777...", + "...77777....", + "....888.....", + "..7777777...", + ".777777777..", + ".7777e7777..", + ".777777777..", + ".777777777..", + "..7777777...", + "....88......", + "....88......", + "...888......", + "...888......", + ]); + let side1 = cel_from_ascii(&[ + ".....cc.....", + ".....88.....", + "...77777....", + "..7777777...", + "..777b777...", + "..7777777...", + "...77777....", + "....888.....", + "..7777777...", + ".777777777..", + ".7777e7777..", + ".777777777..", + ".777777777..", + "..7777777...", + "..88..88....", + "..88..88....", + ".888..888...", + ".888..888...", + ]); + let up = |c: Cel| upscale(&c, 2); + View { + loops: vec![ + ViewLoop { cels: vec![up(front0), up(front1)] }, + ViewLoop { cels: vec![up(back0), up(back1)] }, + ViewLoop { cels: vec![up(side0), up(side1)] }, + ], + } +} diff --git a/mrpci-core/tests/walk.rs b/mrpci-core/tests/walk.rs new file mode 100644 index 0000000..2386c2a --- /dev/null +++ b/mrpci-core/tests/walk.rs @@ -0,0 +1,44 @@ +//! Golden-path integration tests against the generated demo game. +//! (Run `mrpci-headless --sample` first; CI does.) + +use mrpci_core::path::{find_path, nearest_open, Passable}; +use mrpci_core::{Command, GameState, World}; + +fn game_dir() -> Option { + for p in ["games/neon-precinct", "../games/neon-precinct"] { + if std::path::Path::new(p).join("game.json").exists() { + return Some(p.to_string()); + } + } + None +} + +#[test] +fn ego_can_cross_neon_row() { + let Some(dir) = game_dir() else { + eprintln!("demo game not generated; skipping"); + return; + }; + let world = World::load_game(dir); + let mut gs = GameState::new(world); + gs.apply(Command::NewGame { room: None }); + + let p = Passable { + s: &gs.world.screens, + half_w: 8, + water_ok: false, + min: (4, 8), + max: (316, 188), + }; + let start = (gs.ego.x, gs.ego.y); + eprintln!("start {:?} open={:?}", start, nearest_open(&p, start)); + eprintln!("goal open={:?}", nearest_open(&p, (316, 150))); + let path = find_path(&p, start, (316, 150)); + eprintln!("path: {:?}", path); + assert!(!path.is_empty(), "no path across Neon Row"); + + let evs = gs.apply(Command::WalkTo { x: 316, y: 150 }); + let moved = format!("{:?}", (gs.ego.x, gs.ego.y, gs.world.current)); + eprintln!("after walk: {} events={}", moved, evs.len()); + assert_eq!(gs.world.current, 2, "edge exit east should reach Rain Alley (ego at {})", moved); +} diff --git a/mrpci/Cargo.toml b/mrpci/Cargo.toml new file mode 100644 index 0000000..80ce395 --- /dev/null +++ b/mrpci/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "mrpci" +version = "0.1.0" +edition = "2021" +description = "Monster Robot Party Creative Interpreter — the SCI-generation sibling of MRPGI: 256-color rooms, palette cycling, A* click-to-walk, verbs + parser, rhai scripts. This crate is the macroquad GUI; the engine lives in mrpci-core." + +[dependencies] +mrpci-core = { path = "../mrpci-core" } +macroquad = { version = "0.4", features = ["audio"] } diff --git a/mrpci/src/main.rs b/mrpci/src/main.rs new file mode 100644 index 0000000..0cb1baa --- /dev/null +++ b/mrpci/src/main.rs @@ -0,0 +1,482 @@ +//! MRPCI GUI — the macroquad face of the engine. Everything here is a thin +//! shell: input becomes [`Command`]s, [`Event`]s become sound and chrome. +//! The sim itself lives in mrpci-core and never learns what a window is. +//! +//! Controls (the SCI1 muscle-memory set, gently modernized): +//! left click current verb at the pixel (walk = A* click-to-walk) +//! right click cycle verb: walk → look → do → talk +//! F1-F4 pick verb directly arrows walk the ego +//! I inventory (click an item, then click a target) +//! Enter type a command (the parser is always alive) +//! F5 / F7 save / restore ("quick" slot) F9 new game +//! M mute · N scanlines · Esc closes overlays / quits + +mod sound; + +use macroquad::prelude::*; +use mrpci_core::audio::AudioCue; +use mrpci_core::screens::{PIC_H, PIC_W}; +use mrpci_core::{Command, Event, GameState, World}; + +const SCALE: f32 = 4.0; +const BAR_H: f32 = 48.0; +const LOG_H: f32 = 64.0; +const WIN_W: i32 = (PIC_W as f32 * SCALE) as i32; +const WIN_H: i32 = (PIC_H as f32 * SCALE + BAR_H + LOG_H) as i32; + +const VERBS: [&str; 4] = ["walk", "look", "do", "talk"]; + +struct Ui { + verb: usize, + selected_item: Option, + inventory_open: bool, + typing: bool, + input: String, + log: Vec, + flash: f32, + scanlines: bool, + muted: bool, +} + +fn conf() -> Conf { + Conf { + window_title: "MRPCI — Monster Robot Party Creative Interpreter".into(), + window_width: WIN_W, + window_height: WIN_H, + high_dpi: true, + ..Default::default() + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let game_dir = args + .iter() + .position(|a| a == "--game") + .and_then(|i| args.get(i + 1).cloned()) + .unwrap_or_else(|| { + // Default to the demo if it's been generated, else the cwd. + if std::path::Path::new("games/neon-precinct/game.json").exists() { + "games/neon-precinct".into() + } else { + ".".into() + } + }); + macroquad::Window::from_config(conf(), amain(game_dir)); +} + +async fn amain(game_dir: String) { + let world = World::load_game(&game_dir); + let mut gs = GameState::new(world); + let mut audio = sound::LazyAudio::start(&game_dir); + let mut ui = Ui { + verb: 0, + selected_item: None, + inventory_open: false, + typing: false, + input: String::new(), + log: Vec::new(), + flash: 0.0, + scanlines: true, + muted: false, + }; + + // Boot the session properly (intro text, music, checkpoint). + let boot = gs.apply(Command::NewGame { room: None }); + handle_events(&boot, &mut ui, &mut audio); + + let mut image = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK); + let texture = Texture2D::from_image(&image); + texture.set_filter(FilterMode::Nearest); + + let mut last_dir_sent: u8 = 0; + show_mouse(false); + + loop { + // --- input --------------------------------------------------------- + if ui.typing { + while let Some(c) = get_char_pressed() { + if !c.is_control() { + ui.input.push(c); + } + } + if is_key_pressed(KeyCode::Backspace) { + ui.input.pop(); + } + if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { + let line = std::mem::take(&mut ui.input); + ui.typing = false; + let evs = gs.apply(Command::Parse { text: line }); + handle_events(&evs, &mut ui, &mut audio); + } + if is_key_pressed(KeyCode::Escape) { + ui.typing = false; + ui.input.clear(); + } + } else if gs.dlg.is_some() { + for (i, key) in [KeyCode::Key1, KeyCode::Key2, KeyCode::Key3, KeyCode::Key4, KeyCode::Key5, KeyCode::Key6, KeyCode::Key7, KeyCode::Key8, KeyCode::Key9] + .iter() + .enumerate() + { + if is_key_pressed(*key) { + let evs = gs.apply(Command::Choose { n: i + 1 }); + handle_events(&evs, &mut ui, &mut audio); + } + } + if is_key_pressed(KeyCode::Escape) { + let evs = gs.apply(Command::EndDialogue); + handle_events(&evs, &mut ui, &mut audio); + } + } else { + // verbs + if is_key_pressed(KeyCode::F1) { + ui.verb = 0; + } + if is_key_pressed(KeyCode::F2) { + ui.verb = 1; + } + if is_key_pressed(KeyCode::F3) { + ui.verb = 2; + } + if is_key_pressed(KeyCode::F4) { + ui.verb = 3; + } + if is_mouse_button_pressed(MouseButton::Right) { + ui.verb = (ui.verb + 1) % VERBS.len(); + ui.selected_item = None; + } + if is_key_pressed(KeyCode::I) { + ui.inventory_open = !ui.inventory_open; + } + if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { + ui.typing = true; + ui.input.clear(); + } + if is_key_pressed(KeyCode::F5) { + let evs = gs.apply(Command::SaveGame { slot: "quick".into() }); + handle_events(&evs, &mut ui, &mut audio); + } + if is_key_pressed(KeyCode::F7) { + let evs = gs.apply(Command::RestoreGame { slot: "quick".into() }); + handle_events(&evs, &mut ui, &mut audio); + } + if is_key_pressed(KeyCode::F9) { + let evs = gs.apply(Command::NewGame { room: None }); + handle_events(&evs, &mut ui, &mut audio); + } + if is_key_pressed(KeyCode::M) { + ui.muted = !ui.muted; + audio.set_muted(ui.muted); + } + if is_key_pressed(KeyCode::N) { + ui.scanlines = !ui.scanlines; + } + if is_key_pressed(KeyCode::Escape) { + if ui.inventory_open { + ui.inventory_open = false; + } else if ui.selected_item.is_some() { + ui.selected_item = None; + } else { + break; + } + } + + // keyboard walking (held arrows → 8-way dir) + let held = |k: KeyCode| is_key_down(k); + let (h, v) = ( + held(KeyCode::Right) as i32 - held(KeyCode::Left) as i32, + held(KeyCode::Down) as i32 - held(KeyCode::Up) as i32, + ); + let dir = match (h, v) { + (0, -1) => 1, + (1, -1) => 2, + (1, 0) => 3, + (1, 1) => 4, + (0, 1) => 5, + (-1, 1) => 6, + (-1, 0) => 7, + (-1, -1) => 8, + _ => 0, + }; + if dir != last_dir_sent { + last_dir_sent = dir; + let evs = gs.apply(Command::Walk { dir }); + handle_events(&evs, &mut ui, &mut audio); + } + + // clicks + if is_mouse_button_pressed(MouseButton::Left) { + let (mx, my) = mouse_position(); + if my < BAR_H { + click_bar(mx, &mut ui); + } else if ui.inventory_open { + if let Some(item) = inventory_hit(&gs, mx, my) { + ui.selected_item = Some(item); + ui.inventory_open = false; + } else { + ui.inventory_open = false; + } + } else { + let px = (mx / SCALE) as i32; + let py = ((my - BAR_H) / SCALE) as i32; + if px >= 0 && px < PIC_W as i32 && py >= 0 && py < PIC_H as i32 { + let evs = if let Some(item) = ui.selected_item.take() { + gs.apply(Command::UseItemAt { item, x: px, y: py }) + } else if VERBS[ui.verb] == "walk" { + gs.apply(Command::MoveTo { x: px, y: py }) + } else { + gs.apply(Command::VerbAt { verb: VERBS[ui.verb].into(), x: px, y: py }) + }; + handle_events(&evs, &mut ui, &mut audio); + } + } + } + } + + // --- sim ------------------------------------------------------------- + let evs = gs.tick(get_frame_time()); + handle_events(&evs, &mut ui, &mut audio); + audio.tick(); + ui.flash = (ui.flash - get_frame_time()).max(0.0); + + // --- draw ------------------------------------------------------------ + clear_background(Color::from_rgba(12, 12, 16, 255)); + + let rgba = gs.render_visual(true); + image.bytes.copy_from_slice(&rgba); + texture.update(&image); + draw_texture_ex( + &texture, + 0.0, + BAR_H, + WHITE, + DrawTextureParams { dest_size: Some(vec2(PIC_W as f32 * SCALE, PIC_H as f32 * SCALE)), ..Default::default() }, + ); + + if ui.scanlines { + let y0 = BAR_H; + let y1 = BAR_H + PIC_H as f32 * SCALE; + let mut y = y0; + while y < y1 { + draw_line(0.0, y, PIC_W as f32 * SCALE, y, 1.0, Color::from_rgba(0, 0, 0, 46)); + y += SCALE; + } + } + + if ui.flash > 0.0 { + draw_rectangle( + 0.0, + BAR_H, + PIC_W as f32 * SCALE, + PIC_H as f32 * SCALE, + Color::from_rgba(200, 30, 30, (ui.flash * 120.0) as u8), + ); + } + + draw_bar(&gs, &ui); + draw_log(&ui); + + if let Some(d) = &gs.dlg { + draw_dialogue(&d.lines(&gs.flags), &mut gs, &mut ui, &mut audio); + } + if ui.inventory_open { + draw_inventory(&gs); + } + if gs.won { + center_banner("CASE CLOSED", "The city sleeps a little safer. F9 starts a new shift."); + } + if ui.typing { + draw_input(&ui); + } + + draw_cursor(&ui, gs.dlg.is_some()); + + next_frame().await; + } +} + +fn handle_events(evs: &[Event], ui: &mut Ui, audio: &mut sound::LazyAudio) { + for e in evs { + match e { + Event::Transcript { line } => { + ui.log.push(line.clone()); + if ui.log.len() > 200 { + ui.log.remove(0); + } + } + Event::Audio { cue } => audio.play(*cue), + Event::Music { mood } => audio.set_music(*mood), + Event::Died { .. } => ui.flash = 1.2, + Event::Won => audio.play(AudioCue::Win), + Event::Error { message } => { + ui.log.push(format!("[engine] {}", message)); + } + _ => {} + } + } +} + +// --- chrome ------------------------------------------------------------------ + +fn bar_button_rect(i: usize) -> Rect { + Rect::new(10.0 + i as f32 * 96.0, 7.0, 88.0, 34.0) +} + +fn click_bar(mx: f32, ui: &mut Ui) { + for i in 0..VERBS.len() { + if bar_button_rect(i).contains(vec2(mx, 20.0)) { + ui.verb = i; + ui.selected_item = None; + return; + } + } + if bar_button_rect(4).contains(vec2(mx, 20.0)) { + ui.inventory_open = !ui.inventory_open; + } +} + +fn draw_bar(gs: &GameState, ui: &Ui) { + draw_rectangle(0.0, 0.0, WIN_W as f32, BAR_H, Color::from_rgba(24, 26, 34, 255)); + draw_line(0.0, BAR_H - 1.0, WIN_W as f32, BAR_H - 1.0, 1.0, Color::from_rgba(70, 76, 96, 255)); + for (i, v) in VERBS.iter().enumerate() { + let r = bar_button_rect(i); + let active = ui.verb == i && ui.selected_item.is_none(); + let bg = if active { Color::from_rgba(90, 110, 200, 255) } else { Color::from_rgba(44, 48, 62, 255) }; + draw_rectangle(r.x, r.y, r.w, r.h, bg); + draw_text(&v.to_uppercase(), r.x + 14.0, r.y + 23.0, 22.0, WHITE); + } + let r = bar_button_rect(4); + let bg = if ui.selected_item.is_some() { Color::from_rgba(200, 150, 60, 255) } else { Color::from_rgba(44, 48, 62, 255) }; + draw_rectangle(r.x, r.y, r.w, r.h, bg); + let label = match &ui.selected_item { + Some(item) => format!("{}?", item.to_uppercase()), + None => "BAG".into(), + }; + draw_text(&label, r.x + 14.0, r.y + 23.0, 20.0, WHITE); + + // room + score, right side + let room = if gs.world.doc.name.is_empty() { + format!("room {}", gs.world.current) + } else { + gs.world.doc.name.clone() + }; + draw_text(&room, WIN_W as f32 - 380.0, 29.0, 22.0, Color::from_rgba(160, 170, 200, 255)); + if gs.world.manifest.max_score > 0 { + 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)); + } +} + +fn draw_log(ui: &Ui) { + let y0 = BAR_H + mrpci_core::screens::PIC_H as f32 * SCALE; + draw_rectangle(0.0, y0, WIN_W as f32, LOG_H, Color::from_rgba(16, 17, 22, 255)); + let n = ui.log.len(); + for (row, i) in (n.saturating_sub(3)..n).enumerate() { + let alpha = [120u8, 180, 255][row.min(2)]; + draw_text(&ui.log[i], 12.0, y0 + 18.0 + row as f32 * 19.0, 19.0, Color::from_rgba(210, 214, 226, alpha)); + } +} + +fn draw_input(ui: &Ui) { + let y0 = BAR_H + PIC_H as f32 * SCALE - 44.0; + draw_rectangle(8.0, y0, WIN_W as f32 - 16.0, 36.0, Color::from_rgba(10, 12, 18, 235)); + draw_rectangle_lines(8.0, y0, WIN_W as f32 - 16.0, 36.0, 2.0, Color::from_rgba(90, 110, 200, 255)); + draw_text(&format!("> {}_", ui.input), 18.0, y0 + 25.0, 24.0, WHITE); +} + +fn draw_dialogue(lines: &[String], gs: &mut GameState, ui: &mut Ui, audio: &mut sound::LazyAudio) { + let w = WIN_W as f32 - 240.0; + let h = 60.0 + lines.len() as f32 * 26.0; + let x = 120.0; + let y = BAR_H + 80.0; + draw_rectangle(x, y, w, h, Color::from_rgba(18, 20, 30, 242)); + draw_rectangle_lines(x, y, w, h, 3.0, Color::from_rgba(200, 170, 90, 255)); + let mut clicked: Option = None; + let (mx, my) = mouse_position(); + for (i, l) in lines.iter().enumerate() { + let ly = y + 34.0 + i as f32 * 26.0; + let is_choice = l.trim_start().chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false); + let hover = is_choice && my > ly - 18.0 && my < ly + 6.0 && mx > x && mx < x + w; + let col = if hover { Color::from_rgba(255, 230, 150, 255) } else { WHITE }; + draw_text(l, x + 20.0, ly, 22.0, col); + if hover && is_mouse_button_pressed(MouseButton::Left) { + clicked = l.trim_start().chars().next().and_then(|c| c.to_digit(10)).map(|d| d as usize); + } + let _ = i; + } + draw_text("(number keys pick · Esc ends)", x + 20.0, y + h - 12.0, 16.0, Color::from_rgba(140, 146, 168, 255)); + if let Some(n) = clicked { + let evs = gs.apply(Command::Choose { n }); + handle_events(&evs, ui, audio); + } +} + +fn inventory_panel_rect(count: usize) -> Rect { + let h = 70.0 + (count.max(1) as f32) * 30.0; + Rect::new(WIN_W as f32 / 2.0 - 180.0, BAR_H + 60.0, 360.0, h) +} + +fn inventory_hit(gs: &GameState, mx: f32, my: f32) -> Option { + let r = inventory_panel_rect(gs.inventory.len()); + for (i, item) in gs.inventory.iter().enumerate() { + let ly = r.y + 56.0 + i as f32 * 30.0; + if mx > r.x && mx < r.x + r.w && my > ly - 20.0 && my < ly + 8.0 { + return Some(item.clone()); + } + } + None +} + +fn draw_inventory(gs: &GameState) { + let r = inventory_panel_rect(gs.inventory.len()); + draw_rectangle(r.x, r.y, r.w, r.h, Color::from_rgba(18, 20, 30, 242)); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 3.0, Color::from_rgba(90, 110, 200, 255)); + draw_text("EVIDENCE BAG", r.x + 20.0, r.y + 28.0, 24.0, Color::from_rgba(230, 210, 120, 255)); + if gs.inventory.is_empty() { + draw_text("(empty. the city awaits.)", r.x + 20.0, r.y + 60.0, 20.0, WHITE); + } + let (mx, my) = mouse_position(); + for (i, item) in gs.inventory.iter().enumerate() { + let ly = r.y + 56.0 + i as f32 * 30.0; + let hover = mx > r.x && mx < r.x + r.w && my > ly - 20.0 && my < ly + 8.0; + let col = if hover { Color::from_rgba(255, 230, 150, 255) } else { WHITE }; + draw_text(&format!("· {}", item), r.x + 24.0, ly, 22.0, col); + } +} + +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; + draw_text(sub, cx - sw / 2.0, y + 32.0, 20.0, WHITE); +} + +fn draw_cursor(ui: &Ui, in_dialogue: bool) { + 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), + (_, true, _) => Color::from_rgba(255, 180, 80, 255), + (_, _, "walk") => Color::from_rgba(140, 220, 140, 255), + (_, _, "look") => Color::from_rgba(120, 200, 255, 255), + (_, _, "do") => Color::from_rgba(255, 160, 160, 255), + _ => Color::from_rgba(230, 160, 255, 255), + }; + draw_line(mx - 9.0, my, mx - 3.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 + 3.0, mx, my + 9.0, 2.0, col); + let tag = if let Some(item) = &ui.selected_item { + item.clone() + } else if in_dialogue { + String::new() + } else { + VERBS[ui.verb].to_string() + }; + if !tag.is_empty() { + draw_text(&tag, mx + 12.0, my + 14.0, 18.0, col); + } +} diff --git a/mrpci/src/sound.rs b/mrpci/src/sound.rs new file mode 100644 index 0000000..dabfc6d --- /dev/null +++ b/mrpci/src/sound.rs @@ -0,0 +1,165 @@ +//! Audio output — the GUI's sink for the core's audio events. +//! +//! Two sources, per cue/track, checked in order ("always want options"): +//! 1. files in the game folder: `sfx/.{wav,ogg,mp3}`, `music/.{...}` +//! (mood by name — calm/eerie/tense/jolly/spooky/noir — or by index 1-6) +//! 2. the core's built-in multi-voice chiptune synth +//! If the audio backend can't start, everything degrades to silence. + +use macroquad::audio::{load_sound_from_bytes, play_sound, stop_sound, PlaySoundParams, Sound}; +use macroquad::experimental::coroutines::{start_coroutine, Coroutine}; +use mrpci_core::audio::{music_wav, AudioCue, MOODS}; +use std::path::Path; + +/// The loop-facing audio front. Loading (file fetches + decoding) runs in a +/// coroutine so the first frame never waits on it. +pub struct LazyAudio { + inner: Option, + loading: Coroutine, + pub muted: bool, + mood: u8, +} + +impl LazyAudio { + pub fn start(game_dir: &str) -> Self { + let dir = game_dir.to_string(); + LazyAudio { + inner: None, + loading: start_coroutine(async move { AudioOut::load(&dir).await }), + muted: false, + mood: 0, + } + } + + fn poll(&mut self) -> Option<&mut AudioOut> { + if self.inner.is_none() { + if let Some(mut a) = self.loading.retrieve() { + a.muted = self.muted; + a.set_music(self.mood); + self.inner = Some(a); + } + } + self.inner.as_mut() + } + + /// Call once per frame: starts the music as soon as loading finishes. + pub fn tick(&mut self) { + self.poll(); + } + + pub fn play(&mut self, cue: AudioCue) { + if let Some(a) = self.poll() { + a.play(cue); + } + } + + pub fn set_music(&mut self, mood: u8) { + self.mood = mood; + if let Some(a) = self.poll() { + a.set_music(mood); + } + } + + pub fn set_muted(&mut self, m: bool) { + self.muted = m; + if let Some(a) = self.poll() { + a.set_muted(m); + } + } +} + +pub struct AudioOut { + sfx: Vec<(AudioCue, Option)>, + music: Vec>, // by mood index; [0] is silence + current: u8, + pub muted: bool, +} + +async fn file_bytes(dir: &Path, stem: &str) -> Option> { + for ext in ["wav", "ogg", "mp3"] { + let path = dir.join(format!("{stem}.{ext}")); + if let Ok(b) = macroquad::file::load_file(&path.to_string_lossy()).await { + return Some(b); + } + } + None +} + +async fn snd(bytes: &[u8]) -> Option { + load_sound_from_bytes(bytes).await.ok() +} + +impl AudioOut { + pub async fn load(game_dir: &str) -> Self { + let base = Path::new(game_dir); + let mut sfx = Vec::new(); + for cue in AudioCue::ALL { + let bytes = match file_bytes(&base.join("sfx"), cue.name()).await { + Some(b) => b, + None => cue.wav(), + }; + sfx.push((cue, snd(&bytes).await)); + } + let mut music = vec![None]; // mood 0 = silence + for mood in 1u8..=6 { + let mut bytes = file_bytes(&base.join("music"), MOODS[mood as usize]).await; + if bytes.is_none() { + bytes = file_bytes(&base.join("music"), &mood.to_string()).await; + } + let bytes = bytes.or_else(|| music_wav(mood)); + music.push(match bytes { + Some(b) => snd(&b).await, + None => None, + }); + } + AudioOut { sfx, music, current: 255, muted: false } + } + + pub fn play(&self, cue: AudioCue) { + if self.muted { + return; + } + let vol = match cue { + AudioCue::Blip => 0.4, + AudioCue::Pickup => 0.7, + AudioCue::Confirm => 0.55, + AudioCue::Error => 0.6, + AudioCue::Door => 0.6, + AudioCue::Win => 0.85, + AudioCue::Scan => 0.5, + AudioCue::Alert => 0.6, + }; + if let Some((_, Some(s))) = self.sfx.iter().find(|(c, _)| *c == cue) { + play_sound(s, PlaySoundParams { looped: false, volume: vol }); + } + } + + /// Switch to a mood (no-op if already playing it). Loops at low volume. + pub fn set_music(&mut self, mood: u8) { + if mood == self.current { + return; + } + if let Some(Some(s)) = self.music.get(self.current as usize) { + stop_sound(s); + } + self.current = mood; + if !self.muted { + if let Some(Some(s)) = self.music.get(mood as usize) { + play_sound(s, PlaySoundParams { looped: true, volume: 0.13 }); + } + } + } + + pub fn set_muted(&mut self, m: bool) { + self.muted = m; + if m { + for t in self.music.iter().flatten() { + stop_sound(t); + } + } else { + let c = self.current; + self.current = 255; + self.set_music(c); + } + } +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..68a324f --- /dev/null +++ b/run.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Play the demo (generates it first if missing). +# Headless surfaces live in the companion binary: +# cargo run -p mrpci-core --bin mrpci-headless -- --help +set -e +[ -f games/neon-precinct/game.json ] || cargo run -q -p mrpci-core --bin mrpci-headless -- --sample +exec cargo run -p mrpci "$@"