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>
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/target
|
||||
games/*/saves/
|
||||
.DS_Store
|
||||
857
Cargo.lock
generated
Normal 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
@ -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
@ -0,0 +1,109 @@
|
||||
# MRPCI — Monster Robot Party Creative Interpreter
|
||||
|
||||
The **SCI generation** of the Monster Robot Party engine family. Where
|
||||
[MRPGI](../mrpgi) reimagines Sierra's 1980s AGI (16 colors, text parser,
|
||||
one merged priority buffer), MRPCI reimagines the tech behind *King's Quest V*
|
||||
and *Space Quest IV* — and then fixes everything Sierra never could:
|
||||
|
||||
| Sierra SCI (1990) | MRPCI (now) |
|
||||
|---|---|
|
||||
| 256-color VGA, hand-cut palettes | 256-color palettes **quantized live from any PNG** (median cut), EGA/gray/ember ranges kept stable for sprites & cycles |
|
||||
| Palette cycling (waterfalls, neon) | Same trick, `cycles` are room data + script-toggleable |
|
||||
| Priority + control screens | **Four** screens: visual / priority / control / hotspot — hit-testing any pixel is one array read |
|
||||
| Polygon avoidance that wedged egos into corners | Real **A\*** with LOS-verified edges + string-pulling; actors walk the exact verified line, so a legal path can never clip |
|
||||
| Scale tables (SCI1.1) | Per-room perspective: actors shrink toward the horizon, walk speed scales to match |
|
||||
| CPU-speed timers → Error 52, instant Sequel Police | Fixed **30Hz deterministic cycles**; `after()` timers count ticks; the only RNG is seeded xorshift — replays are byte-identical |
|
||||
| `Error 47: Not an object` → crash to DOS | Missing resources = placeholders + an `error` event. A compile-error script = a log line, not a hang |
|
||||
| Die 3 hours after the dead-end | **Checkpoint at every room entry; death rewinds to it.** Save-anywhere slots on top |
|
||||
| OOP bytecode scripts, priest-tier tooling | **rhai** room scripts: `on_enter`, `on_verb(v,n)`, `on_trigger`, `after(ticks,"fn")` — hot-swappable over the bus |
|
||||
| Icon bar (SCI1) *or* parser (SCI0) | **Both.** Clicks and typed commands converge on one verb pipeline |
|
||||
|
||||
The architecture is inherited from MRPGI and kept sacred: **`mrpci-core` is
|
||||
headless** (zero windowing deps — a compile error is the guardrail). The
|
||||
whole sim is a `GameState` behind a typed Command/Event bus, and every
|
||||
control surface — GUI, JSONL stdio, HTTP, MCP — is a thin adapter.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
cargo run -p mrpci-core --bin mrpci-headless -- --sample # write the demo game
|
||||
cargo run -p mrpci # play Neon Precinct
|
||||
```
|
||||
|
||||
**Neon Precinct**: you are Officer Morp-9. Rain, neon that actually cycles,
|
||||
a vend-bot with a tip, a keycard behind crates (watch the A\* go around),
|
||||
a lethal fusebox (watch death *not* ruin your evening), and a case to close.
|
||||
25 points.
|
||||
|
||||
Controls: left-click applies the verb, right-click cycles verbs, F1–F4 pick
|
||||
one, `I` inventory, **Enter types a command** (the parser is always alive),
|
||||
F5/F7 save/restore, F9 new game, M mute, N scanlines.
|
||||
|
||||
## Drive it headless (the point)
|
||||
|
||||
```sh
|
||||
cargo build -p mrpci-core
|
||||
target/debug/mrpci-headless --game games/neon-precinct # JSONL stdio
|
||||
target/debug/mrpci-headless --game games/neon-precinct --serve 8093 # HTTP
|
||||
target/debug/mrpci-headless --game games/neon-precinct --mcp # MCP for Claude
|
||||
target/debug/mrpci-headless --game games/neon-precinct --script play.jsonl # golden replay
|
||||
target/debug/mrpci-headless --game games/neon-precinct --room 2 --ticks 45 --render-room out.png
|
||||
target/debug/mrpci-headless --game games/neon-precinct --screens shots/ # all four screens
|
||||
```
|
||||
|
||||
Register with Claude:
|
||||
|
||||
```sh
|
||||
claude mcp add mrpci -- $PWD/target/debug/mrpci-headless --mcp --game $PWD/games/neon-precinct
|
||||
```
|
||||
|
||||
Claude can then *play* the game (verbs, walks, dialogue), *see* it
|
||||
(`mrpci_render_frame`, plus the invisible priority/control/hotspot screens),
|
||||
and *author* it (upsert rooms and rhai scripts live, validated before commit).
|
||||
See [docs/CONTROL.md](docs/CONTROL.md) for the full bus reference.
|
||||
|
||||
## A game is a folder
|
||||
|
||||
```
|
||||
games/mygame/
|
||||
game.json # manifest: name, start_room, intro, verbs, flags, max_score
|
||||
rooms/room0.json # RoomDoc: ops (paint), background, palette, cycles, scale,
|
||||
# spawn, exits, hotspots, props, npcs, music, script
|
||||
pics/street.png # optional backgrounds (+ street-pri.png / street-ctl.png masks)
|
||||
sprites/*.png # quantized per-room; filename = sprite name
|
||||
scripts/*.rhai # main.rhai + room scripts
|
||||
sfx/ music/ # optional audio overrides (else the built-in chip synth plays)
|
||||
saves/ # save-anywhere slots (gitignored)
|
||||
```
|
||||
|
||||
Rooms paint with `PicOp`s — rects, polygons, floods, **dithered gradients** —
|
||||
through an `Ink` that writes any subset of the four screens at once. Or drop
|
||||
a PNG in `pics/` and let the quantizer fold it into the palette.
|
||||
|
||||
## Source map
|
||||
|
||||
| Crate / file | Role |
|
||||
|---|---|
|
||||
| `mrpci-core/screens.rs` | the four aligned screens + depth bands |
|
||||
| `mrpci-core/palette.rs` | 256-color palettes, cycling LUTs |
|
||||
| `mrpci-core/pic.rs` | vector paint ops, Bayer-dithered gradients |
|
||||
| `mrpci-core/assets.rs` | median-cut quantizer, masks, sprite pipeline |
|
||||
| `mrpci-core/view.rs` | loops/cels, ASCII sprites, mirroring |
|
||||
| `mrpci-core/actor.rs` | ego + NPCs: scaling, line-exact path following |
|
||||
| `mrpci-core/path.rs` | A\*, LOS smoothing, `walk_line` |
|
||||
| `mrpci-core/room.rs` | RoomDoc / World / game folders / validation |
|
||||
| `mrpci-core/script.rs` | rhai host: hooks, Fx airlock, tick timers |
|
||||
| `mrpci-core/state.rs` | **the bus**: GameState, Command/Event, checkpoints |
|
||||
| `mrpci-core/render.rs` | headless compositing → RGBA/PNG |
|
||||
| `mrpci-core/audio.rs` | multi-voice chip synth (square/pulse/tri/noise) |
|
||||
| `mrpci-core/bin/mrpci-headless/` | stdio / HTTP / MCP / replay / sample |
|
||||
| `mrpci/` | the macroquad GUI (icon bar, dialogue windows, sound) |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] v0.1 — the engine above + Neon Precinct
|
||||
- [ ] Priority-mask art workflow polish (paint depth in any image editor)
|
||||
- [ ] In-engine room editor (MRPGI's, upgraded to four screens)
|
||||
- [ ] AI parser lane (LLM maps free text onto already-legal commands, like MRPGI)
|
||||
- [ ] Portraits/talkers in dialogue windows
|
||||
- [ ] wasm build (`mrpci-core` is already windowless — the web is an adapter away)
|
||||
140
docs/CONTROL.md
Normal 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.
|
||||
15
games/neon-precinct/game.json
Normal 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."
|
||||
}
|
||||
}
|
||||
1149
games/neon-precinct/rooms/room0.json
Normal file
842
games/neon-precinct/rooms/room1.json
Normal 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"
|
||||
}
|
||||
684
games/neon-precinct/rooms/room2.json
Normal 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"
|
||||
}
|
||||
8
games/neon-precinct/scripts/main.rhai
Normal 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
|
||||
}
|
||||
14
games/neon-precinct/scripts/room0.rhai
Normal 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.");
|
||||
}
|
||||
}
|
||||
10
games/neon-precinct/scripts/room1.rhai
Normal 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
|
||||
}
|
||||
18
games/neon-precinct/scripts/room2.rhai
Normal 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);
|
||||
}
|
||||
}
|
||||
BIN
games/neon-precinct/sprites/crate.png
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
games/neon-precinct/sprites/dumpster.png
Normal file
|
After Width: | Height: | Size: 260 B |
BIN
games/neon-precinct/sprites/fusebox.png
Normal file
|
After Width: | Height: | Size: 244 B |
BIN
games/neon-precinct/sprites/keycard.png
Normal file
|
After Width: | Height: | Size: 169 B |
BIN
games/neon-precinct/sprites/locker.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
games/neon-precinct/sprites/sergeant.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
games/neon-precinct/sprites/terminal.png
Normal file
|
After Width: | Height: | Size: 314 B |
BIN
games/neon-precinct/sprites/vendbot.png
Normal file
|
After Width: | Height: | Size: 311 B |
22
mrpci-core/Cargo.toml
Normal 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
@ -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
@ -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
@ -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
|
||||
}
|
||||
143
mrpci-core/src/bin/mrpci-headless/http.rs
Normal 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.
|
||||
";
|
||||
129
mrpci-core/src/bin/mrpci-headless/main.rs
Normal 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();
|
||||
}
|
||||
185
mrpci-core/src/bin/mrpci-headless/mcp.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
658
mrpci-core/src/bin/mrpci-headless/sample.rs
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
262
mrpci-core/src/view.rs
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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 "$@"
|
||||