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>
This commit is contained in:
type-two 2026-06-17 17:26:55 +10:00
commit d0caf983a0
62 changed files with 7056 additions and 0 deletions

15
.gitignore vendored Normal file
View 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
View 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
View 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
View 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 **16** 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 ≈ 1220 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
View 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 015). 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 (16)** 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 ~1228 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 015.
**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
View 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 (015, EGA) | what you see |
| **priority** | depth bands (415) + control lines (03) | 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

BIN
assets/floor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

BIN
assets/poster.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

53
kit/ART_MANIFESTO.md Normal file
View 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 ~1228 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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>.`

View 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 }
]
}

View 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 }
]

View 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>.`

View 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 }
]
}

View 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 }
]

View 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>.`

View 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 }
]
}

View 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 }
]

View 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>.`

View 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 }
]
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

BIN
sprites/barrel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

BIN
sprites/chest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

BIN
sprites/crate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

BIN
sprites/door.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

BIN
sprites/key.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

BIN
sprites/orc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

BIN
sprites/potion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

BIN
sprites/rock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

BIN
sprites/sign.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

BIN
sprites/spellbook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

BIN
sprites/sword.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

BIN
sprites/torch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

BIN
sprites/tree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

BIN
sprites/villager.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

134
src/ai.rs Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

87
src/framebuffer.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 }
}