v0.3 — AI parser lane + dialogue talker portraits

AI lane (ported from MRPGI): unknown typed sentences go to a local LLM (MRPCI_AI: ollama default / openrouter / off) that maps them onto already-legal commands; one in flight, polled per tick, hard-off under --script so replays stay byte-identical. Portraits: NPCs/props gain a portrait sprite drawn beside the dialogue window in the GUI (6x) and annotated frames (2x); sergeant + vend-bot faces in the demo. Also: --script + --render-room renders the frame a replay ends on. Verified: 5 test suites green, deterministic 25/25 playthrough, GUI boots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-26 00:26:03 +10:00
parent 64a2db6605
commit 0191b4680d
18 changed files with 836 additions and 34 deletions

477
Cargo.lock generated
View File

@ -58,6 +58,12 @@ 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"
@ -88,6 +94,16 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -156,6 +172,17 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -171,6 +198,12 @@ 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"
@ -197,6 +230,15 @@ dependencies = [
"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 = "futures-core"
version = "0.3.33"
@ -273,6 +315,109 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[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 1.15.2",
"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 1.15.2",
"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"
@ -320,6 +465,12 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[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.33"
@ -407,6 +558,7 @@ dependencies = [
"serde",
"serde_json",
"tiny_http",
"ureq",
]
[[package]]
@ -460,6 +612,12 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -485,6 +643,15 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[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.107"
@ -566,6 +733,55 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys",
]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
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 = "rustversion"
version = "1.0.23"
@ -615,6 +831,12 @@ dependencies = [
"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.10"
@ -659,12 +881,24 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[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.119"
@ -687,6 +921,17 @@ dependencies = [
"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 2.0.119",
]
[[package]]
name = "thin-vec"
version = "0.2.18"
@ -714,6 +959,16 @@ dependencies = [
"log",
]
[[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"
@ -726,6 +981,48 @@ 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 = "version_check"
version = "0.9.5"
@ -802,6 +1099,24 @@ dependencies = [
"wasm-bindgen",
]
[[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.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -824,12 +1139,114 @@ 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 = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[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 2.0.119",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.55"
@ -850,6 +1267,66 @@ dependencies = [
"syn 2.0.119",
]
[[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 2.0.119",
"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 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"

View File

@ -111,13 +111,22 @@ The GUI renders via a **GPU palette shader**: the frame ships to the card
as indices + a 256x1 palette strip, and the fragment shader does the lookup
plus the whole CRT (curvature, scanlines, vignette, room fades).
## The AI parser lane
When the deterministic parser shrugs at a typed sentence, a small local LLM
(Ollama by default; OpenRouter optional) translates it into ONE command from
the *already-legal* verb/noun tables — then that command runs through the
real game logic. The AI can suggest; only the sim decides. Configure with
`MRPCI_AI` (`ollama`/`openrouter`/`off`), `MRPCI_AI_MODEL`, `MRPCI_AI_URL`.
`--script` replays force the lane off, so golden tests stay byte-identical.
## Roadmap
- [x] v0.1 — the engine above + Neon Precinct
- [x] v0.2 — procedural fills (fBm/Voronoi/scatter), weather particles,
in-core font + annotated frames, GPU palette/CRT shader
- [x] v0.3 — world bundles carry art (`--export-bundle`/`--bundle`, the
wasm path), AI parser lane, dialogue talker portraits
- [ ] Priority-mask art workflow polish (paint depth in any image editor)
- [ ] In-engine room editor (MRPGI's, upgraded to four screens)
- [ ] AI parser lane (LLM maps free text onto already-legal commands, like MRPGI)
- [ ] Portraits/talkers in dialogue windows
- [ ] wasm build (`mrpci-core` is already windowless — the web is an adapter away)
- [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core)

View File

@ -25,6 +25,11 @@ mrpci-headless --help
text stamped with the built-in font; add `--raw` for the bare scene),
`--screens dir/`.
NPCs and props with `dialogue` may set `portrait` (a sprite name) — the
talker face drawn beside the dialogue window in the GUI and in annotated
frames. `--script FILE --render-room OUT.png` renders the frame a replay
ends on (screenshot-after-scenario).
RoomDoc extras beyond the basics: procedural paint ops `fbm_fill`
(ramp/scale/octaves/seed), `voronoi_fill` (cell/colors/edge_color/edge/seed),
`scatter` (colors/density/seed) — all deterministic by seed — and a

View File

@ -7,9 +7,9 @@
"max_score": 25,
"ego_sprite": "",
"defaults": {
"smell": "Ozone, rain, and yesterday's synth-noodles.",
"listen": "The city hums in B-flat.",
"look": "Rain-slick chrome and old neon. Nothing more.",
"do": "Your servos find no purchase on that."
"do": "Your servos find no purchase on that.",
"listen": "The city hums in B-flat.",
"smell": "Ozone, rain, and yesterday's synth-noodles."
}
}

View File

@ -1070,8 +1070,8 @@
],
"poly": [],
"msgs": {
"look": "Neon drowns in the puddle. Your chassis manual is very clear about puddles.",
"do": "You are 900 pounds of municipal robot. The puddle wins."
"do": "You are 900 pounds of municipal robot. The puddle wins.",
"look": "Neon drowns in the puddle. Your chassis manual is very clear about puddles."
},
"exit_to": null,
"arrive": null,
@ -1105,9 +1105,9 @@
"x": 288,
"y": 148,
"msgs": {
"smell": "Your olfactory sensor files a grievance.",
"look": "A city dumpster. Something inside is composting on a geological timescale.",
"do": "You rummage. Old circuit boards, a single roller skate, regret.",
"smell": "Your olfactory sensor files a grievance."
"do": "You rummage. Old circuit boards, a single roller skate, regret."
},
"takeable": false,
"synonyms": "",
@ -1117,6 +1117,7 @@
"requires_flag": "",
"sets_flag": "",
"dialogue": [],
"portrait": "",
"points": 0,
"consumes": false,
"kills": false,
@ -1172,6 +1173,7 @@
]
}
],
"portrait": "vendbot-face",
"wander": 34,
"visible_flag": "",
"hidden_by_flag": "",

View File

@ -680,8 +680,8 @@
],
"poly": [],
"msgs": {
"look": "The duty desk. Coffee rings dating back three administrations.",
"do": "You straighten a stack of incident forms. Order is restored, briefly."
"do": "You straighten a stack of incident forms. Order is restored, briefly.",
"look": "The duty desk. Coffee rings dating back three administrations."
},
"exit_to": null,
"arrive": null,
@ -728,6 +728,7 @@
"requires_flag": "",
"sets_flag": "locker_open",
"dialogue": [],
"portrait": "",
"points": 10,
"consumes": false,
"kills": false,
@ -754,6 +755,7 @@
"requires_flag": "",
"sets_flag": "",
"dialogue": [],
"portrait": "",
"points": 10,
"consumes": true,
"kills": false,
@ -830,6 +832,7 @@
]
}
],
"portrait": "sergeant-face",
"wander": 0,
"visible_flag": "",
"hidden_by_flag": "",

View File

@ -617,6 +617,7 @@
"requires_flag": "",
"sets_flag": "",
"dialogue": [],
"portrait": "",
"points": 0,
"consumes": false,
"kills": false,
@ -642,6 +643,7 @@
"requires_flag": "",
"sets_flag": "",
"dialogue": [],
"portrait": "",
"points": 0,
"consumes": false,
"kills": false,
@ -667,6 +669,7 @@
"requires_flag": "",
"sets_flag": "",
"dialogue": [],
"portrait": "",
"points": 5,
"consumes": false,
"kills": false,
@ -692,6 +695,7 @@
"requires_flag": "",
"sets_flag": "",
"dialogue": [],
"portrait": "",
"points": 0,
"consumes": false,
"kills": true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

View File

@ -13,9 +13,11 @@ image = { version = "0.24", default-features = false, features = ["png"] } # PN
# replayed script is bit-identical — the whole CPU-speed-bug class, deleted.
rhai = { version = "1", features = ["sync", "no_time"] }
# Native-only: the HTTP surface doesn't exist in browser builds.
# Native-only: the HTTP surface and the AI parser lane don't exist in
# browser builds (wasm gets the deterministic parser and bundles).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tiny_http = "0.12" # the --serve control surface
ureq = { version = "2", features = ["json", "tls"] } # AI parser lane: Ollama / OpenRouter HTTP
[[bin]]
name = "mrpci-headless"

167
mrpci-core/src/ai.rs Normal file
View File

@ -0,0 +1,167 @@
//! The AI parser lane — MRPGI's proven trick, carried into the SCI era.
//!
//! 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 no caller ever blocks; replays and
//! `--script` runs force the lane off, so determinism is untouched.
//!
//! Provider is chosen by env vars (default = local Ollama, free + offline):
//! MRPCI_AI ollama (default) | openrouter | off
//! MRPCI_AI_MODEL e.g. "gemma3:4b" (ollama) or "google/gemma-2-9b-it:free" (openrouter)
//! MRPCI_AI_URL override the endpoint (e.g. a tailnet Ollama box)
//! OPENROUTER_API_KEY (or MRPCI_AI_KEY) for openrouter
use std::sync::mpsc::{self, Receiver};
#[cfg(not(target_arch = "wasm32"))]
use std::thread;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // browser builds only ever construct Off
#[derive(Clone, PartialEq)]
enum Provider {
Off,
Ollama,
OpenRouter,
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // url/key unused when the lane is compiled out
#[derive(Clone)]
pub struct AiConfig {
provider: Provider,
model: String,
url: String,
key: Option<String>,
}
impl AiConfig {
pub fn from_env() -> Self {
// No HTTP client, no worker threads in the browser build.
#[cfg(target_arch = "wasm32")]
return Self::off();
#[cfg(not(target_arch = "wasm32"))]
Self::from_env_native()
}
#[cfg(not(target_arch = "wasm32"))]
fn from_env_native() -> Self {
let which = std::env::var("MRPCI_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("MRPCI_AI_MODEL").unwrap_or_else(|_| match provider {
Provider::OpenRouter => "google/gemma-2-9b-it:free".into(),
_ => "gemma3:4b".into(),
});
let url = std::env::var("MRPCI_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("MRPCI_AI_KEY").ok());
AiConfig { provider, model, url, key }
}
pub fn off() -> Self {
AiConfig { provider: Provider::Off, model: String::new(), url: String::new(), key: None }
}
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. `verbs` and `things` come from the live tables, so the
/// whitelist the AI sees can never drift from what the parser accepts.
pub fn build_prompt(text: &str, things: &str, inventory: &str, verbs: &str) -> String {
format!(
"You translate a player's sentence into ONE point-and-click adventure command.
Valid verbs: {verbs}.
Things you can see: {things}.
Carried items: {inventory}.
Reply with ONLY one short command like `look sign`, `take keycard`, `talk sergeant`, or `use keycard on locker`, using a verb above and (if it helps) one of the things listed. If nothing fits, reply `look`. No quotes, no explanation.
Player: {text}
Command:"
)
}
/// Fire the translation on a worker thread; poll the returned receiver with
/// `try_recv()`. The result is a sanitized command (or "" = no idea).
#[cfg(not(target_arch = "wasm32"))]
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
}
/// Browser stub — never reached (the wasm AiConfig is always off), but keeps
/// the call sites compiling without a cfg forest.
#[cfg(target_arch = "wasm32")]
pub fn translate_async(_cfg: AiConfig, _prompt: String) -> Receiver<String> {
let (tx, rx) = mpsc::channel();
let _ = tx.send(String::new());
rx
}
#[cfg(not(target_arch = "wasm32"))]
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()
}
#[cfg(not(target_arch = "wasm32"))]
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())
}
}
}

