Initial commit: MRPGI — Apple-Silicon AGI adventure engine
A new-school reimagining of Sierra's 1980s AGI interpreter in Rust + macroquad: - Dual-buffer (visual + priority) room model with hand-painted walkability - In-engine room editor: brush/line/rect/fill/pick/erase/oval/image/spawn/object tools - Sprite pipeline (PNG -> EGA quantize), multi-room worlds with edge exits - Objects with look/use/needs/keys/win conditions (no code) - Forgiving text parser + optional local-LLM lane (Ollama / OpenRouter) - Branching NPC dialogue trees, chiptune SFX, per-room ambient music - Content kit: 61 archetypes across fantasy/scifi/monsters/saucy/horror - Full docs: README, EDITOR_GUIDE, MANUAL, art manifesto + Gemini prompts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Rust build output
|
||||
/target
|
||||
**/*.rs.bk
|
||||
|
||||
# Editor/runtime backups (regenerable; git is the backup now)
|
||||
**/*.bak
|
||||
|
||||
# macOS / IDE cruft
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
||||
# Stray test renders that landed here from another project
|
||||
001.jpeg
|
||||
002.png
|
||||
003.png
|
||||
883
Cargo.lock
generated
Normal file
@ -0,0 +1,883 @@
|
||||
# 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 = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[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 = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[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 = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[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 = "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 = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[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 = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
[[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 = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[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 = "mrpgi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"macroquad",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121"
|
||||
|
||||
[[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 = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quad-rand"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[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 = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[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 = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
20
Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "mrpgi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Monster Robot Party Game Interpreter — a new-school, Apple-Silicon-native reimagining of the Sierra AGI adventure engine."
|
||||
|
||||
[dependencies]
|
||||
macroquad = "0.4"
|
||||
image = { version = "0.24", default-features = false, features = ["png"] } # PNG-only: lean builds
|
||||
serde = { version = "1", features = ["derive"] } # room (.json) save/load
|
||||
serde_json = "1"
|
||||
ureq = { version = "2", features = ["json", "tls"] } # AI parser: local Ollama / OpenRouter HTTP
|
||||
|
||||
# macroquad renders much more smoothly when its dependencies are optimized,
|
||||
# even during `cargo run` dev builds. Our own crate stays fast to recompile.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
230
EDITOR_GUIDE.md
Normal file
@ -0,0 +1,230 @@
|
||||
# MRPGI room editor — the dummy's guide
|
||||
|
||||
You draw a room, tag what's walkable, press a key, and walk around it. That's it.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
|
||||
It opens in **EDIT** mode. Press **`Tab`** any time to flip between **edit** (draw) and
|
||||
**play** (walk). `Esc` quits.
|
||||
|
||||
## The one rule (read this twice)
|
||||
|
||||
Every spot on the screen has **two** properties, and you set them separately:
|
||||
|
||||
- **LOOK** = the colour (what you see). You pick it from the **palette**.
|
||||
- **MEANING** = what it *is* to the game (walkable? a wall?). You pick it from the **meaning** tags.
|
||||
|
||||
So a brown floor and a brown wall look identical but behave differently — because
|
||||
**colour is just looks; the tag is the meaning.** When you fill or draw a region,
|
||||
it gets *both*: the current colour **and** the current meaning tag.
|
||||
|
||||
Meaning tags:
|
||||
- **floor** — walkable (the robot can stand here). Default.
|
||||
- **wall** — blocked (the robot can't enter).
|
||||
- **water** / **trigger** — special (reserved for later).
|
||||
- **none** — paint colour only, don't change walkability.
|
||||
|
||||
## Make your first room (the classic 3-band road)
|
||||
|
||||
1. Press **`Tab`** until the panel says you're editing (you start here).
|
||||
2. Click the **fill** tool (or press `4`).
|
||||
3. Click **blue** in the palette, click the **wall** tag, then click the **top third** → blue sky that blocks.
|
||||
4. Click **green**, keep **wall**, click the **bottom third** → green ground that blocks.
|
||||
5. Click **brown**, click the **floor** tag, click the **middle band** → a brown road that's walkable.
|
||||
6. Press **`Tab`** → you're now playing. Use the **arrow keys** — the robot can only walk the road. 🎉
|
||||
|
||||
Tip: tap **`L`** to cycle the **view** — `both` tints walls red so you can *see*
|
||||
what's walkable vs blocked. `look` shows the plain picture; `ctrl` shows the meaning map.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Key | What it does |
|
||||
|------|-----|--------------|
|
||||
| brush | `1` | freehand paint (colour + meaning) |
|
||||
| line | `2` | click points; **right-click or Enter** to finish the line |
|
||||
| rect | `3` | drag a filled box |
|
||||
| fill | `4` | flood-fill a sealed area (the bucket) |
|
||||
| pick | `5` | eyedropper — grab a colour off the canvas |
|
||||
| erase | `6` | rub back to blank + walkable |
|
||||
| image | `7` | stamp a sprite from `sprites/` (`[` `]` choose, `R` reload) |
|
||||
| oval | `8` | drag a filled ellipse |
|
||||
| spawn | `9` | click to set where the robot appears in play |
|
||||
| object | `0` | stamp a kit archetype (`[` `]` picks from 60+) or a blank object; click again to select |
|
||||
|
||||
Brush/eraser size: **`,`** smaller, **`.`** bigger. The **fill** bucket (`4`) is your fast filler — outline a shape with lines, then click inside it.
|
||||
|
||||
**Sealed areas:** fill only stays inside lines that fully enclose a space. If a
|
||||
fill suddenly floods the whole screen, your outline has a gap — undo and close it.
|
||||
The status line warns you (`LEAK?`).
|
||||
|
||||
## Keys
|
||||
|
||||
- `Tab` edit ⇄ play · `Esc` quit
|
||||
- `1`–`0` tools · `L` cycle view
|
||||
- `[` `]` choose sprite · `,` `.` brush size · `R` reload sprites
|
||||
- `-` `=` previous / next room (the top strip has `<` `>` too)
|
||||
- `Z` or `Backspace` undo · `C` clear room · `K` save · `O` load
|
||||
- In play: **arrows / click** to walk · **type a command + Enter** (try `talk <npc>`) · in a conversation press **1–6** to choose, **Esc** to leave · `F1` CRT · `F2` sound
|
||||
|
||||
## Saving
|
||||
|
||||
`K` saves to `rooms/room.json` (the editable list of your strokes, not flat pixels —
|
||||
so it stays re-openable and tiny). The game **auto-loads** that file on startup, and
|
||||
`O` reloads it. The cyan circle on the canvas is where the robot spawns in play.
|
||||
|
||||
---
|
||||
|
||||
# Bring your own pixel art (Aseprite / Inkscape)
|
||||
|
||||
The dream loop: **draw a thing → drop the PNG in `sprites/` → press `R` → place it with the image tool (`7`).**
|
||||
|
||||
## The spec (what a sprite PNG should be)
|
||||
|
||||
- **Format:** PNG **with transparency** (alpha). Transparent pixels stay see-through in game; a sprite with no transparency acts like a solid tile.
|
||||
- **Size = game size.** The room is 160×168 "fat" pixels — draw small, at 1:1. Rough guide: a person ≈ 12–20 px tall, a crate ≈ 24 px, a door ≈ 30×50. A 500 px sprite will fill the whole screen.
|
||||
- **Colours:** ideally the MRPGI EGA 16 — load `palettes/mrpgi-ega.gpl` into your tool. Off-palette art still works; it's auto-snapped to the nearest EGA colour on load.
|
||||
- **Name = id.** Lowercase, no spaces: `crate.png`, `barrel.png`, `becky.png`. The filename (minus `.png`) is what shows in the sprite tray.
|
||||
- **Where:** the `sprites/` folder. (Starter `crate` / `barrel` / `sign` are already there — replace them or add your own.)
|
||||
|
||||
## Aseprite (pixel art)
|
||||
|
||||
1. **Load the palette:** Palette panel → ☰ menu → *Load Palette* → `palettes/mrpgi-ega.gpl`. Now you're painting in the exact game colours. (Bonus: *Sprite → Color Mode → Indexed* = pixel-perfect, zero quantization.)
|
||||
2. New sprite, small (e.g. 24×24), transparent background.
|
||||
3. Draw. Leave everything that isn't the object transparent.
|
||||
4. **Export:** File → *Export Sprite Sheet* or just *Save As* a `.png` into `sprites/`.
|
||||
5. In MRPGI: press **`R`**, tool **`7`**, **`[` `]`** to your sprite, **click** to place.
|
||||
|
||||
## Inkscape (vector → pixel)
|
||||
|
||||
1. Draw your art. *File → Document Properties* → set a small px canvas (e.g. 24×24).
|
||||
2. **Export:** *File → Export* → Page tab, set width/height in **px**, transparent background → export PNG into `sprites/`.
|
||||
3. Same place step in MRPGI (`R`, `7`, place).
|
||||
|
||||
## How a placed sprite behaves
|
||||
|
||||
The image tool **stamps** the sprite into the room picture and applies your current **meaning** tag underneath it. So:
|
||||
- want a solid crate? pick the **wall** tag *before* you stamp → you can't walk through it.
|
||||
- want a walk-over rug? pick **floor** (or **none** to leave walkability alone).
|
||||
|
||||
It's baked into the room and saved in `room.json`. (A *live* object — a movable entity with a name + verbs for items/NPCs — is the next slice; just ask.)
|
||||
|
||||
## One quirk: fat pixels
|
||||
|
||||
Game pixels are twice as wide as tall (classic AGI). A perfect circle in Aseprite shows up slightly wide in game. Ignore it early — it's part of the retro look.
|
||||
|
||||
---
|
||||
|
||||
# Build a world (multiple rooms)
|
||||
|
||||
One room is a screen. A *world* is rooms linked by their edges.
|
||||
|
||||
- The **top strip** shows `room N` with `<` `>` to move between rooms (also keys `-` / `=`). Going `>` past the last room makes a fresh blank one. Switching rooms **auto-saves** the one you're leaving.
|
||||
- Each room saves to its own file: `rooms/room0.json`, `rooms/room1.json`, …
|
||||
- **Exits** (top strip): the `N E S W` buttons. Click one to cycle its target room number (`N:-` → `N:0` → `N:1` … → off). So `E:1` means "walk off the right edge → arrive in room 1".
|
||||
- **Set the entrance** with the **spawn** tool (`9`): click where the robot should appear. The cyan circle shows it.
|
||||
|
||||
## Make two linked rooms
|
||||
|
||||
1. In **room 0**, leave a walkable gap at the **right** edge (don't wall it). Set exit **`E:1`**.
|
||||
2. Press `>` to go to **room 1**, draw it, leave a walkable gap on its **left** edge, set exit **`W:0`**.
|
||||
3. Press `<` back to room 0, press **`Tab`** to play, walk off the right edge → you're in room 1. Walk back left → room 0. You built a world. 🌍
|
||||
|
||||
Tip: if the robot seems stuck after a transition, the edge it arrived at was walled — leave a floor gap there (it otherwise falls back to that room's spawn point).
|
||||
|
||||
---
|
||||
|
||||
# Objects you can talk to (the parser)
|
||||
|
||||
This is where it becomes a *game*. The **object** tool (`0`) places a sprite as a real thing — and in play you can **type at it.**
|
||||
|
||||
## Place an object
|
||||
1. Tool **`0`** (object). Press **`[` `]`** to flip through the **kit** — 60+ ready-made archetypes (orc, console, cocktail, coffin, sword, spellbook…), shown in the panel as `kit [ ]: <name>`. **Click** the canvas to stamp the selected one — fully written (look/use/needs/wins/synonyms all filled in). Cycle to `(blank)` to instead drop a bare object named after the current sprite.
|
||||
2. Click an object to **select** it. The inspector has editable fields — **name**, **look text**, **use text**, **needs item**, **synonyms** — plus **takeable / wins** toggles and **delete**.
|
||||
3. To write its description: click the **look text** field, type your line (a caret shows), press **Enter** to jump to the next field. Click the canvas or press **Esc** to finish typing.
|
||||
4. `K` saves it with the room.
|
||||
|
||||
## Talk to it (in play)
|
||||
Press **`Tab`** to play. There's a **`>` command line** at the bottom — type and hit **Enter**:
|
||||
- `look barrel` → its description
|
||||
- `take key` → into your inventory (if it's takeable)
|
||||
- `inventory` (or `i`) → what you're carrying
|
||||
- `look around` → everything in the room
|
||||
- `help` → the verb list
|
||||
|
||||
Arrows still walk while you type. The parser is forgiving now: it ignores filler words ("the", "a", "at") and knows verb synonyms — `look` = `examine` = `x` = `check` = `inspect`; `take` = `get` = `grab` = `pick`. And the **synonyms** field lets an object answer to other nouns: put `box wood cargo` on a crate and `examine the box` just works.
|
||||
|
||||
## Try it
|
||||
1. Edit: tool `0`, drop a `crate` and a `barrel`, press `K`.
|
||||
2. `Tab` to play → type `look around`, then `look crate`, then `take barrel`, then `inventory`. The game answers you. 🤖💬
|
||||
|
||||
---
|
||||
|
||||
# Make a puzzle (locks, keys, winning)
|
||||
|
||||
Objects can now *do* things — no code, just fields. Each object's inspector has:
|
||||
- **use text** — what `use <it>` / `open <it>` prints.
|
||||
- **needs item** — an item it requires. Empty = no lock. If set, `use` only works when that item is in your inventory; otherwise it says *"It's locked. You need: key."*
|
||||
- **wins** toggle — using it successfully **wins the game** (a YOU WIN banner).
|
||||
|
||||
## A complete 30-second game
|
||||
1. Drop a sprite, select it, set **name** = `key`, **takeable** = on. (That's your key.)
|
||||
2. Drop another, set **name** = `gate`, **use text** = *"The gate groans open. You're free!"*, **needs item** = `key`, **wins** = yes.
|
||||
3. `K` to save, `Tab` to play.
|
||||
4. Type `use gate` → *"It's locked. You need: key."*
|
||||
5. `take key`, then `use gate` → your line prints + **★ YOU WIN ★** 🏆
|
||||
|
||||
`use` also accepts `open`, `unlock`, `push`, `pull`, `turn`. Keys, locks, and a win condition — a real puzzle, authored entirely with fields.
|
||||
|
||||
---
|
||||
|
||||
# Natural language (the local AI lane)
|
||||
|
||||
When the parser doesn't recognise your verb, it quietly asks a **local LLM** to translate your sentence into one *real* command — then runs THAT through the normal game logic. The AI bends the *language*; your game still decides what's *possible*. It can't invent items or outcomes. It runs on a background thread (the game never freezes) and shows what it decided, e.g. `(≈ open crate)`.
|
||||
|
||||
## It's already set up on your Mac
|
||||
Ollama is running with your Gemma models. Because your models have custom names, point the engine at one when you launch:
|
||||
|
||||
```sh
|
||||
./run.sh
|
||||
# (which is just: MRPGI_AI_MODEL="hf.co/OBLITERATUS/gemma-4-E4B-it-OBLITERATED:latest" cargo run )
|
||||
```
|
||||
|
||||
Then in play, with a `gate` / `key` / `crate` around, try:
|
||||
- `pry the lid off the box` → *(≈ open crate)*
|
||||
- `yank the gate open` → *(≈ use gate)*
|
||||
- `grab that shiny thing` → *(≈ take key)*
|
||||
|
||||
The bottom bar shows `ai: ollama/<model>` so you know it's wired.
|
||||
|
||||
## Settings (env vars)
|
||||
| Var | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `MRPGI_AI` | `ollama` | `ollama` · `openrouter` · `off` |
|
||||
| `MRPGI_AI_MODEL` | `gemma3:4b` | model tag — set to your Ollama tag |
|
||||
| `MRPGI_AI_URL` | `http://localhost:11434/api/generate` | endpoint override |
|
||||
| `OPENROUTER_API_KEY` | — | required when `MRPGI_AI=openrouter` |
|
||||
|
||||
**OpenRouter instead of local:**
|
||||
```sh
|
||||
MRPGI_AI=openrouter OPENROUTER_API_KEY=sk-... MRPGI_AI_MODEL=google/gemma-2-9b-it:free cargo run
|
||||
```
|
||||
|
||||
If Ollama isn't running or the model's missing, the AI lane just stays quiet and you get the normal "I don't understand" — nothing breaks.
|
||||
|
||||
---
|
||||
|
||||
# Talk & tunes (NPCs + ambient music)
|
||||
|
||||
## Talk to NPCs
|
||||
An object can carry a **branching dialogue tree**. In play, `talk <name>` (or `speak` / `ask` / `chat` / `greet`) starts a conversation: the NPC speaks, and you pick a reply by pressing its **number key** (`1`–`6`). Choices branch to other lines; some end the chat. **Esc** leaves early. Walking pauses while you talk.
|
||||
|
||||
Try it: in the fantasy world, head **south** from the forest to the inn and type `talk barkeep` — there are a few branches to explore.
|
||||
|
||||
Authoring is JSON for now: a `dialogue` array on the object (see the inn villager in `rooms/room3.json`). Each node has `says` + `choices`; each choice has `text` + `goto` (a node index, or `-1` to end). A visual tree-builder is a future step.
|
||||
|
||||
## Ambient music
|
||||
Each room can loop a **mood**. In the editor, the top strip has a **♪ button** — click it to cycle: off · calm · eerie · tense · jolly · spooky. It saves with the room (`K`) and plays in play mode, switching as you move between rooms. **F2** mutes music + SFX together.
|
||||
515
MANUAL.md
Normal file
@ -0,0 +1,515 @@
|
||||
# MRPGI — The Complete Manual
|
||||
|
||||
**Monster Robot Party Game Interpreter** — a new-school, Apple-Silicon-native
|
||||
reimagining of Sierra's 1980s AGI adventure engine, written in Rust + macroquad.
|
||||
You paint rooms, drop in objects, write puzzles, and walk through them — with a
|
||||
text parser, optional local-AI understanding, branching dialogue, chiptune sound,
|
||||
and ambient music. No external level files, no build step beyond `cargo`.
|
||||
|
||||
This manual is exhaustive. Jump around with the table of contents.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
1. [What MRPGI is](#1-what-mrpgi-is)
|
||||
2. [Install & requirements](#2-install--requirements)
|
||||
3. [Running it](#3-running-it)
|
||||
4. [The two modes](#4-the-two-modes)
|
||||
5. [Core concepts (read this)](#5-core-concepts-read-this)
|
||||
6. [The editor — full reference](#6-the-editor--full-reference)
|
||||
7. [Keyboard cheat sheet](#7-keyboard-cheat-sheet)
|
||||
8. [Building a world (rooms & exits)](#8-building-a-world-rooms--exits)
|
||||
9. [Objects & game logic](#9-objects--game-logic)
|
||||
10. [The parser](#10-the-parser)
|
||||
11. [The AI parser lane](#11-the-ai-parser-lane)
|
||||
12. [Dialogue trees](#12-dialogue-trees)
|
||||
13. [Sound & music](#13-sound--music)
|
||||
14. [The art / sprite pipeline](#14-the-art--sprite-pipeline)
|
||||
15. [The kit & theme packs](#15-the-kit--theme-packs)
|
||||
16. [Room file format (full schema)](#16-room-file-format-full-schema)
|
||||
17. [Project layout & architecture](#17-project-layout--architecture)
|
||||
18. [Troubleshooting](#18-troubleshooting)
|
||||
19. [Roadmap & known gaps](#19-roadmap--known-gaps)
|
||||
|
||||
---
|
||||
|
||||
## 1. What MRPGI is
|
||||
|
||||
AGI was the engine behind *King's Quest*, *Space Quest*, and *Police Quest*. Its
|
||||
genius was a **dual-buffer** model: every room is drawn into a *visual* screen
|
||||
(what you see) **and** a hidden *priority* screen (depth + walkability). MRPGI
|
||||
keeps that soul and modernizes everything around it:
|
||||
|
||||
- **In-engine room editor** — paint walls, floors, and walkability by hand.
|
||||
- **Sprite pipeline** — drop PNGs in a folder; they're auto-quantized to EGA.
|
||||
- **Multi-room worlds** with edge exits.
|
||||
- **Objects** with descriptions, locks, keys, and win conditions — no code.
|
||||
- **A forgiving parser** + an **optional local LLM** that understands plain English.
|
||||
- **Branching dialogue**, **chiptune SFX**, and **ambient room music**.
|
||||
- **A content kit**: 61 click-to-place archetypes across fantasy, sci-fi,
|
||||
monsters, "saucy" nightlife, and classic horror.
|
||||
|
||||
Everything saves to small, human-readable JSON. The whole thing is one native
|
||||
binary built with `cargo`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Install & requirements
|
||||
|
||||
**Required:**
|
||||
- **Rust** (stable, 1.85+). Install from <https://rustup.rs> or Homebrew.
|
||||
- That's it for building — macroquad pulls its own dependencies and renders
|
||||
natively (Metal on macOS). No system libraries to install.
|
||||
|
||||
**Optional (for the AI parser):**
|
||||
- **[Ollama](https://ollama.com)** running locally with a small instruct model
|
||||
(e.g. a Gemma 4B). Free, offline, private. Or an **OpenRouter** API key.
|
||||
|
||||
First build downloads the crate tree and takes a minute or two; subsequent builds
|
||||
are seconds.
|
||||
|
||||
---
|
||||
|
||||
## 3. Running it
|
||||
|
||||
```sh
|
||||
cargo run # build + run (dev)
|
||||
cargo run --release # optimized build (smoother), slower first compile
|
||||
./run.sh # convenience launcher that points the AI lane at your model
|
||||
```
|
||||
|
||||
### Launch flags
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| *(none)* | Loads `rooms/room0.json` and opens in the **editor**. |
|
||||
| `--sample` | Writes a 3-room "escape the party" demo to `rooms/` then runs it (existing rooms backed up to `*.json.bak`). |
|
||||
| `--kit` | Generates the fantasy starter kit + placeholder sprites, and installs a 4-room fantasy world to `rooms/` (existing → `.bak`). |
|
||||
|
||||
```sh
|
||||
cargo run -- --kit # install + play the fantasy starter world
|
||||
cargo run -- --sample # install + play the robot escape demo
|
||||
```
|
||||
|
||||
### AI environment variables
|
||||
| Var | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `MRPGI_AI` | `ollama` | `ollama` · `openrouter` · `off` |
|
||||
| `MRPGI_AI_MODEL` | `gemma3:4b` | the model tag (set to YOUR Ollama tag) |
|
||||
| `MRPGI_AI_URL` | `http://localhost:11434/api/generate` | endpoint override |
|
||||
| `OPENROUTER_API_KEY` | — | required when `MRPGI_AI=openrouter` |
|
||||
|
||||
```sh
|
||||
MRPGI_AI_MODEL="your-gemma-tag" cargo run
|
||||
MRPGI_AI=openrouter OPENROUTER_API_KEY=sk-... MRPGI_AI_MODEL=google/gemma-2-9b-it:free cargo run
|
||||
MRPGI_AI=off cargo run # deterministic parser only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. The two modes
|
||||
|
||||
MRPGI is always in one of two modes; **`Tab`** flips between them:
|
||||
|
||||
- **EDIT** — the painting/authoring mode. Left panel of tools, a canvas, a top
|
||||
strip for world/room controls. The engine boots here.
|
||||
- **PLAY** — you become the robot. Walk with arrows or click, type commands, talk
|
||||
to NPCs. A bottom panel shows the transcript + command line.
|
||||
|
||||
`Esc` quits (in edit it first un-focuses a text field; in play it first ends a
|
||||
conversation).
|
||||
|
||||
---
|
||||
|
||||
## 5. Core concepts (read this)
|
||||
|
||||
### The dual buffer — LOOK vs MEANING
|
||||
Every pixel of a room has **two** independent properties:
|
||||
|
||||
- **LOOK** — its colour (one of 16 EGA colours). What the player sees.
|
||||
- **MEANING** — what it *is* to the engine: walkable floor, a wall, water, a
|
||||
trigger, or a depth band. Stored in a hidden "priority" buffer.
|
||||
|
||||
**Colour does not imply meaning.** A brown floor and a brown wall look identical
|
||||
but behave differently. When you paint, you set a colour *and* (optionally) a
|
||||
meaning tag; they're applied to the same region but stored separately. This is the
|
||||
single most important idea in MRPGI.
|
||||
|
||||
### Meaning tags
|
||||
| Tag | Effect on the priority buffer |
|
||||
|-----|-------------------------------|
|
||||
| **floor** | walkable, with automatic depth (lower on screen = drawn in front) |
|
||||
| **wall** | blocked — the robot can't enter |
|
||||
| **water** | reserved (special) |
|
||||
| **trigger** | reserved (signal line for future logic) |
|
||||
| **none** | paint colour only; leave walkability unchanged |
|
||||
|
||||
By default an empty room is **entirely walkable** — you paint *walls* where you
|
||||
don't want walking.
|
||||
|
||||
### The picture
|
||||
The play area is **160 × 168 logical pixels**, displayed at a **2:1 pixel ratio**
|
||||
(each pixel is twice as wide as tall — classic AGI). Colours are the 16-colour
|
||||
EGA palette (indices 0–15). A perfect circle drawn in art tools looks slightly
|
||||
wide in-game; that's intentional retro flavour.
|
||||
|
||||
### EGA palette
|
||||
`0` black · `1` blue · `2` green · `3` cyan · `4` red · `5` magenta · `6` brown ·
|
||||
`7` light gray · `8` dark gray · `9` light blue · `10` light green ·
|
||||
`11` light cyan · `12` light red · `13` light magenta · `14` yellow · `15` white.
|
||||
|
||||
### The ego
|
||||
"Ego" is the AGI term for the player avatar (the little robot). Its position is
|
||||
its **feet** (bottom-centre of its sprite), which is what determines its depth and
|
||||
collision. In play it's blocked by `wall` pixels and walks off `exit` edges.
|
||||
|
||||
---
|
||||
|
||||
## 6. The editor — full reference
|
||||
|
||||
The editor screen: a **tool panel** on the left, the **canvas** on the right, and
|
||||
a **world strip** across the top of the canvas.
|
||||
|
||||
### Tools (number keys, or click in the panel)
|
||||
| Key | Tool | What it does |
|
||||
|-----|------|--------------|
|
||||
| `1` | **brush** | freehand paint with the current colour + meaning (size via `,` `.`) |
|
||||
| `2` | **line** | click to add points; **right-click or Enter** finishes the polyline |
|
||||
| `3` | **rect** | drag a filled rectangle |
|
||||
| `4` | **fill** | flood-fill a sealed region (the bucket) — fast |
|
||||
| `5` | **pick** | eyedropper; grab a colour off the canvas |
|
||||
| `6` | **erase** | rub back to blank + walkable |
|
||||
| `7` | **image** | stamp a sprite from `sprites/` (`[` `]` to choose, `R` to reload) |
|
||||
| `8` | **oval** | drag a filled ellipse |
|
||||
| `9` | **spawn** | click to set where the robot appears in play (cyan circle) |
|
||||
| `0` | **object** | place a live object — see below |
|
||||
|
||||
### Colour & meaning
|
||||
- **Palette** (left panel): click a swatch to set the current LOOK colour.
|
||||
- **Meaning** buttons: floor / wall / water / trigger / none — the MEANING applied
|
||||
by fills, rects, brush, etc.
|
||||
|
||||
### View modes (`L` cycles)
|
||||
- **look** — the plain picture.
|
||||
- **both** — picture + walls tinted red, water blue, triggers green (so you can
|
||||
*see* what's walkable).
|
||||
- **ctrl** — the raw meaning map (depth + control).
|
||||
|
||||
### The object tool & kit browser (`0`)
|
||||
With the object tool active the panel shows **`kit [ ]: <name>`**. Press `[` `]`
|
||||
to flip through 60+ ready-made archetypes (orc, console, cocktail, coffin…). Click
|
||||
an empty spot to **stamp** the selected archetype, fully written. Cycle to
|
||||
`(blank)` to drop a bare object named after the current sprite. Click an existing
|
||||
object to **select** it; the inspector then shows editable fields:
|
||||
|
||||
- **name** — what the parser calls it.
|
||||
- **look text** — response to `look`.
|
||||
- **use text** — response to `use`/`open`.
|
||||
- **needs item** — an item required to `use` it (empty = unlocked).
|
||||
- **synonyms** — extra nouns it answers to (space-separated).
|
||||
- **takeable / fixed** toggle, **wins** toggle, **delete**.
|
||||
|
||||
Click a field, type, `Enter` jumps to the next field, `Esc` finishes.
|
||||
|
||||
### The world strip (top)
|
||||
- **room N** + `<` `>` — move between rooms (also `-` `=`). Switching auto-saves.
|
||||
- **exits** `N E S W` — click to cycle each edge's target room number.
|
||||
- **♪** — cycle the room's ambient music mood (off · calm · eerie · tense · jolly · spooky).
|
||||
|
||||
### Saving
|
||||
- `K` saves the current room to `rooms/room<N>.json` (an editable list of strokes
|
||||
+ objects + exits + spawn + music). `O` reloads it. `C` clears, `Z`/`Backspace`
|
||||
undoes. The game auto-loads `rooms/room0.json` on startup.
|
||||
|
||||
---
|
||||
|
||||
## 7. Keyboard cheat sheet
|
||||
|
||||
**Global:** `Tab` edit ⇄ play · `Esc` quit (or un-focus / end conversation) ·
|
||||
`F1` CRT scanlines · `F2` mute sound + music.
|
||||
|
||||
**Editor:** `1`–`0` tools · `L` view · `[` `]` choose sprite / kit archetype ·
|
||||
`,` `.` brush size · `-` `=` prev / next room · `Z`/`Backspace` undo · `C` clear ·
|
||||
`K` save · `O` load · `R` reload sprites + kit.
|
||||
|
||||
**Play:** arrows / click to walk · type a command + `Enter` · `1`–`6` choose a
|
||||
dialogue reply · `Esc` leave a conversation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Building a world (rooms & exits)
|
||||
|
||||
A room is one screen. A world is rooms linked by their edges.
|
||||
|
||||
1. Paint a room. Leave a **walkable gap** in a wall where you want a doorway.
|
||||
2. On the strip, set that edge's **exit** to a room number (e.g. `E: 1`).
|
||||
3. Press `>` to go to that room (going past the last room makes a new blank one),
|
||||
paint it, and set a matching exit back.
|
||||
4. In play, **walk off the edge with an exit** → you arrive in the target room,
|
||||
entering from the opposite side.
|
||||
|
||||
Rooms are files: `rooms/room0.json`, `room1.json`, … Room 0 is where the game
|
||||
starts. The **spawn** tool sets each room's entry point (used if the edge you
|
||||
arrive at is walled).
|
||||
|
||||
---
|
||||
|
||||
## 9. Objects & game logic
|
||||
|
||||
Objects are sprites you can interact with. Each carries:
|
||||
|
||||
- a **name** + **synonyms** (what the parser matches),
|
||||
- **look text** (shown on `look`),
|
||||
- **use text** (shown on `use`/`open`),
|
||||
- **needs** — an item that must be in your inventory to `use` it,
|
||||
- **wins** — using it successfully ends the game with a victory banner,
|
||||
- **takeable** — whether `take` adds it to your inventory.
|
||||
|
||||
This is enough for the classic adventure loop **with no code**:
|
||||
|
||||
> Put a `key` (takeable) in one room and a `gate` in another with **needs = key**,
|
||||
> **use text** = *"The gate grinds open…"*, **wins = on**. Now: `take key` →
|
||||
> travel → `use gate` → **win**.
|
||||
|
||||
Taking an object removes it from the room (runtime only — it returns on a fresh
|
||||
play). Objects do not block movement (walls come from the painted meaning layer).
|
||||
|
||||
---
|
||||
|
||||
## 10. The parser
|
||||
|
||||
Type a sentence; it's lower-cased, filler words are dropped, the first word maps to
|
||||
a canonical verb, and the rest is the noun (matched against object names +
|
||||
synonyms, loosely).
|
||||
|
||||
**Verbs (and their synonyms):**
|
||||
- **look** — look, l, examine, x, inspect, check, read, view, see, study
|
||||
- **take** — take, get, grab, pick, snatch, steal, pocket, collect, nab
|
||||
- **use** — use, open, unlock, activate, push, pull, turn, operate
|
||||
- **drop** — drop, discard, leave, put
|
||||
- **talk** — talk, speak, ask, chat, greet, say
|
||||
- **inventory** — inventory, inv, i, items, bag
|
||||
- **help** — help, ?, commands, verbs
|
||||
|
||||
**Ignored filler:** the, a, an, at, to, with, on, of, my, your, some, please, go, up.
|
||||
|
||||
`look` with no noun (or `look around`) lists what's in the room. Unknown verbs fall
|
||||
through to the AI lane (if enabled) or print "I don't understand".
|
||||
|
||||
---
|
||||
|
||||
## 11. The AI parser lane
|
||||
|
||||
When the deterministic parser can't recognise your verb, MRPGI can hand the raw
|
||||
sentence to a small LLM, which translates it into **one allowed command** — then
|
||||
re-runs *that* through the normal logic. The AI bends the *language*; your game
|
||||
still decides what's *possible*. It never invents items or outcomes, and it runs on
|
||||
a background thread so the game never stalls. The chosen command is shown as
|
||||
`(≈ open crate)`.
|
||||
|
||||
**Setup (local, recommended):** run Ollama with an instruct model and point MRPGI
|
||||
at it via `MRPGI_AI_MODEL` (see §3). Default provider is `ollama`. If Ollama isn't
|
||||
running or the model is missing, the lane stays silent and you get the normal
|
||||
deterministic behaviour — nothing breaks.
|
||||
|
||||
**OpenRouter:** set `MRPGI_AI=openrouter`, `OPENROUTER_API_KEY`, and a
|
||||
`MRPGI_AI_MODEL` like `google/gemma-2-9b-it:free`.
|
||||
|
||||
The bottom bar shows `ai: ollama/<model>` (or `off`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Dialogue trees
|
||||
|
||||
An object can carry a **branching conversation**. In play, `talk <name>` starts it:
|
||||
the NPC speaks and you press a **number key (1–6)** to choose a reply. Choices jump
|
||||
to other lines; some end the chat. `Esc` leaves. Walking pauses while you talk.
|
||||
|
||||
Dialogue is authored as a `dialogue` array on the object (a visual builder is
|
||||
planned). Each entry is a **node**; node 0 is the entry. See the inn barkeep in
|
||||
`rooms/room3.json` for a working example:
|
||||
|
||||
```json
|
||||
"dialogue": [
|
||||
{ "says": "You're for the cave, aren't you?",
|
||||
"choices": [
|
||||
{ "text": "What's down there?", "goto": 1 },
|
||||
{ "text": "Just passing through.", "goto": 3 }
|
||||
] },
|
||||
{ "says": "Orcs. Dark. A spellbook nobody should read aloud.",
|
||||
"choices": [ { "text": "Thanks.", "goto": -1 } ] }
|
||||
]
|
||||
```
|
||||
|
||||
A choice's **`goto`** is the next node index, or **`-1`** to end. A node with no
|
||||
choices is a terminal line (shown, then the conversation ends).
|
||||
|
||||
---
|
||||
|
||||
## 13. Sound & music
|
||||
|
||||
All audio is **synthesized at runtime** (square waves → PCM, no files), in the
|
||||
spirit of AGI's PCjr beeper.
|
||||
|
||||
**SFX (play mode):** a blip when you submit a command, a ding on `take`, a chime on
|
||||
a successful `use`/`look`, a buzz when nothing matches, a whoosh on room change, a
|
||||
4-note fanfare on **win**.
|
||||
|
||||
**Ambient music:** each room can loop one of five moods (calm / eerie / tense /
|
||||
jolly / spooky), set with the **♪** strip button in the editor and saved per room.
|
||||
It plays in play mode and switches as you move between rooms.
|
||||
|
||||
**`F2`** mutes music and SFX together. If a machine has no audio backend, everything
|
||||
degrades to silence.
|
||||
|
||||
---
|
||||
|
||||
## 14. The art / sprite pipeline
|
||||
|
||||
Sprites live in `sprites/` as PNGs. On load, any image is **quantized to the EGA
|
||||
palette** (with transparency from its alpha), so it depth-sorts and walks-behind
|
||||
like native art. Replace any sprite and press **`R`** to reload.
|
||||
|
||||
**The sprite spec:**
|
||||
- **PNG with transparency.** Transparent pixels are see-through.
|
||||
- **Small, at game scale.** A prop is ~12–28 px tall; draw 1:1.
|
||||
- **Filename = id.** lowercase, no spaces (`orc.png`). The object's `sprite` field
|
||||
references this name. A missing sprite shows as a magenta placeholder.
|
||||
- **Bottom-centre = feet.** Stand the thing on the bottom edge of the canvas.
|
||||
- **EGA colours** ideal but not required — load `palettes/mrpgi-ega.gpl` into
|
||||
Aseprite/Inkscape, or let the engine snap your colours on load.
|
||||
|
||||
**Aseprite:** Palette → Load Palette → `palettes/mrpgi-ega.gpl`; draw small;
|
||||
export PNG to `sprites/`; press `R`.
|
||||
|
||||
**Inkscape:** set a tiny px page; Export PNG (transparent) to `sprites/`.
|
||||
|
||||
**AI (Gemini "Nano Banana" / Imagen):** each theme's `gemini_sprites.md` has a
|
||||
shared STYLE line + per-sprite prompts. Generate on a solid magenta background, key
|
||||
it out to transparency, save into `sprites/`. (See also `palettes/mrpgi-ega.gpl`
|
||||
and `palettes/mrpgi-ega.hex`.)
|
||||
|
||||
---
|
||||
|
||||
## 15. The kit & theme packs
|
||||
|
||||
`cargo run -- --kit` generates the **fantasy starter kit** and installs a playable
|
||||
4-room world (forest → cave → dungeon → inn, with a key/spellbook/altar quest).
|
||||
|
||||
The reusable library lives in `kit/`:
|
||||
- **`kit/objects.json`** — 13 fantasy object archetypes.
|
||||
- **`kit/locations/`** — prefab rooms (forest, cave, dungeon, inn).
|
||||
- **`kit/ART_MANIFESTO.md`** — exact sprite specs to replace the placeholders.
|
||||
- **`kit/themes/{scifi,monsters,saucy,horror}/`** — each with `objects.json`
|
||||
(12 archetypes), a sample `locations/*.json`, and `gemini_sprites.md`.
|
||||
|
||||
**Use an object:** the kit browser (object tool, `[` `]`) stamps any of these by
|
||||
clicking. Or copy a `{ … }` block from any `objects.json` into a room's `objects`
|
||||
array. **Use a location:** `cp kit/themes/horror/locations/crypt.json rooms/room5.json`,
|
||||
then wire its exits in the editor.
|
||||
|
||||
---
|
||||
|
||||
## 16. Room file format (full schema)
|
||||
|
||||
A room is a `RoomDoc` serialized as JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"strokes": [ <Stroke>, ... ],
|
||||
"spawn": [x, y],
|
||||
"exits": [N, E, S, W], // each: a room number or null
|
||||
"objects": [ <ObjDef>, ... ],
|
||||
"music": 0 // 0=silence, 1..5 = mood
|
||||
}
|
||||
```
|
||||
|
||||
**Stroke** is an externally-tagged enum — exactly one key:
|
||||
```json
|
||||
{ "Rect": { "x":0,"y":0,"w":160,"h":168,"color":2,"meaning":"Floor" } }
|
||||
{ "Line": { "pts":[[x,y],...], "color":15 } }
|
||||
{ "Brush": { "pts":[[x,y],...], "color":6,"meaning":"Wall","size":2 } }
|
||||
{ "Ellipse": { "x":..,"y":..,"w":..,"h":..,"color":..,"meaning":"None" } }
|
||||
{ "Fill": { "x":..,"y":..,"color":..,"meaning":"Floor" } }
|
||||
{ "Image": { "name":"crate","x":..,"y":..,"meaning":"Wall" } }
|
||||
```
|
||||
`meaning` is one of `"None"`, `"Floor"`, `"Wall"`, `"Water"`, `"Trigger"`.
|
||||
`color` is an EGA index 0–15.
|
||||
|
||||
**ObjDef:**
|
||||
```json
|
||||
{
|
||||
"name": "gate", "sprite": "door", "x": 80, "y": 120,
|
||||
"look": "A heavy gate.", "takeable": false,
|
||||
"synonyms": "door portcullis", "use_text": "It grinds open!",
|
||||
"needs": "key", "wins": true,
|
||||
"dialogue": [ <DlgNode>, ... ]
|
||||
}
|
||||
```
|
||||
**DlgNode:** `{ "says": "...", "choices": [ { "text": "...", "goto": 1 } ] }` —
|
||||
`goto` is a node index or `-1` to end.
|
||||
|
||||
All fields except `name`/`sprite`/`x`/`y` default if omitted, so hand-written JSON
|
||||
can be minimal. Strokes are *replayed* into the buffers on load, so rooms stay tiny
|
||||
and re-editable.
|
||||
|
||||
---
|
||||
|
||||
## 17. Project layout & architecture
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs window, game loop, modes, parser, dialogue runtime, compositing
|
||||
palette.rs the 16-colour EGA palette (index → RGBA)
|
||||
framebuffer.rs visual + priority buffers, the band(y) depth table
|
||||
picture.rs drawing primitives (lines, rects, flood-fill, ellipse, stamps)
|
||||
view.rs sprites as cels; ASCII-grid + image cel builders
|
||||
sprite.rs the ego + props: movement, collision, walk-cycle
|
||||
assets.rs PNG load + EGA quantize/dither; placeholder sprite generation
|
||||
editor.rs the whole editor: tools, panel, RoomDoc, objects, kit, save/load
|
||||
ai.rs the AI parser lane (Ollama / OpenRouter, on a worker thread)
|
||||
sound.rs chiptune SFX + ambient music (square-wave WAV synthesis)
|
||||
room.rs legacy demo-room helper
|
||||
kit/ the archetype/theme library + art manifesto
|
||||
sprites/ sprite PNGs (placeholders + your art)
|
||||
rooms/ your world (room0.json …)
|
||||
palettes/ the EGA palette for Aseprite/Inkscape (.gpl, .hex)
|
||||
```
|
||||
|
||||
**Rendering:** rooms rasterize into a 160×168 visual buffer + a priority buffer.
|
||||
Each frame, the ego and props are drawn back-to-front by depth; a sprite pixel is
|
||||
only drawn where `sprite_priority ≥ scenery_priority` (the walk-behind trick). The
|
||||
result is uploaded to a GPU texture and scaled crisp to the window. A fixed-step
|
||||
"cycle" (20/s) drives movement, decoupled from frame rate.
|
||||
|
||||
---
|
||||
|
||||
## 18. Troubleshooting
|
||||
|
||||
- **Objects are little magenta squares** → that sprite PNG doesn't exist yet in
|
||||
`sprites/`. Make it (see §14) or it's just a placeholder; the object still works.
|
||||
- **A fill flooded the whole screen** → your outline has a gap; the region wasn't
|
||||
sealed. `Z` to undo and close the gap.
|
||||
- **Robot stuck after a room transition** → the edge you arrived at is walled;
|
||||
leave a walkable gap there (it otherwise falls back to the room's spawn).
|
||||
- **AI does nothing** → check the bottom bar `ai:` readout; ensure Ollama is
|
||||
running and `MRPGI_AI_MODEL` matches a pulled model (`ollama list`). It only
|
||||
fires on *unknown verbs*.
|
||||
- **No sound** → some machines lack an audio backend; MRPGI stays silent rather
|
||||
than crashing. Check `F2` isn't muted.
|
||||
- **Typing a letter flipped a mode** → in play, letters go to the command line;
|
||||
use `Tab`/`F1`/`F2`/`Esc` (non-letters) for controls.
|
||||
|
||||
---
|
||||
|
||||
## 19. Roadmap & known gaps
|
||||
|
||||
- In-editor **dialogue-tree builder** (dialogue is JSON-authored for now).
|
||||
- The **kit-browser villager** has no built-in dialogue yet (the live inn one does).
|
||||
- More theme packs; **title screen / world picker**; **save/load a playthrough**.
|
||||
- Water/trigger control lines are reserved but not yet wired to behaviour.
|
||||
- True-colour ("HD") background layer and polygon fill regions.
|
||||
|
||||
---
|
||||
|
||||
*MRPGI — built from "let's reimagine AGI" into a complete, AI-assisted, audible
|
||||
adventure-game maker. Have fun making weird little worlds.* 🤖🎸
|
||||
78
README.md
Normal file
@ -0,0 +1,78 @@
|
||||
# MRPGI — Monster Robot Party Game Interpreter
|
||||
|
||||
A **new-school, Apple-Silicon-native reimagining of Sierra's 1980s AGI engine** —
|
||||
the tech behind *King's Quest*, *Space Quest*, and *Police Quest* — rebuilt in
|
||||
Rust + [macroquad](https://macroquad.rs) so you can make cool, fun adventure games.
|
||||
|
||||
It keeps the *soul* of AGI (vector rooms, the invisible priority/depth screen,
|
||||
loops-and-cels sprites, a verb-noun parser, flags & vars, the ego) and bridges
|
||||
it forward (Retina-crisp rendering, hand-editable text resources, a real
|
||||
embedded scripting language, and an optional AI parser).
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
cargo run # dev build (fast to recompile)
|
||||
cargo run --release # smooth, optimized build
|
||||
```
|
||||
|
||||
A window opens with the demo room. **Walk the robot with the arrow keys or by
|
||||
clicking** — and stroll *behind the column* to watch the priority system place
|
||||
you correctly in depth. `N` toggles CRT scanlines. `Esc` quits.
|
||||
|
||||
## How it works (the AGI model, faithfully)
|
||||
|
||||
Every room is drawn into **two buffers at once**:
|
||||
|
||||
| Buffer | What it holds | Why |
|
||||
|--------|---------------|-----|
|
||||
| **visual** | palette indices (0–15, EGA) | what you see |
|
||||
| **priority** | depth bands (4–15) + control lines (0–3) | depth sorting & collision |
|
||||
|
||||
Lower on screen ⇒ higher priority ⇒ drawn nearer the camera. The ego is drawn a
|
||||
pixel at a time, *only where its priority ≥ the scenery's* — that's the whole
|
||||
"walk behind the tree" trick, with zero per-object Z bookkeeping. Control lines
|
||||
(priority `0`) are walls the ego can't cross.
|
||||
|
||||
## Source map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `palette.rs` | the 16-color EGA palette (indices → RGBA) |
|
||||
| `framebuffer.rs` | the visual + priority buffers and the `band(y)` depth table |
|
||||
| `picture.rs` | vector room painting (`PicOp` rectangles / lines / pixels) |
|
||||
| `view.rs` | sprites as loops + cels, authored from tiny ASCII grids |
|
||||
| `sprite.rs` | the ego: movement, click-to-walk, collision, walk animation |
|
||||
| `room.rs` | the hand-authored demo room |
|
||||
| `main.rs` | window, game loop, fixed-step cycles, compositing, HUD |
|
||||
|
||||
## Asset pipeline (the new-school bit)
|
||||
|
||||
Images don't sit *on top* of the game — they're folded *into* it. Any PNG is
|
||||
quantized to the EGA palette (optional Bayer dithering for smooth retro
|
||||
gradients) and becomes indexed pixels, so from that moment it depth-sorts, gets
|
||||
occluded, and walks-behind exactly like hand-authored art. Three ways in:
|
||||
|
||||
- **Tile** an image across a region (textures — e.g. the floor)
|
||||
- **Pour** an image into a shape via `stamp_cel_fit` with a fit mode
|
||||
(stretch / tile / center) — *the shape also stamps depth & collision.*
|
||||
This is the "draw a shape, load an image into it" idea, realized.
|
||||
- **Objects** — a PNG becomes a `Prop` that sorts with the ego
|
||||
|
||||
Drop your own art into `assets/`, then press **R** in-game to hot-reload it.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] **v0.1 — rendering core**: vector room, priority depth, walkable ego, click-to-walk, CRT toggle
|
||||
- [x] **v0.2 — asset pipeline**: PNG → EGA quantize + dither, tile / pour-into-shape / object insertion, hot-reload (`R`)
|
||||
- [x] **v0.3 — room editor**: in-engine paint tool with edit/play modes, dual-layer LOOK+MEANING painting, flood-fill, tools (brush/line/rect/fill/pick/erase), undo, and JSON room save/load — see [EDITOR_GUIDE.md](EDITOR_GUIDE.md)
|
||||
- [ ] **Priority-mask backgrounds**: a full-room art PNG + a companion mask PNG whose colors encode depth bands & control lines (the pro art workflow)
|
||||
- [ ] **True-color "HD" layer mode**: skip quantization for painted backdrops; the priority buffer stays indexed
|
||||
- [ ] **Polygon shapes** for `stamp_cel_fit` (not just rectangles)
|
||||
- [ ] **Directional views** (4 loops + horizontal mirroring) for the robot
|
||||
- [ ] **Text parser**: vocabulary with synonyms → `said("open","door")` matching, plus an on-screen input line
|
||||
- [ ] **Rhai room logic**: rooms scripted in an AGI-flavored embedded language (`if said(...) { print(...) }`), hot-reloadable
|
||||
- [ ] **Room transitions** (`new_room`), inventory, flags & vars
|
||||
- [ ] **AI parser adapter**: when the classic parse misses, an LLM maps free text onto an *already-allowed* room command (deterministic core stays authoritative; works offline without it)
|
||||
- [ ] **3-voice + noise chiptune synth**
|
||||
- [ ] **Loadable game folders** (text/JSON resources) so games ship without recompiling the engine
|
||||
BIN
assets/crate.png
Normal file
|
After Width: | Height: | Size: 670 B |
BIN
assets/floor.png
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
assets/poster.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
53
kit/ART_MANIFESTO.md
Normal file
@ -0,0 +1,53 @@
|
||||
# MRPGI starter-kit art manifesto
|
||||
|
||||
The kit ships **crude placeholder sprites** so everything's visible immediately.
|
||||
Replace any of them with real art and the game upgrades instantly — **same
|
||||
filename in `sprites/`, then press `R` in the editor.** This doc is the spec for
|
||||
making the real ones.
|
||||
|
||||
## The sprite spec (always)
|
||||
- **PNG with transparency.** Transparent pixels = see-through in game.
|
||||
- **Small, at game scale.** The play area is 160×168 *fat* pixels. A prop is ~12–28 px; draw 1:1.
|
||||
- **EGA 16 colours.** Load `palettes/mrpgi-ega.gpl` into Aseprite/Inkscape. Off-palette art is auto-snapped to nearest EGA on load (so it still works, just less crisp).
|
||||
- **Filename = id.** lowercase, no spaces: `orc.png`, `spellbook.png`. The name is what the game and objects reference.
|
||||
- **Bottom-centre is the "feet."** The sprite is drawn standing on the click point, so put the base of the thing at the bottom of the canvas.
|
||||
- **Fat pixels:** game pixels are 2× wide. A circle drawn round looks slightly wide in game (classic AGI). Ignore it, or pre-squash horizontally if you care.
|
||||
|
||||
## The kit sprites (replace these)
|
||||
|
||||
| file | size (w×h) | what | key EGA colours |
|
||||
|------|-----------|------|-----------------|
|
||||
| `tree` | 18×26 | leafy tree, trunk at bottom | green 2/10, brown 6 |
|
||||
| `rock` | 16×12 | mossy boulder | gray 7/8 |
|
||||
| `orc` | 14×22 | green brute, red eyes | green 2/10, red 12 |
|
||||
| `sword` | 10×26 | upright blade + hilt | light gray 7, brown 6, yellow 14 |
|
||||
| `chest` | 20×15 | banded wooden chest | brown 6, dark gray 8, yellow 14 |
|
||||
| `torch` | 8×24 | stick + flame | brown 6, red 12, yellow 14 |
|
||||
| `potion` | 10×18 | vial, red liquid, cork | cyan 11 glass, magenta 13, brown 6 |
|
||||
| `door` | 16×26 | banded wooden door + knob | brown 6, dark brown, yellow 14 |
|
||||
| `villager` | 12×22 | robed person, skin head | blue 1/9, skin (light red 12) |
|
||||
| `spellbook` | 16×12 | purple tome + rune | magenta 5/13, white 15, yellow 14 |
|
||||
| `key` | 16×8 | iron key, bow + teeth | yellow 14 |
|
||||
| `altar` | 20×16 | stone block + glow | gray 7/8, cyan 11 |
|
||||
| `crate` `barrel` `sign` | — | already generated; same idea | brown/gray |
|
||||
|
||||
(Want more? Same recipe: `skeleton`, `goblin`, `coin`/`gold`, `shield`, `bottle`,
|
||||
`mushroom`, `lantern`, `gravestone`, `bookshelf`, `bed`, `well`, `bridge`.)
|
||||
|
||||
## How to make them
|
||||
|
||||
**Aseprite (best):**
|
||||
1. Palette ☰ → *Load Palette* → `palettes/mrpgi-ega.gpl`. (Optional: *Sprite → Color Mode → Indexed* = pixel-perfect.)
|
||||
2. New sprite at the size above, transparent bg. Draw the thing standing on the bottom edge.
|
||||
3. Export `.png` into `sprites/` with the matching name. `R` in the editor to reload.
|
||||
|
||||
**Inkscape:** draw it, set a tiny px page (Document Properties), Export → PNG (transparent) into `sprites/`.
|
||||
|
||||
**AI rough-start (Gemini/Imagen — then clean up in Aseprite):**
|
||||
> `16-colour EGA pixel-art sprite of a {THING}, about {W}×{H} pixels, transparent background, single object centred and standing on the bottom edge, bold readable silhouette, flat colours, retro 1980s Sierra adventure style, NO text, NO drop shadow, NO background.`
|
||||
>
|
||||
> e.g. THING = "a green orc with red eyes holding a dagger", W×H = 14×22.
|
||||
|
||||
Image models are sloppy at tiny exact pixel art, so treat their output as a base
|
||||
and tidy it in Aseprite against the EGA palette. For *backgrounds* (whole rooms),
|
||||
you can also just paint them with the editor's own tools — no files needed.
|
||||
39
kit/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# MRPGI fantasy starter kit
|
||||
|
||||
Drop-in building blocks for fantasy games. Regenerate any time with:
|
||||
|
||||
```sh
|
||||
cargo run -- --kit
|
||||
```
|
||||
|
||||
That writes placeholder sprites into `sprites/`, this kit into `kit/`, **and**
|
||||
installs a playable 4-room fantasy world into `rooms/` (your existing rooms are
|
||||
backed up to `*.json.bak` first).
|
||||
|
||||
## What's here
|
||||
- **`objects.json`** — a catalog of object archetypes (sword, spellbook, orc, key, chest, door, altar, potion, torch, villager, tree, rock, sign). Each is a ready-made object with name / look text / use text / `needs` / `wins` / synonyms already written.
|
||||
- **`locations/*.json`** — prefab rooms: `forest`, `cave`, `dungeon`, `inn`. Each is a complete, valid room (painted scenery + objects + spawn + exits).
|
||||
- **`ART_MANIFESTO.md`** — exact specs to replace the crude placeholder sprites with real art.
|
||||
|
||||
## How to drop a location into your game
|
||||
Rooms live at `rooms/room0.json`, `room1.json`, … (room 0 is where you start).
|
||||
|
||||
```sh
|
||||
cp kit/locations/forest.json rooms/room4.json
|
||||
```
|
||||
Then launch, press `>` in the top strip to walk to room 4, and edit away. (Re-wire
|
||||
its **exits** on the top strip so it connects to your other rooms.)
|
||||
|
||||
## How to drop an object into a room
|
||||
Open `kit/objects.json`, copy one object block, and paste it into the `objects:
|
||||
[ … ]` array of any `rooms/roomN.json` (adjust its `x`/`y`). Or just place a
|
||||
sprite with the object tool (`0`) in the editor and copy the look/use/needs text
|
||||
from the catalog into its fields.
|
||||
|
||||
## The built-in fantasy quest (what `--kit` installs)
|
||||
0. **Forest** — grab the **sword**. Paths: cave (east), inn (south).
|
||||
1. **Cave** — an **orc**, a **chest** (needs a key), and the **spellbook** on a ledge.
|
||||
2. **Dungeon** (north of cave) — an **altar**. `use altar` with the spellbook → **win**.
|
||||
3. **Inn** (south of forest) — the **villager**, a **potion**, and the **key** (for the chest).
|
||||
|
||||
Solution: forest → inn (get key) → forest → cave (get spellbook; key opens the chest for gold) → dungeon → `use altar`. Win. Try natural language too — `pry the chest open`, `read the spellbook`, `bonk the orc`.
|
||||
164
kit/locations/cave.json
Normal file
@ -0,0 +1,164 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 8,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 160,
|
||||
"h": 8,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 70,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 100,
|
||||
"w": 6,
|
||||
"h": 68,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 40,
|
||||
"y": 60,
|
||||
"w": 10,
|
||||
"h": 6,
|
||||
"color": 7,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 110,
|
||||
"y": 80,
|
||||
"w": 12,
|
||||
"h": 6,
|
||||
"color": 7,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
80,
|
||||
130
|
||||
],
|
||||
"exits": [
|
||||
2,
|
||||
null,
|
||||
null,
|
||||
0
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "orc",
|
||||
"sprite": "orc",
|
||||
"x": 92,
|
||||
"y": 118,
|
||||
"look": "A green orc picking its teeth with a dagger. It hasn't noticed you. Yet.",
|
||||
"takeable": false,
|
||||
"synonyms": "monster brute goblin",
|
||||
"use_text": "You poke the orc. This was a mistake.",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "spellbook",
|
||||
"sprite": "spellbook",
|
||||
"x": 122,
|
||||
"y": 132,
|
||||
"look": "A spellbook on a ledge, runes squirming. The orc is guarding it (badly).",
|
||||
"takeable": true,
|
||||
"synonyms": "book tome grimoire",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "torch",
|
||||
"sprite": "torch",
|
||||
"x": 36,
|
||||
"y": 110,
|
||||
"look": "A guttering wall-torch. Grab it — it's dark deeper in.",
|
||||
"takeable": true,
|
||||
"synonyms": "light flame",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "chest",
|
||||
"sprite": "chest",
|
||||
"x": 58,
|
||||
"y": 140,
|
||||
"look": "A banded chest, half-buried. Locked.",
|
||||
"takeable": false,
|
||||
"synonyms": "box trunk",
|
||||
"use_text": "The key turns; old gold spills out across the cave floor.",
|
||||
"needs": "key",
|
||||
"wins": false
|
||||
}
|
||||
]
|
||||
}
|
||||
120
kit/locations/dungeon.json
Normal file
@ -0,0 +1,120 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 8,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 16,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 58,
|
||||
"y": 30,
|
||||
"w": 44,
|
||||
"h": 10,
|
||||
"color": 13,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
80,
|
||||
138
|
||||
],
|
||||
"exits": [
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
null
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "altar",
|
||||
"sprite": "altar",
|
||||
"x": 80,
|
||||
"y": 98,
|
||||
"look": "A stone altar humming with cold light. A book-shaped hollow waits in its surface.",
|
||||
"takeable": false,
|
||||
"synonyms": "shrine pedestal",
|
||||
"use_text": "You lay the spellbook on the altar. Light pours up, a doorway tears open, and you step through. YOU ESCAPED — adventure complete. THE END.",
|
||||
"needs": "spellbook",
|
||||
"wins": true
|
||||
},
|
||||
{
|
||||
"name": "torch",
|
||||
"sprite": "torch",
|
||||
"x": 30,
|
||||
"y": 120,
|
||||
"look": "A torch in a rusted bracket.",
|
||||
"takeable": true,
|
||||
"synonyms": "light flame",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
}
|
||||
]
|
||||
}
|
||||
164
kit/locations/forest.json
Normal file
@ -0,0 +1,164 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 2,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 10,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 16,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 70,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 100,
|
||||
"w": 6,
|
||||
"h": 68,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 30,
|
||||
"y": 120,
|
||||
"w": 18,
|
||||
"h": 8,
|
||||
"color": 2,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 100,
|
||||
"y": 60,
|
||||
"w": 22,
|
||||
"h": 8,
|
||||
"color": 2,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
24,
|
||||
130
|
||||
],
|
||||
"exits": [
|
||||
null,
|
||||
1,
|
||||
3,
|
||||
null
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "tree",
|
||||
"sprite": "tree",
|
||||
"x": 44,
|
||||
"y": 70,
|
||||
"look": "An ancient tree, bark like old leather.",
|
||||
"takeable": false,
|
||||
"synonyms": "oak trunk wood",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "sword",
|
||||
"sprite": "sword",
|
||||
"x": 80,
|
||||
"y": 132,
|
||||
"look": "A notched iron sword someone left in the grass. Finders keepers.",
|
||||
"takeable": true,
|
||||
"synonyms": "blade weapon",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "rock",
|
||||
"sprite": "rock",
|
||||
"x": 120,
|
||||
"y": 134,
|
||||
"look": "A mossy boulder, smug and immovable.",
|
||||
"takeable": false,
|
||||
"synonyms": "boulder stone",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "sign",
|
||||
"sprite": "sign",
|
||||
"x": 80,
|
||||
"y": 44,
|
||||
"look": "A signpost: CAVE east, INN south.",
|
||||
"takeable": false,
|
||||
"synonyms": "notice post",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
}
|
||||
]
|
||||
}
|
||||
154
kit/locations/inn.json
Normal file
@ -0,0 +1,154 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 7,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 160,
|
||||
"h": 8,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 40,
|
||||
"y": 122,
|
||||
"w": 80,
|
||||
"h": 28,
|
||||
"color": 4,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 20,
|
||||
"y": 24,
|
||||
"w": 8,
|
||||
"h": 8,
|
||||
"color": 14,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
80,
|
||||
138
|
||||
],
|
||||
"exits": [
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "villager",
|
||||
"sprite": "villager",
|
||||
"x": 60,
|
||||
"y": 112,
|
||||
"look": "A nervous barkeep. 'Heading to the cave? Take the key — and my prayers.'",
|
||||
"takeable": false,
|
||||
"synonyms": "barkeep person npc",
|
||||
"use_text": "'Be careful in there,' the barkeep says, sliding you nothing useful.",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"sprite": "key",
|
||||
"x": 44,
|
||||
"y": 140,
|
||||
"look": "An iron key hanging by the door. The barkeep nods at you to take it.",
|
||||
"takeable": true,
|
||||
"synonyms": "ironkey",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "potion",
|
||||
"sprite": "potion",
|
||||
"x": 112,
|
||||
"y": 132,
|
||||
"look": "A complimentary vial of bubbling red liquid. Probably fine.",
|
||||
"takeable": true,
|
||||
"synonyms": "vial elixir flask",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "barrel",
|
||||
"sprite": "barrel",
|
||||
"x": 122,
|
||||
"y": 120,
|
||||
"look": "A barrel of inn ale. Smells like courage and mistakes.",
|
||||
"takeable": false,
|
||||
"synonyms": "ale keg",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
}
|
||||
]
|
||||
}
|
||||
158
kit/objects.json
Normal file
@ -0,0 +1,158 @@
|
||||
[
|
||||
{
|
||||
"name": "sword",
|
||||
"sprite": "sword",
|
||||
"x": 80,
|
||||
"y": 130,
|
||||
"look": "A notched iron sword. It has Opinions about orcs.",
|
||||
"takeable": true,
|
||||
"synonyms": "blade weapon",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "spellbook",
|
||||
"sprite": "spellbook",
|
||||
"x": 80,
|
||||
"y": 130,
|
||||
"look": "A spellbook bound in something best not identified. The runes won't hold still.",
|
||||
"takeable": true,
|
||||
"synonyms": "book tome grimoire",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"sprite": "key",
|
||||
"x": 80,
|
||||
"y": 130,
|
||||
"look": "A heavy iron key, cold and important-looking.",
|
||||
"takeable": true,
|
||||
"synonyms": "ironkey",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "potion",
|
||||
"sprite": "potion",
|
||||
"x": 80,
|
||||
"y": 130,
|
||||
"look": "A vial of bubbling red liquid. Almost certainly fine.",
|
||||
"takeable": true,
|
||||
"synonyms": "vial elixir flask",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "torch",
|
||||
"sprite": "torch",
|
||||
"x": 80,
|
||||
"y": 130,
|
||||
"look": "A guttering torch. It pushes the dark back a little.",
|
||||
"takeable": true,
|
||||
"synonyms": "light flame",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "orc",
|
||||
"sprite": "orc",
|
||||
"x": 80,
|
||||
"y": 120,
|
||||
"look": "A green orc, picking its teeth with a dagger. It hasn't noticed you. Yet.",
|
||||
"takeable": false,
|
||||
"synonyms": "monster brute goblin",
|
||||
"use_text": "You poke the orc. It looks up. In hindsight, a mistake.",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "villager",
|
||||
"sprite": "villager",
|
||||
"x": 80,
|
||||
"y": 120,
|
||||
"look": "A nervous villager. 'You're... not from the cave, are you?'",
|
||||
"takeable": false,
|
||||
"synonyms": "person man woman npc",
|
||||
"use_text": "'Sorry, nothing for you,' the villager mumbles.",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "chest",
|
||||
"sprite": "chest",
|
||||
"x": 80,
|
||||
"y": 135,
|
||||
"look": "A banded wooden chest. Locked.",
|
||||
"takeable": false,
|
||||
"synonyms": "box trunk",
|
||||
"use_text": "The key turns; the lid yawns open and old gold spills out.",
|
||||
"needs": "key",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "door",
|
||||
"sprite": "door",
|
||||
"x": 80,
|
||||
"y": 120,
|
||||
"look": "A stout wooden door, banded with iron.",
|
||||
"takeable": false,
|
||||
"synonyms": "gate",
|
||||
"use_text": "The lock clunks and the door swings wide.",
|
||||
"needs": "key",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "altar",
|
||||
"sprite": "altar",
|
||||
"x": 80,
|
||||
"y": 100,
|
||||
"look": "A stone altar humming with cold light. Something is meant to rest here.",
|
||||
"takeable": false,
|
||||
"synonyms": "shrine pedestal",
|
||||
"use_text": "You lay the spellbook on the altar. A doorway tears open in the air. THE END.",
|
||||
"needs": "spellbook",
|
||||
"wins": true
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"sprite": "tree",
|
||||
"x": 60,
|
||||
"y": 80,
|
||||
"look": "An ancient tree, bark like old leather.",
|
||||
"takeable": false,
|
||||
"synonyms": "oak trunk wood",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "rock",
|
||||
"sprite": "rock",
|
||||
"x": 120,
|
||||
"y": 130,
|
||||
"look": "A mossy boulder. Immovable, and a little smug about it.",
|
||||
"takeable": false,
|
||||
"synonyms": "boulder stone",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "sign",
|
||||
"sprite": "sign",
|
||||
"x": 80,
|
||||
"y": 50,
|
||||
"look": "A weathered signpost, paint long faded.",
|
||||
"takeable": false,
|
||||
"synonyms": "notice post",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
}
|
||||
]
|
||||
43
kit/themes/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# MRPGI theme packs
|
||||
|
||||
Drop-in archetypes across four genres. Each folder has:
|
||||
- **`objects.json`** — a catalog of ready-made objects (name / look / use text / `needs` / `wins` / synonyms all written). Copy any into a room.
|
||||
- **`locations/*.json`** — a sample room you can use as-is or as a template.
|
||||
- **`gemini_sprites.md`** — Nano Banana prompts to make the art when you have time.
|
||||
|
||||
| theme | vibe | object highlights |
|
||||
|-------|------|-------------------|
|
||||
| `scifi/` | derelict-ship sci-fi | console, blaster, alien, robot, keycard, airlock (win) |
|
||||
| `monsters/` | a bestiary | goblin, slime, troll, ghost, dragon, mimic, skeleton |
|
||||
| `saucy/` | flirty nightlife comedy | rose, wine, cocktail, jukebox, letter, velvet door (win) |
|
||||
| `horror/` | gothic candlelit dread | candle, skull, coffin, ouija, portrait, crypt door (win) |
|
||||
|
||||
## Use an object
|
||||
Open a theme's `objects.json`, copy one `{ … }` block, and paste it into the
|
||||
`objects: [ … ]` array of any `rooms/roomN.json` (tweak its `x`/`y`). Or place a
|
||||
sprite with the object tool (`0`) in the editor and copy the look/use/needs text
|
||||
into its fields.
|
||||
|
||||
## Use a location
|
||||
```sh
|
||||
cp kit/themes/horror/locations/crypt.json rooms/room5.json
|
||||
```
|
||||
Launch, press `>` to walk to room 5, then re-wire its **exits** on the top strip
|
||||
to connect it to your world. (Sample locations ship sealed — you add the doorways.)
|
||||
|
||||
## Make the art (later)
|
||||
Each `gemini_sprites.md` has a shared STYLE line + a per-sprite table. The flow:
|
||||
1. Generate on a **solid magenta background** (image models don't do transparency well).
|
||||
2. Key the magenta to transparent in any editor (magic-wand / "remove background").
|
||||
3. Save as `sprites/<filename>.png` — the name must match the object's `sprite` field.
|
||||
4. Press `R` in the editor. Done.
|
||||
|
||||
Until then, objects whose sprite doesn't exist show as a small **magenta square**
|
||||
placeholder — totally fine for blocking out a game. Many sprites already exist
|
||||
from the fantasy kit (`orc`, `chest`, `door`, `key`, `torch`, `potion`, …) and are
|
||||
reused across themes.
|
||||
|
||||
## Note on the EGA palette
|
||||
Don't stress matching colours exactly — the engine snaps every loaded image to the
|
||||
16-colour EGA palette automatically. Load `palettes/mrpgi-ega.gpl` in Aseprite if
|
||||
you want to paint in the exact colours from the start.
|
||||
25
kit/themes/horror/gemini_sprites.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Classic horror sprites — Gemini Nano Banana prompts
|
||||
|
||||
Gothic, candlelit, a little funny. Generate each on a **solid flat magenta
|
||||
(#FF00FF) background**, key out the magenta, drop into `sprites/`, press `R`.
|
||||
Colours snap to EGA on load.
|
||||
|
||||
**STYLE (prepend to every prompt):**
|
||||
> 16-colour EGA pixel-art sprite, single object centred on the bottom edge, bold readable silhouette, flat hard-edged colours, desaturated gothic palette — greys, bone-white, sickly green and deep violet, with a single warm candle-amber accent — retro 1980s Sierra/AGI horror look, eerie but a touch comedic, on a solid flat magenta (#FF00FF) background, NO text, NO drop shadow, NO scene.
|
||||
|
||||
| filename | size | subject |
|
||||
|----------|------|---------|
|
||||
| `candle` | 8×20 | a black candle with a small amber flame and dripping wax |
|
||||
| `skull` | 14×12 | a yellowed human skull, grinning, hollow eye sockets |
|
||||
| `coffin` | 22×14 | a stone/wood coffin, lid slightly ajar |
|
||||
| `gravestone` | 16×18 | a leaning weathered headstone with a scratched-out name |
|
||||
| `cobweb` | 16×16 | a corner cobweb, thick lace, one waiting spider |
|
||||
| `portrait` | 16×20 | an old framed portrait of a pale figure whose eyes seem to follow |
|
||||
| `clock` | 14×26 | a tall grandfather clock stopped at 3:07, pendulum still |
|
||||
| `doll` | 12×18 | a porcelain doll in a faded dress, cracked face, blank stare |
|
||||
| `diary` | 14×12 | a small locked leather diary with an iron clasp |
|
||||
| `ouija` | 18×14 | a wooden ouija board with a heart-shaped planchette |
|
||||
|
||||
Reuses: `mirror` (from the saucy pack — a foxed antique mirror works fine), `crypt door`→`door`, and `key`.
|
||||
|
||||
**Flat template:** `<STYLE> Subject: <subject>.`
|
||||
21
kit/themes/horror/locations/crypt.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"strokes": [
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 168, "color": 0, "meaning": "None" } },
|
||||
{ "Rect": { "x": 6, "y": 16, "w": 148, "h": 146, "color": 8, "meaning": "Floor" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 16, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 160, "w": 160, "h": 8, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 6, "h": 168, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 154, "y": 0, "w": 6, "h": 168, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 24, "y": 60, "w": 12, "h": 18, "color": 8, "meaning": "None" } },
|
||||
{ "Rect": { "x": 126, "y": 60, "w": 12, "h": 18, "color": 8, "meaning": "None" } },
|
||||
{ "Rect": { "x": 70, "y": 40, "w": 20, "h": 6, "color": 5, "meaning": "None" } }
|
||||
],
|
||||
"spawn": [80, 150],
|
||||
"exits": [null, null, null, null],
|
||||
"objects": [
|
||||
{ "name": "coffin", "sprite": "coffin", "x": 70, "y": 120, "look": "A stone coffin in the centre of the crypt, lid ajar. You did not leave it ajar.", "takeable": false, "synonyms": "casket tomb", "use_text": "You lift the lid. Empty — but a skull rolls out and stops, facing you.", "needs": "", "wins": false },
|
||||
{ "name": "candle", "sprite": "candle", "x": 40, "y": 130, "look": "A black candle, burning with a flame that gives heat but somehow no comfort.", "takeable": true, "synonyms": "light flame", "use_text": "You take the candle. The crypt's shadows shuffle a polite step back.", "needs": "", "wins": false },
|
||||
{ "name": "skull", "sprite": "skull", "x": 110, "y": 134, "look": "A yellowed skull on a plinth, grinning like it knows the way out.", "takeable": true, "synonyms": "head bone", "use_text": "You take the skull. It feels expectant, like it's been waiting to be useful.", "needs": "", "wins": false },
|
||||
{ "name": "crypt door", "sprite": "door", "x": 80, "y": 116, "look": "The crypt door, sealed, with a skull-shaped recess at eye height.", "takeable": false, "synonyms": "door gate stone exit", "use_text": "You set the skull into the recess. Stone grinds, the door opens, grey dawn pours in. You escape. THE END.", "needs": "skull", "wins": true }
|
||||
]
|
||||
}
|
||||
14
kit/themes/horror/objects.json
Normal file
@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "name": "candle", "sprite": "candle", "x": 40, "y": 120, "look": "A guttering candle. The flame leans away from something you can't see.", "takeable": true, "synonyms": "light flame taper", "use_text": "You shield the candle. The dark leans back in, patient.", "needs": "", "wins": false },
|
||||
{ "name": "skull", "sprite": "skull", "x": 110, "y": 130, "look": "A yellowed skull. It grins, because that's the only expression it has left.", "takeable": true, "synonyms": "head bone cranium", "use_text": "You hold the skull up. 'Alas,' you begin. It does not laugh. It cannot.", "needs": "", "wins": false },
|
||||
{ "name": "coffin", "sprite": "coffin", "x": 70, "y": 118, "look": "A coffin with the lid slightly... ajar. You did not leave it ajar.", "takeable": false, "synonyms": "casket box tomb", "use_text": "You ease the coffin open. It's empty. Somehow that's worse.", "needs": "", "wins": false },
|
||||
{ "name": "gravestone", "sprite": "gravestone", "x": 30, "y": 124, "look": "A leaning gravestone. The name has been scratched out. Recently. By a fingernail.", "takeable": false, "synonyms": "headstone grave tomb marker", "use_text": "You trace the scratched-out name. It's almost yours. Almost.", "needs": "", "wins": false },
|
||||
{ "name": "cobweb", "sprite": "cobweb", "x": 130, "y": 40, "look": "A cobweb thick as lace. Something built this with patience and a plan.", "takeable": false, "synonyms": "web spiderweb silk", "use_text": "You brush the cobweb aside. It re-knits behind you. You decide not to notice.", "needs": "", "wins": false },
|
||||
{ "name": "portrait", "sprite": "portrait", "x": 80, "y": 50, "look": "An old portrait. The eyes follow you. You test this twice. Twice is one time too many.", "takeable": false, "synonyms": "painting picture frame", "use_text": "You turn the portrait to the wall. You hear it turn back.", "needs": "", "wins": false },
|
||||
{ "name": "clock", "sprite": "clock", "x": 120, "y": 118, "look": "A grandfather clock stopped at 3:07. It ticks once, just for you.", "takeable": false, "synonyms": "grandfather time pendulum", "use_text": "You wind the clock. Somewhere upstairs, something else wakes up.", "needs": "", "wins": false },
|
||||
{ "name": "mirror", "sprite": "mirror", "x": 20, "y": 90, "look": "A foxed mirror. Your reflection is a half-second late, and a little too pleased.", "takeable": false, "synonyms": "looking glass reflection", "use_text": "You wave. The reflection finishes waving after you stop.", "needs": "", "wins": false },
|
||||
{ "name": "doll", "sprite": "doll", "x": 60, "y": 132, "look": "A porcelain doll. It was facing the wall. It is not, now.", "takeable": true, "synonyms": "toy figure puppet", "use_text": "You turn the doll to face the wall again. It feels like a truce. A temporary one.", "needs": "", "wins": false },
|
||||
{ "name": "diary", "sprite": "diary", "x": 90, "y": 130, "look": "A locked diary, the leather warm as skin. It wants to be read. That's the problem.", "takeable": false, "synonyms": "journal book log", "use_text": "The little key fits. The diary falls open to a page that simply reads, in fresh ink: RUN.", "needs": "key", "wins": false },
|
||||
{ "name": "ouija", "sprite": "ouija", "x": 100, "y": 134, "look": "An ouija board. The planchette already rests on GOODBYE, and you haven't asked anything.", "takeable": false, "synonyms": "board spirit planchette", "use_text": "You touch the planchette. It drags to your name and spells it. Correctly.", "needs": "", "wins": false },
|
||||
{ "name": "crypt door", "sprite": "door", "x": 80, "y": 116, "look": "A stone crypt door with a skull-shaped recess. The way out, if there is one, is through it.", "takeable": false, "synonyms": "door gate stone exit", "use_text": "You set the skull into the recess. Stone grinds on stone, the door yawns wide, and grey dawn light spills in. You stumble out, alive. THE END.", "needs": "skull", "wins": true }
|
||||
]
|
||||
26
kit/themes/monsters/gemini_sprites.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Monster sprites — Gemini Nano Banana prompts
|
||||
|
||||
Generate each on a **solid flat magenta (#FF00FF) background**, key the magenta to
|
||||
transparent, drop into `sprites/` with the filename shown, press `R`. (Or draw in
|
||||
Aseprite — cleaner for tiny pixels.) Colours snap to EGA on load.
|
||||
|
||||
**STYLE (prepend to every prompt):**
|
||||
> 16-colour EGA pixel-art creature sprite, single monster centred and standing on the bottom edge, bold readable silhouette, flat hard-edged colours, vivid saturated creature palette, retro 1980s Sierra/AGI look, slightly comedic, facing the viewer, on a solid flat magenta (#FF00FF) background, NO text, NO drop shadow, NO scene.
|
||||
|
||||
| filename | size | subject |
|
||||
|----------|------|---------|
|
||||
| `goblin` | 14×20 | a small hunched green goblin with big ears and a crooked grin |
|
||||
| `slime` | 16×12 | a glossy green blob with two dot eyes, a boot half-dissolved inside |
|
||||
| `bat` | 16×10 | a leathery purple bat, wings spread, fangs |
|
||||
| `spider` | 18×14 | a fat dinner-plate spider, eight legs, cluster of red eyes |
|
||||
| `skeleton` | 14×22 | a grinning white skeleton, loose-jointed, casual pose |
|
||||
| `zombie` | 14×22 | a shambling green-grey zombie, one arm out, torn clothes |
|
||||
| `troll` | 18×24 | a big lumpy blue-grey troll, tiny head, huge fists |
|
||||
| `ghost` | 14×20 | a translucent pale ghost with a trailing wispy tail, hollow eyes |
|
||||
| `dragon` | 20×18 | a small fat red dragon, tiny wings, smug, guarding one spoon |
|
||||
| `eyeball` | 12×12 | a single floating bloodshot eyeball, veiny, alert |
|
||||
| `wolf` | 18×14 | a lean grey wolf in profile, ears flat, yellow eyes |
|
||||
|
||||
Reuses: `mimic`→`chest`. (And `orc` already exists from the fantasy kit.)
|
||||
|
||||
**Flat template:** `<STYLE> Subject: <subject>.`
|
||||
21
kit/themes/monsters/locations/den.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"strokes": [
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 168, "color": 0, "meaning": "None" } },
|
||||
{ "Rect": { "x": 6, "y": 16, "w": 148, "h": 146, "color": 8, "meaning": "Floor" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 16, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 160, "w": 160, "h": 8, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 6, "h": 168, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 154, "y": 0, "w": 6, "h": 168, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 30, "y": 70, "w": 12, "h": 5, "color": 7, "meaning": "None" } },
|
||||
{ "Rect": { "x": 120, "y": 90, "w": 14, "h": 5, "color": 7, "meaning": "None" } },
|
||||
{ "Rect": { "x": 70, "y": 140, "w": 16, "h": 4, "color": 15, "meaning": "None" } }
|
||||
],
|
||||
"spawn": [80, 138],
|
||||
"exits": [null, null, null, null],
|
||||
"objects": [
|
||||
{ "name": "goblin", "sprite": "goblin", "x": 60, "y": 120, "look": "A goblin guarding the den entrance, sharpening a spoon into a worse spoon.", "takeable": false, "synonyms": "gobbo imp", "use_text": "The goblin bares its teeth, which are also mostly spoons.", "needs": "", "wins": false },
|
||||
{ "name": "slime", "sprite": "slime", "x": 110, "y": 130, "look": "A slime oozing across old bones. It has all the time in the world.", "takeable": false, "synonyms": "ooze blob", "use_text": "You poke the slime. It eats the stick you didn't know you were holding.", "needs": "", "wins": false },
|
||||
{ "name": "skeleton", "sprite": "skeleton", "x": 90, "y": 124, "look": "A skeleton lounging on a throne of femurs. It points lazily at a chest.", "takeable": false, "synonyms": "bones undead", "use_text": "The skeleton gestures grandly at the chest. Too grandly.", "needs": "", "wins": false },
|
||||
{ "name": "chest", "sprite": "chest", "x": 120, "y": 140, "look": "A treasure chest in a monster den. Suspiciously well-kept. It has teeth marks. Inside.", "takeable": false, "synonyms": "box mimic trap treasure", "use_text": "You open the chest. It opens back. The skeleton applauds.", "needs": "", "wins": false }
|
||||
]
|
||||
}
|
||||
14
kit/themes/monsters/objects.json
Normal file
@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "name": "goblin", "sprite": "goblin", "x": 70, "y": 122, "look": "A goblin counting a fistful of stolen teeth. Yours look appealing to it.", "takeable": false, "synonyms": "gobbo imp creature", "use_text": "You shoo the goblin. It hisses and pockets a tooth that definitely wasn't there a second ago.", "needs": "", "wins": false },
|
||||
{ "name": "slime", "sprite": "slime", "x": 90, "y": 130, "look": "A quivering green slime. It has eaten a boot. The boot looks resigned.", "takeable": false, "synonyms": "ooze blob jelly", "use_text": "You prod the slime. It jiggles, deeply insulted.", "needs": "", "wins": false },
|
||||
{ "name": "bat", "sprite": "bat", "x": 50, "y": 50, "look": "A leathery bat hangs in the dark, judging your life choices upside-down.", "takeable": false, "synonyms": "flyer wing", "use_text": "You wave at the bat. It screeches a review of your performance.", "needs": "", "wins": false },
|
||||
{ "name": "spider", "sprite": "spider", "x": 120, "y": 120, "look": "A spider the size of a dinner plate. It waves three legs. Friendly? Unclear.", "takeable": false, "synonyms": "arachnid web crawler", "use_text": "You and the spider make eye contact. All eight rounds of it.", "needs": "", "wins": false },
|
||||
{ "name": "skeleton", "sprite": "skeleton", "x": 60, "y": 124, "look": "A skeleton, politely reassembling itself after an undignified fall.", "takeable": false, "synonyms": "bones undead skelly", "use_text": "You greet the skeleton. It rattles what is almost certainly a laugh.", "needs": "", "wins": false },
|
||||
{ "name": "zombie", "sprite": "zombie", "x": 110, "y": 126, "look": "A zombie shuffling a slow, committed circle. You have to respect the dedication.", "takeable": false, "synonyms": "undead walker corpse", "use_text": "You step aside. The zombie continues its circle, unbothered, eternal.", "needs": "", "wins": false },
|
||||
{ "name": "troll", "sprite": "troll", "x": 80, "y": 116, "look": "A troll under the bridge, doing a crossword. 'Riddle me later,' it grunts.", "takeable": false, "synonyms": "ogre brute giant", "use_text": "You offer the troll a four-letter word for 'doom'. It nods, grateful.", "needs": "", "wins": false },
|
||||
{ "name": "ghost", "sprite": "ghost", "x": 40, "y": 110, "look": "A translucent ghost caught mid-moan. It pauses, embarrassed you noticed.", "takeable": false, "synonyms": "spirit phantom wraith spook", "use_text": "You walk through the ghost. You both apologise.", "needs": "", "wins": false },
|
||||
{ "name": "dragon", "sprite": "dragon", "x": 90, "y": 120, "look": "A small dragon, fiercely guarding a hoard of one (1) very nice spoon.", "takeable": false, "synonyms": "wyrm drake lizard", "use_text": "You brandish the sword. The dragon sighs theatrically and surrenders the spoon.", "needs": "sword", "wins": false },
|
||||
{ "name": "eyeball", "sprite": "eyeball", "x": 80, "y": 60, "look": "A floating eyeball. It blinks. You blink. It wins.", "takeable": false, "synonyms": "eye watcher orb", "use_text": "You stare back. The eyeball waters first, but does not look away.", "needs": "", "wins": false },
|
||||
{ "name": "wolf", "sprite": "wolf", "x": 55, "y": 128, "look": "A grey wolf, ears flat, deciding if you're friend, food, or furniture.", "takeable": false, "synonyms": "dog beast hound", "use_text": "You hold out a hand. The wolf sniffs it and recategorises you as 'furniture'.", "needs": "", "wins": false },
|
||||
{ "name": "mimic", "sprite": "chest", "x": 120, "y": 135, "look": "A perfectly ordinary chest. Definitely. It has teeth marks. On the inside.", "takeable": false, "synonyms": "chest box trap treasure", "use_text": "You open the 'chest'. It opens back, wider, with intent. You reconsider every choice that led here.", "needs": "", "wins": false }
|
||||
]
|
||||
26
kit/themes/saucy/gemini_sprites.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Saucy / nightlife sprites — Gemini Nano Banana prompts
|
||||
|
||||
Flirty cocktail-lounge comedy vibe — keep it cheeky, not explicit. Generate each
|
||||
on a **solid flat magenta (#FF00FF) background**, key out the magenta, drop into
|
||||
`sprites/`, press `R`. Colours snap to EGA on load.
|
||||
|
||||
**STYLE (prepend to every prompt):**
|
||||
> 16-colour EGA pixel-art sprite, single object centred on the bottom edge, bold readable silhouette, flat hard-edged colours, warm romantic palette — reds, hot pinks, gold and candle-amber — retro 1980s Sierra/AGI look, classy lounge mood, on a solid flat magenta (#FF00FF) background, NO text, NO drop shadow, NO scene.
|
||||
|
||||
| filename | size | subject |
|
||||
|----------|------|---------|
|
||||
| `rose` | 10×18 | a single red long-stemmed rose |
|
||||
| `wine` | 10×20 | a green champagne bottle, gold foil top |
|
||||
| `perfume` | 10×14 | an ornate perfume bottle with a bulb atomizer |
|
||||
| `letter` | 16×12 | a folded love letter sealed with a red heart |
|
||||
| `couch` | 28×16 | a plush red velvet two-seater couch, gold trim |
|
||||
| `lamp` | 12×22 | a curvy mood lamp glowing warm amber |
|
||||
| `locket` | 10×12 | a gold heart-shaped locket on a chain |
|
||||
| `cocktail` | 10×16 | a martini glass with a pink drink and a paper umbrella |
|
||||
| `jukebox` | 18×26 | a glowing chrome-and-neon jukebox, sunset colours |
|
||||
| `mirror` | 14×24 | a tall gilded oval mirror in an ornate gold frame |
|
||||
| `bouncer` | 16×26 | a huge friendly bouncer in a black suit, arms crossed |
|
||||
|
||||
Reuses: `velvet door`→`door`.
|
||||
|
||||
**Flat template:** `<STYLE> Subject: <subject>.`
|
||||
21
kit/themes/saucy/locations/lounge.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"strokes": [
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 168, "color": 5, "meaning": "None" } },
|
||||
{ "Rect": { "x": 6, "y": 16, "w": 148, "h": 146, "color": 4, "meaning": "Floor" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 16, "color": 5, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 160, "w": 160, "h": 8, "color": 5, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 6, "h": 168, "color": 5, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 154, "y": 0, "w": 6, "h": 168, "color": 5, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 30, "y": 130, "w": 100, "h": 30, "color": 12, "meaning": "None" } },
|
||||
{ "Rect": { "x": 20, "y": 22, "w": 8, "h": 8, "color": 14, "meaning": "None" } },
|
||||
{ "Rect": { "x": 132, "y": 22, "w": 8, "h": 8, "color": 14, "meaning": "None" } }
|
||||
],
|
||||
"spawn": [80, 150],
|
||||
"exits": [null, null, null, null],
|
||||
"objects": [
|
||||
{ "name": "bouncer", "sprite": "bouncer", "x": 80, "y": 116, "look": "The bouncer guards a velvet-roped staircase, manicure immaculate, heart of gold.", "takeable": false, "synonyms": "guard doorman", "use_text": "'List or letter, sugar,' the bouncer rumbles, almost gently.", "needs": "", "wins": false },
|
||||
{ "name": "letter", "sprite": "letter", "x": 40, "y": 140, "look": "A love letter, abandoned on the bar: 'meet me upstairs ;)'. Finders, flirters.", "takeable": true, "synonyms": "note card invite", "use_text": "You pocket the letter and try out a confident eyebrow.", "needs": "", "wins": false },
|
||||
{ "name": "jukebox", "sprite": "jukebox", "x": 120, "y": 130, "look": "A jukebox glowing like a sunset, loaded entirely with slow numbers.", "takeable": false, "synonyms": "music machine", "use_text": "The jukebox starts something smoky. The whole lounge leans in.", "needs": "", "wins": false },
|
||||
{ "name": "velvet door", "sprite": "door", "x": 112, "y": 118, "look": "The MEMBERS ONLY door at the top of the stairs. The real party is up there.", "takeable": false, "synonyms": "door rope vip upstairs", "use_text": "You show the bouncer the letter. The rope drops. 'Have fun, sugar.' THE END.", "needs": "letter", "wins": true }
|
||||
]
|
||||
}
|
||||
14
kit/themes/saucy/objects.json
Normal file
@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "name": "rose", "sprite": "rose", "x": 60, "y": 130, "look": "A single red rose, thornless. Someone planned ahead.", "takeable": true, "synonyms": "flower bloom", "use_text": "You offer the rose to the room in general. The room blushes.", "needs": "", "wins": false },
|
||||
{ "name": "wine", "sprite": "wine", "x": 110, "y": 132, "look": "A bottle of something bubbly and overconfident.", "takeable": true, "synonyms": "bottle champagne bubbly drink", "use_text": "You pop the cork. It arcs across the room and somehow lands in a glass.", "needs": "", "wins": false },
|
||||
{ "name": "perfume", "sprite": "perfume", "x": 40, "y": 128, "look": "A perfume bottle labelled 'Regret, No.5'. Dangerously, undeniably good.", "takeable": true, "synonyms": "scent cologne spray", "use_text": "One spritz. You are now 14% more mysterious and 3% more flammable.", "needs": "", "wins": false },
|
||||
{ "name": "letter", "sprite": "letter", "x": 80, "y": 60, "look": "A love letter in looping handwriting. It just says: 'meet me upstairs ;)'.", "takeable": true, "synonyms": "note card love invite", "use_text": "You read the letter again. The winky face is doing a lot of work.", "needs": "", "wins": false },
|
||||
{ "name": "couch", "sprite": "couch", "x": 50, "y": 120, "look": "A red velvet couch that has clearly heard things. It keeps them to itself.", "takeable": false, "synonyms": "sofa seat settee", "use_text": "You flop onto the couch. It exhales, supportively, like an old confidant.", "needs": "", "wins": false },
|
||||
{ "name": "lamp", "sprite": "lamp", "x": 25, "y": 110, "look": "A lamp dialed to 'intriguing'. The whole room looks 12% more attractive.", "takeable": false, "synonyms": "light mood lava", "use_text": "You nudge the dial to 'scandalous'. The shadows get ideas.", "needs": "", "wins": false },
|
||||
{ "name": "locket", "sprite": "locket", "x": 90, "y": 132, "look": "A heart locket. Inside: a tiny portrait of someone winking.", "takeable": true, "synonyms": "necklace heart pendant", "use_text": "You open the locket. The little portrait blows you a kiss and shuts itself.", "needs": "", "wins": false },
|
||||
{ "name": "cocktail", "sprite": "cocktail", "x": 120, "y": 128, "look": "A cocktail with a paper umbrella and big ambitions.", "takeable": true, "synonyms": "drink martini glass", "use_text": "You sip the cocktail. It tastes like a bad decision you'd happily repeat.", "needs": "", "wins": false },
|
||||
{ "name": "jukebox", "sprite": "jukebox", "x": 130, "y": 116, "look": "A jukebox glowing like a sunset. It only plays mood music.", "takeable": false, "synonyms": "music machine records", "use_text": "The jukebox drops a slow number. The lighting takes the hint immediately.", "needs": "", "wins": false },
|
||||
{ "name": "mirror", "sprite": "mirror", "x": 20, "y": 90, "look": "A gilded mirror. You look, frankly, like a catch.", "takeable": false, "synonyms": "looking glass reflection", "use_text": "You wink at the mirror. It winks back a half-second early, showing off.", "needs": "", "wins": false },
|
||||
{ "name": "bouncer", "sprite": "bouncer", "x": 80, "y": 118, "look": "A bouncer the size of a vending machine, manicure immaculate, heart of gold.", "takeable": false, "synonyms": "guard doorman security", "use_text": "'List or letter, sugar,' the bouncer says, not unkindly.", "needs": "", "wins": false },
|
||||
{ "name": "velvet door", "sprite": "door", "x": 110, "y": 120, "look": "A velvet-roped door marked MEMBERS ONLY. The good party is clearly behind it.", "takeable": false, "synonyms": "door rope vip upstairs", "use_text": "You flash the letter. The bouncer reads it, raises an eyebrow, and drops the rope. 'Have fun, sugar.'", "needs": "letter", "wins": true }
|
||||
]
|
||||
26
kit/themes/scifi/gemini_sprites.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Sci-fi sprites — Gemini Nano Banana prompts
|
||||
|
||||
How to use: generate each on a **solid flat magenta (#FF00FF) background**, save the
|
||||
PNG, then key the magenta to transparent (magic-wand / "remove background" in any
|
||||
editor), and drop it into `sprites/` with the filename shown. Press `R` in the
|
||||
editor. (Or just hand-draw in Aseprite — image models are rough at tiny pixels.)
|
||||
The engine snaps colours to EGA on load, so don't sweat exact palette.
|
||||
|
||||
**STYLE (prepend to every prompt):**
|
||||
> 16-colour EGA pixel-art sprite, single object centred and standing on the bottom edge, bold readable silhouette, flat hard-edged colours, retro 1980s Sierra/AGI look, cool steel greys with cyan and teal neon accents, on a solid flat magenta (#FF00FF) background, NO text, NO drop shadow, NO scene — just the object.
|
||||
|
||||
| filename | size | subject |
|
||||
|----------|------|---------|
|
||||
| `blaster` | 16×10 | a chunky retro plasma pistol, glowing cyan vent |
|
||||
| `console` | 26×22 | a slanted control console, blinking cyan keys and a small screen |
|
||||
| `terminal` | 20×24 | a wall computer terminal, green-on-black screen |
|
||||
| `alien` | 16×24 | a translucent teal alien with several glowing eyes, slender |
|
||||
| `robot` | 18×24 | a boxy service robot on treads, single round eye, antenna |
|
||||
| `datapad` | 12×16 | a cracked handheld tablet, glowing screen |
|
||||
| `medkit` | 16×12 | a white case with a cyan cross |
|
||||
| `cell` | 10×16 | a glowing cyan energy cell / canister, humming |
|
||||
| `vending` | 20×26 | a tall sci-fi vending machine, lit panel |
|
||||
|
||||
Reuses existing placeholders: `keycard`→`key`, `airlock`→`door`, plus `crate`/`barrel`.
|
||||
|
||||
**Flat template to paste:** `<STYLE> Subject: <subject from the table>.`
|
||||
24
kit/themes/scifi/locations/bridge.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"strokes": [
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 168, "color": 1, "meaning": "None" } },
|
||||
{ "Rect": { "x": 6, "y": 16, "w": 148, "h": 146, "color": 8, "meaning": "Floor" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 160, "h": 16, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 160, "w": 160, "h": 8, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 0, "y": 0, "w": 6, "h": 168, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 154, "y": 0, "w": 6, "h": 168, "color": 0, "meaning": "Wall" } },
|
||||
{ "Rect": { "x": 36, "y": 22, "w": 88, "h": 14, "color": 9, "meaning": "None" } },
|
||||
{ "Rect": { "x": 20, "y": 44, "w": 40, "h": 8, "color": 11, "meaning": "None" } },
|
||||
{ "Rect": { "x": 100, "y": 44, "w": 40, "h": 8, "color": 11, "meaning": "None" } },
|
||||
{ "Rect": { "x": 50, "y": 26, "w": 1, "h": 1, "color": 15, "meaning": "None" } },
|
||||
{ "Rect": { "x": 78, "y": 30, "w": 1, "h": 1, "color": 15, "meaning": "None" } },
|
||||
{ "Rect": { "x": 104, "y": 25, "w": 1, "h": 1, "color": 15, "meaning": "None" } }
|
||||
],
|
||||
"spawn": [80, 140],
|
||||
"exits": [null, null, null, null],
|
||||
"objects": [
|
||||
{ "name": "viewscreen", "sprite": "terminal", "x": 80, "y": 40, "look": "The main viewscreen: a long, patient field of stars and one blinking red dot getting closer.", "takeable": false, "synonyms": "screen window stars", "use_text": "", "needs": "", "wins": false },
|
||||
{ "name": "console", "sprite": "console", "x": 36, "y": 100, "look": "The helm console. Most of it is asking you to confirm something you'd rather not.", "takeable": false, "synonyms": "helm computer controls", "use_text": "You badge in and the deck unlocks with a chorus of clunks.", "needs": "keycard", "wins": false },
|
||||
{ "name": "alien", "sprite": "alien", "x": 110, "y": 118, "look": "An alien sits in the captain's chair, sipping something through a straw it definitely brought itself.", "takeable": false, "synonyms": "creature xeno captain", "use_text": "The alien gestures at the empty co-pilot seat. An offer? A threat? Both?", "needs": "", "wins": false },
|
||||
{ "name": "datapad", "sprite": "datapad", "x": 60, "y": 134, "look": "A datapad on the floor, screen cracked, log still scrolling: ...DO NOT OPEN THE CARGO BAY...", "takeable": true, "synonyms": "pad tablet log", "use_text": "", "needs": "", "wins": false }
|
||||
]
|
||||
}
|
||||
14
kit/themes/scifi/objects.json
Normal file
@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "name": "keycard", "sprite": "key", "x": 60, "y": 130, "look": "A scuffed access keycard. Clearance level: questionable.", "takeable": true, "synonyms": "card pass swipe", "use_text": "", "needs": "", "wins": false },
|
||||
{ "name": "blaster", "sprite": "blaster", "x": 80, "y": 132, "look": "A chunky plasma blaster. The safety switch may be decorative.", "takeable": true, "synonyms": "gun pistol laser weapon", "use_text": "", "needs": "", "wins": false },
|
||||
{ "name": "console", "sprite": "console", "x": 40, "y": 80, "look": "A flickering control console, half its keys worn blank.", "takeable": false, "synonyms": "computer panel controls", "use_text": "You badge in. The console blinks awake and locks clunk open across the deck.", "needs": "keycard", "wins": false },
|
||||
{ "name": "terminal", "sprite": "terminal", "x": 120, "y": 90, "look": "A wall terminal. The cursor blinks like it is waiting for an apology.", "takeable": false, "synonyms": "screen monitor display", "use_text": "The terminal scrolls a crew manifest. Most names are crossed out.", "needs": "", "wins": false },
|
||||
{ "name": "alien", "sprite": "alien", "x": 95, "y": 120, "look": "A translucent alien, regarding you with slightly too many eyes. It clicks, politely.", "takeable": false, "synonyms": "creature xeno being thing", "use_text": "You attempt diplomacy. The alien clicks louder and tilts its head.", "needs": "", "wins": false },
|
||||
{ "name": "robot", "sprite": "robot", "x": 60, "y": 120, "look": "A battered service robot, looping a cheerful greeting in a dead language.", "takeable": false, "synonyms": "bot android droid unit", "use_text": "You tap the robot. It salutes the wrong direction and resumes looping.", "needs": "", "wins": false },
|
||||
{ "name": "datapad", "sprite": "datapad", "x": 110, "y": 132, "look": "A cracked datapad still scrolling a captain's last, increasingly worried, log entry.", "takeable": true, "synonyms": "tablet pad log device", "use_text": "", "needs": "", "wins": false },
|
||||
{ "name": "medkit", "sprite": "medkit", "x": 30, "y": 130, "look": "A med-kit: stims, sealant, and a relentlessly cheerful safety pamphlet.", "takeable": true, "synonyms": "medical kit firstaid heal", "use_text": "", "needs": "", "wins": false },
|
||||
{ "name": "cell", "sprite": "cell", "x": 50, "y": 134, "look": "A glowing energy cell, warm and faintly humming a single held note.", "takeable": true, "synonyms": "battery power core fuel", "use_text": "", "needs": "", "wins": false },
|
||||
{ "name": "crate", "sprite": "crate", "x": 130, "y": 134, "look": "A mag-locked supply crate stamped with a corporate logo nobody misses.", "takeable": false, "synonyms": "box container cargo", "use_text": "", "needs": "", "wins": false },
|
||||
{ "name": "vending", "sprite": "vending", "x": 20, "y": 110, "look": "A vending machine whose menu is written in pure marketing.", "takeable": false, "synonyms": "machine dispenser snacks", "use_text": "The machine whirrs and drops a ration bar older than you are.", "needs": "cell", "wins": false },
|
||||
{ "name": "airlock", "sprite": "door", "x": 80, "y": 120, "look": "A heavy airlock door, frost creeping along its seams. Beyond it: the dark.", "takeable": false, "synonyms": "door hatch exit", "use_text": "The keycard reads green. The airlock cycles, hisses, and opens onto a sea of stars. You step out. ESCAPE COMPLETE.", "needs": "keycard", "wins": true }
|
||||
]
|
||||
20
palettes/mrpgi-ega.gpl
Normal file
@ -0,0 +1,20 @@
|
||||
GIMP Palette
|
||||
Name: MRPGI EGA 16
|
||||
Columns: 8
|
||||
#
|
||||
0 0 0 black
|
||||
0 0 170 blue
|
||||
0 170 0 green
|
||||
0 170 170 cyan
|
||||
170 0 0 red
|
||||
170 0 170 magenta
|
||||
170 85 0 brown
|
||||
170 170 170 light gray
|
||||
85 85 85 dark gray
|
||||
85 85 255 light blue
|
||||
85 255 85 light green
|
||||
85 255 255 light cyan
|
||||
255 85 85 light red
|
||||
255 85 255 light magenta
|
||||
255 255 85 yellow
|
||||
255 255 255 white
|
||||
16
palettes/mrpgi-ega.hex
Normal file
@ -0,0 +1,16 @@
|
||||
000000
|
||||
0000aa
|
||||
00aa00
|
||||
00aaaa
|
||||
aa0000
|
||||
aa00aa
|
||||
aa5500
|
||||
aaaaaa
|
||||
555555
|
||||
5555ff
|
||||
55ff55
|
||||
55ffff
|
||||
ff5555
|
||||
ff55ff
|
||||
ffff55
|
||||
ffffff
|
||||
165
rooms/room0.json
Normal file
@ -0,0 +1,165 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 2,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 10,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 16,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 70,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 100,
|
||||
"w": 6,
|
||||
"h": 68,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 30,
|
||||
"y": 120,
|
||||
"w": 18,
|
||||
"h": 8,
|
||||
"color": 2,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 100,
|
||||
"y": 60,
|
||||
"w": 22,
|
||||
"h": 8,
|
||||
"color": 2,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
24,
|
||||
130
|
||||
],
|
||||
"exits": [
|
||||
null,
|
||||
1,
|
||||
3,
|
||||
null
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "tree",
|
||||
"sprite": "tree",
|
||||
"x": 44,
|
||||
"y": 70,
|
||||
"look": "An ancient tree, bark like old leather.",
|
||||
"takeable": false,
|
||||
"synonyms": "oak trunk wood",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "sword",
|
||||
"sprite": "sword",
|
||||
"x": 80,
|
||||
"y": 132,
|
||||
"look": "A notched iron sword someone left in the grass. Finders keepers.",
|
||||
"takeable": true,
|
||||
"synonyms": "blade weapon",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "rock",
|
||||
"sprite": "rock",
|
||||
"x": 120,
|
||||
"y": 134,
|
||||
"look": "A mossy boulder, smug and immovable.",
|
||||
"takeable": false,
|
||||
"synonyms": "boulder stone",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "sign",
|
||||
"sprite": "sign",
|
||||
"x": 80,
|
||||
"y": 44,
|
||||
"look": "A signpost: CAVE east, INN south.",
|
||||
"takeable": false,
|
||||
"synonyms": "notice post",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
}
|
||||
],
|
||||
"music": 1
|
||||
}
|
||||
165
rooms/room1.json
Normal file
@ -0,0 +1,165 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 8,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 160,
|
||||
"h": 8,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 70,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 100,
|
||||
"w": 6,
|
||||
"h": 68,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 40,
|
||||
"y": 60,
|
||||
"w": 10,
|
||||
"h": 6,
|
||||
"color": 7,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 110,
|
||||
"y": 80,
|
||||
"w": 12,
|
||||
"h": 6,
|
||||
"color": 7,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
80,
|
||||
130
|
||||
],
|
||||
"exits": [
|
||||
2,
|
||||
null,
|
||||
null,
|
||||
0
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "orc",
|
||||
"sprite": "orc",
|
||||
"x": 92,
|
||||
"y": 118,
|
||||
"look": "A green orc picking its teeth with a dagger. It hasn't noticed you. Yet.",
|
||||
"takeable": false,
|
||||
"synonyms": "monster brute goblin",
|
||||
"use_text": "You poke the orc. This was a mistake.",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "spellbook",
|
||||
"sprite": "spellbook",
|
||||
"x": 122,
|
||||
"y": 132,
|
||||
"look": "A spellbook on a ledge, runes squirming. The orc is guarding it (badly).",
|
||||
"takeable": true,
|
||||
"synonyms": "book tome grimoire",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "torch",
|
||||
"sprite": "torch",
|
||||
"x": 36,
|
||||
"y": 110,
|
||||
"look": "A guttering wall-torch. Grab it — it's dark deeper in.",
|
||||
"takeable": true,
|
||||
"synonyms": "light flame",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "chest",
|
||||
"sprite": "chest",
|
||||
"x": 58,
|
||||
"y": 140,
|
||||
"look": "A banded chest, half-buried. Locked.",
|
||||
"takeable": false,
|
||||
"synonyms": "box trunk",
|
||||
"use_text": "The key turns; old gold spills out across the cave floor.",
|
||||
"needs": "key",
|
||||
"wins": false
|
||||
}
|
||||
],
|
||||
"music": 2
|
||||
}
|
||||
121
rooms/room2.json
Normal file
@ -0,0 +1,121 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 8,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 16,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 160,
|
||||
"w": 65,
|
||||
"h": 8,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 0,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 58,
|
||||
"y": 30,
|
||||
"w": 44,
|
||||
"h": 10,
|
||||
"color": 13,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
80,
|
||||
138
|
||||
],
|
||||
"exits": [
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
null
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "altar",
|
||||
"sprite": "altar",
|
||||
"x": 80,
|
||||
"y": 98,
|
||||
"look": "A stone altar humming with cold light. A book-shaped hollow waits in its surface.",
|
||||
"takeable": false,
|
||||
"synonyms": "shrine pedestal",
|
||||
"use_text": "You lay the spellbook on the altar. Light pours up, a doorway tears open, and you step through. YOU ESCAPED — adventure complete. THE END.",
|
||||
"needs": "spellbook",
|
||||
"wins": true
|
||||
},
|
||||
{
|
||||
"name": "torch",
|
||||
"sprite": "torch",
|
||||
"x": 30,
|
||||
"y": 120,
|
||||
"look": "A torch in a rusted bracket.",
|
||||
"takeable": true,
|
||||
"synonyms": "light flame",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
}
|
||||
],
|
||||
"music": 3
|
||||
}
|
||||
162
rooms/room3.json
Normal file
@ -0,0 +1,162 @@
|
||||
{
|
||||
"strokes": [
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 160,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 6,
|
||||
"y": 16,
|
||||
"w": 148,
|
||||
"h": 146,
|
||||
"color": 7,
|
||||
"meaning": "Floor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 95,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 16,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 160,
|
||||
"w": 160,
|
||||
"h": 8,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 154,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 168,
|
||||
"color": 6,
|
||||
"meaning": "Wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 40,
|
||||
"y": 122,
|
||||
"w": 80,
|
||||
"h": 28,
|
||||
"color": 4,
|
||||
"meaning": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Rect": {
|
||||
"x": 20,
|
||||
"y": 24,
|
||||
"w": 8,
|
||||
"h": 8,
|
||||
"color": 14,
|
||||
"meaning": "None"
|
||||
}
|
||||
}
|
||||
],
|
||||
"spawn": [
|
||||
80,
|
||||
138
|
||||
],
|
||||
"exits": [
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "villager",
|
||||
"sprite": "villager",
|
||||
"x": 60,
|
||||
"y": 112,
|
||||
"look": "A nervous barkeep. 'Heading to the cave? Take the key — and my prayers.'",
|
||||
"takeable": false,
|
||||
"synonyms": "barkeep person npc",
|
||||
"use_text": "'Be careful in there,' the barkeep says, sliding you nothing useful.",
|
||||
"needs": "",
|
||||
"wins": false,
|
||||
"dialogue": [
|
||||
{ "says": "Oh — a traveller! You're for the cave, aren't you? I can see it in your... whatever those are.", "choices": [ { "text": "What's down there?", "goto": 1 }, { "text": "Maybe. Got anything useful?", "goto": 2 }, { "text": "Just passing through.", "goto": 3 } ] },
|
||||
{ "says": "Orcs. Dark. A spellbook nobody should read aloud. Take the key by the door — it opens the old chest.", "choices": [ { "text": "Thanks, I'll be careful.", "goto": -1 }, { "text": "A spellbook, you say?", "goto": 4 } ] },
|
||||
{ "says": "Key's by the door, potion's on the house. Beyond that, just my prayers, friend.", "choices": [ { "text": "I'll take them. Cheers.", "goto": -1 } ] },
|
||||
{ "says": "Nobody just passes through here. But suit yourself.", "choices": [ { "text": "...fine. What IS in the cave?", "goto": 1 } ] },
|
||||
{ "says": "Don't read it aloud. Bring it to the altar in the dungeon and step well back.", "choices": [ { "text": "Noted. Thank you.", "goto": -1 } ] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"sprite": "key",
|
||||
"x": 44,
|
||||
"y": 140,
|
||||
"look": "An iron key hanging by the door. The barkeep nods at you to take it.",
|
||||
"takeable": true,
|
||||
"synonyms": "ironkey",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "potion",
|
||||
"sprite": "potion",
|
||||
"x": 112,
|
||||
"y": 132,
|
||||
"look": "A complimentary vial of bubbling red liquid. Probably fine.",
|
||||
"takeable": true,
|
||||
"synonyms": "vial elixir flask",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
},
|
||||
{
|
||||
"name": "barrel",
|
||||
"sprite": "barrel",
|
||||
"x": 122,
|
||||
"y": 120,
|
||||
"look": "A barrel of inn ale. Smells like courage and mistakes.",
|
||||
"takeable": false,
|
||||
"synonyms": "ale keg",
|
||||
"use_text": "",
|
||||
"needs": "",
|
||||
"wins": false
|
||||
}
|
||||
],
|
||||
"music": 4
|
||||
}
|
||||
11
run.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Launch MRPGI with the local AI parser pointed at your Gemma model.
|
||||
# Override by exporting MRPGI_AI_MODEL first, or edit the default below.
|
||||
export MRPGI_AI_MODEL="${MRPGI_AI_MODEL:-hf.co/OBLITERATUS/gemma-4-E4B-it-OBLITERATED:latest}"
|
||||
#
|
||||
# Use OpenRouter instead of local Ollama:
|
||||
# MRPGI_AI=openrouter OPENROUTER_API_KEY=sk-... MRPGI_AI_MODEL=google/gemma-2-9b-it:free ./run.sh
|
||||
# Disable the AI lane entirely:
|
||||
# MRPGI_AI=off ./run.sh
|
||||
#
|
||||
exec cargo run "$@"
|
||||
BIN
sprites/altar.png
Normal file
|
After Width: | Height: | Size: 202 B |
BIN
sprites/barrel.png
Normal file
|
After Width: | Height: | Size: 266 B |
BIN
sprites/chest.png
Normal file
|
After Width: | Height: | Size: 224 B |
BIN
sprites/crate.png
Normal file
|
After Width: | Height: | Size: 670 B |
BIN
sprites/door.png
Normal file
|
After Width: | Height: | Size: 250 B |
BIN
sprites/key.png
Normal file
|
After Width: | Height: | Size: 201 B |
BIN
sprites/orc.png
Normal file
|
After Width: | Height: | Size: 258 B |
BIN
sprites/potion.png
Normal file
|
After Width: | Height: | Size: 197 B |
BIN
sprites/rock.png
Normal file
|
After Width: | Height: | Size: 266 B |
BIN
sprites/sign.png
Normal file
|
After Width: | Height: | Size: 206 B |
BIN
sprites/spellbook.png
Normal file
|
After Width: | Height: | Size: 196 B |
BIN
sprites/sword.png
Normal file
|
After Width: | Height: | Size: 235 B |
BIN
sprites/torch.png
Normal file
|
After Width: | Height: | Size: 304 B |
BIN
sprites/tree.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
sprites/villager.png
Normal file
|
After Width: | Height: | Size: 269 B |
134
src/ai.rs
Normal file
@ -0,0 +1,134 @@
|
||||
//! The AI parser lane.
|
||||
//!
|
||||
//! When the deterministic parser can't map a sentence to a command, a small
|
||||
//! LLM translates the player's free text into ONE *allowed* command — which is
|
||||
//! then re-run through the real game logic, so logic stays authoritative and
|
||||
//! the AI can never invent state. The call runs on a worker thread and reports
|
||||
//! back over a channel, so the game loop never blocks.
|
||||
//!
|
||||
//! Provider is chosen by env vars (default = local Ollama, free + offline):
|
||||
//! MRPGI_AI ollama (default) | openrouter | off
|
||||
//! MRPGI_AI_MODEL e.g. "gemma3:4b" (ollama) or "google/gemma-2-9b-it:free" (openrouter)
|
||||
//! MRPGI_AI_URL override the endpoint
|
||||
//! OPENROUTER_API_KEY (or MRPGI_AI_KEY) for openrouter
|
||||
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Provider {
|
||||
Off,
|
||||
Ollama,
|
||||
OpenRouter,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AiConfig {
|
||||
provider: Provider,
|
||||
model: String,
|
||||
url: String,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
impl AiConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let which = std::env::var("MRPGI_AI").unwrap_or_else(|_| "ollama".into()).to_lowercase();
|
||||
let provider = match which.as_str() {
|
||||
"off" | "none" | "0" => Provider::Off,
|
||||
"openrouter" | "or" => Provider::OpenRouter,
|
||||
_ => Provider::Ollama,
|
||||
};
|
||||
let model = std::env::var("MRPGI_AI_MODEL").unwrap_or_else(|_| match provider {
|
||||
Provider::OpenRouter => "google/gemma-2-9b-it:free".into(),
|
||||
_ => "gemma3:4b".into(),
|
||||
});
|
||||
let url = std::env::var("MRPGI_AI_URL").unwrap_or_else(|_| match provider {
|
||||
Provider::OpenRouter => "https://openrouter.ai/api/v1/chat/completions".into(),
|
||||
_ => "http://localhost:11434/api/generate".into(),
|
||||
});
|
||||
let key = std::env::var("OPENROUTER_API_KEY").ok().or_else(|| std::env::var("MRPGI_AI_KEY").ok());
|
||||
AiConfig { provider, model, url, key }
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.provider != Provider::Off
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self.provider {
|
||||
Provider::Off => "off".into(),
|
||||
Provider::Ollama => format!("ollama/{}", self.model),
|
||||
Provider::OpenRouter => format!("openrouter/{}", self.model),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The instruction we send the model — tight, so even a 4B local model behaves.
|
||||
pub fn build_prompt(text: &str, things: &str) -> String {
|
||||
format!(
|
||||
"You translate a player's sentence into ONE text-adventure command.
|
||||
Valid verbs: look, examine, take, drop, use, open, inventory.
|
||||
Things you can see: {things}.
|
||||
Reply with ONLY one short command like `use gate` or `take key`, using a verb above and (if it helps) one of the things listed. If nothing fits, reply `look around`. No quotes, no explanation.
|
||||
Player: {text}
|
||||
Command:"
|
||||
)
|
||||
}
|
||||
|
||||
/// Fire the translation on a worker thread; poll the returned receiver each
|
||||
/// frame with `try_recv()`. The result is a sanitized command (or "" = no idea).
|
||||
pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let raw = call(&cfg, &prompt).unwrap_or_default();
|
||||
let _ = tx.send(sanitize(&raw));
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("")
|
||||
.trim_matches(|c: char| c == '`' || c == '"' || c == '\'' || c == '.' || c == '>' || c == '*')
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn call(cfg: &AiConfig, prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_secs(5))
|
||||
.timeout_read(Duration::from_secs(25))
|
||||
.build();
|
||||
match cfg.provider {
|
||||
Provider::Off => Ok(String::new()),
|
||||
Provider::Ollama => {
|
||||
let resp = agent
|
||||
.post(&cfg.url)
|
||||
.send_json(serde_json::json!({
|
||||
"model": cfg.model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
"options": { "temperature": 0.1, "num_predict": 24 }
|
||||
}))?
|
||||
.into_json::<serde_json::Value>()?;
|
||||
Ok(resp["response"].as_str().unwrap_or("").to_string())
|
||||
}
|
||||
Provider::OpenRouter => {
|
||||
let key = cfg.key.clone().ok_or_else(|| "no OPENROUTER_API_KEY set".to_string())?;
|
||||
let resp = agent
|
||||
.post(&cfg.url)
|
||||
.set("Authorization", &format!("Bearer {}", key))
|
||||
.send_json(serde_json::json!({
|
||||
"model": cfg.model,
|
||||
"max_tokens": 24,
|
||||
"temperature": 0.1,
|
||||
"messages": [{ "role": "user", "content": prompt }]
|
||||
}))?
|
||||
.into_json::<serde_json::Value>()?;
|
||||
Ok(resp["choices"][0]["message"]["content"].as_str().unwrap_or("").to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
440
src/assets.rs
Normal file
@ -0,0 +1,440 @@
|
||||
//! The new-school asset pipeline.
|
||||
//!
|
||||
//! The trick that makes images feel native to an AGI engine: we don't blit
|
||||
//! PNGs *over* the game — we fold them *into* it. Any image is quantized to
|
||||
//! the 16-color EGA palette (optionally Bayer-dithered for smooth retro
|
||||
//! gradients) and becomes an indexed [`Cel`]. From that moment it depth-sorts,
|
||||
//! gets occluded by scenery, and walks-behind exactly like hand-authored art.
|
||||
//!
|
||||
//! Drop your own PNGs into `assets/` and the engine will use them; the sample
|
||||
//! art here is generated on first run so the project is playable out of the box.
|
||||
|
||||
use crate::palette::EGA;
|
||||
use crate::view::{Cel, TRANSPARENT};
|
||||
use std::path::Path;
|
||||
|
||||
/// 4x4 ordered (Bayer) dither matrix.
|
||||
const BAYER4: [[u8; 4]; 4] = [
|
||||
[0, 8, 2, 10],
|
||||
[12, 4, 14, 6],
|
||||
[3, 11, 1, 9],
|
||||
[15, 7, 13, 5],
|
||||
];
|
||||
|
||||
/// Nearest EGA palette index to an RGB color (squared-distance match).
|
||||
fn nearest_ega(r: u8, g: u8, b: u8) -> u8 {
|
||||
let mut best = 0u8;
|
||||
let mut best_d = i32::MAX;
|
||||
for (i, &(pr, pg, pb)) in EGA.iter().enumerate() {
|
||||
let dr = r as i32 - pr as i32;
|
||||
let dg = g as i32 - pg as i32;
|
||||
let db = b as i32 - pb as i32;
|
||||
let d = dr * dr + dg * dg + db * db;
|
||||
if d < best_d {
|
||||
best_d = d;
|
||||
best = i as u8;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Convert an RGBA image into an EGA-indexed [`Cel`]. With `dither`, ordered
|
||||
/// dithering nudges each pixel before matching, so gradients resolve into that
|
||||
/// classic stippled EGA look instead of hard color banding. Pixels with
|
||||
/// alpha < 128 become transparent.
|
||||
pub fn quantize(img: &image::RgbaImage, dither: bool) -> Cel {
|
||||
let w = img.width() as usize;
|
||||
let h = img.height() as usize;
|
||||
let mut pixels = vec![TRANSPARENT; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = img.get_pixel(x as u32, y as u32).0;
|
||||
if p[3] < 128 {
|
||||
continue; // transparent
|
||||
}
|
||||
let (mut r, mut g, mut b) = (p[0] as f32, p[1] as f32, p[2] as f32);
|
||||
if dither {
|
||||
// Bias toward/away the nearest palette step (~85 apart in EGA).
|
||||
let t = BAYER4[y & 3][x & 3] as f32 / 16.0 - 0.5;
|
||||
let s = 90.0 * t;
|
||||
r = (r + s).clamp(0.0, 255.0);
|
||||
g = (g + s).clamp(0.0, 255.0);
|
||||
b = (b + s).clamp(0.0, 255.0);
|
||||
}
|
||||
pixels[y * w + x] = nearest_ega(r as u8, g as u8, b as u8);
|
||||
}
|
||||
}
|
||||
Cel { w, h, pixels }
|
||||
}
|
||||
|
||||
/// Load a PNG and quantize it. Returns a small magenta placeholder on failure
|
||||
/// so missing art is obvious rather than a crash.
|
||||
pub fn load_cel(path: &str, dither: bool) -> Cel {
|
||||
match image::open(path) {
|
||||
Ok(img) => quantize(&img.to_rgba8(), dither),
|
||||
Err(_) => Cel {
|
||||
w: 2,
|
||||
h: 2,
|
||||
pixels: vec![13, 13, 13, 13],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// All the art a room might want. Add fields here as games grow; for now this
|
||||
/// holds the demo's three pieces.
|
||||
pub struct Assets {
|
||||
pub poster: Cel,
|
||||
pub crate_box: Cel,
|
||||
pub floor: Cel,
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
pub fn load(dir: &str) -> Self {
|
||||
Assets {
|
||||
poster: load_cel(&format!("{dir}/poster.png"), true),
|
||||
crate_box: load_cel(&format!("{dir}/crate.png"), false),
|
||||
floor: load_cel(&format!("{dir}/floor.png"), true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample-art generation (so the project runs with zero external files).
|
||||
// These write real PNGs you can open, edit, and hot-reload with `R`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn ensure_sample_assets(dir: &str) {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
write_if_missing(&format!("{dir}/poster.png"), gen_poster);
|
||||
write_if_missing(&format!("{dir}/crate.png"), gen_crate);
|
||||
write_if_missing(&format!("{dir}/floor.png"), gen_floor);
|
||||
}
|
||||
|
||||
fn write_if_missing(path: &str, f: fn() -> image::RgbaImage) {
|
||||
if !Path::new(path).exists() {
|
||||
f().save(path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn put_rect(im: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, c: [u8; 4]) {
|
||||
for yy in y..(y + h).min(im.height()) {
|
||||
for xx in x..(x + w).min(im.width()) {
|
||||
im.put_pixel(xx, yy, image::Rgba(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A neon "Monster Robot Party" poster — a smooth gradient with a robot face,
|
||||
/// so dithering has something to chew on.
|
||||
fn gen_poster() -> image::RgbaImage {
|
||||
let (w, h) = (64u32, 48u32);
|
||||
let mut im = image::RgbaImage::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let t = y as f32 / h as f32;
|
||||
let r = (90.0 * (1.0 - t)) as u8;
|
||||
let g = (20.0 + 110.0 * t) as u8;
|
||||
let b = (130.0 * (1.0 - t) + 140.0 * t) as u8;
|
||||
im.put_pixel(x, y, image::Rgba([r, g, b, 255]));
|
||||
}
|
||||
}
|
||||
// Robot head.
|
||||
let (cx, cy) = (32.0, 24.0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let dx = x as f32 - cx;
|
||||
let dy = (y as f32 - cy) * 1.1;
|
||||
if dx * dx + dy * dy < 13.0 * 13.0 {
|
||||
im.put_pixel(x, y, image::Rgba([200, 200, 210, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
put_rect(&mut im, 26, 20, 4, 4, [40, 230, 230, 255]); // eye
|
||||
put_rect(&mut im, 34, 20, 4, 4, [40, 230, 230, 255]); // eye
|
||||
put_rect(&mut im, 27, 30, 10, 2, [230, 60, 180, 255]); // mouth
|
||||
put_rect(&mut im, 31, 8, 2, 6, [220, 220, 220, 255]); // antenna
|
||||
put_rect(&mut im, 30, 5, 4, 4, [255, 230, 60, 255]); // antenna tip
|
||||
// Confetti.
|
||||
let cols = [
|
||||
[255, 80, 80, 255],
|
||||
[80, 255, 120, 255],
|
||||
[255, 230, 60, 255],
|
||||
[120, 160, 255, 255],
|
||||
];
|
||||
for i in 0..44u32 {
|
||||
let x = (i * 37 + 5) % w;
|
||||
let y = (i * 23 + 3) % h;
|
||||
im.put_pixel(x, y, image::Rgba(cols[(i % 4) as usize]));
|
||||
}
|
||||
im
|
||||
}
|
||||
|
||||
/// A pixel-art wooden crate (an object you can walk behind).
|
||||
fn gen_crate() -> image::RgbaImage {
|
||||
let (w, h) = (28u32, 24u32);
|
||||
let mut im = image::RgbaImage::new(w, h);
|
||||
put_rect(&mut im, 0, 0, w, h, [150, 95, 45, 255]); // wood
|
||||
let dark = [95, 60, 25, 255];
|
||||
put_rect(&mut im, 0, 0, w, 2, dark);
|
||||
put_rect(&mut im, 0, h - 2, w, 2, dark);
|
||||
put_rect(&mut im, 0, 0, 2, h, dark);
|
||||
put_rect(&mut im, w - 2, 0, 2, h, dark);
|
||||
let light = [190, 135, 75, 255];
|
||||
for i in 0..h {
|
||||
let x = (i * (w - 1) / (h - 1)).min(w - 1);
|
||||
im.put_pixel(x, i, image::Rgba(light));
|
||||
im.put_pixel(w - 1 - x, i, image::Rgba(light));
|
||||
}
|
||||
im
|
||||
}
|
||||
|
||||
/// A 16x16 dance-floor tile (tiles seamlessly; 16 is a multiple of the dither
|
||||
/// period so there are no seams).
|
||||
fn gen_floor() -> image::RgbaImage {
|
||||
let (w, h) = (16u32, 16u32);
|
||||
let mut im = image::RgbaImage::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let base = if ((x / 8) + (y / 8)) % 2 == 0 {
|
||||
[22, 62, 56, 255]
|
||||
} else {
|
||||
[16, 48, 46, 255]
|
||||
};
|
||||
im.put_pixel(x, y, image::Rgba(base));
|
||||
}
|
||||
}
|
||||
for x in 0..w {
|
||||
im.put_pixel(x, 0, image::Rgba([10, 30, 30, 255]));
|
||||
}
|
||||
for y in 0..h {
|
||||
im.put_pixel(0, y, image::Rgba([10, 30, 30, 255]));
|
||||
}
|
||||
for (x, y, c) in [
|
||||
(4u32, 5u32, [80, 255, 160, 255]),
|
||||
(11, 9, [255, 90, 200, 255]),
|
||||
(7, 13, [120, 160, 255, 255]),
|
||||
] {
|
||||
im.put_pixel(x, y, image::Rgba(c));
|
||||
}
|
||||
im
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sprite pipeline — drop PNGs in a folder, the editor places them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load every PNG in `dir` as an EGA-quantized cel (no dither — keep pixel art
|
||||
/// crisp). Returns (name-without-extension, cel), sorted by name.
|
||||
pub fn load_sprite_dir(dir: &str) -> Vec<(String, Cel)> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
let mut paths: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
for p in paths {
|
||||
let is_png = p
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("png"))
|
||||
.unwrap_or(false);
|
||||
if !is_png {
|
||||
continue;
|
||||
}
|
||||
let name = p.file_stem().and_then(|s| s.to_str()).unwrap_or("sprite").to_string();
|
||||
if let Some(ps) = p.to_str() {
|
||||
out.push((name, load_cel(ps, false)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Create `dir` and drop a few starter sprites if it's empty.
|
||||
pub fn ensure_sample_sprites(dir: &str) {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
let has_png = std::fs::read_dir(dir)
|
||||
.map(|rd| {
|
||||
rd.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("png"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if has_png {
|
||||
return;
|
||||
}
|
||||
gen_crate().save(format!("{dir}/crate.png")).ok();
|
||||
gen_barrel().save(format!("{dir}/barrel.png")).ok();
|
||||
gen_sign().save(format!("{dir}/sign.png")).ok();
|
||||
}
|
||||
|
||||
fn gen_barrel() -> image::RgbaImage {
|
||||
let (w, h) = (16u32, 22u32);
|
||||
let mut im = image::RgbaImage::new(w, h);
|
||||
put_rect(&mut im, 2, 1, 12, 20, [150, 95, 45, 255]);
|
||||
put_rect(&mut im, 2, 1, 2, 20, [110, 68, 30, 255]);
|
||||
put_rect(&mut im, 12, 1, 2, 20, [110, 68, 30, 255]);
|
||||
put_rect(&mut im, 2, 3, 12, 2, [95, 95, 95, 255]);
|
||||
put_rect(&mut im, 2, 17, 12, 2, [95, 95, 95, 255]);
|
||||
im
|
||||
}
|
||||
|
||||
fn gen_sign() -> image::RgbaImage {
|
||||
let (w, h) = (28u32, 18u32);
|
||||
let mut im = image::RgbaImage::new(w, h);
|
||||
put_rect(&mut im, 12, 8, 4, 10, [120, 75, 35, 255]);
|
||||
put_rect(&mut im, 2, 1, 24, 9, [200, 170, 60, 255]);
|
||||
put_rect(&mut im, 2, 1, 24, 2, [150, 120, 40, 255]);
|
||||
im
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fantasy starter-kit placeholder sprites (crude but distinct). Replace with
|
||||
// real art any time — same filenames in sprites/, press R. See ART_MANIFESTO.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GREEN: [u8; 4] = [0, 170, 0, 255];
|
||||
const DGREEN: [u8; 4] = [0, 100, 0, 255];
|
||||
const BROWN: [u8; 4] = [170, 85, 0, 255];
|
||||
const DBROWN: [u8; 4] = [110, 55, 0, 255];
|
||||
const GRAY: [u8; 4] = [170, 170, 170, 255];
|
||||
const DGRAY: [u8; 4] = [85, 85, 85, 255];
|
||||
const RED: [u8; 4] = [200, 40, 40, 255];
|
||||
const YELLOW: [u8; 4] = [255, 235, 85, 255];
|
||||
const MAGENTA: [u8; 4] = [255, 85, 255, 255];
|
||||
const PURPLE: [u8; 4] = [128, 0, 150, 255];
|
||||
const CYAN: [u8; 4] = [85, 255, 255, 255];
|
||||
const WHITE: [u8; 4] = [235, 235, 235, 255];
|
||||
const SKIN: [u8; 4] = [255, 170, 120, 255];
|
||||
const BLUE: [u8; 4] = [40, 60, 200, 255];
|
||||
|
||||
fn put_circle(im: &mut image::RgbaImage, cx: i32, cy: i32, r: i32, c: [u8; 4]) {
|
||||
for y in (cy - r)..=(cy + r) {
|
||||
for x in (cx - r)..=(cx + r) {
|
||||
if x >= 0 && y >= 0 && (x as u32) < im.width() && (y as u32) < im.height() {
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
if dx * dx + dy * dy <= r * r {
|
||||
im.put_pixel(x as u32, y as u32, image::Rgba(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write ~12 crude placeholder sprites into `dir` (only those missing).
|
||||
pub fn write_kit_sprites(dir: &str) {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
let gens: [(&str, fn() -> image::RgbaImage); 12] = [
|
||||
("tree", gen_tree),
|
||||
("rock", gen_rock),
|
||||
("orc", gen_orc),
|
||||
("sword", gen_sword),
|
||||
("chest", gen_chest),
|
||||
("torch", gen_torch),
|
||||
("potion", gen_potion),
|
||||
("door", gen_door),
|
||||
("villager", gen_villager),
|
||||
("spellbook", gen_spellbook),
|
||||
("key", gen_key),
|
||||
("altar", gen_altar),
|
||||
];
|
||||
for (name, f) in gens {
|
||||
let p = format!("{dir}/{name}.png");
|
||||
if !std::path::Path::new(&p).exists() {
|
||||
f().save(&p).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_tree() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(18, 26);
|
||||
put_rect(&mut im, 7, 16, 4, 10, DBROWN);
|
||||
put_circle(&mut im, 9, 9, 8, GREEN);
|
||||
put_circle(&mut im, 6, 7, 4, DGREEN);
|
||||
im
|
||||
}
|
||||
fn gen_rock() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(16, 12);
|
||||
put_circle(&mut im, 8, 9, 6, GRAY);
|
||||
put_circle(&mut im, 6, 10, 3, DGRAY);
|
||||
im
|
||||
}
|
||||
fn gen_orc() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(14, 22);
|
||||
put_rect(&mut im, 3, 9, 8, 11, GREEN);
|
||||
put_circle(&mut im, 7, 6, 5, GREEN);
|
||||
put_rect(&mut im, 4, 5, 2, 2, RED);
|
||||
put_rect(&mut im, 8, 5, 2, 2, RED);
|
||||
put_rect(&mut im, 3, 19, 3, 3, DGREEN);
|
||||
put_rect(&mut im, 8, 19, 3, 3, DGREEN);
|
||||
im
|
||||
}
|
||||
fn gen_sword() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(10, 26);
|
||||
put_rect(&mut im, 4, 2, 2, 16, GRAY);
|
||||
put_rect(&mut im, 2, 18, 6, 2, BROWN);
|
||||
put_rect(&mut im, 4, 20, 2, 5, DBROWN);
|
||||
put_rect(&mut im, 4, 24, 2, 2, YELLOW);
|
||||
im
|
||||
}
|
||||
fn gen_chest() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(20, 15);
|
||||
put_rect(&mut im, 2, 5, 16, 9, BROWN);
|
||||
put_rect(&mut im, 2, 2, 16, 3, DBROWN);
|
||||
put_rect(&mut im, 2, 8, 16, 2, DGRAY);
|
||||
put_rect(&mut im, 9, 6, 2, 3, YELLOW);
|
||||
im
|
||||
}
|
||||
fn gen_torch() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(8, 24);
|
||||
put_rect(&mut im, 3, 8, 2, 14, DBROWN);
|
||||
put_circle(&mut im, 4, 5, 4, RED);
|
||||
put_circle(&mut im, 4, 4, 2, YELLOW);
|
||||
im
|
||||
}
|
||||
fn gen_potion() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(10, 18);
|
||||
put_rect(&mut im, 3, 9, 4, 6, MAGENTA);
|
||||
put_rect(&mut im, 2, 6, 6, 9, CYAN);
|
||||
put_rect(&mut im, 3, 9, 4, 6, MAGENTA);
|
||||
put_rect(&mut im, 4, 3, 2, 3, BROWN);
|
||||
im
|
||||
}
|
||||
fn gen_door() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(16, 26);
|
||||
put_rect(&mut im, 1, 1, 14, 25, DBROWN);
|
||||
put_rect(&mut im, 3, 3, 10, 21, BROWN);
|
||||
put_rect(&mut im, 8, 3, 1, 21, DBROWN);
|
||||
put_rect(&mut im, 11, 13, 2, 2, YELLOW);
|
||||
im
|
||||
}
|
||||
fn gen_villager() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(12, 22);
|
||||
put_rect(&mut im, 3, 9, 6, 12, BLUE);
|
||||
put_circle(&mut im, 6, 5, 4, SKIN);
|
||||
im
|
||||
}
|
||||
fn gen_spellbook() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(16, 12);
|
||||
put_rect(&mut im, 2, 2, 12, 9, PURPLE);
|
||||
put_rect(&mut im, 2, 3, 12, 1, WHITE);
|
||||
put_rect(&mut im, 7, 5, 2, 3, YELLOW);
|
||||
im
|
||||
}
|
||||
fn gen_key() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(16, 8);
|
||||
put_circle(&mut im, 4, 4, 3, YELLOW);
|
||||
put_rect(&mut im, 6, 3, 8, 2, YELLOW);
|
||||
put_rect(&mut im, 12, 5, 1, 2, YELLOW);
|
||||
put_rect(&mut im, 14, 5, 1, 2, YELLOW);
|
||||
im
|
||||
}
|
||||
fn gen_altar() -> image::RgbaImage {
|
||||
let mut im = image::RgbaImage::new(20, 16);
|
||||
put_rect(&mut im, 3, 8, 14, 7, GRAY);
|
||||
put_rect(&mut im, 2, 6, 16, 3, DGRAY);
|
||||
put_rect(&mut im, 5, 4, 10, 2, CYAN);
|
||||
im
|
||||
}
|
||||
1362
src/editor.rs
Normal file
87
src/framebuffer.rs
Normal file
@ -0,0 +1,87 @@
|
||||
//! AGI's core rendering trick: every room is drawn into TWO buffers at once.
|
||||
//!
|
||||
//! * the **visual** buffer is what the player sees (palette indices), and
|
||||
//! * the **priority** buffer is invisible — it encodes depth bands and
|
||||
//! control lines so the ego can walk *behind* a column and be blocked by a
|
||||
//! wall, all without any per-object Z bookkeeping.
|
||||
//!
|
||||
//! Values 4..=15 in the priority buffer are depth bands (higher = nearer the
|
||||
//! camera = drawn on top). Values 0..=3 are "control" lines with special
|
||||
//! meaning to the movement system.
|
||||
|
||||
pub const PIC_W: usize = 160;
|
||||
pub const PIC_H: usize = 168;
|
||||
|
||||
/// The "open" color the visual buffer clears to (white, like AGI fills).
|
||||
pub const BG_VISUAL: u8 = 15;
|
||||
|
||||
// --- Control-line values (priority buffer 0..=3) ---------------------------
|
||||
/// Unconditional barrier — nothing may step here.
|
||||
pub const CTRL_BLOCK: u8 = 0;
|
||||
/// Water: sets the on-water state for the ego. (reserved for later)
|
||||
pub const CTRL_WATER: u8 = 1;
|
||||
/// Signal line: stepping on it can trigger room logic. (reserved for later)
|
||||
pub const CTRL_TRIGGER: u8 = 2;
|
||||
/// Conditional barrier. (reserved for later)
|
||||
pub const CTRL_COND: u8 = 3;
|
||||
|
||||
/// Depth bands run from here up to 15.
|
||||
pub const FIRST_BAND: u8 = 4;
|
||||
|
||||
/// Map a screen row to its default depth priority: 4 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 {
|
||||
let b = FIRST_BAND as usize + (y * 12) / PIC_H;
|
||||
b.min(15) as u8
|
||||
}
|
||||
|
||||
pub struct FrameBuffer {
|
||||
pub visual: Vec<u8>,
|
||||
pub priority: Vec<u8>,
|
||||
}
|
||||
|
||||
impl FrameBuffer {
|
||||
pub fn new() -> Self {
|
||||
let mut fb = FrameBuffer {
|
||||
visual: vec![BG_VISUAL; PIC_W * PIC_H],
|
||||
priority: vec![FIRST_BAND; PIC_W * PIC_H],
|
||||
};
|
||||
fb.clear();
|
||||
fb
|
||||
}
|
||||
|
||||
/// Reset to a blank room: white visual, default depth bands on priority.
|
||||
pub fn clear(&mut self) {
|
||||
for y in 0..PIC_H {
|
||||
let p = band(y);
|
||||
for x in 0..PIC_W {
|
||||
let i = y * PIC_W + x;
|
||||
self.visual[i] = BG_VISUAL;
|
||||
self.priority[i] = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn in_bounds(x: i32, y: i32) -> bool {
|
||||
x >= 0 && y >= 0 && (x as usize) < PIC_W && (y as usize) < PIC_H
|
||||
}
|
||||
|
||||
/// Priority at a point; off-screen reads as a solid wall.
|
||||
#[inline]
|
||||
pub fn priority_at(&self, x: i32, y: i32) -> u8 {
|
||||
if Self::in_bounds(x, y) {
|
||||
self.priority[y as usize * PIC_W + x as usize]
|
||||
} else {
|
||||
CTRL_BLOCK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrameBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
728
src/main.rs
Normal file
@ -0,0 +1,728 @@
|
||||
//! MRPGI — Monster Robot Party Game Interpreter
|
||||
//!
|
||||
//! A new-school, Apple-Silicon-native reimagining of Sierra's AGI engine.
|
||||
//! Two modes: EDIT (paint a room — visual + walkability) and PLAY (walk it).
|
||||
//! Press `E` to switch.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod ai;
|
||||
mod assets;
|
||||
mod editor;
|
||||
mod framebuffer;
|
||||
mod palette;
|
||||
mod picture;
|
||||
mod room;
|
||||
mod sound;
|
||||
mod sprite;
|
||||
mod view;
|
||||
|
||||
use editor::{Editor, ObjDef};
|
||||
use framebuffer::{FrameBuffer, PIC_H, PIC_W};
|
||||
use macroquad::prelude::*;
|
||||
use sprite::{Prop, ScreenObj};
|
||||
use view::{cel_from_ascii, View, ViewLoop};
|
||||
|
||||
fn window_conf() -> Conf {
|
||||
Conf {
|
||||
window_title: "MRPGI — Monster Robot Party Game Interpreter".to_owned(),
|
||||
window_width: 1024,
|
||||
window_height: 720,
|
||||
high_dpi: true,
|
||||
window_resizable: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_H: f32 = 150.0;
|
||||
const CYCLE_DT: f32 = 1.0 / 20.0;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Mode {
|
||||
Edit,
|
||||
Play,
|
||||
}
|
||||
|
||||
#[macroquad::main(window_conf)]
|
||||
async fn main() {
|
||||
let mut editor = Editor::new();
|
||||
if std::env::args().any(|a| a == "--sample") {
|
||||
editor::write_sample_world();
|
||||
}
|
||||
if std::env::args().any(|a| a == "--kit") {
|
||||
editor::write_kit();
|
||||
}
|
||||
editor.boot(); // load room 0 (or a legacy room.json) if present
|
||||
let ai_cfg = ai::AiConfig::from_env();
|
||||
let ai_label = ai_cfg.label();
|
||||
|
||||
let mut mode = Mode::Edit;
|
||||
let mut ego = ScreenObj::new(robot_view(), 80, 150);
|
||||
|
||||
let mut play_img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK);
|
||||
let play_tex = Texture2D::from_image(&play_img);
|
||||
play_tex.set_filter(FilterMode::Nearest);
|
||||
|
||||
let mut acc = 0.0f32;
|
||||
let mut crt = false;
|
||||
let mut command = String::new();
|
||||
let mut transcript: Vec<String> = Vec::new();
|
||||
let mut inventory: Vec<String> = Vec::new();
|
||||
let mut taken: Vec<(u32, String)> = Vec::new();
|
||||
let mut won = false;
|
||||
let mut pending: Option<std::sync::mpsc::Receiver<String>> = None;
|
||||
let mut sfx = sound::Sfx::load().await;
|
||||
let mut music = sound::Music::load().await;
|
||||
let mut dlg: Option<Dlg> = None;
|
||||
|
||||
loop {
|
||||
if is_key_pressed(KeyCode::Escape) {
|
||||
if mode == Mode::Edit && editor.is_editing() {
|
||||
editor.stop_editing();
|
||||
} else if dlg.is_some() {
|
||||
dlg = None;
|
||||
transcript.push("(you end the conversation.)".to_string());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if is_key_pressed(KeyCode::Tab) {
|
||||
mode = match mode {
|
||||
Mode::Edit => {
|
||||
// Entering Play: fresh run — spawn the ego, reset session.
|
||||
let (sx, sy) = editor::find_spawn(&editor.fb, editor.spawn());
|
||||
ego.x = sx;
|
||||
ego.y = sy;
|
||||
ego.dir = 0;
|
||||
ego.move_target = None;
|
||||
ego.min_x = 4;
|
||||
ego.max_x = PIC_W as i32 - 4;
|
||||
ego.min_y = 16;
|
||||
ego.max_y = PIC_H as i32 - 4;
|
||||
acc = 0.0;
|
||||
command.clear();
|
||||
inventory.clear();
|
||||
taken.clear();
|
||||
won = false;
|
||||
pending = None;
|
||||
dlg = None;
|
||||
transcript.clear();
|
||||
transcript.push("You blink awake. Type 'help'. (Tab = back to editor)".to_string());
|
||||
Mode::Play
|
||||
}
|
||||
Mode::Play => {
|
||||
music.set(0);
|
||||
Mode::Edit
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if is_key_pressed(KeyCode::F2) {
|
||||
sfx.muted = !sfx.muted;
|
||||
music.set_muted(sfx.muted);
|
||||
}
|
||||
|
||||
match mode {
|
||||
Mode::Edit => editor.frame(),
|
||||
|
||||
Mode::Play => {
|
||||
if is_key_pressed(KeyCode::F1) {
|
||||
crt = !crt;
|
||||
}
|
||||
music.set(editor.music());
|
||||
let layout = Layout::compute();
|
||||
let in_dlg = dlg.is_some();
|
||||
|
||||
// --- movement (arrows / click; paused while talking) ---------
|
||||
let kdir = if in_dlg { 0 } else { read_keyboard_dir() };
|
||||
if kdir != 0 {
|
||||
ego.move_target = None;
|
||||
ego.dir = kdir;
|
||||
} else if ego.move_target.is_none() {
|
||||
ego.dir = 0;
|
||||
}
|
||||
if !in_dlg && is_mouse_button_pressed(MouseButton::Left) {
|
||||
let (mx, my) = mouse_position();
|
||||
if let Some(p) = layout.to_picture(mx, my) {
|
||||
ego.move_target = Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dialogue choices, or the parser (paused while talking) --
|
||||
if in_dlg {
|
||||
if let Some(k) = digit_pressed() {
|
||||
let res = dlg.as_mut().map(|d| d.choose(k));
|
||||
if let Some((lines, ended)) = res {
|
||||
for l in lines {
|
||||
transcript.push(l);
|
||||
}
|
||||
if ended {
|
||||
dlg = None;
|
||||
transcript.push("(the conversation ends.)".to_string());
|
||||
sfx.confirm();
|
||||
} else {
|
||||
sfx.blip();
|
||||
}
|
||||
while transcript.len() > 7 {
|
||||
transcript.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if is_key_pressed(KeyCode::Backspace) {
|
||||
command.pop();
|
||||
}
|
||||
while let Some(c) = get_char_pressed() {
|
||||
if !c.is_control() && command.chars().count() < 64 {
|
||||
command.push(c);
|
||||
}
|
||||
}
|
||||
if is_key_pressed(KeyCode::Enter) {
|
||||
let line = command.trim().to_string();
|
||||
command.clear();
|
||||
if !line.is_empty() {
|
||||
transcript.push(format!("> {}", line));
|
||||
let won_b = won;
|
||||
let inv_b = inventory.len();
|
||||
let mut talk_target: Option<usize> = None;
|
||||
let (lines, understood) = run_command(&line, editor.objects(), editor.current(), &mut inventory, &mut taken, &mut won, &mut talk_target);
|
||||
if let Some(i) = talk_target {
|
||||
let nodes = editor.objects()[i].dialogue.clone();
|
||||
if !nodes.is_empty() {
|
||||
let d = Dlg { nodes, node: 0 };
|
||||
for l in d.lines() {
|
||||
transcript.push(l);
|
||||
}
|
||||
if !d.terminal() {
|
||||
dlg = Some(d);
|
||||
}
|
||||
sfx.confirm();
|
||||
}
|
||||
} else if !understood && ai_cfg.enabled() && pending.is_none() {
|
||||
let room_now = editor.current();
|
||||
let things: Vec<String> = editor
|
||||
.objects()
|
||||
.iter()
|
||||
.filter(|o| !taken.iter().any(|(r, n)| *r == room_now && *n == o.name))
|
||||
.map(|o| o.name.clone())
|
||||
.collect();
|
||||
transcript.push("…(thinking)…".to_string());
|
||||
pending = Some(ai::translate_async(ai_cfg.clone(), ai::build_prompt(&line, &things.join(", "))));
|
||||
sfx.blip();
|
||||
} else {
|
||||
for l in lines {
|
||||
transcript.push(l);
|
||||
}
|
||||
if won && !won_b {
|
||||
sfx.win();
|
||||
} else if inventory.len() > inv_b {
|
||||
sfx.pickup();
|
||||
} else if understood {
|
||||
sfx.confirm();
|
||||
} else {
|
||||
sfx.error();
|
||||
}
|
||||
}
|
||||
while transcript.len() > 7 {
|
||||
transcript.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI worker: poll for a translated command ----------------
|
||||
if pending.is_some() {
|
||||
let got = pending.as_ref().and_then(|rx| rx.try_recv().ok());
|
||||
if let Some(cmd) = got {
|
||||
pending = None;
|
||||
if cmd.is_empty() {
|
||||
transcript.push("You can't do that.".to_string());
|
||||
sfx.error();
|
||||
} else {
|
||||
transcript.push(format!("(\u{2248} {})", cmd));
|
||||
let won_b = won;
|
||||
let inv_b = inventory.len();
|
||||
let mut talk_target: Option<usize> = None;
|
||||
let (lines2, _) = run_command(&cmd, editor.objects(), editor.current(), &mut inventory, &mut taken, &mut won, &mut talk_target);
|
||||
if let Some(i) = talk_target {
|
||||
let nodes = editor.objects()[i].dialogue.clone();
|
||||
if !nodes.is_empty() {
|
||||
let d = Dlg { nodes, node: 0 };
|
||||
for l in d.lines() {
|
||||
transcript.push(l);
|
||||
}
|
||||
if !d.terminal() {
|
||||
dlg = Some(d);
|
||||
}
|
||||
sfx.confirm();
|
||||
}
|
||||
} else {
|
||||
for l in lines2 {
|
||||
transcript.push(l);
|
||||
}
|
||||
if won && !won_b {
|
||||
sfx.win();
|
||||
} else if inventory.len() > inv_b {
|
||||
sfx.pickup();
|
||||
} else {
|
||||
sfx.confirm();
|
||||
}
|
||||
}
|
||||
}
|
||||
while transcript.len() > 7 {
|
||||
transcript.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- game cycles & transitions (paused while talking) --------
|
||||
if !in_dlg {
|
||||
acc += get_frame_time();
|
||||
let mut guard = 0;
|
||||
while acc >= CYCLE_DT && guard < 5 {
|
||||
ego.update(&editor.fb);
|
||||
acc -= CYCLE_DT;
|
||||
guard += 1;
|
||||
}
|
||||
|
||||
// --- walk off an edge with an exit → next room ---------------
|
||||
let exits = editor.current_exits();
|
||||
let trig = 5;
|
||||
let leave = if ego.x <= 4 + trig && exits[3].is_some() {
|
||||
Some((exits[3].unwrap(), 3u8))
|
||||
} else if ego.x >= PIC_W as i32 - 4 - trig && exits[1].is_some() {
|
||||
Some((exits[1].unwrap(), 1u8))
|
||||
} else if ego.y <= 16 + trig && exits[0].is_some() {
|
||||
Some((exits[0].unwrap(), 0u8))
|
||||
} else if ego.y >= PIC_H as i32 - 4 - trig && exits[2].is_some() {
|
||||
Some((exits[2].unwrap(), 2u8))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((target, from)) = leave {
|
||||
editor.goto_room(target);
|
||||
let inset = 14;
|
||||
match from {
|
||||
3 => ego.x = PIC_W as i32 - 4 - inset,
|
||||
1 => ego.x = 4 + inset,
|
||||
0 => ego.y = PIC_H as i32 - 4 - inset,
|
||||
_ => ego.y = 16 + inset,
|
||||
}
|
||||
if editor.fb.priority_at(ego.x, ego.y) == framebuffer::CTRL_BLOCK {
|
||||
let (sx, sy) = editor::find_spawn(&editor.fb, editor.spawn());
|
||||
ego.x = sx;
|
||||
ego.y = sy;
|
||||
}
|
||||
ego.dir = 0;
|
||||
ego.move_target = None;
|
||||
acc = 0.0;
|
||||
transcript.push(format!("You step into room {}.", editor.current()));
|
||||
sfx.door();
|
||||
while transcript.len() > 7 {
|
||||
transcript.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- live objects → render props (skipping any taken) --------
|
||||
let room = editor.current();
|
||||
let mut props: Vec<Prop> = Vec::new();
|
||||
for o in editor.objects() {
|
||||
if taken.iter().any(|(r, n)| *r == room && n == &o.name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(cel) = editor.sprite_cel(&o.sprite) {
|
||||
props.push(Prop { x: o.x, y: o.y, cel: cel.clone(), fixed_priority: None, name: o.name.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
compose(&mut play_img, &editor.fb, &ego, &props);
|
||||
play_tex.update(&play_img);
|
||||
|
||||
clear_background(BLACK);
|
||||
draw_texture_ex(
|
||||
&play_tex,
|
||||
layout.x,
|
||||
layout.y,
|
||||
WHITE,
|
||||
DrawTextureParams { dest_size: Some(vec2(layout.w, layout.h)), ..Default::default() },
|
||||
);
|
||||
if crt {
|
||||
draw_scanlines(&layout);
|
||||
}
|
||||
draw_play_panel(&transcript, &command, &inventory, room, crt, &ai_label, !sfx.muted, dlg.is_some());
|
||||
if won {
|
||||
let sw2 = screen_width();
|
||||
let by = screen_height() * 0.34;
|
||||
draw_rectangle(0.0, by, sw2, 56.0, color_u8!(18, 40, 30, 235));
|
||||
let t = "\u{2605} YOU WIN \u{2605}";
|
||||
let dim = measure_text(t, None, 40, 1.0);
|
||||
draw_text(t, (sw2 - dim.width) * 0.5, by + 40.0, 40.0, color_u8!(120, 255, 180, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next_frame().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_keyboard_dir() -> u8 {
|
||||
let up = is_key_down(KeyCode::Up);
|
||||
let down = is_key_down(KeyCode::Down);
|
||||
let left = is_key_down(KeyCode::Left);
|
||||
let right = is_key_down(KeyCode::Right);
|
||||
match (up, down, left, right) {
|
||||
(true, false, false, false) => 1,
|
||||
(true, false, false, true) => 2,
|
||||
(false, false, false, true) => 3,
|
||||
(false, true, false, true) => 4,
|
||||
(false, true, false, false) => 5,
|
||||
(false, true, true, false) => 6,
|
||||
(false, false, true, false) => 7,
|
||||
(true, false, true, false) => 8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn compose(img: &mut Image, room: &FrameBuffer, ego: &ScreenObj, props: &[Prop]) {
|
||||
let mut vis = room.visual.clone();
|
||||
|
||||
let mut draws: Vec<(u8, &view::Cel, i32, i32)> = Vec::with_capacity(props.len() + 1);
|
||||
draws.push((ego.priority(), ego.cel(), ego.x, ego.y));
|
||||
for p in props {
|
||||
draws.push((p.priority(), p.cel(), p.x, p.y));
|
||||
}
|
||||
draws.sort_by_key(|d| d.0);
|
||||
|
||||
for (pri, cel, bx, by) in draws {
|
||||
let ox = bx - cel.w as i32 / 2;
|
||||
let oy = by - cel.h as i32;
|
||||
for cy in 0..cel.h {
|
||||
for cx in 0..cel.w {
|
||||
let c = cel.at(cx, cy);
|
||||
if c == view::TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let sx = ox + cx as i32;
|
||||
let sy = oy + cy as i32;
|
||||
if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H {
|
||||
continue;
|
||||
}
|
||||
let i = sy as usize * PIC_W + sx as usize;
|
||||
if pri >= room.priority[i] {
|
||||
vis[i] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = img.get_image_data_mut();
|
||||
for (i, &v) in vis.iter().enumerate() {
|
||||
data[i] = palette::rgba(v);
|
||||
}
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
fn compute() -> Self {
|
||||
let sw = screen_width();
|
||||
let sh = screen_height();
|
||||
let avail_h = (sh - STATUS_H).max(1.0);
|
||||
let scale = (sw / 320.0).min(avail_h / PIC_H as f32);
|
||||
let w = 320.0 * scale;
|
||||
let h = PIC_H as f32 * scale;
|
||||
Layout { x: (sw - w) * 0.5, y: 0.0, w, h }
|
||||
}
|
||||
|
||||
fn to_picture(&self, mx: f32, my: f32) -> Option<(i32, i32)> {
|
||||
let px = (mx - self.x) / (self.w / PIC_W as f32);
|
||||
let py = (my - self.y) / (self.h / PIC_H as f32);
|
||||
if px >= 0.0 && py >= 0.0 && px < PIC_W as f32 && py < PIC_H as f32 {
|
||||
Some((px as i32, py as i32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_scanlines(l: &Layout) {
|
||||
let mut y = l.y;
|
||||
while y < l.y + l.h {
|
||||
draw_line(l.x, y, l.x + l.w, y, 1.0, Color::new(0.0, 0.0, 0.0, 0.28));
|
||||
y += 3.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_play_panel(transcript: &[String], command: &str, inventory: &[String], room: u32, crt: bool, ai: &str, sound_on: bool, talking: bool) {
|
||||
let sw = screen_width();
|
||||
let sh = screen_height();
|
||||
let top = sh - STATUS_H;
|
||||
draw_rectangle(0.0, top, sw, STATUS_H, color_u8!(12, 14, 18, 255));
|
||||
draw_line(0.0, top, sw, top, 1.0, color_u8!(60, 70, 85, 255));
|
||||
|
||||
let mut yy = top + 20.0;
|
||||
let start = transcript.len().saturating_sub(5);
|
||||
for line in &transcript[start..] {
|
||||
let col = if line.starts_with('>') {
|
||||
color_u8!(255, 135, 215, 255)
|
||||
} else {
|
||||
color_u8!(205, 213, 225, 255)
|
||||
};
|
||||
draw_text(line, 12.0, yy, 18.0, col);
|
||||
yy += 18.0;
|
||||
}
|
||||
|
||||
if talking {
|
||||
draw_text("(type a number to choose · Esc to leave)", 12.0, sh - 30.0, 18.0, color_u8!(95, 255, 215, 255));
|
||||
} else {
|
||||
draw_text(&format!("> {}_", command), 12.0, sh - 30.0, 20.0, color_u8!(95, 255, 215, 255));
|
||||
}
|
||||
let inv = if inventory.is_empty() { "—".to_string() } else { inventory.join(", ") };
|
||||
draw_text(
|
||||
&format!("room {} · inv: {} · ai: {} · Tab: edit · F1: CRT[{}] · F2: snd[{}] · Esc: quit", room, inv, ai, if crt { "on" } else { "off" }, if sound_on { "on" } else { "off" }),
|
||||
12.0,
|
||||
sh - 8.0,
|
||||
14.0,
|
||||
color_u8!(120, 130, 145, 255),
|
||||
);
|
||||
}
|
||||
|
||||
/// A live, in-progress conversation (a clone of an NPC's dialogue tree).
|
||||
struct Dlg {
|
||||
nodes: Vec<editor::DlgNode>,
|
||||
node: usize,
|
||||
}
|
||||
|
||||
impl Dlg {
|
||||
fn lines(&self) -> Vec<String> {
|
||||
let n = &self.nodes[self.node];
|
||||
let mut out = vec![format!("\u{201C}{}\u{201D}", n.says)];
|
||||
for (i, c) in n.choices.iter().enumerate() {
|
||||
out.push(format!(" {}) {}", i + 1, c.text));
|
||||
}
|
||||
out
|
||||
}
|
||||
fn terminal(&self) -> bool {
|
||||
self.nodes[self.node].choices.is_empty()
|
||||
}
|
||||
/// Pick choice `k` (1-based). Returns (lines to print, conversation ended).
|
||||
fn choose(&mut self, k: usize) -> (Vec<String>, bool) {
|
||||
let nlen = self.nodes.len();
|
||||
let choices = self.nodes[self.node].choices.clone();
|
||||
if k == 0 || k > choices.len() {
|
||||
return (vec![], false);
|
||||
}
|
||||
let c = &choices[k - 1];
|
||||
let mut out = vec![format!("> {}", c.text)];
|
||||
if c.goto < 0 || c.goto as usize >= nlen {
|
||||
return (out, true);
|
||||
}
|
||||
self.node = c.goto as usize;
|
||||
out.extend(self.lines());
|
||||
(out, self.terminal())
|
||||
}
|
||||
}
|
||||
|
||||
fn digit_pressed() -> Option<usize> {
|
||||
for (k, code) in [
|
||||
(1usize, KeyCode::Key1),
|
||||
(2, KeyCode::Key2),
|
||||
(3, KeyCode::Key3),
|
||||
(4, KeyCode::Key4),
|
||||
(5, KeyCode::Key5),
|
||||
(6, KeyCode::Key6),
|
||||
] {
|
||||
if is_key_pressed(code) {
|
||||
return Some(k);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const IGNORE: &[&str] = &["the", "a", "an", "at", "to", "with", "on", "of", "my", "your", "some", "please", "go", "up"];
|
||||
|
||||
/// Map a typed word to a canonical verb ("" if unknown).
|
||||
fn canon_verb(w: &str) -> &'static str {
|
||||
match w {
|
||||
"look" | "l" | "examine" | "x" | "inspect" | "check" | "read" | "view" | "see" | "study" => "look",
|
||||
"take" | "get" | "grab" | "pick" | "snatch" | "steal" | "pocket" | "collect" | "nab" => "take",
|
||||
"use" | "open" | "unlock" | "activate" | "push" | "pull" | "turn" | "operate" => "use",
|
||||
"talk" | "speak" | "ask" | "chat" | "greet" | "say" => "talk",
|
||||
"drop" | "discard" | "leave" | "put" => "drop",
|
||||
"inventory" | "inv" | "i" | "items" | "bag" => "inventory",
|
||||
"help" | "?" | "commands" | "verbs" => "help",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this object answer to `noun` (its name or any synonym, loosely)?
|
||||
fn obj_matches(o: &ObjDef, noun: &str) -> bool {
|
||||
if noun.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut names: Vec<String> = vec![o.name.to_lowercase()];
|
||||
for s in o.synonyms.split_whitespace() {
|
||||
names.push(s.to_lowercase());
|
||||
}
|
||||
names.iter().any(|nm| nm.as_str() == noun || nm.contains(noun) || noun.contains(nm.as_str()))
|
||||
}
|
||||
|
||||
/// Index of a visible (not-taken) object that answers to `noun`.
|
||||
fn match_obj(objects: &[ObjDef], room: u32, taken: &[(u32, String)], noun: &str) -> Option<usize> {
|
||||
objects.iter().position(|o| {
|
||||
let visible = !taken.iter().any(|(r, n)| *r == room && *n == o.name);
|
||||
visible && obj_matches(o, noun)
|
||||
})
|
||||
}
|
||||
|
||||
/// The parser: typed line → ignore-words stripped → canonical verb + noun →
|
||||
/// response. Deterministic; the AI lane plugs into the catch-all branch later.
|
||||
fn run_command(input: &str, objects: &[ObjDef], room: u32, inventory: &mut Vec<String>, taken: &mut Vec<(u32, String)>, won: &mut bool, talk_target: &mut Option<usize>) -> (Vec<String>, bool) {
|
||||
let lower = input.trim().to_lowercase();
|
||||
let words: Vec<&str> = lower.split_whitespace().filter(|w| !IGNORE.contains(w)).collect();
|
||||
if words.is_empty() {
|
||||
return (vec![], true);
|
||||
}
|
||||
let verb = canon_verb(words[0]);
|
||||
let noun = words[1..].join(" ");
|
||||
let understood = !verb.is_empty();
|
||||
let lines = match verb {
|
||||
"look" => {
|
||||
if noun.is_empty() || noun == "around" || noun == "room" || noun == "here" {
|
||||
let names: Vec<String> = objects
|
||||
.iter()
|
||||
.filter(|o| !taken.iter().any(|(r, n)| *r == room && *n == o.name))
|
||||
.map(|o| o.name.clone())
|
||||
.collect();
|
||||
if names.is_empty() {
|
||||
vec![format!("Room {}. Nothing in particular stands out.", room)]
|
||||
} else {
|
||||
vec![format!("Room {}. You see: {}.", room, names.join(", "))]
|
||||
}
|
||||
} else if let Some(i) = match_obj(objects, room, taken, &noun) {
|
||||
vec![objects[i].look.clone()]
|
||||
} else {
|
||||
vec![format!("You don't see any '{}' here.", noun)]
|
||||
}
|
||||
}
|
||||
"take" => {
|
||||
if let Some(i) = match_obj(objects, room, taken, &noun) {
|
||||
if objects[i].takeable {
|
||||
inventory.push(objects[i].name.clone());
|
||||
taken.push((room, objects[i].name.clone()));
|
||||
vec![format!("You take the {}.", objects[i].name)]
|
||||
} else {
|
||||
vec![format!("You can't take the {}.", objects[i].name)]
|
||||
}
|
||||
} else {
|
||||
vec![format!("There's no '{}' to take.", noun)]
|
||||
}
|
||||
}
|
||||
"drop" => {
|
||||
if let Some(p) = inventory.iter().position(|n| !noun.is_empty() && n.to_lowercase().contains(noun.as_str())) {
|
||||
let n = inventory.remove(p);
|
||||
vec![format!("You drop the {}.", n)]
|
||||
} else {
|
||||
vec![format!("You aren't carrying a '{}'.", noun)]
|
||||
}
|
||||
}
|
||||
"inventory" => {
|
||||
if inventory.is_empty() {
|
||||
vec!["You're carrying nothing.".to_string()]
|
||||
} else {
|
||||
vec![format!("Carrying: {}.", inventory.join(", "))]
|
||||
}
|
||||
}
|
||||
"use" => {
|
||||
if let Some(i) = match_obj(objects, room, taken, &noun) {
|
||||
let needs = objects[i].needs.to_lowercase();
|
||||
if !needs.is_empty() && !inventory.iter().any(|it| it.to_lowercase() == needs) {
|
||||
vec![format!("It's locked. You need: {}.", objects[i].needs)]
|
||||
} else {
|
||||
let mut out = vec![if objects[i].use_text.is_empty() {
|
||||
format!("You use the {}. Nothing obvious happens.", objects[i].name)
|
||||
} else {
|
||||
objects[i].use_text.clone()
|
||||
}];
|
||||
if objects[i].wins {
|
||||
*won = true;
|
||||
out.push("\u{2605} THE END — you win! \u{2605}".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
} else {
|
||||
vec![format!("There's no '{}' to use.", noun)]
|
||||
}
|
||||
}
|
||||
"talk" => {
|
||||
if let Some(i) = match_obj(objects, room, taken, &noun) {
|
||||
if objects[i].dialogue.is_empty() {
|
||||
vec![format!("{} has nothing to say.", objects[i].name)]
|
||||
} else {
|
||||
*talk_target = Some(i);
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![format!("There's no '{}' to talk to.", noun)]
|
||||
}
|
||||
}
|
||||
"help" => vec!["Verbs: look · take · drop · use · talk · inventory. Arrows walk.".to_string()],
|
||||
_ => vec![format!("I don't understand \"{}\".", input.trim())],
|
||||
};
|
||||
(lines, understood)
|
||||
}
|
||||
|
||||
/// A little front-facing party robot. Two cels make a 2-frame walk bob.
|
||||
fn robot_view() -> View {
|
||||
let cel0 = cel_from_ascii(&[
|
||||
".....c.....",
|
||||
".....8.....",
|
||||
"...77777...",
|
||||
"..7777777..",
|
||||
"..7b777b7..",
|
||||
"..7777777..",
|
||||
"...77777...",
|
||||
"....888....",
|
||||
"..7777777..",
|
||||
".777777777.",
|
||||
".7777e7777.",
|
||||
".777777777.",
|
||||
"..7777777..",
|
||||
"..8.....8..",
|
||||
"..88...88..",
|
||||
"..88...88..",
|
||||
".888...888.",
|
||||
".888...888.",
|
||||
]);
|
||||
let cel1 = cel_from_ascii(&[
|
||||
".....c.....",
|
||||
".....8.....",
|
||||
"...77777...",
|
||||
"..7777777..",
|
||||
"..7b777b7..",
|
||||
"..7777777..",
|
||||
"...77777...",
|
||||
"....888....",
|
||||
"..7777777..",
|
||||
".777777777.",
|
||||
".7777e7777.",
|
||||
".777777777.",
|
||||
"..7777777..",
|
||||
"..8.....8..",
|
||||
"..88...88..",
|
||||
"..88...88..",
|
||||
"..888.888..",
|
||||
"..888.888..",
|
||||
]);
|
||||
View {
|
||||
loops: vec![ViewLoop {
|
||||
cels: vec![cel0, cel1],
|
||||
}],
|
||||
}
|
||||
}
|
||||
40
src/palette.rs
Normal file
@ -0,0 +1,40 @@
|
||||
//! The 16-color EGA palette MRPGI inherits from the original AGI.
|
||||
//!
|
||||
//! Everything in the engine works in palette *indices* (0..=15); we only
|
||||
//! convert to real RGBA at the very last moment, when we hand pixels to the GPU.
|
||||
|
||||
use macroquad::prelude::Color;
|
||||
|
||||
/// Standard IBM EGA / CGA 16-color palette, as (R, G, B).
|
||||
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
|
||||
];
|
||||
|
||||
/// Raw RGBA bytes for a palette index (for writing straight into an Image).
|
||||
#[inline]
|
||||
pub fn rgba(idx: u8) -> [u8; 4] {
|
||||
let (r, g, b) = EGA[(idx & 0x0F) as usize];
|
||||
[r, g, b, 255]
|
||||
}
|
||||
|
||||
/// macroquad `Color` for a palette index (for `draw_*` calls).
|
||||
#[inline]
|
||||
pub fn color(idx: u8) -> Color {
|
||||
let (r, g, b) = EGA[(idx & 0x0F) as usize];
|
||||
Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
|
||||
}
|
||||
283
src/picture.rs
Normal file
@ -0,0 +1,283 @@
|
||||
//! Vector-style room painting.
|
||||
//!
|
||||
//! In real AGI a PICTURE resource is a stream of drawing opcodes. MRPGI keeps
|
||||
//! that spirit but in a clean, hand-editable form: a room is just a list of
|
||||
//! [`PicOp`]s that paint into the visual and/or priority buffers. Leaving a
|
||||
//! buffer as `None` lets you, say, set depth without drawing anything visible.
|
||||
|
||||
use crate::framebuffer::{band, FrameBuffer, PIC_H, PIC_W};
|
||||
use crate::view::{Cel, TRANSPARENT};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum PicOp {
|
||||
/// Filled rectangle.
|
||||
Rect {
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
vis: Option<u8>,
|
||||
pri: Option<u8>,
|
||||
},
|
||||
/// Connected polyline through the given points.
|
||||
Line {
|
||||
points: Vec<(i32, i32)>,
|
||||
vis: u8,
|
||||
pri: Option<u8>,
|
||||
},
|
||||
/// Single pixel.
|
||||
Pixel {
|
||||
x: i32,
|
||||
y: i32,
|
||||
vis: Option<u8>,
|
||||
pri: Option<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn draw(fb: &mut FrameBuffer, ops: &[PicOp]) {
|
||||
for op in ops {
|
||||
match op {
|
||||
PicOp::Rect { x, y, w, h, vis, pri } => {
|
||||
for yy in *y..(*y + *h) {
|
||||
for xx in *x..(*x + *w) {
|
||||
put(fb, xx, yy, *vis, *pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
PicOp::Pixel { x, y, vis, pri } => put(fb, *x, *y, *vis, *pri),
|
||||
PicOp::Line { points, vis, pri } => {
|
||||
for pair in points.windows(2) {
|
||||
line(fb, pair[0], pair[1], *vis, *pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn put(fb: &mut FrameBuffer, x: i32, y: i32, vis: Option<u8>, pri: Option<u8>) {
|
||||
if x < 0 || y < 0 || x as usize >= PIC_W || y as usize >= PIC_H {
|
||||
return;
|
||||
}
|
||||
let i = y as usize * PIC_W + x as usize;
|
||||
if let Some(c) = vis {
|
||||
fb.visual[i] = c;
|
||||
}
|
||||
if let Some(p) = pri {
|
||||
fb.priority[i] = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bresenham line into the visual buffer (and priority, if given).
|
||||
fn line(fb: &mut FrameBuffer, a: (i32, i32), b: (i32, i32), vis: u8, pri: Option<u8>) {
|
||||
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 {
|
||||
put(fb, x0, y0, Some(vis), pri);
|
||||
if x0 == x1 && y0 == y1 {
|
||||
break;
|
||||
}
|
||||
let e2 = 2 * err;
|
||||
if e2 >= dy {
|
||||
err += dy;
|
||||
x0 += sx;
|
||||
}
|
||||
if e2 <= dx {
|
||||
err += dx;
|
||||
y0 += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an image maps into a region when its size doesn't match.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Fit {
|
||||
/// Scale to fill the region exactly (nearest-neighbor, keeps pixels crisp).
|
||||
Stretch,
|
||||
/// Repeat the image across the region.
|
||||
Tile,
|
||||
/// Place at original size, centered, clipped to the region.
|
||||
Center,
|
||||
}
|
||||
|
||||
/// Stamp a cel (a quantized image or sprite) into the buffers with its
|
||||
/// top-left at (dx, dy). Transparent pixels are skipped; `pri` optionally
|
||||
/// bakes depth/control under the painted pixels (AGI's `add.to.pic`).
|
||||
pub fn stamp_cel(fb: &mut FrameBuffer, cel: &Cel, dx: i32, dy: i32, pri: Option<u8>) {
|
||||
for cy in 0..cel.h {
|
||||
for cx in 0..cel.w {
|
||||
let c = cel.at(cx, cy);
|
||||
if c == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
put(fb, dx + cx as i32, dy + cy as i32, Some(c), pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "draw a shape, pour an image into it" primitive: fill a rectangular
|
||||
/// region with an image using a [`Fit`] mode. The region is the shape (and can
|
||||
/// also stamp depth via `pri`); the image supplies the look.
|
||||
pub fn stamp_cel_fit(
|
||||
fb: &mut FrameBuffer,
|
||||
region: (i32, i32, i32, i32),
|
||||
cel: &Cel,
|
||||
fit: Fit,
|
||||
pri: Option<u8>,
|
||||
) {
|
||||
let (rx, ry, rw, rh) = region;
|
||||
if rw <= 0 || rh <= 0 || cel.w == 0 || cel.h == 0 {
|
||||
return;
|
||||
}
|
||||
for dyk in 0..rh {
|
||||
for dxk in 0..rw {
|
||||
let (sx, sy) = match fit {
|
||||
Fit::Stretch => (
|
||||
(dxk * cel.w as i32 / rw) as usize,
|
||||
(dyk * cel.h as i32 / rh) as usize,
|
||||
),
|
||||
Fit::Tile => ((dxk as usize) % cel.w, (dyk as usize) % cel.h),
|
||||
Fit::Center => {
|
||||
let ox = (rw - cel.w as i32) / 2;
|
||||
let oy = (rh - cel.h as i32) / 2;
|
||||
let sx = dxk - ox;
|
||||
let sy = dyk - oy;
|
||||
if sx < 0 || sy < 0 || sx >= cel.w as i32 || sy >= cel.h as i32 {
|
||||
continue;
|
||||
}
|
||||
(sx as usize, sy as usize)
|
||||
}
|
||||
};
|
||||
let c = cel.at(sx.min(cel.w - 1), sy.min(cel.h - 1));
|
||||
if c == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
put(fb, rx + dxk, ry + dyk, Some(c), pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor primitives — painting into the LOOK (visual) and MEANING (priority)
|
||||
// buffers. `PriFill` is how a paint stroke writes the priority/control layer:
|
||||
// leave it (Keep), stamp a fixed control value (Set), or restore the default
|
||||
// walkable depth band for that row (Band).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum PriFill {
|
||||
Keep,
|
||||
Set(u8),
|
||||
Band,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn put_pri(fb: &mut FrameBuffer, x: i32, y: i32, p: PriFill) {
|
||||
if x < 0 || y < 0 || x as usize >= PIC_W || y as usize >= PIC_H {
|
||||
return;
|
||||
}
|
||||
let i = y as usize * PIC_W + x as usize;
|
||||
match p {
|
||||
PriFill::Keep => {}
|
||||
PriFill::Set(v) => fb.priority[i] = v,
|
||||
PriFill::Band => fb.priority[i] = band(y as usize),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one pixel: optional visual colour + a priority write.
|
||||
pub fn px(fb: &mut FrameBuffer, x: i32, y: i32, color: Option<u8>, pri: PriFill) {
|
||||
if let Some(c) = color {
|
||||
put(fb, x, y, Some(c), None);
|
||||
}
|
||||
put_pri(fb, x, y, pri);
|
||||
}
|
||||
|
||||
/// Bresenham line writing visual colour (+ optional priority).
|
||||
pub fn line_into(fb: &mut FrameBuffer, a: (i32, i32), b: (i32, i32), color: u8, pri: PriFill) {
|
||||
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(fb, x0, y0, Some(color), pri);
|
||||
if x0 == x1 && y0 == y1 {
|
||||
break;
|
||||
}
|
||||
let e2 = 2 * err;
|
||||
if e2 >= dy {
|
||||
err += dy;
|
||||
x0 += sx;
|
||||
}
|
||||
if e2 <= dx {
|
||||
err += dx;
|
||||
y0 += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filled rectangle into visual (+ optional priority).
|
||||
pub fn rect_into(fb: &mut FrameBuffer, x: i32, y: i32, w: i32, h: i32, color: Option<u8>, pri: PriFill) {
|
||||
for yy in y..(y + h) {
|
||||
for xx in x..(x + w) {
|
||||
px(fb, xx, yy, color, pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flood fill the connected region sharing the start pixel's visual colour.
|
||||
/// Writes `color` to the visual buffer and `pri` to the priority buffer for
|
||||
/// every filled pixel. Returns the number of pixels filled (for leak checks).
|
||||
pub fn flood_fill(fb: &mut FrameBuffer, sx: i32, sy: i32, color: Option<u8>, pri: PriFill) -> usize {
|
||||
if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H {
|
||||
return 0;
|
||||
}
|
||||
let target = fb.visual[sy as usize * PIC_W + sx as usize];
|
||||
let mut visited = vec![false; PIC_W * PIC_H];
|
||||
let mut stack = vec![(sx, sy)];
|
||||
let mut count = 0usize;
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
if x < 0 || y < 0 || x as usize >= PIC_W || y as usize >= PIC_H {
|
||||
continue;
|
||||
}
|
||||
let i = y as usize * PIC_W + x as usize;
|
||||
if visited[i] || fb.visual[i] != target {
|
||||
continue;
|
||||
}
|
||||
visited[i] = true;
|
||||
px(fb, x, y, color, pri);
|
||||
count += 1;
|
||||
stack.push((x + 1, y));
|
||||
stack.push((x - 1, y));
|
||||
stack.push((x, y + 1));
|
||||
stack.push((x, y - 1));
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Filled ellipse inscribed in the rect (x, y, w, h).
|
||||
pub fn ellipse_into(fb: &mut FrameBuffer, x: i32, y: i32, w: i32, h: i32, color: Option<u8>, pri: PriFill) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return;
|
||||
}
|
||||
let rx = w as f32 / 2.0;
|
||||
let ry = h as f32 / 2.0;
|
||||
let cx = x as f32 + rx - 0.5;
|
||||
let cy = y as f32 + ry - 0.5;
|
||||
for yy in y..(y + h) {
|
||||
for xx in x..(x + w) {
|
||||
let nx = (xx as f32 - cx) / rx;
|
||||
let ny = (yy as f32 - cy) / ry;
|
||||
if nx * nx + ny * ny <= 1.0 {
|
||||
px(fb, xx, yy, color, pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/room.rs
Normal file
@ -0,0 +1,72 @@
|
||||
//! The demo room — now a showcase for the asset pipeline. It mixes
|
||||
//! hand-authored vector ops (walls, depth, control lines) with three kinds of
|
||||
//! image insertion: a TILED floor, an image POURED into a wall shape, and an
|
||||
//! OBJECT (the crate) you can walk behind — all sourced from PNGs.
|
||||
|
||||
use crate::assets::Assets;
|
||||
use crate::framebuffer::{band, FrameBuffer, CTRL_BLOCK};
|
||||
use crate::picture::{draw, stamp_cel_fit, Fit, PicOp};
|
||||
use crate::sprite::Prop;
|
||||
|
||||
pub struct Scene {
|
||||
pub fb: FrameBuffer,
|
||||
pub props: Vec<Prop>,
|
||||
}
|
||||
|
||||
pub fn demo_room(assets: &Assets) -> Scene {
|
||||
let mut fb = FrameBuffer::new();
|
||||
let col_pri = band(138);
|
||||
|
||||
// 1. Wall + skirting — both barriers (you can't walk into the wall).
|
||||
draw(
|
||||
&mut fb,
|
||||
&[
|
||||
PicOp::Rect { x: 0, y: 0, w: 160, h: 72, vis: Some(1), pri: Some(CTRL_BLOCK) },
|
||||
PicOp::Rect { x: 0, y: 70, w: 160, h: 2, vis: Some(8), pri: Some(CTRL_BLOCK) },
|
||||
],
|
||||
);
|
||||
|
||||
// 2. TILE a pixel-art floor across the play area (keeps default depth bands).
|
||||
stamp_cel_fit(&mut fb, (0, 72, 160, 96), &assets.floor, Fit::Tile, None);
|
||||
|
||||
// 3. Wall dressing: neon signs + a doorway (the door is a barrier).
|
||||
draw(
|
||||
&mut fb,
|
||||
&[
|
||||
PicOp::Rect { x: 12, y: 14, w: 30, h: 8, vis: Some(13), pri: None },
|
||||
PicOp::Rect { x: 118, y: 14, w: 30, h: 8, vis: Some(11), pri: None },
|
||||
PicOp::Rect { x: 60, y: 8, w: 40, h: 6, vis: Some(14), pri: None },
|
||||
PicOp::Rect { x: 66, y: 28, w: 28, h: 44, vis: Some(6), pri: Some(CTRL_BLOCK) },
|
||||
PicOp::Rect { x: 70, y: 32, w: 20, h: 40, vis: Some(0), pri: Some(CTRL_BLOCK) },
|
||||
PicOp::Pixel { x: 87, y: 52, vis: Some(14), pri: None },
|
||||
],
|
||||
);
|
||||
|
||||
// 4. POUR an image into a shape on the wall (the "draw a shape, load an
|
||||
// image into it" primitive). Visual only — it's flat on the wall.
|
||||
stamp_cel_fit(&mut fb, (10, 24, 44, 34), &assets.poster, Fit::Stretch, None);
|
||||
|
||||
// 5. The walk-behind column, drawn on top of the floor (depth only).
|
||||
draw(
|
||||
&mut fb,
|
||||
&[
|
||||
PicOp::Rect { x: 116, y: 74, w: 12, h: 64, vis: Some(7), pri: Some(col_pri) },
|
||||
PicOp::Rect { x: 117, y: 74, w: 3, h: 64, vis: Some(15), pri: Some(col_pri) },
|
||||
],
|
||||
);
|
||||
|
||||
// An OBJECT sourced from a PNG. As a live prop it sorts with the ego, so
|
||||
// you can walk in front of and behind it.
|
||||
let crate_prop = Prop {
|
||||
x: 48,
|
||||
y: 132,
|
||||
cel: assets.crate_box.clone(),
|
||||
fixed_priority: None, // depth taken from its feet, like any object
|
||||
name: "crate".to_string(),
|
||||
};
|
||||
|
||||
Scene {
|
||||
fb,
|
||||
props: vec![crate_prop],
|
||||
}
|
||||
}
|
||||
151
src/sound.rs
Normal file
@ -0,0 +1,151 @@
|
||||
//! Tiny chiptune SFX — square waves synthesized to PCM at runtime (no files),
|
||||
//! in the spirit of AGI's PCjr beeper. Each cue is a short tone or arpeggio.
|
||||
//! If the audio backend can't start, everything degrades to silence.
|
||||
|
||||
use macroquad::audio::{load_sound_from_bytes, play_sound, stop_sound, PlaySoundParams, Sound};
|
||||
|
||||
const SR: u32 = 22050;
|
||||
|
||||
pub struct Sfx {
|
||||
blip: Option<Sound>,
|
||||
pickup: Option<Sound>,
|
||||
confirm: Option<Sound>,
|
||||
error: Option<Sound>,
|
||||
door: Option<Sound>,
|
||||
win: Option<Sound>,
|
||||
pub muted: bool,
|
||||
}
|
||||
|
||||
impl Sfx {
|
||||
pub async fn load() -> Self {
|
||||
Sfx {
|
||||
blip: snd(&seq(&[(660.0, 18)], 0.18)).await,
|
||||
pickup: snd(&seq(&[(660.0, 55), (988.0, 80)], 0.20)).await,
|
||||
confirm: snd(&seq(&[(523.0, 55), (784.0, 85)], 0.20)).await,
|
||||
error: snd(&seq(&[(196.0, 90), (147.0, 150)], 0.22)).await,
|
||||
door: snd(&seq(&[(392.0, 60), (294.0, 60), (196.0, 120)], 0.20)).await,
|
||||
win: snd(&seq(&[(523.0, 90), (659.0, 90), (784.0, 90), (1047.0, 240)], 0.22)).await,
|
||||
muted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn go(&self, s: &Option<Sound>, vol: f32) {
|
||||
if self.muted {
|
||||
return;
|
||||
}
|
||||
if let Some(snd) = s {
|
||||
play_sound(snd, PlaySoundParams { looped: false, volume: vol });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blip(&self) { self.go(&self.blip, 0.4); }
|
||||
pub fn pickup(&self) { self.go(&self.pickup, 0.7); }
|
||||
pub fn confirm(&self) { self.go(&self.confirm, 0.55); }
|
||||
pub fn error(&self) { self.go(&self.error, 0.6); }
|
||||
pub fn door(&self) { self.go(&self.door, 0.6); }
|
||||
pub fn win(&self) { self.go(&self.win, 0.85); }
|
||||
}
|
||||
|
||||
async fn snd(bytes: &[u8]) -> Option<Sound> {
|
||||
load_sound_from_bytes(bytes).await.ok()
|
||||
}
|
||||
|
||||
/// Looping ambient room music. Index 0 = silence; 1..=5 are moods that match
|
||||
/// the editor's mood picker (calm / eerie / tense / jolly / spooky).
|
||||
pub struct Music {
|
||||
tracks: Vec<Option<Sound>>,
|
||||
current: u8,
|
||||
pub muted: bool,
|
||||
}
|
||||
|
||||
impl Music {
|
||||
pub async fn load() -> Self {
|
||||
let tracks = vec![
|
||||
None, // 0 = silence
|
||||
snd(&seq(&[(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.16)).await,
|
||||
snd(&seq(&[(220.0, 420), (0.0, 320), (311.0, 420), (0.0, 520), (233.0, 420), (0.0, 900)], 0.16)).await,
|
||||
snd(&seq(&[(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.18)).await,
|
||||
snd(&seq(&[(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.15)).await,
|
||||
snd(&seq(&[(165.0, 520), (0.0, 700), (196.0, 420), (0.0, 520), (147.0, 620), (0.0, 1100)], 0.16)).await,
|
||||
];
|
||||
Music { tracks, current: 255, muted: false }
|
||||
}
|
||||
|
||||
/// Switch to a mood (no-op if already playing it). Loops at low volume.
|
||||
pub fn set(&mut self, mood: u8) {
|
||||
if mood == self.current {
|
||||
return;
|
||||
}
|
||||
if let Some(Some(s)) = self.tracks.get(self.current as usize) {
|
||||
stop_sound(s);
|
||||
}
|
||||
self.current = mood;
|
||||
if !self.muted {
|
||||
if let Some(Some(s)) = self.tracks.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.tracks {
|
||||
if let Some(s) = t {
|
||||
stop_sound(s);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let c = self.current;
|
||||
self.current = 255;
|
||||
self.set(c); // replay current mood
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a sequence of (frequency Hz, duration ms) square-wave notes to a WAV.
|
||||
fn seq(notes: &[(f32, u32)], vol: f32) -> Vec<u8> {
|
||||
let mut samples: Vec<i16> = Vec::new();
|
||||
for &(freq, ms) in notes {
|
||||
let n = (SR as u64 * ms as u64 / 1000) as usize;
|
||||
if freq <= 0.0 {
|
||||
for _ in 0..n {
|
||||
samples.push(0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let period = (SR as f32 / freq).max(2.0);
|
||||
for i in 0..n {
|
||||
let phase = (i as f32 % period) / period;
|
||||
let square = if phase < 0.5 { 1.0 } else { -1.0 };
|
||||
// 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);
|
||||
let v = square * vol * env * 0.7;
|
||||
samples.push((v * i16::MAX as f32) as i16);
|
||||
}
|
||||
}
|
||||
wav_bytes(&samples)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
166
src/sprite.rs
Normal file
@ -0,0 +1,166 @@
|
||||
//! Screen objects — the ego (player avatar) and, eventually, NPCs.
|
||||
//!
|
||||
//! A screen object's position is the bottom-center of its current cel — its
|
||||
//! "feet". That single point drives both depth sorting (which priority band
|
||||
//! it's standing in) and collision (what control line it's about to step on),
|
||||
//! exactly like AGI's base-point model.
|
||||
|
||||
use crate::framebuffer::{band, FrameBuffer, CTRL_BLOCK};
|
||||
use crate::view::{Cel, View};
|
||||
|
||||
/// 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).
|
||||
const DIRV: [(i32, i32); 9] = [
|
||||
(0, 0),
|
||||
(0, -1),
|
||||
(1, -1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
(0, 1),
|
||||
(-1, 1),
|
||||
(-1, 0),
|
||||
(-1, -1),
|
||||
];
|
||||
|
||||
pub struct ScreenObj {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub view: View,
|
||||
pub cur_loop: usize,
|
||||
pub cur_cel: usize,
|
||||
pub dir: u8,
|
||||
pub step_size: i32,
|
||||
pub cycle_div: u32,
|
||||
cycle_count: u32,
|
||||
pub fixed_priority: Option<u8>,
|
||||
pub move_target: Option<(i32, i32)>,
|
||||
// Base-point clamp box for this room.
|
||||
pub min_x: i32,
|
||||
pub max_x: i32,
|
||||
pub min_y: i32,
|
||||
pub max_y: i32,
|
||||
}
|
||||
|
||||
impl ScreenObj {
|
||||
pub fn new(view: View, x: i32, y: i32) -> Self {
|
||||
ScreenObj {
|
||||
x,
|
||||
y,
|
||||
view,
|
||||
cur_loop: 0,
|
||||
cur_cel: 0,
|
||||
dir: 0,
|
||||
step_size: 2,
|
||||
cycle_div: 4,
|
||||
cycle_count: 0,
|
||||
fixed_priority: None,
|
||||
move_target: None,
|
||||
min_x: 8,
|
||||
max_x: 152,
|
||||
min_y: 74,
|
||||
max_y: 165,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cel(&self) -> &Cel {
|
||||
&self.view.loops[self.cur_loop].cels[self.cur_cel]
|
||||
}
|
||||
|
||||
/// 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 as usize))
|
||||
}
|
||||
|
||||
/// Advance one game cycle: resolve any click destination into a direction,
|
||||
/// attempt to move, handle collisions, and run the walk animation.
|
||||
pub fn update(&mut self, fb: &FrameBuffer) {
|
||||
// Click-to-walk: steer toward the target, arriving when close.
|
||||
if let Some((tx, ty)) = self.move_target {
|
||||
let dx = tx - self.x;
|
||||
let dy = ty - self.y;
|
||||
if dx.abs() <= self.step_size && dy.abs() <= self.step_size {
|
||||
self.x = tx.clamp(self.min_x, self.max_x);
|
||||
self.y = ty.clamp(self.min_y, self.max_y);
|
||||
self.move_target = None;
|
||||
self.dir = 0;
|
||||
} else {
|
||||
self.dir = dir_from_delta(dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
if self.dir == 0 {
|
||||
self.cur_cel = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let (sx, sy) = DIRV[self.dir as usize];
|
||||
let nx = (self.x + sx * self.step_size).clamp(self.min_x, self.max_x);
|
||||
let ny = (self.y + sy * self.step_size).clamp(self.min_y, self.max_y);
|
||||
|
||||
// Blocked by a control barrier?
|
||||
if fb.priority_at(nx, ny) == CTRL_BLOCK {
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
// Clamped against an edge with nowhere to go?
|
||||
if nx == self.x && ny == self.y {
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
self.x = nx;
|
||||
self.y = ny;
|
||||
|
||||
// Walk-cycle animation.
|
||||
self.cycle_count += 1;
|
||||
if self.cycle_count >= self.cycle_div {
|
||||
self.cycle_count = 0;
|
||||
let n = self.view.loops[self.cur_loop].cels.len();
|
||||
if n > 0 {
|
||||
self.cur_cel = (self.cur_cel + 1) % n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.dir = 0;
|
||||
self.move_target = None;
|
||||
self.cur_cel = 0;
|
||||
self.cycle_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_from_delta(dx: i32, dy: i32) -> u8 {
|
||||
match (dx.signum(), dy.signum()) {
|
||||
(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,
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed object in the room — scenery, items, future NPCs. Like the ego,
|
||||
/// its base point is the bottom-center "feet", which gives it a depth band so
|
||||
/// it sorts correctly against the player and the scenery.
|
||||
pub struct Prop {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub cel: Cel,
|
||||
pub fixed_priority: Option<u8>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Prop {
|
||||
pub fn priority(&self) -> u8 {
|
||||
self.fixed_priority.unwrap_or_else(|| band(self.y as usize))
|
||||
}
|
||||
|
||||
pub fn cel(&self) -> &Cel {
|
||||
&self.cel
|
||||
}
|
||||
}
|
||||
51
src/view.rs
Normal file
@ -0,0 +1,51 @@
|
||||
//! VIEW resources: animated sprites.
|
||||
//!
|
||||
//! An AGI view is a set of *loops* (one per facing direction), each a sequence
|
||||
//! of *cels* (animation frames). MRPGI authors cels as little ASCII grids:
|
||||
//! every character is a hex palette index (`0`-`9`, `a`-`f`) and `.`/space mean
|
||||
//! transparent. It's a tiny, diff-friendly sprite format you can edit by hand.
|
||||
|
||||
pub const TRANSPARENT: u8 = 255;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Cel {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub pixels: Vec<u8>, // palette index, or TRANSPARENT
|
||||
}
|
||||
|
||||
impl Cel {
|
||||
#[inline]
|
||||
pub fn at(&self, x: usize, y: usize) -> u8 {
|
||||
self.pixels[y * self.w + x]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ViewLoop {
|
||||
pub cels: Vec<Cel>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct View {
|
||||
pub loops: Vec<ViewLoop>,
|
||||
}
|
||||
|
||||
/// Build a cel from ASCII rows. Each char: hex `0`-`9`/`a`-`f` = palette index,
|
||||
/// `.` or space = transparent. Rows shorter than the widest are right-padded
|
||||
/// with transparency.
|
||||
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 pixels = vec![TRANSPARENT; w * h];
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
for (x, ch) in row.chars().enumerate() {
|
||||
let v = match ch {
|
||||
'.' | ' ' => TRANSPARENT,
|
||||
c => c.to_digit(16).map(|d| d as u8).unwrap_or(TRANSPARENT),
|
||||
};
|
||||
pixels[y * w + x] = v;
|
||||
}
|
||||
}
|
||||
Cel { w, h, pixels }
|
||||
}
|
||||