MRPGI/mrpgi-core/src/render.rs
type-two 96b45561d0 Carve the engine into a headless core behind a command/event bus
The handover plan (docs/HANDOVER.md), P0-P6, in one pass. One insight
powers it: CLI/HTTP/MCP/Python is one feature, not four — a headless
core driven by Commands in, Events out, with every surface a thin
adapter.

- mrpgi-core: the whole sim as a library with zero macroquad (the
  workspace boundary is the guardrail). GameState::apply/tick, fixed
  1/20s cycles, deterministic replays, headless RGBA/PNG rendering.
- mrpgi-headless: JSONL stdio REPL, --script replay (AI lane off =
  byte-identical), --render-room PNG, --serve HTTP (state/command/
  events cursor/frame.png), --mcp JSON-RPC stdio server whose
  render_frame returns a real PNG so a model sees its own moves.
- Games are folders: game.json manifest (all fields default — legacy
  dirs still load), data-driven verb table feeding both the parser and
  the AI whitelist, flags with requires_flag/sets_flag gating on
  objects and dialogue choices, live UpsertRoom lore ingestion with
  validation.
- mrpgi (GUI): now one consumer of the bus; editor commits through the
  same commands MCP uses; audio became core events with per-game
  sfx/ music/ file overrides beating the chiptune synth.
- Tests: parser goldens + a full win-path playthrough asserting
  byte-identical transcripts across runs.

Deleted: the old fused src/ (root is now a virtual workspace), the
dead demo Scene/Assets path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:18:33 +10:00

62 lines
2.2 KiB
Rust

//! Headless compositing: room buffers + depth-sorted screen objects → RGBA
//! pixels → optionally a PNG. No window required — this is what lets an LLM
//! *see* the frame it just authored.
use crate::framebuffer::{FrameBuffer, PIC_H, PIC_W};
use crate::palette;
use crate::sprite::{Prop, ScreenObj};
use crate::view;
/// The real AGI trick: draw each object bottom-up by priority, but test each
/// pixel against the room's priority buffer, so a high-priority column drawn
/// into the *room* still occludes a sprite standing behind it.
pub fn compose(room: &FrameBuffer, ego: Option<&ScreenObj>, props: &[Prop]) -> Vec<u8> {
let mut vis = room.visual.clone();
let mut draws: Vec<(u8, &view::Cel, i32, i32)> = Vec::with_capacity(props.len() + 1);
if let Some(ego) = ego {
draws.push((ego.priority(), ego.cel(), ego.x, ego.y));
}
for p in props {
draws.push((p.priority(), p.cel(), p.x, p.y));
}
draws.sort_by_key(|d| d.0);
for (pri, cel, bx, by) in draws {
let ox = bx - cel.w as i32 / 2;
let oy = by - cel.h as i32;
for cy in 0..cel.h {
for cx in 0..cel.w {
let c = cel.at(cx, cy);
if c == view::TRANSPARENT {
continue;
}
let sx = ox + cx as i32;
let sy = oy + cy as i32;
if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H {
continue;
}
let i = sy as usize * PIC_W + sx as usize;
if pri >= room.priority[i] {
vis[i] = c;
}
}
}
}
let mut rgba = Vec::with_capacity(PIC_W * PIC_H * 4);
for &v in &vis {
rgba.extend_from_slice(&palette::rgba(v));
}
rgba
}
/// Encode raw RGBA (PIC_W x PIC_H) to PNG bytes.
pub fn encode_png(rgba: &[u8]) -> Vec<u8> {
let img = image::RgbaImage::from_raw(PIC_W as u32, PIC_H as u32, rgba.to_vec())
.expect("buffer is always PIC_W*PIC_H*4");
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageOutputFormat::Png).expect("png encode of a valid image");
out.into_inner()
}