View File

@ -93,11 +93,14 @@ fn main() {
gs.apply(Command::Tick { n });
}
if let Some(path) = get("--render-room") {
let png = if has("--raw") { gs.render_png(true) } else { gs.render_png_annotated() };
std::fs::write(&path, png).expect("write png");
println!("rendered {} ({} room {})", path, gs.world.manifest.name, gs.world.current);
return;
// (With --script, rendering happens after the replay instead.)
if get("--script").is_none() {
if let Some(path) = get("--render-room") {
let png = if has("--raw") { gs.render_png(true) } else { gs.render_png_annotated() };
std::fs::write(&path, png).expect("write png");
println!("rendered {} ({} room {})", path, gs.world.manifest.name, gs.world.current);
return;
}
}
if let Some(dir) = get("--screens") {
@ -123,6 +126,9 @@ fn main() {
}
if let Some(path) = get("--script") {
// Replays must be byte-identical: the AI lane is hard-off here no
// matter what the environment says.
gs.ai = mrpci_core::ai::AiConfig::off();
let f = std::fs::File::open(&path).unwrap_or_else(|e| {
eprintln!("can't open {}: {}", path, e);
std::process::exit(1);
@ -130,6 +136,13 @@ fn main() {
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
run_line(&mut gs, &line);
}
// `--script X --render-room Y` renders the frame the replay ended
// on — screenshot-after-scenario, the golden-test's visual twin.
if let Some(out) = get("--render-room") {
let png = if has("--raw") { gs.render_png(true) } else { gs.render_png_annotated() };
std::fs::write(&out, png).expect("write png");
eprintln!("rendered {}", out);
}
return;
}

View File

@ -252,6 +252,7 @@ fn room0() -> RoomDoc {
npcs: vec![NpcDef {
name: "vend-bot".into(),
sprite: "vendbot".into(),
portrait: "vendbot-face".into(),
x: 150,
y: 132,
wander: 34,
@ -381,6 +382,7 @@ fn room1() -> RoomDoc {
npcs: vec![NpcDef {
name: "sergeant".into(),
sprite: "sergeant".into(),
portrait: "sergeant-face".into(),
x: 162,
y: 132,
wander: 0,
@ -697,6 +699,27 @@ fn write_sprites(dir: &Path) -> std::io::Result<()> {
fill(&mut vendbot, 0, 26, 18, 4, [30, 30, 34]);
save("vendbot", &vendbot)?;
// Talker portraits (drawn 2x in the core window, 6x in the GUI).
let mut serg_face = RgbaImage::new(24, 24);
fill(&mut serg_face, 0, 0, 24, 24, [26, 30, 44]); // backdrop
fill(&mut serg_face, 5, 6, 14, 14, [140, 150, 170]); // head
fill(&mut serg_face, 4, 2, 16, 5, [40, 60, 140]); // cap
fill(&mut serg_face, 4, 6, 16, 1, [230, 200, 60]); // cap band
fill(&mut serg_face, 8, 11, 3, 3, [80, 200, 255]); // eyes
fill(&mut serg_face, 14, 11, 3, 3, [80, 200, 255]);
fill(&mut serg_face, 9, 17, 7, 2, [70, 78, 104]); // mouth grille
fill(&mut serg_face, 10, 20, 5, 1, [70, 78, 104]);
save("sergeant-face", &serg_face)?;
let mut vend_face = RgbaImage::new(24, 24);
fill(&mut vend_face, 0, 0, 24, 24, [60, 18, 18]);
fill(&mut vend_face, 3, 4, 18, 16, [180, 60, 60]); // faceplate
fill(&mut vend_face, 6, 8, 4, 4, [255, 220, 140]); // lamp eyes
fill(&mut vend_face, 14, 8, 4, 4, [255, 220, 140]);
fill(&mut vend_face, 7, 15, 10, 2, [30, 30, 34]); // speaker mouth
fill(&mut vend_face, 9, 2, 6, 2, [230, 200, 60]); // marquee light
save("vendbot-face", &vend_face)?;
let mut fusebox = RgbaImage::new(14, 18);
fill(&mut fusebox, 0, 0, 14, 18, [110, 114, 124]);
fill(&mut fusebox, 2, 2, 10, 14, [80, 84, 96]);

View File

@ -40,6 +40,9 @@ pub struct Dlg {
pub node: usize,
/// Who we're talking to (prefixes the says lines).
pub with: String,
/// Sprite name for the talker portrait ("" = no portrait) — the SCI1.1
/// "talker" flourish; renderers draw it beside the window if present.
pub portrait: String,
}
impl Dlg {

View File

@ -16,6 +16,7 @@
//! [`Command`]s in and reads [`Event`]s out.
pub mod actor;
pub mod ai;
pub mod assets;
pub mod audio;
pub mod dialogue;

View File

@ -77,6 +77,9 @@ pub struct PropDef {
pub sets_flag: String,
#[serde(default)]
pub dialogue: Vec<DlgNode>, // node 0 = entry; empty = no conversation
/// Sprite drawn beside this thing's dialogue window ("" = none).
#[serde(default)]
pub portrait: String,
/// Points for taking it (takeable) or first successful use (otherwise).
#[serde(default)]
pub points: u32,
@ -125,6 +128,9 @@ pub struct NpcDef {
pub msgs: HashMap<String, String>,
#[serde(default)]
pub dialogue: Vec<DlgNode>,
/// Sprite drawn beside this NPC's dialogue window ("" = none).
#[serde(default)]
pub portrait: String,
/// Wander radius in pixels (0 = stands still).
#[serde(default)]
pub wander: i32,

View File

@ -14,6 +14,7 @@
//! Error 47 hard-crash class is a log line here.
use crate::actor::Actor;
use crate::ai;
use crate::audio::AudioCue;
use crate::dialogue::Dlg;
use crate::font;
@ -210,6 +211,10 @@ pub struct GameState {
checkpoint: Option<SaveData>,
/// Presentation-only particle layer (rain/snow/embers).
pub weather: Weather,
/// The AI parser lane (off by env, off under --script).
pub ai: ai::AiConfig,
/// A translation in flight — at most one, polled each cycle.
pending_ai: Option<std::sync::mpsc::Receiver<String>>,
}
impl GameState {
@ -240,6 +245,8 @@ impl GameState {
pending_verb: None,
checkpoint: None,
weather: Weather::new(0, 1),
ai: ai::AiConfig::from_env(),
pending_ai: None,
};
let _ = gs.enter_room(None);
gs
@ -290,7 +297,7 @@ impl GameState {
}
}
Command::Parse { text } => {
ev.extend(self.run_parse(&text));
ev.extend(self.run_parse(&text, false));
}
Command::Choose { n } => {
if let Some(mut d) = self.dlg.take() {
@ -455,6 +462,7 @@ impl GameState {
self.score = 0;
self.scored.clear();
self.dlg = None;
self.pending_ai = None;
self.transcript.clear();
self.flags = self.world.manifest.flags.clone();
self.vars.clear();
@ -647,6 +655,7 @@ impl GameState {
let mut ev = Vec::new();
self.ticks += 1;
self.weather.step();
ev.extend(self.poll_ai());
// Deterministic timers: count down in ticks, fire script functions.
if !self.timers.is_empty() {
@ -969,12 +978,12 @@ impl GameState {
let m = self.cascade(&p.msgs, effective).unwrap();
ev.push(self.line(&m));
if verb == "talk" && !p.dialogue.is_empty() {
ev.extend(self.open_dialogue(&p.name, p.dialogue.clone()));
ev.extend(self.open_dialogue(&p.name, &p.portrait, p.dialogue.clone()));
}
return ev;
}
"talk" if !p.dialogue.is_empty() => {
ev.extend(self.open_dialogue(&p.name, p.dialogue.clone()));
ev.extend(self.open_dialogue(&p.name, &p.portrait, p.dialogue.clone()));
return ev;
}
_ => {}
@ -1065,7 +1074,7 @@ impl GameState {
if let Some(m) = self.cascade(&def.msgs, effective) {
ev.push(self.line(&m));
}
ev.extend(self.open_dialogue(&def.name, def.dialogue.clone()));
ev.extend(self.open_dialogue(&def.name, &def.portrait, def.dialogue.clone()));
return ev;
}
let m = self
@ -1095,9 +1104,9 @@ impl GameState {
ev
}
fn open_dialogue(&mut self, with: &str, nodes: Vec<crate::dialogue::DlgNode>) -> Vec<Event> {
fn open_dialogue(&mut self, with: &str, portrait: &str, nodes: Vec<crate::dialogue::DlgNode>) -> Vec<Event> {
let mut ev = Vec::new();
let d = Dlg { nodes, node: 0, with: with.to_string() };
let d = Dlg { nodes, node: 0, with: with.to_string(), portrait: portrait.to_string() };
let lines = d.lines(&self.flags);
for l in &lines {
ev.push(self.line(l));
@ -1126,7 +1135,7 @@ impl GameState {
// --- the parser lane -----------------------------------------------------
fn run_parse(&mut self, text: &str) -> Vec<Event> {
fn run_parse(&mut self, text: &str, from_ai: bool) -> Vec<Event> {
let mut ev = Vec::new();
let line = text.trim().to_string();
if line.is_empty() {
@ -1139,7 +1148,8 @@ impl GameState {
ev.push(self.line("(type a number to answer, or end the conversation.)"));
return ev;
}
ev.push(self.line(&format!("> {}", line)));
let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) };
ev.push(self.line(&echo));
match parser::parse(&line, &self.verb_idx) {
Parsed::Empty => {}
@ -1197,13 +1207,45 @@ impl GameState {
}
}
Parsed::Unknown(orig) => {
ev.push(self.line(&format!("The parser squints at \u{201C}{}\u{201D} and shrugs.", orig)));
ev.push(Event::Audio { cue: AudioCue::Error });
// The AI lane: hand the sentence to a small LLM that may map
// it onto an already-legal command. One in flight at a time;
// the AI's own output never re-enters the lane.
if !from_ai && self.ai.enabled() && self.pending_ai.is_none() {
let mut things = self.visible_things();
things.extend(self.world.doc.hotspots.iter().map(|h| h.name.clone()).filter(|n| !n.is_empty()));
let prompt = ai::build_prompt(
&orig,
&things.join(", "),
&self.inventory.join(", "),
&parser::verb_whitelist(&self.world.manifest.verbs).join(", "),
);
self.pending_ai = Some(ai::translate_async(self.ai.clone(), prompt));
ev.push(self.line("\u{2026}(thinking)\u{2026}"));
ev.push(Event::Audio { cue: AudioCue::Blip });
} else {
ev.push(self.line(&format!("The parser squints at \u{201C}{}\u{201D} and shrugs.", orig)));
ev.push(Event::Audio { cue: AudioCue::Error });
}
}
}
ev
}
/// Poll the AI worker; if a translation arrived, run it through the real
/// parser (the AI can only ever *suggest* an already-legal command).
fn poll_ai(&mut self) -> Vec<Event> {
let got = self.pending_ai.as_ref().and_then(|rx| rx.try_recv().ok());
let Some(cmd) = got else { return Vec::new() };
self.pending_ai = None;
if cmd.is_empty() {
let mut ev = vec![self.line("You can't do that.")];
ev.push(Event::Audio { cue: AudioCue::Error });
ev
} else {
self.run_parse(&cmd, true)
}
}
fn match_inventory(&self, words: &str) -> Option<String> {
let w = words.to_lowercase();
self.inventory
@ -1513,16 +1555,39 @@ impl GameState {
}
if let Some(d) = &self.dlg {
// The classic Sierra window: white, red border, black text.
// The classic Sierra window: white, red border, black text —
// with the SCI1.1 talker portrait beside the prose when the
// speaker has one.
let portrait = if d.portrait.is_empty() { None } else { self.world.cel(&d.portrait).cloned() };
let (pw, ph) = portrait.as_ref().map(|c| (c.w as i32 * 2, c.h as i32 * 2)).unwrap_or((0, 0));
let text_x_off = if pw > 0 { pw + 10 } else { 0 };
let wrap_w = (264 - text_x_off).max(80) as usize;
let mut lines: Vec<String> = Vec::new();
for l in d.lines(&self.flags) {
lines.extend(font::wrap(&l, 264));
lines.extend(font::wrap(&l, wrap_w));
}
let h = lines.len() as i32 * 9 + 10;
let h = (lines.len() as i32 * 9 + 10).max(ph + 12);
let (x, y) = (24, 24);
font::draw_box(&mut vis, x, y, 272, h, 15, 4);
if let Some(cel) = portrait {
// 2x nearest stamp, black frame around it.
font::draw_box(&mut vis, x + 4, y + 4, pw + 4, ph + 4, 15, 0);
for cy in 0..cel.h {
for cx in 0..cel.w {
if let Some(c) = cel.at(cx, cy) {
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let px = x + 6 + cx as i32 * 2 + dx;
let py = y + 6 + cy as i32 * 2 + dy;
if px >= 0 && py >= 0 && (px as usize) < PIC_W && (py as usize) < PIC_H {
vis[py as usize * PIC_W + px as usize] = c;
}
}
}
}
}
}
for (i, l) in lines.iter().enumerate() {
font::draw_text(&mut vis, x + 6, y + 5 + i as i32 * 9, l, 0);
font::draw_text(&mut vis, x + 6 + text_x_off, y + 5 + i as i32 * 9, l, 0);
}
} else {
// Last transcript lines, bottom strip.

View File

@ -453,12 +453,34 @@ fn draw_input(ui: &Ui) {
}
fn draw_dialogue(lines: &[String], gs: &mut GameState, ui: &mut Ui, audio: &mut sound::LazyAudio) {
// Talker portrait (SCI1.1 style): the speaker's face, chunky-pixeled,
// beside the prose.
let portrait = gs
.dlg
.as_ref()
.filter(|d| !d.portrait.is_empty())
.and_then(|d| gs.world.cel(&d.portrait).cloned());
const PSCALE: f32 = 6.0;
let pw = portrait.as_ref().map(|c| c.w as f32 * PSCALE + 28.0).unwrap_or(0.0);
let w = WIN_W as f32 - 240.0;
let h = 60.0 + lines.len() as f32 * 26.0;
let h = (60.0 + lines.len() as f32 * 26.0).max(portrait.as_ref().map(|c| c.h as f32 * PSCALE + 44.0).unwrap_or(0.0));
let x = 120.0;
let y = BAR_H + 80.0;
draw_rectangle(x, y, w, h, Color::from_rgba(18, 20, 30, 242));
draw_rectangle_lines(x, y, w, h, 3.0, Color::from_rgba(200, 170, 90, 255));
if let Some(cel) = &portrait {
let (px0, py0) = (x + 16.0, y + 22.0);
draw_rectangle_lines(px0 - 3.0, py0 - 3.0, cel.w as f32 * PSCALE + 6.0, cel.h as f32 * PSCALE + 6.0, 2.0, Color::from_rgba(200, 170, 90, 255));
for cy in 0..cel.h {
for cx in 0..cel.w {
if let Some(idx) = cel.at(cx, cy) {
let [r, g, b, _] = gs.world.palette.rgba(idx);
draw_rectangle(px0 + cx as f32 * PSCALE, py0 + cy as f32 * PSCALE, PSCALE, PSCALE, Color::from_rgba(r, g, b, 255));
}
}
}
}
let mut clicked: Option<usize> = None;
let (mx, my) = mouse_position();
for (i, l) in lines.iter().enumerate() {
@ -466,7 +488,7 @@ fn draw_dialogue(lines: &[String], gs: &mut GameState, ui: &mut Ui, audio: &mut
let is_choice = l.trim_start().chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false);
let hover = is_choice && my > ly - 18.0 && my < ly + 6.0 && mx > x && mx < x + w;
let col = if hover { Color::from_rgba(255, 230, 150, 255) } else { WHITE };
draw_text(l, x + 20.0, ly, 22.0, col);
draw_text(l, x + 20.0 + pw, ly, 22.0, col);
if hover && is_mouse_button_pressed(MouseButton::Left) {
clicked = l.trim_start().chars().next().and_then(|c| c.to_digit(10)).map(|d| d as usize);
}