Files
Verbatim/src/render/terminal.rs
T
Emil d889e24cf6 feat: store color directly in Cell (reality layer)
Each cell now stores its own fg/bg color inline, instead of looking
up from MaterialRegistry every frame.

Cell struct:
- fg: [u8; 3] — foreground color (for rendering)
- bg: [u8; 3] — background color (for rendering)
- temp: f32 — temperature (already there)
- material: MaterialId — for physics property lookup
- variant: u8 — visual/behavioral variant
- updated_this_tick: bool — CA flag

Colors are copied from MaterialRegistry at Cell::new() time.
This allows per-cell color variation in the future (water depth
shading, lava gradient, damaged materials, etc.) without registry
changes.

MaterialRegistry still stores physics properties (density, solid,
liquid, flammable, etc.) — looked up only during CA/physics steps,
not during rendering.

Renderers (terminal, ascii, graphics) updated to use cell.fg/cell.bg
directly — no more registry lookup in render path.

Cell size: ~16 bytes (was ~12). 250x250 grid = 1MB (was 750KB).
126 tests, 0 failures
2026-06-21 01:21:38 +03:00

191 lines
6.1 KiB
Rust

use std::io::{self, Write, stdout};
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, PopKeyboardEnhancementFlags},
execute, queue,
style::{Color, SetBackgroundColor, SetForegroundColor, ResetColor, Print},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, size as term_size},
};
use crate::entity::EntityManager;
use crate::render::Renderer;
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
use crate::world::material::MaterialRegistry;
pub struct TerminalRenderer {
width: usize,
height: usize,
prev_frame: Vec<(char, (u8, u8, u8), (u8, u8, u8))>,
initialized: bool,
}
impl TerminalRenderer {
pub fn new() -> Self {
Self {
width: 80,
height: 25,
prev_frame: Vec::new(),
initialized: false,
}
}
fn detect_size(&mut self) -> io::Result<()> {
let (w, h) = term_size().unwrap_or((200, 60));
self.width = (w as usize).min(250).max(80);
self.height = (h as usize).min(120).max(25);
Ok(())
}
fn empty_cell() -> (char, (u8, u8, u8), (u8, u8, u8)) {
(' ', (0, 0, 0), (10, 10, 15))
}
}
impl Renderer for TerminalRenderer {
fn init(&mut self) -> io::Result<()> {
self.detect_size()?;
let total = self.width * self.height;
self.prev_frame = vec![Self::empty_cell(); total];
terminal::enable_raw_mode().map_err(|e| io::Error::other(e))?;
execute!(
stdout(),
EnterAlternateScreen,
Hide,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
),
EnableMouseCapture,
Clear(ClearType::All),
)?;
self.initialized = true;
Ok(())
}
fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) -> io::Result<()> {
if !self.initialized {
return Ok(());
}
let reg = MaterialRegistry::instance();
let mut out = stdout();
let mut frame: Vec<(char, (u8, u8, u8), (u8, u8, u8))>;
let total = self.width * self.height;
frame = vec![Self::empty_cell(); total];
for dy in 0..self.height {
for dx in 0..self.width {
let wx = cam_x + dx as i32;
let wy = cam_y + dy as i32;
if !grid.in_bounds(wx, wy) {
let idx = dy * self.width + dx;
frame[idx] = ('?', (80, 80, 80), (10, 10, 15));
continue;
}
let cell = grid.get(wx, wy);
let idx = dy * self.width + dx;
if cell.is_empty() {
frame[idx] = (' ', (cell.fg[0], cell.fg[1], cell.fg[2]), (cell.bg[0], cell.bg[1], cell.bg[2]));
} else {
let ch = cell.material.display_char();
let fg = if cell.material == MaterialId::Lava {
let r = 200u8.saturating_add(cell.variant / 2);
(r, 60, 20)
} else {
(cell.fg[0], cell.fg[1], cell.fg[2])
};
frame[idx] = (ch, fg, (cell.bg[0], cell.bg[1], cell.bg[2]));
}
}
}
for e in entities.all() {
for b in &e.bodies {
if !b.alive {
continue;
}
let sx = b.x as i32 - cam_x;
let sy = b.y as i32 - cam_y;
if sx >= 0 && sx < self.width as i32 && sy >= 0 && sy < self.height as i32 {
let idx = sy as usize * self.width + sx as usize;
let ch = match e.kind {
crate::entity::EntityKind::Player if e.alive => '@',
crate::entity::EntityKind::Goblin if e.alive => 'g',
_ => '%',
};
let fg = if e.on_fire {
(255, 160, 40)
} else if !e.alive {
(100, 60, 60)
} else {
match e.kind {
crate::entity::EntityKind::Player => (255, 255, 100),
crate::entity::EntityKind::Goblin => (100, 220, 100),
_ => (180, 50, 50),
}
};
frame[idx] = (ch, fg, (20, 10, 10));
}
}
}
let mut prev_color: Option<(Color, Color)> = None;
for i in 0..total {
if frame[i] == self.prev_frame[i] {
continue;
}
let dx = i % self.width;
let dy = i / self.width;
let (ch, fg, bg) = frame[i];
queue!(out, MoveTo(dx as u16, dy as u16))?;
let new_fg = Color::Rgb { r: fg.0, g: fg.1, b: fg.2 };
let new_bg = Color::Rgb { r: bg.0, g: bg.1, b: bg.2 };
if prev_color != Some((new_fg, new_bg)) {
queue!(out, SetForegroundColor(new_fg), SetBackgroundColor(new_bg))?;
prev_color = Some((new_fg, new_bg));
}
queue!(out, Print(ch))?;
}
out.flush()?;
self.prev_frame = frame;
Ok(())
}
fn shutdown(&mut self) -> io::Result<()> {
if !self.initialized {
return Ok(());
}
execute!(
stdout(),
ResetColor,
Show,
PopKeyboardEnhancementFlags,
LeaveAlternateScreen,
DisableMouseCapture,
)?;
terminal::disable_raw_mode().map_err(|e| io::Error::other(e))?;
self.initialized = false;
Ok(())
}
fn viewport_w(&self) -> usize {
self.width
}
fn viewport_h(&self) -> usize {
self.height
}
}
impl Drop for TerminalRenderer {
fn drop(&mut self) {
let _ = self.shutdown();
}
}