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.
6.2 kB · 129 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130//! Shared all-surface cells; wide glyphs have explicit continuation cells.use unicode_width::UnicodeWidthChar;pub const MAX_AXIS: usize = 4096;pub const MAX_CELLS: usize = 262_144;#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct DimensionError;impl std::fmt::Display for DimensionError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("frame dimensions must be nonzero, axes <=4096 and area <=262144 cells") }}impl std::error::Error for DimensionError {}pub fn validate_dimensions(width: usize, height: usize) -> Result<usize, DimensionError> { if width == 0 || height == 0 || width > MAX_AXIS || height > MAX_AXIS { return Err(DimensionError); } width.checked_mul(height).filter(|area| *area <= MAX_CELLS).ok_or(DimensionError)}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub struct Style { pub foreground: Option<[u8; 3]>, pub background: Option<[u8; 3]>, pub bold: bool,}#[derive(Clone, Debug, PartialEq, Eq)]pub struct Cell { pub(crate) text: String, pub(crate) continuation: bool, pub(crate) style: Style,}#[derive(Clone, Debug, PartialEq, Eq)]pub struct ComposedFrame { pub(crate) width: usize, pub(crate) height: usize, pub(crate) cells: Vec<Cell>,}impl Cell { pub fn text(&self) -> &str { &self.text } pub fn continuation(&self) -> bool { self.continuation } pub fn style(&self) -> Style { self.style }}impl ComposedFrame { pub fn width(&self) -> usize { self.width } pub fn height(&self) -> usize { self.height } pub fn cells(&self) -> &[Cell] { &self.cells } pub fn new(width: usize, height: usize) -> Result<Self, DimensionError> { let area = validate_dimensions(width, height)?; Ok(Self { width, height, cells: vec![Cell { text: " ".into(), continuation: false, style: Style::default() }; area] }) } pub fn scene(&mut self, scene: &hexwork_render::FrameBuffer) { for y in 0..scene.h.min(self.height) { for x in 0..scene.w.min(self.width) { let Some(source) = y.checked_mul(scene.w).and_then(|i| i.checked_add(x)).and_then(|i| scene.cells.get(i)).copied() else { continue; }; self.cells[y * self.width + x] = Cell { // Scene raster cells are one-column semantic marks. Invalid glyphs // stay visibly present rather than hiding an actor or threat. text: if UnicodeWidthChar::width(source.glyph) == Some(1) && !source.glyph.is_control() { source.glyph } else { '?' }.to_string(), continuation: false, style: Style { foreground: Some(source.fg), background: Some(source.bg), bold: false }, }; } } } pub fn text_row(&mut self, row: usize, text: &str, style: Style) { if row >= self.height { return; } let fitted = hexwork_ui::text::fit_terminal_cells(text, self.width); let start = row * self.width; for cell in &mut self.cells[start..start + self.width] { *cell = Cell { text: " ".into(), continuation: false, style }; } let mut x = 0; let mut lead: Option<usize> = None; for glyph in fitted.chars() { let width = UnicodeWidthChar::width(glyph).unwrap_or(0); if width == 0 { if let Some(index) = lead { self.cells[index].text.push(glyph); } } else if x + width <= self.width { self.cells[start + x] = Cell { text: glyph.to_string(), continuation: false, style }; lead = Some(start + x); for offset in 1..width { self.cells[start + x + offset] = Cell { text: String::new(), continuation: true, style }; } x += width; } } } /// Compose all visible surfaces once. Modal rows replace the world viewport. pub fn compose(scene: &hexwork_render::FrameBuffer, modal: Option<&[String]>, presentation: &crate::app::PresentationSnapshot, ribbon: &str, logs: &hexwork_ui::logfmt::RecentLogWindow) -> Result<Self, DimensionError> { validate_dimensions(scene.w, scene.h)?; let height = scene.h.checked_add(usize::from(crate::chrome::TERMINAL_CHROME_ROWS)).ok_or(DimensionError)?; let mut frame = Self::new(scene.w, height)?; if let Some(rows) = modal { for (y, row) in rows.iter().take(scene.h).enumerate() { frame.text_row(y, row, Style::default()); } } else { frame.scene(scene); } for (y, row) in crate::chrome::chrome_rows(scene.w, presentation, ribbon, logs).iter().enumerate() { frame.text_row(scene.h + y, row, Style::default()); } Ok(frame) } pub fn present(&self, sink: &mut impl CellSink) { sink.begin(self.width, self.height); for (i, cell) in self.cells.iter().enumerate() { sink.cell(i % self.width, i / self.width, cell); } sink.end(); }}#[cfg(test)]mod tests { use super::*; #[test] fn huge_zero_and_finite_over_budget_dimensions_rejected_before_allocation() { for (w,h) in [(usize::MAX,usize::MAX),(usize::MAX,1),(1,usize::MAX),(0,24),(80,0),(4097,1),(1024,1024)] { assert_eq!(ComposedFrame::new(w,h), Err(DimensionError)); } assert_eq!(validate_dimensions(512,512), Ok(MAX_CELLS)); assert_eq!(validate_dimensions(513,512), Err(DimensionError)); assert!(ComposedFrame::new(80,24).is_ok()); } #[test] fn scene_invalid_width_glyphs_remain_visible_without_overwriting_neighbors() { let mut scene = hexwork_render::FrameBuffer::new(5,1); for (cell, glyph) in scene.cells.iter_mut().zip(['深','@','\u{301}','\x1b','!']) { cell.glyph = glyph; } let mut frame = ComposedFrame::new(5,1).unwrap(); frame.scene(&scene); assert_eq!(frame.cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>(), ["?","@","?","?","!"]); assert!(frame.cells.iter().all(|c| !c.continuation)); }}pub trait CellSink { fn begin(&mut self, width: usize, height: usize); fn cell(&mut self, x: usize, y: usize, cell: &Cell); fn end(&mut self) {}}