v0.2 — procedural fills, weather, in-core font, GPU palette/CRT shader

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-25 23:23:19 +10:00
parent c83c90711d
commit e014a0c54e
18 changed files with 848 additions and 57 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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."
}
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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") => {

View File

@ -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;

View File

@ -123,7 +123,13 @@ fn call_tool(gs: &mut GameState, name: &str, args: Value) -> Result<Value, (i64,
"mrpci_load_game" => 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" }]
}));

View File

@ -84,15 +84,29 @@ pub fn write_sample(dir: &str) -> std::io::Result<()> {
fn room0() -> RoomDoc {
let mut ops: Vec<PicOp> = 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()
}

149
mrpci-core/src/font.rs Normal file
View File

@ -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<String> {
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 };
}
}
}

View File

@ -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;

132
mrpci-core/src/particles.rs Normal file
View File

@ -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<Particle>,
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);
}
_ => {}
}
}
}
}

View File

@ -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<u8>,
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<PicOp>`, 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<u8>, ink: Ink },
HGradient { x: i32, y: i32, w: i32, h: i32, ramp: Vec<u8>, ink: Ink },
/// fBm noise through a ramp — clouds, asphalt, grime, water.
FbmFill { x: i32, y: i32, w: i32, h: i32, ramp: Vec<u8>, 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<u8>,
edge_color: Option<u8>,
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<u8>, 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)
}
}
}

View File

@ -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<DrawObj>) -> Vec<u8> {
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<DrawObj>) -> Vec<u8> {
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<u8> {
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<u8> {
let mut rgba = Vec::with_capacity(PIC_W * PIC_H * 4);

View File

@ -178,6 +178,10 @@ pub struct RoomDoc {
pub npcs: Vec<NpcDef>,
#[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<String, String>,
@ -209,6 +213,7 @@ impl Default for RoomDoc {
props: Vec::new(),
npcs: Vec::new(),
music: 0,
weather: 0,
defaults: HashMap::new(),
script: String::new(),
}

View File

@ -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<SaveData>,
/// 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<u8> {
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<u8> {
let table = &self.world.doc.scale;
let mut draws: Vec<DrawObj> = 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<u8> {
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<u8> {
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<u8> {
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<String> = 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<String> = Vec::new();
for l in tail.iter().rev() {
lines.extend(font::wrap(l, PIC_W - 12));
}
let lines: Vec<String> = 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 {

View File

@ -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 } => {