Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
11 kB · 297 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298//! Minimal ANSI diff encoder: the SSH-playability path (docs/05 §5.2//! steps 6–7, capability matrix §5.3).//!//! [`encode_diff`] compares a cell buffer against the last-flushed frame and//! emits cursor-move (`ESC[row;colH`) + SGR (`38;2;r;g;b` foreground,//! `48;2;r;g;b` background) sequences only for changed cells, scanned in//! row-major runs. Typical frames diff <15% of cells, which is what keeps//! the game playable over SSH (docs/05 §5.2 step 6). [`encode_full`] redraws//! everything for the resize / palette-swap path. [`quantize_256`] maps//! truecolor to the 6×6×6-cube index for the 256-color fallback (docs/05//! §5.2 step 6, §5.3 table).//!//! Empty-vs-home decision: when nothing changed, [`encode_diff`] emits ZERO//! bytes (no `\x1b[H`, no reset) — the caller simply keeps the last flush.//! `\x1b[H` home is emitted once at the start and `\x1b[0m` reset at the end//! only when at least one cell changed. This is pinned by//! `identical_frames_emit_nothing` below.//!//! All functions are pure string building: no I/O, no RNG, no time.//!//! Parent-unification notes (parallel workstream `ray.rs` / `shade.rs`)://! - The encoder consumes [`crate::FrameBuffer`] / [`crate::Cell`] only, so//! whatever glyph+fg/bg contract `shade.rs` settles on flows through//! unchanged — including the no-color fallback path (density glyphs +//! bold, docs/05 §5.2 step 6), which the parent implements by mapping//! brightness to glyph/bold before calling into this module.
use crate::FrameBuffer;use std::fmt::Write as _;
/// Emit one cell's colors + glyph as two SGR sequences plus the glyph.fn push_cell(out: &mut String, glyph: char, fg: [u8; 3], bg: [u8; 3]) { let _ = write!( out, "\x1b[38;2;{};{};{}m\x1b[48;2;{};{};{}m{}", fg[0], fg[1], fg[2], bg[0], bg[1], bg[2], glyph );}
/// Encode the diff from `prev` to `next` into `out` (docs/05 §5.2 step 6).////// Changed cells are grouped into row-major runs: one cursor-move per run,/// then SGR+glyph per changed cell. Emits `\x1b[H` home once at the start/// when anything changed and `\x1b[0m` reset at the end; emits nothing when/// the frames are identical (see module docs). Mismatched dimensions take/// the resize path and fall back to [`encode_full`]. Never panics: short or/// empty cell vectors are clamped, never indexed out of bounds.pub fn encode_diff(prev: &FrameBuffer, next: &FrameBuffer, out: &mut String) { if prev.w != next.w || prev.h != next.h || prev.cells.len() != next.cells.len() { // Resize path (docs/05 §5.2 step 6): geometry changed → full redraw. encode_full(next, out); return; } let (w, h) = (next.w, next.h); if w == 0 || h == 0 || next.cells.is_empty() { return; } let n = w.saturating_mul(h).min(next.cells.len()); if n == 0 || prev.cells[..n] == next.cells[..n] { return; } out.push_str("\x1b[H"); for y in 0..h { let mut x = 0; while x < w { let i = y * w + x; if i >= n || prev.cells[i] == next.cells[i] { x += 1; continue; } let _ = write!(out, "\x1b[{};{}H", y + 1, x + 1); while x < w { let j = y * w + x; if j >= n || prev.cells[j] == next.cells[j] { break; } let c = next.cells[j]; push_cell(out, c.glyph, c.fg, c.bg); x += 1; } } } out.push_str("\x1b[0m");}
/// Full redraw into `out`: every cell, row by row (docs/05 §5.2 step 6).////// Used on resize and palette swap. Emits `\x1b[H` home once, one/// cursor-move per row, SGR+glyph per cell, and a trailing `\x1b[0m`/// reset. Emits nothing for empty buffers; never panics.pub fn encode_full(fb: &FrameBuffer, out: &mut String) { let (w, h) = (fb.w, fb.h); if w == 0 || h == 0 || fb.cells.is_empty() { return; } let n = w.saturating_mul(h).min(fb.cells.len()); if n == 0 { return; } out.push_str("\x1b[H"); for y in 0..h { let _ = write!(out, "\x1b[{};1H", y + 1); for x in 0..w { let i = y * w + x; if i >= n { break; } let c = fb.cells[i]; push_cell(out, c.glyph, c.fg, c.bg); } } out.push_str("\x1b[0m");}
/// Nearest actual xterm cube or grayscale RGB by squared channel distance./// Equal distances select the lower index; terminal-dependent system 0..15/// colors are deliberately excluded. Black → 16, white → 231, red → 196.pub fn quantize_256(rgb: [u8; 3]) -> u8 { const CUBE: [i32; 6] = [0, 95, 135, 175, 215, 255]; let mut best = (i32::MAX, 16u8); for index in 16u16..=255 { let c = if index < 232 { let n = usize::from(index - 16); [CUBE[n / 36], CUBE[n / 6 % 6], CUBE[n % 6]] } else { [8 + 10 * i32::from(index - 232); 3] }; let distance = (0..3).map(|i| (i32::from(rgb[i]) - c[i]).pow(2)).sum(); if distance < best.0 { best = (distance, index as u8); } } best.1}
/// Output capability, independent of semantic/ASCII conversion.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum ColorMode { TrueColor, Ansi256, NoColor,}fn mode_cell(out: &mut String, c: crate::Cell, mode: ColorMode) { match mode { ColorMode::TrueColor => push_cell(out, c.glyph, c.fg, c.bg), ColorMode::Ansi256 => { let _ = write!( out, "\x1b[38;5;{}m\x1b[48;5;{}m{}", quantize_256(c.fg), quantize_256(c.bg), c.glyph ); } ColorMode::NoColor => { out.push_str(if c.fg.iter().copied().max().unwrap_or(0) >= 128 { "\x1b[1m" } else { "\x1b[22m" }); out.push(c.glyph); } }}/// Full mode-aware redraw, resetting SGR before subsequent chrome.pub fn encode_full_mode(fb: &FrameBuffer, out: &mut String, mode: ColorMode) { encode_mode(None, fb, out, mode);}/// Mode-aware diff. Caller must force full redraw after changing capability.pub fn encode_diff_mode(prev: &FrameBuffer, next: &FrameBuffer, out: &mut String, mode: ColorMode) { let compatible = prev.w == next.w && prev.h == next.h && prev.cells.len() == next.cells.len(); encode_mode(if compatible { Some(prev) } else { None }, next, out, mode);}fn encode_mode(prev: Option<&FrameBuffer>, fb: &FrameBuffer, out: &mut String, mode: ColorMode) { if fb.w == 0 || fb.h == 0 { return; } let n = fb.w.saturating_mul(fb.h).min(fb.cells.len()); let mut started = false; for i in 0..n { if prev.is_some_and(|p| p.cells[i] == fb.cells[i]) { continue; } if !started { out.push_str("\x1b[H\x1b[0m"); started = true; } let _ = write!(out, "\x1b[{};{}H", i / fb.w + 1, i % fb.w + 1); mode_cell(out, fb.cells[i], mode); } if started { out.push_str("\x1b[0m"); }}
#[cfg(test)]mod tests { use super::*; use crate::Cell;
fn cell(glyph: char, fg: [u8; 3], bg: [u8; 3]) -> Cell { Cell { glyph, fg, bg } }
#[test] fn identical_frames_emit_nothing() { // DECISION PIN (see module docs): no change → zero bytes, so an // idle 60 fps render costs nothing over SSH (docs/05 §5.2 step 6). let a = FrameBuffer::new(4, 2); let b = FrameBuffer::new(4, 2); let mut out = String::new(); encode_diff(&a, &b, &mut out); assert!(out.is_empty(), "identical frames must emit zero bytes"); }
#[test] fn single_cell_change_emits_glyph_and_move() { let prev = FrameBuffer::new(4, 2); let mut next = FrameBuffer::new(4, 2); next.cells[2] = cell('X', [255, 0, 0], [0, 0, 0]); let mut out = String::new(); encode_diff(&prev, &next, &mut out); assert!(out.starts_with("\x1b[H"), "must home once at start"); assert!(out.contains("\x1b[1;3H"), "must move to row 1, col 3"); assert!(out.contains('X'), "must emit the changed glyph"); assert!(out.ends_with("\x1b[0m"), "must reset at end"); }
#[test] fn full_redraw_covers_all_cells() { let mut fb = FrameBuffer::new(3, 2); for (i, c) in fb.cells.iter_mut().enumerate() { *c = cell(char::from(b'a' + i as u8), [200, 200, 200], [10, 10, 10]); } let mut out = String::new(); encode_full(&fb, &mut out); for g in ['a', 'b', 'c', 'd', 'e', 'f'] { assert!(out.contains(g), "full redraw must cover glyph {g}"); } assert!(out.ends_with("\x1b[0m")); }
#[test] fn quantize_spots() { assert_eq!(quantize_256([0, 0, 0]), 16); assert_eq!(quantize_256([255, 255, 255]), 231); assert_eq!(quantize_256([255, 0, 0]), 196); }
#[test] fn sgr_sequences_well_formed() { let prev = FrameBuffer::new(2, 1); let mut next = FrameBuffer::new(2, 1); next.cells[0] = cell('@', [255, 214, 64], [8, 8, 16]); let mut out = String::new(); encode_diff(&prev, &next, &mut out); assert!(out.contains("38;2"), "foreground SGR must be present"); assert!(out.contains("48;2"), "background SGR must be present"); assert!(out.contains("\x1b[38;2;255;214;64m")); assert!(out.contains("\x1b[48;2;8;8;16m")); }
#[test] fn empty_buffer_never_panics() { let e = FrameBuffer::new(0, 0); let mut out = String::new(); encode_diff(&e, &e, &mut out); encode_full(&e, &mut out); assert!(out.is_empty()); }
#[test] fn dim_mismatch_falls_back_to_full_redraw() { // Resize path (docs/05 §5.2 step 6): diff with changed geometry // must equal a full redraw of the new frame. let prev = FrameBuffer::new(2, 2); let next = FrameBuffer::new(3, 2); let mut a = String::new(); encode_diff(&prev, &next, &mut a); let mut b = String::new(); encode_full(&next, &mut b); assert_eq!(a, b); }
#[test] fn diff_run_groups_one_row_move() { // Two adjacent changes on one row → a single cursor move. let prev = FrameBuffer::new(4, 1); let mut next = FrameBuffer::new(4, 1); next.cells[1] = cell('A', [1, 2, 3], [4, 5, 6]); next.cells[2] = cell('B', [7, 8, 9], [10, 11, 12]); let mut out = String::new(); encode_diff(&prev, &next, &mut out); assert_eq!(out.matches("\x1b[1;").count(), 1); assert!(out.contains('A') && out.contains('B')); }}