use crossterm::{ cursor::{Hide, MoveTo, Show}, event::{ DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, queue, style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor}, terminal::{ self, size as term_size, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, }, }; use std::io::{self, stdout, Write}; use crate::entity::EntityManager; use crate::render::lighting::{self, apply_light_tuple, LightGrid}; use crate::render::Renderer; use crate::world::cell::MaterialId; use crate::world::chunked_grid::ChunkedGrid; fn entity_priority(kind: crate::entity::EntityKind) -> u32 { match kind { crate::entity::EntityKind::Player => 3, crate::entity::EntityKind::Goblin => 2, crate::entity::EntityKind::Slime => 1, crate::entity::EntityKind::Corpse => 0, } } fn background_color(wx: i32, wy: i32, vy: i32, view_h: i32) -> (u8, u8, u8) { let t = (vy as f32 / view_h as f32).clamp(0.0, 1.0); let base_r = (10.0 + t * 15.0) as u8; let base_g = (10.0 + t * 25.0) as u8; let base_b = (25.0 + t * 35.0) as u8; let hash = ((wx.wrapping_mul(73856093)) ^ (wy.wrapping_mul(19349663))).abs(); if hash % 80 == 0 { let brightness = (60 + (hash % 120) as u8).min(255); return (brightness, brightness, (brightness + 20).min(255)); } (base_r, base_g, base_b) } 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: &ChunkedGrid, entities: &EntityManager, items: &crate::entity::item::ItemManager, ui: &crate::ui::UiLayer, cam_x: i32, cam_y: i32, lighting: Option<&LightGrid>, ) -> io::Result<()> { if !self.initialized { return Ok(()); } 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]; let h = self.height as i32; let mut entity_map: std::collections::HashMap<(i32, i32), (u32, char, (u8, u8, u8))> = std::collections::HashMap::new(); 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 ch = match e.kind { crate::entity::EntityKind::Player if e.alive => '@', crate::entity::EntityKind::Goblin if e.alive => 'g', crate::entity::EntityKind::Slime if e.alive => 's', _ => '%', }; let fg = if e.on_fire { (255, 160, 40) } else { (b.color[0], b.color[1], b.color[2]) }; let priority = entity_priority(e.kind); let key = (sx, sy); if entity_map .get(&key) .map(|(p, _, _)| priority > *p) .unwrap_or(true) { entity_map.insert(key, (priority, ch, fg)); } } } } let mut shadow_map: std::collections::HashSet<(i32, i32)> = std::collections::HashSet::new(); for (&(ex, ey), _) in entity_map.iter() { for dy in -1..=1 { for dx in -1..=1 { if dx == 0 && dy == 0 { continue; } let sx = ex + dx; let sy = ey + dy; if sx < 0 || sx >= self.width as i32 || sy < 0 || sy >= self.height as i32 { continue; } if entity_map.contains_key(&(sx, sy)) { continue; } let wx = cam_x + sx; let wy = cam_y + sy; if grid.in_bounds(wx, wy) && grid.get(wx, wy).is_empty() { shadow_map.insert((sx, sy)); } } } } 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; let idx = dy * self.width + dx; let light = lighting .map(|l| l.get(dx as i32, dy as i32)) .unwrap_or_else(lighting::ambient_light); if !grid.in_bounds(wx, wy) { let bg = background_color(wx, wy, dy as i32, h); let bg = apply_light_tuple(bg, light); frame[idx] = ('?', apply_light_tuple((80, 80, 80), light), bg); continue; } let cell = grid.get(wx, wy); if cell.is_empty() { let bg = background_color(wx, wy, dy as i32, h); let bg = if shadow_map.contains(&(dx as i32, dy as i32)) { ( (bg.0 as f32 * 0.4) as u8, (bg.1 as f32 * 0.4) as u8, (bg.2 as f32 * 0.4) as u8, ) } else { bg }; let bg = apply_light_tuple(bg, light); let fg = apply_light_tuple((cell.fg[0], cell.fg[1], cell.fg[2]), light); frame[idx] = (' ', fg, bg); } 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]) }; let fg = apply_light_tuple(fg, light); let bg = apply_light_tuple((cell.bg[0], cell.bg[1], cell.bg[2]), light); frame[idx] = (ch, fg, bg); } } } for ((sx, sy), (_, ch, fg)) in entity_map { let idx = sy as usize * self.width + sx as usize; let light = lighting .map(|l| l.get(sx, sy)) .unwrap_or_else(lighting::ambient_light); let fg = apply_light_tuple(fg, light); frame[idx] = (ch, fg, apply_light_tuple((20, 10, 10), light)); } for item in items.all() { let sx = item.x - cam_x; let sy = item.y - 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 color = item.color(); let light = lighting .map(|l| l.get(sx, sy)) .unwrap_or_else(lighting::ambient_light); frame[idx] = ( item.display_char(), apply_light_tuple((color[0], color[1], color[2]), light), apply_light_tuple((20, 10, 10), light), ); } } for (x, y) in ui.keys() { let tx = *x / crate::ui::UI_SCALE; let ty = *y / crate::ui::UI_SCALE; if tx < 0 || tx >= self.width as i32 || ty < 0 || ty >= self.height as i32 { continue; } let cell = ui.get(*x, *y).unwrap(); let idx = ty as usize * self.width + tx as usize; let a = cell.alpha as f32 / 255.0; let (_, old_fg, old_bg) = frame[idx]; let blend = |c: u8, o: u8| (c as f32 * a + o as f32 * (1.0 - a)).min(255.0) as u8; frame[idx] = ( cell.ch, ( blend(cell.fg[0], old_fg.0), blend(cell.fg[1], old_fg.1), blend(cell.fg[2], old_fg.2), ), ( blend(cell.bg[0], old_bg.0), blend(cell.bg[1], old_bg.1), blend(cell.bg[2], old_bg.2), ), ); } 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(); } }