A new-school reimagining of Sierra's 1980s AGI interpreter in Rust + macroquad: - Dual-buffer (visual + priority) room model with hand-painted walkability - In-engine room editor: brush/line/rect/fill/pick/erase/oval/image/spawn/object tools - Sprite pipeline (PNG -> EGA quantize), multi-room worlds with edge exits - Objects with look/use/needs/keys/win conditions (no code) - Forgiving text parser + optional local-LLM lane (Ollama / OpenRouter) - Branching NPC dialogue trees, chiptune SFX, per-room ambient music - Content kit: 61 archetypes across fantasy/scifi/monsters/saucy/horror - Full docs: README, EDITOR_GUIDE, MANUAL, art manifesto + Gemini prompts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
284 lines
8.5 KiB
Rust
284 lines
8.5 KiB
Rust
//! Vector-style room painting.
|
|
//!
|
|
//! In real AGI a PICTURE resource is a stream of drawing opcodes. MRPGI keeps
|
|
//! that spirit but in a clean, hand-editable form: a room is just a list of
|
|
//! [`PicOp`]s that paint into the visual and/or priority buffers. Leaving a
|
|
//! buffer as `None` lets you, say, set depth without drawing anything visible.
|
|
|
|
use crate::framebuffer::{band, FrameBuffer, PIC_H, PIC_W};
|
|
use crate::view::{Cel, TRANSPARENT};
|
|
|
|
#[derive(Clone)]
|
|
pub enum PicOp {
|
|
/// Filled rectangle.
|
|
Rect {
|
|
x: i32,
|
|
y: i32,
|
|
w: i32,
|
|
h: i32,
|
|
vis: Option<u8>,
|
|
pri: Option<u8>,
|
|
},
|
|
/// Connected polyline through the given points.
|
|
Line {
|
|
points: Vec<(i32, i32)>,
|
|
vis: u8,
|
|
pri: Option<u8>,
|
|
},
|
|
/// Single pixel.
|
|
Pixel {
|
|
x: i32,
|
|
y: i32,
|
|
vis: Option<u8>,
|
|
pri: Option<u8>,
|
|
},
|
|
}
|
|
|
|
pub fn draw(fb: &mut FrameBuffer, ops: &[PicOp]) {
|
|
for op in ops {
|
|
match op {
|
|
PicOp::Rect { x, y, w, h, vis, pri } => {
|
|
for yy in *y..(*y + *h) {
|
|
for xx in *x..(*x + *w) {
|
|
put(fb, xx, yy, *vis, *pri);
|
|
}
|
|
}
|
|
}
|
|
PicOp::Pixel { x, y, vis, pri } => put(fb, *x, *y, *vis, *pri),
|
|
PicOp::Line { points, vis, pri } => {
|
|
for pair in points.windows(2) {
|
|
line(fb, pair[0], pair[1], *vis, *pri);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn put(fb: &mut FrameBuffer, x: i32, y: i32, vis: Option<u8>, pri: Option<u8>) {
|
|
if x < 0 || y < 0 || x as usize >= PIC_W || y as usize >= PIC_H {
|
|
return;
|
|
}
|
|
let i = y as usize * PIC_W + x as usize;
|
|
if let Some(c) = vis {
|
|
fb.visual[i] = c;
|
|
}
|
|
if let Some(p) = pri {
|
|
fb.priority[i] = p;
|
|
}
|
|
}
|
|
|
|
/// Bresenham line into the visual buffer (and priority, if given).
|
|
fn line(fb: &mut FrameBuffer, a: (i32, i32), b: (i32, i32), vis: u8, pri: Option<u8>) {
|
|
let (mut x0, mut y0) = a;
|
|
let (x1, y1) = b;
|
|
let dx = (x1 - x0).abs();
|
|
let dy = -(y1 - y0).abs();
|
|
let sx = if x0 < x1 { 1 } else { -1 };
|
|
let sy = if y0 < y1 { 1 } else { -1 };
|
|
let mut err = dx + dy;
|
|
loop {
|
|
put(fb, x0, y0, Some(vis), pri);
|
|
if x0 == x1 && y0 == y1 {
|
|
break;
|
|
}
|
|
let e2 = 2 * err;
|
|
if e2 >= dy {
|
|
err += dy;
|
|
x0 += sx;
|
|
}
|
|
if e2 <= dx {
|
|
err += dx;
|
|
y0 += sy;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How an image maps into a region when its size doesn't match.
|
|
#[derive(Clone, Copy)]
|
|
pub enum Fit {
|
|
/// Scale to fill the region exactly (nearest-neighbor, keeps pixels crisp).
|
|
Stretch,
|
|
/// Repeat the image across the region.
|
|
Tile,
|
|
/// Place at original size, centered, clipped to the region.
|
|
Center,
|
|
}
|
|
|
|
/// Stamp a cel (a quantized image or sprite) into the buffers with its
|
|
/// top-left at (dx, dy). Transparent pixels are skipped; `pri` optionally
|
|
/// bakes depth/control under the painted pixels (AGI's `add.to.pic`).
|
|
pub fn stamp_cel(fb: &mut FrameBuffer, cel: &Cel, dx: i32, dy: i32, pri: Option<u8>) {
|
|
for cy in 0..cel.h {
|
|
for cx in 0..cel.w {
|
|
let c = cel.at(cx, cy);
|
|
if c == TRANSPARENT {
|
|
continue;
|
|
}
|
|
put(fb, dx + cx as i32, dy + cy as i32, Some(c), pri);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The "draw a shape, pour an image into it" primitive: fill a rectangular
|
|
/// region with an image using a [`Fit`] mode. The region is the shape (and can
|
|
/// also stamp depth via `pri`); the image supplies the look.
|
|
pub fn stamp_cel_fit(
|
|
fb: &mut FrameBuffer,
|
|
region: (i32, i32, i32, i32),
|
|
cel: &Cel,
|
|
fit: Fit,
|
|
pri: Option<u8>,
|
|
) {
|
|
let (rx, ry, rw, rh) = region;
|
|
if rw <= 0 || rh <= 0 || cel.w == 0 || cel.h == 0 {
|
|
return;
|
|
}
|
|
for dyk in 0..rh {
|
|
for dxk in 0..rw {
|
|
let (sx, sy) = match fit {
|
|
Fit::Stretch => (
|
|
(dxk * cel.w as i32 / rw) as usize,
|
|
(dyk * cel.h as i32 / rh) as usize,
|
|
),
|
|
Fit::Tile => ((dxk as usize) % cel.w, (dyk as usize) % cel.h),
|
|
Fit::Center => {
|
|
let ox = (rw - cel.w as i32) / 2;
|
|
let oy = (rh - cel.h as i32) / 2;
|
|
let sx = dxk - ox;
|
|
let sy = dyk - oy;
|
|
if sx < 0 || sy < 0 || sx >= cel.w as i32 || sy >= cel.h as i32 {
|
|
continue;
|
|
}
|
|
(sx as usize, sy as usize)
|
|
}
|
|
};
|
|
let c = cel.at(sx.min(cel.w - 1), sy.min(cel.h - 1));
|
|
if c == TRANSPARENT {
|
|
continue;
|
|
}
|
|
put(fb, rx + dxk, ry + dyk, Some(c), pri);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Editor primitives — painting into the LOOK (visual) and MEANING (priority)
|
|
// buffers. `PriFill` is how a paint stroke writes the priority/control layer:
|
|
// leave it (Keep), stamp a fixed control value (Set), or restore the default
|
|
// walkable depth band for that row (Band).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum PriFill {
|
|
Keep,
|
|
Set(u8),
|
|
Band,
|
|
}
|
|
|
|
#[inline]
|
|
fn put_pri(fb: &mut FrameBuffer, x: i32, y: i32, p: PriFill) {
|
|
if x < 0 || y < 0 || x as usize >= PIC_W || y as usize >= PIC_H {
|
|
return;
|
|
}
|
|
let i = y as usize * PIC_W + x as usize;
|
|
match p {
|
|
PriFill::Keep => {}
|
|
PriFill::Set(v) => fb.priority[i] = v,
|
|
PriFill::Band => fb.priority[i] = band(y as usize),
|
|
}
|
|
}
|
|
|
|
/// Paint one pixel: optional visual colour + a priority write.
|
|
pub fn px(fb: &mut FrameBuffer, x: i32, y: i32, color: Option<u8>, pri: PriFill) {
|
|
if let Some(c) = color {
|
|
put(fb, x, y, Some(c), None);
|
|
}
|
|
put_pri(fb, x, y, pri);
|
|
}
|
|
|
|
/// Bresenham line writing visual colour (+ optional priority).
|
|
pub fn line_into(fb: &mut FrameBuffer, a: (i32, i32), b: (i32, i32), color: u8, pri: PriFill) {
|
|
let (mut x0, mut y0) = a;
|
|
let (x1, y1) = b;
|
|
let dx = (x1 - x0).abs();
|
|
let dy = -(y1 - y0).abs();
|
|
let sx = if x0 < x1 { 1 } else { -1 };
|
|
let sy = if y0 < y1 { 1 } else { -1 };
|
|
let mut err = dx + dy;
|
|
loop {
|
|
px(fb, x0, y0, Some(color), pri);
|
|
if x0 == x1 && y0 == y1 {
|
|
break;
|
|
}
|
|
let e2 = 2 * err;
|
|
if e2 >= dy {
|
|
err += dy;
|
|
x0 += sx;
|
|
}
|
|
if e2 <= dx {
|
|
err += dx;
|
|
y0 += sy;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Filled rectangle into visual (+ optional priority).
|
|
pub fn rect_into(fb: &mut FrameBuffer, x: i32, y: i32, w: i32, h: i32, color: Option<u8>, pri: PriFill) {
|
|
for yy in y..(y + h) {
|
|
for xx in x..(x + w) {
|
|
px(fb, xx, yy, color, pri);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Flood fill the connected region sharing the start pixel's visual colour.
|
|
/// Writes `color` to the visual buffer and `pri` to the priority buffer for
|
|
/// every filled pixel. Returns the number of pixels filled (for leak checks).
|
|
pub fn flood_fill(fb: &mut FrameBuffer, sx: i32, sy: i32, color: Option<u8>, pri: PriFill) -> usize {
|
|
if sx < 0 || sy < 0 || sx as usize >= PIC_W || sy as usize >= PIC_H {
|
|
return 0;
|
|
}
|
|
let target = fb.visual[sy as usize * PIC_W + sx as usize];
|
|
let mut visited = vec![false; PIC_W * PIC_H];
|
|
let mut stack = vec![(sx, sy)];
|
|
let mut count = 0usize;
|
|
while let Some((x, y)) = stack.pop() {
|
|
if x < 0 || y < 0 || x as usize >= PIC_W || y as usize >= PIC_H {
|
|
continue;
|
|
}
|
|
let i = y as usize * PIC_W + x as usize;
|
|
if visited[i] || fb.visual[i] != target {
|
|
continue;
|
|
}
|
|
visited[i] = true;
|
|
px(fb, x, y, color, pri);
|
|
count += 1;
|
|
stack.push((x + 1, y));
|
|
stack.push((x - 1, y));
|
|
stack.push((x, y + 1));
|
|
stack.push((x, y - 1));
|
|
}
|
|
count
|
|
}
|
|
|
|
/// Filled ellipse inscribed in the rect (x, y, w, h).
|
|
pub fn ellipse_into(fb: &mut FrameBuffer, x: i32, y: i32, w: i32, h: i32, color: Option<u8>, pri: PriFill) {
|
|
if w <= 0 || h <= 0 {
|
|
return;
|
|
}
|
|
let rx = w as f32 / 2.0;
|
|
let ry = h as f32 / 2.0;
|
|
let cx = x as f32 + rx - 0.5;
|
|
let cy = y as f32 + ry - 0.5;
|
|
for yy in y..(y + h) {
|
|
for xx in x..(x + w) {
|
|
let nx = (xx as f32 - cx) / rx;
|
|
let ny = (yy as f32 - cy) / ry;
|
|
if nx * nx + ny * ny <= 1.0 {
|
|
px(fb, xx, yy, color, pri);
|
|
}
|
|
}
|
|
}
|
|
}
|