From e014a0c54e962da0dd903ff657a93d57dbd6a046 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sat, 25 Jul 2026 23:23:19 +1000 Subject: [PATCH] =?UTF-8?q?v0.2=20=E2=80=94=20procedural=20fills,=20weathe?= =?UTF-8?q?r,=20in-core=20font,=20GPU=20palette/CRT=20shader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adaptations from the software-rendering playbook, at MRPCI's level of the stack: - pic.rs: fBm noise fill, jittered Voronoi fill (with mortar edges) and scatter — seeded, deterministic, Bayer-dithered through palette ramps - particles.rs: rain/snow/embers layer on its own RNG stream; RoomDoc gains `weather`; visuals can never perturb gameplay replays - font.rs: built-in 5x7 font stamped in index space; headless frames are now annotated (room chip, score, dialogue windows, transcript strip) so MCP render_frame shows what a player actually reads (--raw to skip) - render.rs: compose split into index-space compositing + palette blit; effective_palette() exports the cycle-LUT-applied strip - GUI: frame ships to the GPU as indices + a 256x1 palette texture; a fragment shader does the lookup plus CRT curvature/scanlines/vignette and room-change fades - Neon Precinct: fBm night sky + stars, Voronoi sidewalk & wet cobbles, fBm asphalt, rain in Neon Row and Rain Alley Verified: replay determinism (byte-identical events x2), 25/25 win path, GUI boots with the shader. Co-Authored-By: Claude Fable 5 --- README.md | 20 ++- docs/CONTROL.md | 10 +- games/neon-precinct/game.json | 6 +- games/neon-precinct/rooms/room0.json | 56 ++++-- games/neon-precinct/rooms/room1.json | 5 +- games/neon-precinct/rooms/room2.json | 36 +++- mrpci-core/src/bin/mrpci-headless/http.rs | 3 + mrpci-core/src/bin/mrpci-headless/main.rs | 2 +- mrpci-core/src/bin/mrpci-headless/mcp.rs | 8 +- mrpci-core/src/bin/mrpci-headless/sample.rs | 74 +++++++- mrpci-core/src/font.rs | 149 ++++++++++++++++ mrpci-core/src/lib.rs | 2 + mrpci-core/src/particles.rs | 132 ++++++++++++++ mrpci-core/src/pic.rs | 180 ++++++++++++++++++++ mrpci-core/src/render.rs | 26 ++- mrpci-core/src/room.rs | 5 + mrpci-core/src/state.rs | 89 +++++++++- mrpci/src/main.rs | 102 +++++++++-- 18 files changed, 848 insertions(+), 57 deletions(-) create mode 100644 mrpci-core/src/font.rs create mode 100644 mrpci-core/src/particles.rs diff --git a/README.md b/README.md index 9b13acd..b946643 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,13 @@ games/mygame/ saves/ # save-anywhere slots (gitignored) ``` -Rooms paint with `PicOp`s — rects, polygons, floods, **dithered gradients** — -through an `Ink` that writes any subset of the four screens at once. Or drop -a PNG in `pics/` and let the quantizer fold it into the palette. +Rooms paint with `PicOp`s — rects, polygons, floods, **dithered gradients**, +plus procedural fills: **fBm noise** (clouds, asphalt, grime), **Voronoi +cells** (cobbles, slabs) and **scatter** (stars, glints) — all seeded, all +deterministic — through an `Ink` that writes any subset of the four screens +at once. Or drop a PNG in `pics/` and let the quantizer fold it into the +palette. Rooms can also set `weather` (rain / snow / embers): a particle +layer on its own RNG stream, so visuals never perturb gameplay replays. ## Source map @@ -99,9 +103,19 @@ a PNG in `pics/` and let the quantizer fold it into the palette. | `mrpci-core/bin/mrpci-headless/` | stdio / HTTP / MCP / replay / sample | | `mrpci/` | the macroquad GUI (icon bar, dialogue windows, sound) | +Headless frames are **annotated**: `--render-room`, HTTP `/frame.png` and +MCP `mrpci_render_frame` stamp the room name, score, open dialogue window +and transcript strip into the frame with the built-in 5x7 font — an LLM +sees exactly what a player reads (add `--raw` / `/frame-raw.png` to skip). +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). + ## 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 - [ ] 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) diff --git a/docs/CONTROL.md b/docs/CONTROL.md index 9ab7ff6..7497c8e 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -21,7 +21,15 @@ mrpci-headless --help `--game DIR` points any mode at a game folder. Useful boot flags: `--seed N` (deterministic RNG), `--room N`, `--ticks N`, -`--render-room out.png`, `--screens dir/`. +`--render-room out.png` (annotated: room chip, score, dialogue/transcript +text stamped with the built-in font; add `--raw` for the bare scene), +`--screens dir/`. + +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 +`weather` field (0 none, 1 rain, 2 snow, 3 embers) whose particles run on a +separate RNG stream (visual-only; event replays unaffected). ## 1. JSONL stdio (the substrate) diff --git a/games/neon-precinct/game.json b/games/neon-precinct/game.json index 8ab2012..137fd38 100644 --- a/games/neon-precinct/game.json +++ b/games/neon-precinct/game.json @@ -7,9 +7,9 @@ "max_score": 25, "ego_sprite": "", "defaults": { - "listen": "The city hums in B-flat.", "smell": "Ozone, rain, and yesterday's synth-noodles.", - "do": "Your servos find no purchase on that.", - "look": "Rain-slick chrome and old neon. Nothing more." + "listen": "The city hums in B-flat.", + "look": "Rain-slick chrome and old neon. Nothing more.", + "do": "Your servos find no purchase on that." } } \ No newline at end of file diff --git a/games/neon-precinct/rooms/room0.json b/games/neon-precinct/rooms/room0.json index dc4e601..f9810f6 100644 --- a/games/neon-precinct/rooms/room0.json +++ b/games/neon-precinct/rooms/room0.json @@ -4,17 +4,21 @@ "background": "", "ops": [ { - "VGradient": { + "FbmFill": { "x": 0, "y": 0, "w": 320, "h": 84, "ramp": [ + 33, 33, 34, 70, 77 ], + "scale": 46.0, + "octaves": 4, + "seed": 7, "ink": { "color": 0, "pri": "Keep", @@ -25,6 +29,27 @@ } } }, + { + "Scatter": { + "x": 0, + "y": 0, + "w": 320, + "h": 34, + "colors": [ + 26, + 23, + 15 + ], + "density": 5, + "seed": 8, + "ink": { + "color": 0, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, { "Rect": { "x": 0, @@ -826,16 +851,21 @@ } }, { - "VGradient": { + "VoronoiFill": { "x": 0, "y": 108, "w": 320, "h": 42, - "ramp": [ + "cell": 26, + "colors": [ + 22, 22, 21, - 20 + 22 ], + "edge_color": 20, + "edge": 1.1, + "seed": 11, "ink": { "color": 0, "pri": "Band", @@ -861,15 +891,20 @@ } }, { - "VGradient": { + "FbmFill": { "x": 0, "y": 153, "w": 320, "h": 37, "ramp": [ + 18, 19, - 18 + 18, + 20 ], + "scale": 9.0, + "octaves": 3, + "seed": 12, "ink": { "color": 0, "pri": "Band", @@ -1035,8 +1070,8 @@ ], "poly": [], "msgs": { - "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." + "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." }, "exit_to": null, "arrive": null, @@ -1071,8 +1106,8 @@ "y": 148, "msgs": { "look": "A city dumpster. Something inside is composting on a geological timescale.", - "smell": "Your olfactory sensor files a grievance.", - "do": "You rummage. Old circuit boards, a single roller skate, regret." + "do": "You rummage. Old circuit boards, a single roller skate, regret.", + "smell": "Your olfactory sensor files a grievance." }, "takeable": false, "synonyms": "", @@ -1144,6 +1179,7 @@ } ], "music": 6, + "weather": 1, "defaults": {}, "script": "room0.rhai" } \ No newline at end of file diff --git a/games/neon-precinct/rooms/room1.json b/games/neon-precinct/rooms/room1.json index 4a58abb..c6bca86 100644 --- a/games/neon-precinct/rooms/room1.json +++ b/games/neon-precinct/rooms/room1.json @@ -743,8 +743,8 @@ "x": 36, "y": 130, "msgs": { - "look": "The case terminal. Slot: DATA-SLATE. It has been hungry for weeks.", - "do": "It wants the evidence, not your fingerprints." + "do": "It wants the evidence, not your fingerprints.", + "look": "The case terminal. Slot: DATA-SLATE. It has been hungry for weeks." }, "takeable": false, "synonyms": "", @@ -837,6 +837,7 @@ } ], "music": 1, + "weather": 0, "defaults": {}, "script": "room1.rhai" } \ No newline at end of file diff --git a/games/neon-precinct/rooms/room2.json b/games/neon-precinct/rooms/room2.json index 17aad03..9527310 100644 --- a/games/neon-precinct/rooms/room2.json +++ b/games/neon-precinct/rooms/room2.json @@ -428,16 +428,21 @@ } }, { - "VGradient": { + "VoronoiFill": { "x": 0, "y": 100, "w": 320, "h": 90, - "ramp": [ + "cell": 11, + "colors": [ + 20, 21, 19, - 18 + 76 ], + "edge_color": 17, + "edge": 1.4, + "seed": 21, "ink": { "color": 0, "pri": "Band", @@ -448,6 +453,26 @@ } } }, + { + "Scatter": { + "x": 0, + "y": 100, + "w": 320, + "h": 90, + "colors": [ + 24, + 77 + ], + "density": 7, + "seed": 22, + "ink": { + "color": 0, + "pri": "Keep", + "ctl": "Keep", + "hot": null + } + } + }, { "Ellipse": { "cx": 150, @@ -545,8 +570,8 @@ ], "poly": [], "msgs": { - "listen": "Drip. Drip. Drip. It's in 7/8 time, somehow.", - "look": "A drainage pipe keeping its own beat." + "look": "A drainage pipe keeping its own beat.", + "listen": "Drip. Drip. Drip. It's in 7/8 time, somehow." }, "exit_to": null, "arrive": null, @@ -679,6 +704,7 @@ ], "npcs": [], "music": 2, + "weather": 1, "defaults": {}, "script": "room2.rhai" } \ No newline at end of file diff --git a/mrpci-core/src/bin/mrpci-headless/http.rs b/mrpci-core/src/bin/mrpci-headless/http.rs index 3f75856..61cb6f0 100644 --- a/mrpci-core/src/bin/mrpci-headless/http.rs +++ b/mrpci-core/src/bin/mrpci-headless/http.rs @@ -90,6 +90,9 @@ pub fn serve(mut gs: GameState, port: u16) { json(req, 200, serde_json::to_value(gs.snapshot()).unwrap_or_default()); } (Method::Get, "/frame.png") => { + respond(req, 200, gs.render_png_annotated(), "image/png"); + } + (Method::Get, "/frame-raw.png") => { respond(req, 200, gs.render_png(true), "image/png"); } (Method::Get, p) if p.starts_with("/screen/") && p.ends_with(".png") => { diff --git a/mrpci-core/src/bin/mrpci-headless/main.rs b/mrpci-core/src/bin/mrpci-headless/main.rs index 92f9212..bf2852a 100644 --- a/mrpci-core/src/bin/mrpci-headless/main.rs +++ b/mrpci-core/src/bin/mrpci-headless/main.rs @@ -61,7 +61,7 @@ fn main() { } if let Some(path) = get("--render-room") { - let png = gs.render_png(true); + 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; diff --git a/mrpci-core/src/bin/mrpci-headless/mcp.rs b/mrpci-core/src/bin/mrpci-headless/mcp.rs index d110d29..0df679a 100644 --- a/mrpci-core/src/bin/mrpci-headless/mcp.rs +++ b/mrpci-core/src/bin/mrpci-headless/mcp.rs @@ -123,7 +123,13 @@ fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result Command::LoadGame { dir: args["dir"].as_str().unwrap_or(".").to_string() }, "mrpci_save_room" => Command::SaveRoom { n: args["room"].as_u64().map(|n| n as u32) }, "mrpci_render_frame" => { - let png = gs.render_png(args["with_actors"].as_bool().unwrap_or(true)); + // Annotated by default: the frame includes the room chip, score, + // dialogue window / transcript strip — what a player would read. + let png = if args["with_actors"].as_bool() == Some(false) { + gs.render_png(false) + } else { + gs.render_png_annotated() + }; return Ok(json!({ "content": [{ "type": "image", "data": b64(&png), "mimeType": "image/png" }] })); diff --git a/mrpci-core/src/bin/mrpci-headless/sample.rs b/mrpci-core/src/bin/mrpci-headless/sample.rs index 8227139..6620ac4 100644 --- a/mrpci-core/src/bin/mrpci-headless/sample.rs +++ b/mrpci-core/src/bin/mrpci-headless/sample.rs @@ -84,15 +84,29 @@ pub fn write_sample(dir: &str) -> std::io::Result<()> { fn room0() -> RoomDoc { let mut ops: Vec = Vec::new(); - // Night sky, dithered down to the rooftops. - ops.push(PicOp::VGradient { + // Night sky: fBm cloud cover instead of a flat gradient, plus stars + // peeking through where the noise runs dark. + ops.push(PicOp::FbmFill { x: 0, y: 0, w: 320, h: 84, - ramp: vec![cube(0, 0, 1), cube(0, 0, 2), cube(1, 0, 2), cube(1, 1, 3)], + ramp: vec![cube(0, 0, 1), cube(0, 0, 1), cube(0, 0, 2), cube(1, 0, 2), cube(1, 1, 3)], + scale: 46.0, + octaves: 4, + seed: 7, ink: ink_wall(0), }); + ops.push(PicOp::Scatter { + x: 0, + y: 0, + w: 320, + h: 34, + colors: vec![gray(10), gray(7), 15], + density: 5, + seed: 8, + ink: ink_color(0), + }); // Building slabs. ops.push(PicOp::Rect { x: 0, y: 30, w: 90, h: 78, ink: ink_wall(cube(1, 1, 2)) }); ops.push(PicOp::Rect { x: 90, y: 44, w: 70, h: 64, ink: ink_wall(cube(2, 1, 2)) }); @@ -127,10 +141,31 @@ fn room0() -> RoomDoc { { ops.push(PicOp::Rect { x: seg.0, y: seg.1 - 8, w: seg.2, h: seg.3, ink: ink_wall(248 + (i % 8) as u8) }); } - // Sidewalk (walkable), street (walkable), curb. - ops.push(PicOp::VGradient { x: 0, y: 108, w: 320, h: 42, ramp: vec![gray(6), gray(5), gray(4)], ink: ink_floor(0) }); + // Sidewalk: Voronoi pavement slabs with crack lines. Street: fBm asphalt. + ops.push(PicOp::VoronoiFill { + x: 0, + y: 108, + w: 320, + h: 42, + cell: 26, + colors: vec![gray(6), gray(6), gray(5), gray(6)], + edge_color: Some(gray(4)), + edge: 1.1, + seed: 11, + ink: ink_floor(0), + }); ops.push(PicOp::Rect { x: 0, y: 150, w: 320, h: 3, ink: ink_color(gray(8)) }); - ops.push(PicOp::VGradient { x: 0, y: 153, w: 320, h: 37, ramp: vec![gray(3), gray(2)], ink: ink_floor(0) }); + ops.push(PicOp::FbmFill { + x: 0, + y: 153, + w: 320, + h: 37, + ramp: vec![gray(2), gray(3), gray(2), gray(4)], + scale: 9.0, + octaves: 3, + seed: 12, + ink: ink_floor(0), + }); // A rain puddle the robot won't ford (CTL_WATER). ops.push(PicOp::Ellipse { cx: 60, @@ -239,6 +274,7 @@ fn room0() -> RoomDoc { ..Default::default() }], music: 6, // noir + weather: 1, // rain, of course script: "room0.rhai".into(), ..Default::default() } @@ -398,8 +434,29 @@ fn room2() -> RoomDoc { for (i, seg) in [(250i32, 30i32, 4i32, 16i32), (254, 30, 6, 4), (254, 36, 6, 4), (254, 42, 6, 4)].iter().enumerate() { ops.push(PicOp::Rect { x: seg.0, y: seg.1, w: seg.2, h: seg.3, ink: ink_wall(248 + (i * 2 % 8) as u8) }); } - // Wet cobbles. - ops.push(PicOp::VGradient { x: 0, y: 100, w: 320, h: 90, ramp: vec![gray(5), gray(3), gray(2)], ink: ink_floor(0) }); + // Wet cobbles: Voronoi stones with dark mortar, then rain glints. + ops.push(PicOp::VoronoiFill { + x: 0, + y: 100, + w: 320, + h: 90, + cell: 11, + colors: vec![gray(4), gray(5), gray(3), cube(1, 1, 2)], + edge_color: Some(gray(1)), + edge: 1.4, + seed: 21, + ink: ink_floor(0), + }); + ops.push(PicOp::Scatter { + x: 0, + y: 100, + w: 320, + h: 90, + colors: vec![gray(8), cube(1, 1, 3)], + density: 7, + seed: 22, + ink: ink_color(0), + }); // A long puddle down the middle — water, walk around it. ops.push(PicOp::Ellipse { cx: 150, @@ -480,6 +537,7 @@ fn room2() -> RoomDoc { }, ], music: 2, // eerie + weather: 1, // it isn't called Rain Alley for nothing script: "room2.rhai".into(), ..Default::default() } diff --git a/mrpci-core/src/font.rs b/mrpci-core/src/font.rs new file mode 100644 index 0000000..cf9c25a --- /dev/null +++ b/mrpci-core/src/font.rs @@ -0,0 +1,149 @@ +//! A built-in 5x7 bitmap font, stamped straight into the indexed frame. +//! +//! Why the core owns text: the GUI could always draw strings, but headless +//! frames couldn't — so an LLM (or a screenshot test) saw a mute movie. +//! With the font in index space, dialogue windows, captions and the score +//! render identically everywhere a frame is composed. + +use crate::screens::{PIC_H, PIC_W}; + +pub const GLYPH_W: usize = 5; +pub const GLYPH_H: usize = 7; +/// Advance per character (1px spacing). +pub const ADV: usize = GLYPH_W + 1; + +/// 5-bit rows, bit 4 = leftmost pixel. +fn glyph(c: char) -> [u8; 7] { + match c { + 'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], + 'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E], + 'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E], + 'D' => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E], + 'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F], + 'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10], + 'G' => [0x0E, 0x11, 0x10, 0x10, 0x13, 0x11, 0x0F], + 'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], + 'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], + 'J' => [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C], + 'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], + 'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F], + 'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11], + 'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11], + 'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + 'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10], + 'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D], + 'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11], + 'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E], + 'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], + 'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + 'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04], + 'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A], + 'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11], + 'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04], + 'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F], + '0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E], + '1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E], + '2' => [0x0E, 0x11, 0x01, 0x06, 0x08, 0x10, 0x1F], + '3' => [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E], + '4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02], + '5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E], + '6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E], + '7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], + '8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E], + '9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C], + '!' => [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], + '?' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], + '.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C], + ',' => [0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08], + ':' => [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00], + ';' => [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x04, 0x08], + '-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00], + '_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F], + '\'' => [0x04, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00], + '"' => [0x0A, 0x0A, 0x14, 0x00, 0x00, 0x00, 0x00], + '(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], + ')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], + '/' => [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10], + '>' => [0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10], + '<' => [0x01, 0x02, 0x04, 0x08, 0x04, 0x02, 0x01], + '+' => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00], + '=' => [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00], + '%' => [0x19, 0x1A, 0x02, 0x04, 0x08, 0x0B, 0x13], + '&' => [0x0C, 0x12, 0x14, 0x08, 0x15, 0x12, 0x0D], + '*' => [0x00, 0x0A, 0x04, 0x1F, 0x04, 0x0A, 0x00], + _ => [0; 7], // space and anything unmapped + } +} + +/// Fold the fancy typography the engine emits down to the 5x7 set. +fn fold(c: char) -> char { + match c { + '\u{2014}' | '\u{2013}' => '-', + '\u{2018}' | '\u{2019}' => '\'', + '\u{201C}' | '\u{201D}' => '"', + '\u{2020}' => '+', + '\u{2026}' => '.', // ellipsis loses two dots; close enough at 5px + '\u{2248}' => '=', + c => c.to_ascii_uppercase(), + } +} + +pub fn measure(text: &str) -> usize { + text.chars().count() * ADV +} + +/// Stamp a string into an index buffer. Returns the x after the last glyph. +pub fn draw_text(vis: &mut [u8], x: i32, y: i32, text: &str, color: u8) -> i32 { + let mut cx = x; + for ch in text.chars() { + let g = glyph(fold(ch)); + for (row, bits) in g.iter().enumerate() { + for col in 0..GLYPH_W { + if bits & (0x10 >> col) != 0 { + let (px, py) = (cx + col as i32, y + row as i32); + if px >= 0 && py >= 0 && (px as usize) < PIC_W && (py as usize) < PIC_H { + vis[py as usize * PIC_W + px as usize] = color; + } + } + } + } + cx += ADV as i32; + } + cx +} + +/// Word-wrap a string to a pixel width; the window renderer's helper. +pub fn wrap(text: &str, max_px: usize) -> Vec { + let max_chars = (max_px / ADV).max(4); + let mut out = Vec::new(); + for hard in text.split('\n') { + let mut line = String::new(); + for word in hard.split_whitespace() { + let need = if line.is_empty() { word.chars().count() } else { line.chars().count() + 1 + word.chars().count() }; + if need > max_chars && !line.is_empty() { + out.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() || hard.is_empty() { + out.push(line); + } + } + out +} + +/// A filled, bordered box in index space (the classic Sierra text window). +pub fn draw_box(vis: &mut [u8], x: i32, y: i32, w: i32, h: i32, bg: u8, border: u8) { + for yy in y..y + h { + for xx in x..x + w { + if xx < 0 || yy < 0 || xx as usize >= PIC_W || yy as usize >= PIC_H { + continue; + } + let on_edge = yy == y || yy == y + h - 1 || xx == x || xx == x + w - 1; + vis[yy as usize * PIC_W + xx as usize] = if on_edge { border } else { bg }; + } + } +} diff --git a/mrpci-core/src/lib.rs b/mrpci-core/src/lib.rs index bd8c4e0..e7fe098 100644 --- a/mrpci-core/src/lib.rs +++ b/mrpci-core/src/lib.rs @@ -19,7 +19,9 @@ pub mod actor; pub mod assets; pub mod audio; pub mod dialogue; +pub mod font; pub mod palette; +pub mod particles; pub mod parser; pub mod path; pub mod pic; diff --git a/mrpci-core/src/particles.rs b/mrpci-core/src/particles.rs new file mode 100644 index 0000000..f83e723 --- /dev/null +++ b/mrpci-core/src/particles.rs @@ -0,0 +1,132 @@ +//! Weather — the SCI flourish (KQ5 snow, LSL rain) as a tiny particle layer. +//! +//! Particles are *presentation*: they draw straight into the composited +//! index buffer, above the scene, and are never part of the sim. They run +//! on their own xorshift stream (seeded per room), so adding rain to a room +//! cannot shift a single gameplay RNG roll — replays of old command logs +//! stay byte-identical in events even when it starts pouring. + +use crate::screens::{PIC_H, PIC_W}; + +pub const WEATHER_NONE: u8 = 0; +pub const WEATHER_RAIN: u8 = 1; +pub const WEATHER_SNOW: u8 = 2; +pub const WEATHER_EMBERS: u8 = 3; + +struct Particle { + x: f32, + y: f32, + vx: f32, + vy: f32, + color: u8, +} + +pub struct Weather { + pub kind: u8, + parts: Vec, + rng: u64, +} + +impl Weather { + pub fn new(kind: u8, seed: u64) -> Self { + let mut w = Weather { kind, parts: Vec::new(), rng: seed.wrapping_mul(0x9E37_79B9).max(1) }; + let n = match kind { + WEATHER_RAIN => 90, + WEATHER_SNOW => 70, + WEATHER_EMBERS => 36, + _ => 0, + }; + for _ in 0..n { + let p = w.spawn(true); + w.parts.push(p); + } + w + } + + fn rand01(&mut self) -> f32 { + let mut x = self.rng; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.rng = x; + ((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) & 0xFFFF) as f32 / 65536.0 + } + + fn spawn(&mut self, anywhere: bool) -> Particle { + let x = self.rand01() * PIC_W as f32; + let y = if anywhere { self.rand01() * PIC_H as f32 } else { -2.0 }; + match self.kind { + WEATHER_RAIN => Particle { + x, + y, + vx: -0.6, + vy: 4.0 + self.rand01() * 2.5, + // gray ramp 16..32 is palette-stable in every room + color: 24 + (self.rand01() * 3.0) as u8, + }, + WEATHER_SNOW => Particle { + x, + y, + vx: (self.rand01() - 0.5) * 0.6, + vy: 0.5 + self.rand01() * 0.7, + color: 28 + (self.rand01() * 4.0) as u8, + }, + _ => Particle { + // embers rise from the floor line + x, + y: if anywhere { self.rand01() * PIC_H as f32 } else { PIC_H as f32 + 2.0 }, + vx: (self.rand01() - 0.5) * 0.4, + vy: -(0.4 + self.rand01() * 0.8), + color: 251 + (self.rand01() * 4.0) as u8, // the ember ramp + }, + } + } + + /// One fixed tick of motion. Off-screen particles respawn at the source + /// edge, so the stream is continuous. + pub fn step(&mut self) { + if self.kind == WEATHER_NONE { + return; + } + for i in 0..self.parts.len() { + self.parts[i].x += self.parts[i].vx; + self.parts[i].y += self.parts[i].vy; + if self.kind == WEATHER_SNOW { + // lazy sideways waft, still deterministic + let waft = (self.rand01() - 0.5) * 0.3; + self.parts[i].vx = (self.parts[i].vx + waft).clamp(-0.8, 0.8); + } + let p = &self.parts[i]; + let gone = p.y > PIC_H as f32 + 3.0 || p.y < -4.0 || p.x < -4.0 || p.x > PIC_W as f32 + 4.0; + if gone { + self.parts[i] = self.spawn(false); + } + } + } + + /// Stamp the particles over a composited index buffer (PIC_W x PIC_H). + /// Weather draws over everything — it's between the camera and the room. + pub fn draw(&self, vis: &mut [u8]) { + let put = |vis: &mut [u8], x: i32, y: i32, c: u8| { + if x >= 0 && y >= 0 && (x as usize) < PIC_W && (y as usize) < PIC_H { + vis[y as usize * PIC_W + x as usize] = c; + } + }; + for p in &self.parts { + let (x, y) = (p.x as i32, p.y as i32); + match self.kind { + WEATHER_RAIN => { + // a short streak along the fall direction + put(vis, x, y, p.color); + put(vis, x, y - 1, p.color); + put(vis, x + 1, y - 2, p.color.saturating_sub(2)); + } + WEATHER_SNOW => put(vis, x, y, p.color), + WEATHER_EMBERS => { + put(vis, x, y, p.color); + } + _ => {} + } + } + } +} diff --git a/mrpci-core/src/pic.rs b/mrpci-core/src/pic.rs index 92531b3..d6e6858 100644 --- a/mrpci-core/src/pic.rs +++ b/mrpci-core/src/pic.rs @@ -249,6 +249,160 @@ pub fn hgradient(s: &mut Screens, x: i32, y: i32, w: i32, h: i32, ramp: &[u8], i } } +// --- procedural texture fills ------------------------------------------------ +// Sierra hand-painted every background; we can also *grow* them. All three +// fills below are pure functions of their seed — same JSON, same pixels, +// forever — so replay determinism survives and rooms stay diffable. + +/// Integer hash → [0, 1). The only randomness texture fills are allowed. +#[inline] +fn hash01(x: i32, y: i32, seed: u32) -> f32 { + let mut h = (x as u32).wrapping_mul(374_761_393) + ^ (y as u32).wrapping_mul(668_265_263) + ^ seed.wrapping_mul(2_246_822_519); + h = (h ^ (h >> 13)).wrapping_mul(1_274_126_177); + ((h ^ (h >> 16)) & 0x00FF_FFFF) as f32 / 16_777_216.0 +} + +/// Smooth value noise at a point (bilinear + smoothstep). +fn value_noise(x: f32, y: f32, seed: u32) -> f32 { + let (ix, iy) = (x.floor() as i32, y.floor() as i32); + let (fx, fy) = (x - x.floor(), y - y.floor()); + let (sx, sy) = (fx * fx * (3.0 - 2.0 * fx), fy * fy * (3.0 - 2.0 * fy)); + let n00 = hash01(ix, iy, seed); + let n10 = hash01(ix + 1, iy, seed); + let n01 = hash01(ix, iy + 1, seed); + let n11 = hash01(ix + 1, iy + 1, seed); + let a = n00 + (n10 - n00) * sx; + let b = n01 + (n11 - n01) * sx; + a + (b - a) * sy +} + +/// Fractal Brownian motion: octaves of value noise, halving amplitude and +/// doubling frequency. Returns [0, 1). +pub fn fbm(x: f32, y: f32, octaves: u8, seed: u32) -> f32 { + let mut total = 0.0; + let mut amp = 0.5; + let mut freq = 1.0; + for o in 0..octaves.clamp(1, 8) { + total += value_noise(x * freq, y * freq, seed.wrapping_add(o as u32 * 101)) * amp; + freq *= 2.0; + amp *= 0.5; + } + total.clamp(0.0, 0.999) +} + +/// Map a [0,1) value onto a palette ramp with Bayer dithering between the +/// two nearest stops — the house technique for banding-free 256-color art. +#[inline] +fn ramp_dither(v: f32, ramp: &[u8], x: i32, y: i32) -> u8 { + let t = v * (ramp.len() as f32 - 1.0); + let lo = t.floor() as usize; + let hi = (lo + 1).min(ramp.len() - 1); + let frac = t - lo as f32; + let threshold = BAYER[(y & 3) as usize][(x & 3) as usize] as f32 / 16.0; + if frac > threshold { + ramp[hi] + } else { + ramp[lo] + } +} + +/// fBm noise fill: clouds, asphalt, rust, grime, water shimmer — pick a ramp +/// and a scale. `scale` is pixels per noise cell (bigger = broader features). +pub fn fbm_fill(s: &mut Screens, x: i32, y: i32, w: i32, h: i32, ramp: &[u8], scale: f32, octaves: u8, seed: u32, ink: Ink) { + if ramp.is_empty() || scale <= 0.0 { + return; + } + for yy in y..y + h { + for xx in x..x + w { + let v = fbm(xx as f32 / scale, yy as f32 / scale, octaves, seed); + let mut k = ink; + k.color = Some(ramp_dither(v, ramp, xx, yy)); + px(s, xx, yy, k); + } + } +} + +/// Jittered-grid Voronoi fill: cobbles, pavement slabs, foliage clumps, +/// cracked mud. Each cell is colored by hash from `colors`; pixels near a +/// cell boundary (F2 - F1 < edge) take `edge_color` — the mortar lines. +#[allow(clippy::too_many_arguments)] +pub fn voronoi_fill( + s: &mut Screens, + x: i32, + y: i32, + w: i32, + h: i32, + cell: i32, + colors: &[u8], + edge_color: Option, + edge: f32, + seed: u32, + ink: Ink, +) { + if colors.is_empty() || cell < 2 { + return; + } + let point = |cx: i32, cy: i32| -> (f32, f32) { + ( + (cx * cell) as f32 + hash01(cx, cy, seed) * cell as f32, + (cy * cell) as f32 + hash01(cx, cy, seed ^ 0x9E37) * cell as f32, + ) + }; + for yy in y..y + h { + for xx in x..x + w { + let (gx, gy) = (xx.div_euclid(cell), yy.div_euclid(cell)); + let mut d1 = f32::MAX; + let mut d2 = f32::MAX; + let mut owner = (gx, gy); + for oy in -1..=1 { + for ox in -1..=1 { + let (px_, py_) = point(gx + ox, gy + oy); + let dx = px_ - xx as f32; + let dy = py_ - yy as f32; + let d = (dx * dx + dy * dy).sqrt(); + if d < d1 { + d2 = d1; + d1 = d; + owner = (gx + ox, gy + oy); + } else if d < d2 { + d2 = d; + } + } + } + let c = if let (Some(ec), true) = (edge_color, d2 - d1 < edge) { + ec + } else { + let pick = hash01(owner.0, owner.1, seed ^ 0x51ED) * colors.len() as f32; + colors[(pick as usize).min(colors.len() - 1)] + }; + let mut k = ink; + k.color = Some(c); + px(s, xx, yy, k); + } + } +} + +/// Sparse speckle: stars, drizzle glints, gravel, static. `density` is +/// pixels-per-1000 that get painted. +pub fn scatter(s: &mut Screens, x: i32, y: i32, w: i32, h: i32, colors: &[u8], density: u32, seed: u32, ink: Ink) { + if colors.is_empty() { + return; + } + for yy in y..y + h { + for xx in x..x + w { + let r = hash01(xx, yy, seed); + if r * 1000.0 < density as f32 { + let pick = hash01(xx, yy, seed ^ 0xBEEF) * colors.len() as f32; + let mut k = ink; + k.color = Some(colors[(pick as usize).min(colors.len() - 1)]); + px(s, xx, yy, k); + } + } + } +} + /// A room-authoring paint op — the serde face of the functions above. /// A room's procedural look is `Vec`, replayed in order onto blank /// screens (then the background PNG, masks and hotspot shapes layer in). @@ -262,6 +416,23 @@ pub enum PicOp { Flood { x: i32, y: i32, ink: Ink }, VGradient { x: i32, y: i32, w: i32, h: i32, ramp: Vec, ink: Ink }, HGradient { x: i32, y: i32, w: i32, h: i32, ramp: Vec, ink: Ink }, + /// fBm noise through a ramp — clouds, asphalt, grime, water. + FbmFill { x: i32, y: i32, w: i32, h: i32, ramp: Vec, scale: f32, octaves: u8, seed: u32, ink: Ink }, + /// Jittered Voronoi cells — cobbles, slabs, foliage. `edge` in pixels. + VoronoiFill { + x: i32, + y: i32, + w: i32, + h: i32, + cell: i32, + colors: Vec, + edge_color: Option, + edge: f32, + seed: u32, + ink: Ink, + }, + /// Sparse speckle — stars, gravel, glints. `density` per 1000 px. + Scatter { x: i32, y: i32, w: i32, h: i32, colors: Vec, density: u32, seed: u32, ink: Ink }, } pub fn apply_op(s: &mut Screens, op: &PicOp) { @@ -278,6 +449,15 @@ pub fn apply_op(s: &mut Screens, op: &PicOp) { PicOp::Flood { x, y, ink } => flood(s, *x, *y, *ink), PicOp::VGradient { x, y, w, h, ramp, ink } => vgradient(s, *x, *y, *w, *h, ramp, *ink), PicOp::HGradient { x, y, w, h, ramp, ink } => hgradient(s, *x, *y, *w, *h, ramp, *ink), + PicOp::FbmFill { x, y, w, h, ramp, scale, octaves, seed, ink } => { + fbm_fill(s, *x, *y, *w, *h, ramp, *scale, *octaves, *seed, *ink) + } + PicOp::VoronoiFill { x, y, w, h, cell, colors, edge_color, edge, seed, ink } => { + voronoi_fill(s, *x, *y, *w, *h, *cell, colors, *edge_color, *edge, *seed, *ink) + } + PicOp::Scatter { x, y, w, h, colors, density, seed, ink } => { + scatter(s, *x, *y, *w, *h, colors, *density, *seed, *ink) + } } } diff --git a/mrpci-core/src/render.rs b/mrpci-core/src/render.rs index a4c9e72..24074cd 100644 --- a/mrpci-core/src/render.rs +++ b/mrpci-core/src/render.rs @@ -21,6 +21,14 @@ pub struct DrawObj<'a> { /// still occludes a sprite standing behind it. New here: per-object scale /// (nearest-neighbor, crisp) and the palette-cycling LUT applied at the exit. pub fn compose(room: &Screens, palette: &Palette, lut: &[u8; 256], draws: &mut Vec) -> Vec { + let vis = compose_indices(room, draws); + indices_to_rgba(&vis, palette, lut) +} + +/// Composite scene + sprites in palette-index space. This is the frame's +/// true form — the GUI's palette shader, the weather layer and the text +/// stamper all want indices, not RGBA. +pub fn compose_indices(room: &Screens, draws: &mut Vec) -> Vec { let mut vis = room.visual.clone(); draws.sort_by_key(|d| d.pri); @@ -50,14 +58,28 @@ pub fn compose(room: &Screens, palette: &Palette, lut: &[u8; 256], draws: &mut V } } } + vis +} - let mut rgba = Vec::with_capacity(PIC_W * PIC_H * 4); - for &v in &vis { +/// Indices → RGBA through the palette with the cycle LUT applied. +pub fn indices_to_rgba(vis: &[u8], palette: &Palette, lut: &[u8; 256]) -> Vec { + let mut rgba = Vec::with_capacity(vis.len() * 4); + for &v in vis { rgba.extend_from_slice(&palette.rgba(lut[v as usize])); } rgba } +/// The palette as the GPU wants it: 256 RGBA entries with the cycle LUT +/// pre-applied, ready to upload as a 256x1 texture each frame. +pub fn effective_palette_rgba(palette: &Palette, lut: &[u8; 256]) -> [u8; 1024] { + let mut out = [0u8; 1024]; + for i in 0..256 { + out[i * 4..i * 4 + 4].copy_from_slice(&palette.rgba(lut[i])); + } + out +} + /// Debug composites: see the invisible screens the way the engine does. pub fn debug_screen(room: &Screens, which: &str) -> Vec { let mut rgba = Vec::with_capacity(PIC_W * PIC_H * 4); diff --git a/mrpci-core/src/room.rs b/mrpci-core/src/room.rs index c215dea..53d6214 100644 --- a/mrpci-core/src/room.rs +++ b/mrpci-core/src/room.rs @@ -178,6 +178,10 @@ pub struct RoomDoc { pub npcs: Vec, #[serde(default)] pub music: u8, // 0 = silence, 1.. = ambient mood + /// Weather overlay: 0 none, 1 rain, 2 snow, 3 embers. Pure presentation — + /// particles run on their own RNG stream and never touch the sim. + #[serde(default)] + pub weather: u8, /// Per-room default verb responses (checked before the global stock lines). #[serde(default)] pub defaults: HashMap, @@ -209,6 +213,7 @@ impl Default for RoomDoc { props: Vec::new(), npcs: Vec::new(), music: 0, + weather: 0, defaults: HashMap::new(), script: String::new(), } diff --git a/mrpci-core/src/state.rs b/mrpci-core/src/state.rs index 838aeb9..3d9ee92 100644 --- a/mrpci-core/src/state.rs +++ b/mrpci-core/src/state.rs @@ -16,7 +16,9 @@ use crate::actor::Actor; use crate::audio::AudioCue; use crate::dialogue::Dlg; +use crate::font; use crate::palette::{cycle_lut, PalCycle}; +use crate::particles::Weather; use crate::parser::{self, Parsed}; use crate::render::{self, DrawObj}; use crate::room::{find_spawn, prop_visible, RoomDoc, World}; @@ -206,6 +208,8 @@ pub struct GameState { pending_verb: Option<(String, String, i32, i32)>, /// The room-entry autosave that death restores. checkpoint: Option, + /// Presentation-only particle layer (rain/snow/embers). + pub weather: Weather, } impl GameState { @@ -235,6 +239,7 @@ impl GameState { inside_hotspot: 0, pending_verb: None, checkpoint: None, + weather: Weather::new(0, 1), }; let _ = gs.enter_room(None); gs @@ -488,6 +493,7 @@ impl GameState { self.pending_verb = None; self.blocked_latch = [false; 4]; self.cycles = self.world.doc.cycles.clone(); + self.weather = Weather::new(self.world.doc.weather, self.world.current as u64 + 1); self.build_npcs(); self.inside_hotspot = self.world.screens.hotspot_at(sx, sy); ev.extend(self.load_scripts()); @@ -640,6 +646,7 @@ impl GameState { } let mut ev = Vec::new(); self.ticks += 1; + self.weather.step(); // Deterministic timers: count down in ticks, fire script functions. if !self.timers.is_empty() { @@ -1413,9 +1420,9 @@ impl GameState { } } - /// Composite the current frame to raw RGBA (no window anywhere). - pub fn render_visual(&self, with_actors: bool) -> Vec { - let lut = cycle_lut(&self.cycles, self.ticks); + /// The frame in its true form: composited palette indices (scene + + /// sprites + weather). The GUI's palette shader eats this directly. + pub fn render_indices(&self, with_actors: bool) -> Vec { let table = &self.world.doc.scale; let mut draws: Vec = Vec::new(); if with_actors { @@ -1457,12 +1464,86 @@ impl GameState { mirrored: self.ego.mirrored, }); } - render::compose(&self.world.screens, &self.world.palette, &lut, &mut draws) + let mut vis = render::compose_indices(&self.world.screens, &mut draws); + self.weather.draw(&mut vis); + vis + } + + /// The palette as the GPU wants it: cycle LUT pre-applied, 256 RGBA + /// entries. Upload as a 256x1 texture; re-upload per frame (1KB). + pub fn effective_palette(&self) -> [u8; 1024] { + let lut = cycle_lut(&self.cycles, self.ticks); + render::effective_palette_rgba(&self.world.palette, &lut) + } + + /// Composite the current frame to raw RGBA (no window anywhere). + pub fn render_visual(&self, with_actors: bool) -> Vec { + let lut = cycle_lut(&self.cycles, self.ticks); + let vis = self.render_indices(with_actors); + render::indices_to_rgba(&vis, &self.world.palette, &lut) } pub fn render_png(&self, with_actors: bool) -> Vec { render::encode_png(&self.render_visual(with_actors)) } + + /// The frame as a *player* would read it: scene plus room-name chip, + /// score, the open dialogue window or the last transcript lines — all + /// stamped in index space with the built-in font. This is what MCP's + /// render_frame and `--render-room` serve, so an LLM sees the text the + /// player sees. + pub fn render_png_annotated(&self) -> Vec { + let lut = cycle_lut(&self.cycles, self.ticks); + let mut vis = self.render_indices(true); + + // Room name chip, top-left; score, top-right (EGA colors survive + // every room palette). + let name = if self.world.doc.name.is_empty() { + format!("ROOM {}", self.world.current) + } else { + self.world.doc.name.clone() + }; + font::draw_box(&mut vis, 2, 2, font::measure(&name) as i32 + 5, 11, 0, 8); + font::draw_text(&mut vis, 5, 4, &name, 15); + if self.world.manifest.max_score > 0 { + let s = format!("{}/{}", self.score, self.world.manifest.max_score); + let w = font::measure(&s) as i32 + 5; + font::draw_box(&mut vis, PIC_W as i32 - w - 2, 2, w, 11, 0, 8); + font::draw_text(&mut vis, PIC_W as i32 - w + 1, 4, &s, 14); + } + + if let Some(d) = &self.dlg { + // The classic Sierra window: white, red border, black text. + let mut lines: Vec = Vec::new(); + for l in d.lines(&self.flags) { + lines.extend(font::wrap(&l, 264)); + } + let h = lines.len() as i32 * 9 + 10; + let (x, y) = (24, 24); + font::draw_box(&mut vis, x, y, 272, h, 15, 4); + for (i, l) in lines.iter().enumerate() { + font::draw_text(&mut vis, x + 6, y + 5 + i as i32 * 9, l, 0); + } + } else { + // Last transcript lines, bottom strip. + let tail: Vec<&String> = self.transcript.iter().rev().take(2).collect(); + let mut lines: Vec = Vec::new(); + for l in tail.iter().rev() { + lines.extend(font::wrap(l, PIC_W - 12)); + } + let lines: Vec = lines.into_iter().rev().take(3).rev().collect(); + if !lines.is_empty() { + let h = lines.len() as i32 * 9 + 7; + let y = PIC_H as i32 - h - 1; + font::draw_box(&mut vis, 1, y, PIC_W as i32 - 2, h, 0, 8); + for (i, l) in lines.iter().enumerate() { + font::draw_text(&mut vis, 5, y + 4 + i as i32 * 9, l, 15); + } + } + } + + render::encode_png(&render::indices_to_rgba(&vis, &self.world.palette, &lut)) + } } fn safe_slot(slot: &str) -> String { diff --git a/mrpci/src/main.rs b/mrpci/src/main.rs index 0cb1baa..cbbf765 100644 --- a/mrpci/src/main.rs +++ b/mrpci/src/main.rs @@ -36,8 +36,55 @@ struct Ui { flash: f32, scanlines: bool, muted: bool, + /// Room-change fade-in, 0 → 1 (a shader uniform, not a draw call). + fade: f32, } +/// The palette blit, moved to the GPU (thanks, Lague): the frame crosses to +/// the card as raw palette *indices* plus a 256x1 palette strip with the +/// cycle LUT pre-applied. The fragment shader does the lookup — and, while +/// it's there, the whole CRT: barrel curvature, scanlines, vignette, fades. +const CRT_VERTEX: &str = r#"#version 100 +attribute vec3 position; +attribute vec2 texcoord; +varying lowp vec2 uv; +uniform mat4 Model; +uniform mat4 Projection; +void main() { + gl_Position = Projection * Model * vec4(position, 1); + uv = texcoord; +}"#; + +const CRT_FRAGMENT: &str = r#"#version 100 +precision mediump float; +varying lowp vec2 uv; +uniform sampler2D Texture; // palette indices in the R channel +uniform sampler2D palette_tex; // 256x1 RGBA, cycle LUT applied +uniform float crt; +uniform float fade; + +void main() { + vec2 warped = uv; + if (crt > 0.5) { + vec2 c = uv - 0.5; + warped = uv + c * dot(c, c) * 0.10; + } + if (warped.x < 0.0 || warped.x > 1.0 || warped.y < 0.0 || warped.y > 1.0) { + gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + float idx = texture2D(Texture, warped).r; + vec3 col = texture2D(palette_tex, vec2((idx * 255.0 + 0.5) / 256.0, 0.5)).rgb; + if (crt > 0.5) { + float row = fract(warped.y * 190.0); + col *= 1.0 - 0.22 * smoothstep(0.55, 1.0, row); // scanline gap + vec2 v = warped - 0.5; + col *= 1.0 - dot(v, v) * 0.5; // vignette + col *= 1.10; // phosphor lift + } + gl_FragColor = vec4(col * fade, 1.0); +}"#; + fn conf() -> Conf { Conf { window_title: "MRPCI — Monster Robot Party Creative Interpreter".into(), @@ -79,15 +126,32 @@ async fn amain(game_dir: String) { flash: 0.0, scanlines: true, muted: false, + fade: 0.0, }; // Boot the session properly (intro text, music, checkpoint). let boot = gs.apply(Command::NewGame { room: None }); handle_events(&boot, &mut ui, &mut audio); - let mut image = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK); - let texture = Texture2D::from_image(&image); - texture.set_filter(FilterMode::Nearest); + // Index frame + palette strip textures for the shader path. + let mut index_img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK); + let index_tex = Texture2D::from_image(&index_img); + index_tex.set_filter(FilterMode::Nearest); + let mut pal_img = Image::gen_image_color(256, 1, BLACK); + let pal_tex = Texture2D::from_image(&pal_img); + pal_tex.set_filter(FilterMode::Nearest); + let material = load_material( + ShaderSource::Glsl { vertex: CRT_VERTEX, fragment: CRT_FRAGMENT }, + MaterialParams { + textures: vec!["palette_tex".to_string()], + uniforms: vec![ + UniformDesc::new("crt", UniformType::Float1), + UniformDesc::new("fade", UniformType::Float1), + ], + ..Default::default() + }, + ) + .expect("CRT shader compiles"); let mut last_dir_sent: u8 = 0; show_mouse(false); @@ -238,30 +302,33 @@ async fn amain(game_dir: String) { handle_events(&evs, &mut ui, &mut audio); audio.tick(); ui.flash = (ui.flash - get_frame_time()).max(0.0); + ui.fade = (ui.fade + get_frame_time() * 2.6).min(1.0); // --- draw ------------------------------------------------------------ clear_background(Color::from_rgba(12, 12, 16, 255)); - let rgba = gs.render_visual(true); - image.bytes.copy_from_slice(&rgba); - texture.update(&image); + // Indices into the R channel; the shader does the palette lookup. + let indices = gs.render_indices(true); + for (i, &idx) in indices.iter().enumerate() { + index_img.bytes[i * 4] = idx; + index_img.bytes[i * 4 + 3] = 255; + } + index_tex.update(&index_img); + pal_img.bytes.copy_from_slice(&gs.effective_palette()); + pal_tex.update(&pal_img); + + gl_use_material(&material); + material.set_texture("palette_tex", pal_tex.clone()); + material.set_uniform("crt", if ui.scanlines { 1.0f32 } else { 0.0f32 }); + material.set_uniform("fade", ui.fade); draw_texture_ex( - &texture, + &index_tex, 0.0, BAR_H, WHITE, DrawTextureParams { dest_size: Some(vec2(PIC_W as f32 * SCALE, PIC_H as f32 * SCALE)), ..Default::default() }, ); - - if ui.scanlines { - let y0 = BAR_H; - let y1 = BAR_H + PIC_H as f32 * SCALE; - let mut y = y0; - while y < y1 { - draw_line(0.0, y, PIC_W as f32 * SCALE, y, 1.0, Color::from_rgba(0, 0, 0, 46)); - y += SCALE; - } - } + gl_use_default_material(); if ui.flash > 0.0 { draw_rectangle( @@ -306,6 +373,7 @@ fn handle_events(evs: &[Event], ui: &mut Ui, audio: &mut sound::LazyAudio) { } Event::Audio { cue } => audio.play(*cue), Event::Music { mood } => audio.set_music(*mood), + Event::RoomChanged { .. } => ui.fade = 0.0, // shader fades the room in Event::Died { .. } => ui.flash = 1.2, Event::Won => audio.play(AudioCue::Win), Event::Error { message } => {