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:
parent
c83c90711d
commit
e014a0c54e
20
README.md
20
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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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") => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" }]
|
||||
}));
|
||||
|
||||
@ -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
149
mrpci-core/src/font.rs
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
132
mrpci-core/src/particles.rs
Normal 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 } => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user