MRPCI v0.1 — the SCI-generation Monster Robot Party engine

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-25 22:52:39 +10:00
commit c83c90711d
46 changed files with 10228 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
games/*/saves/
.DS_Store

857
Cargo.lock generated Normal file
View File

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

11
Cargo.toml Normal file
View File

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

109
README.md Normal file
View File

@ -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, F1F4 pick
one, `I` inventory, **Enter types a command** (the parser is always alive),
F5/F7 save/restore, F9 new game, M mute, N scanlines.
## 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)

140
docs/CONTROL.md Normal file
View File

@ -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:<item>"/...
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.

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

22
mrpci-core/Cargo.toml Normal file
View File

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

302
mrpci-core/src/actor.rs Normal file
View File

@ -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<u8>,
/// Overrides the room scale table (billboards, giants, pickups).
pub fixed_scale: Option<f32>,
/// 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<String>, 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
}
}

189
mrpci-core/src/assets.rs Normal file
View File

@ -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<Vec<u8>> {
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<(u8, u8, u8)>> = 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<SpriteSrc> {
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 }
}

225
mrpci-core/src/audio.rs Normal file
View File

@ -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<u8> {
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<Vec<u8>> {
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<f32>, 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<u8> {
let mut buf: Vec<f32> = Vec::new();
for (w, notes, vol) in voices {
render_voice(&mut buf, *w, notes, *vol);
}
let samples: Vec<i16> = 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<u8> {
mix(&[(Wave::Square, notes.to_vec(), vol)])
}
pub fn wav_bytes(samples: &[i16]) -> Vec<u8> {
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
}

View File

@ -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<serde_json::Value>,
}
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<u8>, 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::<Command>(&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<u32> = p.trim_start_matches("/rooms/").parse().ok();
let body = read_body(&mut req);
match (n, serde_json::from_str::<mrpci_core::RoomDoc>(&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.
";

View File

@ -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<String> = std::env::args().collect();
let get = |flag: &str| -> Option<String> {
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::<u64>().ok()) {
gs.apply(Command::SetSeed { seed });
}
if let Some(n) = get("--room").and_then(|s| s.parse::<u32>().ok()) {
gs.apply(Command::GotoRoom { n });
}
if let Some(n) = get("--ticks").and_then(|s| s.parse::<u32>().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::<Command>(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();
}

View File

@ -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::<Value>(&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<Value> {
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<Value, (i64, String)> {
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");
}
}

View File

@ -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<String, String> {
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<PicOp> = 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<PicOp> = 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<PicOp> = 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]));
}
}
}

103
mrpci-core/src/dialogue.rs Normal file
View File

@ -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<DlgChoice>,
}
/// A live conversation: the nodes plus where we are in them.
#[derive(Clone)]
pub struct Dlg {
pub nodes: Vec<DlgNode>,
pub node: usize,
/// Who we're talking to (prefixes the says lines).
pub with: String,
}
impl Dlg {
fn offered<'a>(&'a self, flags: &HashMap<String, bool>) -> 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<String, bool>) -> Vec<String> {
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<String, bool>) -> 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<String, bool>,
score: &mut u32,
) -> (Vec<String>, 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)
}
}

34
mrpci-core/src/lib.rs Normal file
View File

@ -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};

173
mrpci-core/src/palette.rs Normal file
View File

@ -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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let flat: Vec<u8> = self.0.iter().flat_map(|&(r, g, b)| [r, g, b]).collect();
flat.serialize(s)
}
}
impl<'de> Deserialize<'de> for Palette {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let flat = Vec::<u8>::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
}

154
mrpci-core/src/parser.rs Normal file
View File

@ -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 <item words> on <target words>" / "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<String, String> {
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<String, String>) -> HashMap<String, String> {
let mut idx = HashMap::new();
let mut fold = |canon: &str, syns: &str| {
idx.insert(canon.to_string(), canon.to_string());
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::<Vec<_>>()
.join(" ")
}
/// Parse one typed line. `verbs` is the word → canonical index.
pub fn parse(line: &str, verbs: &HashMap<String, String>) -> 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<String, String>) -> Vec<String> {
let mut v: Vec<String> = 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(_)));
}
}

257
mrpci-core/src/path.rs Normal file
View File

@ -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<i32> = vec![-1; n];
let mut heap: BinaryHeap<Reverse<(u32, i32, i32)>> = 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
}

290
mrpci-core/src/pic.rs Normal file
View File

@ -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<u8>,
#[serde(default = "keep_pri")]
pub pri: PriInk,
#[serde(default = "keep_ctl")]
pub ctl: CtlInk,
#[serde(default)]
pub hot: Option<u8>,
}
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<i32> = 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<PicOp>`, 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<u8>, ink: Ink },
HGradient { x: i32, y: i32, w: i32, h: i32, ramp: Vec<u8>, 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);
}
}

99
mrpci-core/src/render.rs Normal file
View File

@ -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<DrawObj>) -> Vec<u8> {
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<u8> {
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<u8> {
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()
}

599
mrpci-core/src/room.rs Normal file
View File

@ -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:<item>" for inventory-on-hotspot.
#[serde(default)]
pub msgs: HashMap<String, String>,
/// Walking into this region changes rooms (doors!). The ego arrives at
/// `arrive` in the target room (or that room's spawn).
#[serde(default)]
pub exit_to: Option<u32>,
#[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<String, String>,
#[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<DlgNode>, // 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<u8>,
#[serde(default)]
pub fixed_scale: Option<f32>,
}
/// 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<String, bool>,
) -> 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<String, String>,
#[serde(default)]
pub dialogue: Vec<DlgNode>,
/// 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<f32>,
}
#[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 `<name>-pri.png` and
/// `<name>-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<PicOp>,
/// A full room palette (usually written by the quantizer). None = base.
#[serde(default)]
pub palette: Option<Palette>,
/// Palette cycling ranges: waterfalls, neon, lava.
#[serde(default)]
pub cycles: Vec<PalCycle>,
/// 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<u32>; 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<Hotspot>,
#[serde(default)]
pub props: Vec<PropDef>,
#[serde(default)]
pub npcs: Vec<NpcDef>,
#[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<String, String>,
/// Room logic: rhai source file under `scripts/` ("" = `room<N>.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<String, String>,
/// Initial flag values for a new game.
#[serde(default)]
pub flags: HashMap<String, bool>,
/// 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<String, String>,
}
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<u32, RoomDoc>,
/// Script sources by file name (bundles travel with their logic).
#[serde(default)]
pub scripts: HashMap<String, String>,
}
/// 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<u32, RoomDoc>,
/// In-memory scripts (bundles / upserts); disk is the fallback.
pub script_overlay: HashMap<String, String>,
}
impl World {
/// Load a game folder: `game.json` (all-defaults if absent) + `rooms/` +
/// `sprites/` + `pics/` + `scripts/`.
pub fn load_game(dir: impl Into<PathBuf>) -> 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<Item = (u32, RoomDoc)>,
scripts: impl IntoIterator<Item = (String, String)>,
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<RoomDoc> {
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<String> {
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<String> {
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<u32, RoomDoc> = 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;

124
mrpci-core/src/screens.rs Normal file
View File

@ -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<u8>,
pub priority: Vec<u8>,
pub control: Vec<u8>,
pub hotspot: Vec<u8>,
}
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()
}
}

238
mrpci-core/src/script.rs Normal file
View File

@ -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<String, bool>,
pub vars: HashMap<String, i64>,
pub inventory: Vec<String>,
pub ego: (i32, i32),
pub room: u32,
pub rng: u64,
pub fx: Vec<Fx>,
}
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<AST>,
pub ctx: Arc<Mutex<ScriptCtx>>,
/// Last compile error, surfaced once as an Event::Error by the caller.
pub compile_error: Option<String>,
}
impl ScriptHost {
pub fn new() -> Self {
let ctx: Arc<Mutex<ScriptCtx>> = 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<AST> = 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::<rhai::Dynamic>(&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<bool, String> {
let Some(ast) = &self.ast else {
return Ok(false);
};
let mut scope = Scope::new();
match self.engine.call_fn::<rhai::Dynamic>(&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()
}
}

1475
mrpci-core/src/state.rs Normal file

File diff suppressed because it is too large Load Diff

262
mrpci-core/src/view.rs Normal file
View File

@ -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<u8>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Cel {
pub w: usize,
pub h: usize,
pub pix: Vec<u8>,
/// 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<bool>,
}
impl Cel {
/// Palette index at (x, y), or None if transparent / out of bounds.
#[inline]
pub fn at(&self, x: usize, y: usize) -> Option<u8> {
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<u8> {
if x < self.w {
self.at(self.w - 1 - x, y)
} else {
None
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ViewLoop {
pub cels: Vec<Cel>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct View {
pub loops: Vec<ViewLoop>,
}
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)] },
],
}
}

44
mrpci-core/tests/walk.rs Normal file
View File

@ -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<String> {
for p in ["games/neon-precinct", "../games/neon-precinct"] {
if std::path::Path::new(p).join("game.json").exists() {
return Some(p.to_string());
}
}
None
}
#[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);
}

9
mrpci/Cargo.toml Normal file
View File

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

482
mrpci/src/main.rs Normal file
View File

@ -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<String>,
inventory_open: bool,
typing: bool,
input: String,
log: Vec<String>,
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<String> = 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<usize> = 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<String> {
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);
}
}

165
mrpci/src/sound.rs Normal file
View File

@ -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/<cue>.{wav,ogg,mp3}`, `music/<mood>.{...}`
//! (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<AudioOut>,
loading: Coroutine<AudioOut>,
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<Sound>)>,
music: Vec<Option<Sound>>, // by mood index; [0] is silence
current: u8,
pub muted: bool,
}
async fn file_bytes(dir: &Path, stem: &str) -> Option<Vec<u8>> {
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<Sound> {
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);
}
}
}

7
run.sh Executable file
View File

@ -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 "$@"