diff --git a/Cargo.lock b/Cargo.lock index bbf2a88..e349609 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/README.md b/README.md index b946643..77cb54c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/CONTROL.md b/docs/CONTROL.md index 7497c8e..baa06d3 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -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 diff --git a/games/neon-precinct/game.json b/games/neon-precinct/game.json index 137fd38..d772049 100644 --- a/games/neon-precinct/game.json +++ b/games/neon-precinct/game.json @@ -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." } } \ No newline at end of file diff --git a/games/neon-precinct/rooms/room0.json b/games/neon-precinct/rooms/room0.json index f9810f6..ae42471 100644 --- a/games/neon-precinct/rooms/room0.json +++ b/games/neon-precinct/rooms/room0.json @@ -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": "", diff --git a/games/neon-precinct/rooms/room1.json b/games/neon-precinct/rooms/room1.json index c6bca86..900ac3e 100644 --- a/games/neon-precinct/rooms/room1.json +++ b/games/neon-precinct/rooms/room1.json @@ -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": "", diff --git a/games/neon-precinct/rooms/room2.json b/games/neon-precinct/rooms/room2.json index 9527310..dd07f76 100644 --- a/games/neon-precinct/rooms/room2.json +++ b/games/neon-precinct/rooms/room2.json @@ -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, diff --git a/games/neon-precinct/sprites/sergeant-face.png b/games/neon-precinct/sprites/sergeant-face.png new file mode 100644 index 0000000..aa8a4ea Binary files /dev/null and b/games/neon-precinct/sprites/sergeant-face.png differ diff --git a/games/neon-precinct/sprites/vendbot-face.png b/games/neon-precinct/sprites/vendbot-face.png new file mode 100644 index 0000000..9633d86 Binary files /dev/null and b/games/neon-precinct/sprites/vendbot-face.png differ diff --git a/mrpci-core/Cargo.toml b/mrpci-core/Cargo.toml index 2faf660..9471279 100644 --- a/mrpci-core/Cargo.toml +++ b/mrpci-core/Cargo.toml @@ -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" diff --git a/mrpci-core/src/ai.rs b/mrpci-core/src/ai.rs new file mode 100644 index 0000000..6d234fd --- /dev/null +++ b/mrpci-core/src/ai.rs @@ -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, +} + +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 { + 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 { + 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> { + 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::()?; + 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::()?; + Ok(resp["choices"][0]["message"]["content"].as_str().unwrap_or("").to_string()) + } + } +} diff --git a/mrpci-core/src/bin/mrpci-headless/main.rs b/mrpci-core/src/bin/mrpci-headless/main.rs index 673cba1..b54db7a 100644 --- a/mrpci-core/src/bin/mrpci-headless/main.rs +++ b/mrpci-core/src/bin/mrpci-headless/main.rs @@ -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; } diff --git a/mrpci-core/src/bin/mrpci-headless/sample.rs b/mrpci-core/src/bin/mrpci-headless/sample.rs index 6620ac4..e88f44c 100644 --- a/mrpci-core/src/bin/mrpci-headless/sample.rs +++ b/mrpci-core/src/bin/mrpci-headless/sample.rs @@ -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]); diff --git a/mrpci-core/src/dialogue.rs b/mrpci-core/src/dialogue.rs index 68ad4a4..d3126df 100644 --- a/mrpci-core/src/dialogue.rs +++ b/mrpci-core/src/dialogue.rs @@ -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 { diff --git a/mrpci-core/src/lib.rs b/mrpci-core/src/lib.rs index e7fe098..4f5d104 100644 --- a/mrpci-core/src/lib.rs +++ b/mrpci-core/src/lib.rs @@ -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; diff --git a/mrpci-core/src/room.rs b/mrpci-core/src/room.rs index 1c5bfe7..c2381aa 100644 --- a/mrpci-core/src/room.rs +++ b/mrpci-core/src/room.rs @@ -77,6 +77,9 @@ pub struct PropDef { pub sets_flag: String, #[serde(default)] pub dialogue: Vec, // 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, #[serde(default)] pub dialogue: Vec, + /// 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, diff --git a/mrpci-core/src/state.rs b/mrpci-core/src/state.rs index 3d9ee92..ecd93f5 100644 --- a/mrpci-core/src/state.rs +++ b/mrpci-core/src/state.rs @@ -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, /// 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>, } 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) -> Vec { + fn open_dialogue(&mut self, with: &str, portrait: &str, nodes: Vec) -> Vec { 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 { + fn run_parse(&mut self, text: &str, from_ai: bool) -> Vec { 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 { + 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 { 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 = 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. diff --git a/mrpci/src/main.rs b/mrpci/src/main.rs index cbbf765..9e92f99 100644 --- a/mrpci/src/main.rs +++ b/mrpci/src/main.rs @@ -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 = 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); }