diff --git a/src/entity/entity.rs b/src/entity/entity.rs index d98be67..6b70aaf 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -1,4 +1,4 @@ -use crate::physics::verlet::{SubBody, Constraint}; +use crate::physics::verlet::{Constraint, SubBody}; use crate::world::cell::MaterialId; pub type EntityId = u32; @@ -110,55 +110,105 @@ impl Entity { self.cy = cy; self.cvx = 0.0; self.cvy = 0.0; - self.half_w = 3.5; - self.half_h = 2.5; + self.half_w = 3.0; + self.half_h = 3.5; let r = 0.5; - let mat = match self.kind { - EntityKind::Player => MaterialId::Flesh, - EntityKind::Goblin => MaterialId::Flesh, - EntityKind::Corpse => MaterialId::Flesh, + let mat = MaterialId::Flesh; + + let (head_c, torso_c, arm_c, leg_c) = match self.kind { + EntityKind::Player => ( + [255, 230, 130, 255], + [230, 180, 70, 255], + [200, 155, 50, 255], + [170, 130, 35, 255], + ), + EntityKind::Goblin => ( + [130, 230, 110, 255], + [80, 180, 70, 255], + [60, 150, 50, 255], + [45, 120, 35, 255], + ), + EntityKind::Corpse => ( + [120, 90, 80, 255], + [100, 75, 65, 255], + [90, 65, 55, 255], + [80, 55, 45, 255], + ), }; - // 5x5 body + 2 arms = 27 bodies - let layout: [(f32, f32, MaterialId); 27] = [ - // Row 0 (top): head - (-2.0, -2.0, mat), (-1.0, -2.0, mat), ( 0.0, -2.0, mat), ( 1.0, -2.0, mat), ( 2.0, -2.0, mat), - // Row 1: shoulders - (-2.0, -1.0, mat), (-1.0, -1.0, mat), ( 0.0, -1.0, mat), ( 1.0, -1.0, mat), ( 2.0, -1.0, mat), - // Row 2: torso - (-2.0, 0.0, mat), (-1.0, 0.0, mat), ( 0.0, 0.0, mat), ( 1.0, 0.0, mat), ( 2.0, 0.0, mat), - // Row 3: hips - (-2.0, 1.0, mat), (-1.0, 1.0, mat), ( 0.0, 1.0, mat), ( 1.0, 1.0, mat), ( 2.0, 1.0, mat), - // Row 4: legs - (-2.0, 2.0, mat), (-1.0, 2.0, mat), ( 0.0, 2.0, mat), ( 1.0, 2.0, mat), ( 2.0, 2.0, mat), - // Arms - ( 3.0, -1.0, mat), ( 3.0, 0.0, mat), + let layout: [(f32, f32, [u8; 4]); 23] = [ + (-1.0, -3.0, head_c), + (0.0, -3.0, head_c), + (1.0, -3.0, head_c), + (-1.0, -2.0, head_c), + (0.0, -2.0, head_c), + (1.0, -2.0, head_c), + (-2.0, -1.0, arm_c), + (-1.0, -1.0, torso_c), + (0.0, -1.0, torso_c), + (1.0, -1.0, torso_c), + (2.0, -1.0, arm_c), + (-2.0, 0.0, arm_c), + (-1.0, 0.0, torso_c), + (0.0, 0.0, torso_c), + (1.0, 0.0, torso_c), + (2.0, 0.0, arm_c), + (-1.0, 1.0, torso_c), + (0.0, 1.0, torso_c), + (1.0, 1.0, torso_c), + (-1.0, 2.0, leg_c), + (1.0, 2.0, leg_c), + (-1.0, 3.0, leg_c), + (1.0, 3.0, leg_c), ]; - for &(ox, oy, m) in &layout { + for &(ox, oy, color) in &layout { self.rest_offsets.push((ox, oy)); - self.bodies.push(SubBody::new(cx + ox, cy + oy, r, m)); + let mut b = SubBody::new(cx + ox, cy + oy, r, mat); + b.color = color; + self.bodies.push(b); } let mk = |a: usize, b: usize, len: f32| Constraint::new(a, b, len, 1.0); - // Horizontal connections - for row in 0..5 { - let base = row * 5; - for col in 0..4 { - self.constraints.push(mk(base + col, base + col + 1, 1.0)); - } - } - // Vertical connections - for col in 0..5 { - for row in 0..4 { - self.constraints.push(mk(row * 5 + col, (row + 1) * 5 + col, 1.0)); - } - } - // Arm connections - self.constraints.push(mk(9, 25, 1.0)); - self.constraints.push(mk(25, 26, 1.0)); + self.constraints.push(mk(0, 1, 1.0)); + self.constraints.push(mk(1, 2, 1.0)); + self.constraints.push(mk(3, 4, 1.0)); + self.constraints.push(mk(4, 5, 1.0)); + self.constraints.push(mk(0, 3, 1.0)); + self.constraints.push(mk(1, 4, 1.0)); + self.constraints.push(mk(2, 5, 1.0)); + + self.constraints.push(mk(3, 7, 1.0)); + self.constraints.push(mk(4, 8, 1.0)); + self.constraints.push(mk(5, 9, 1.0)); + + self.constraints.push(mk(7, 8, 1.0)); + self.constraints.push(mk(8, 9, 1.0)); + self.constraints.push(mk(12, 13, 1.0)); + self.constraints.push(mk(13, 14, 1.0)); + self.constraints.push(mk(16, 17, 1.0)); + self.constraints.push(mk(17, 18, 1.0)); + + self.constraints.push(mk(7, 12, 1.0)); + self.constraints.push(mk(8, 13, 1.0)); + self.constraints.push(mk(9, 14, 1.0)); + self.constraints.push(mk(12, 16, 1.0)); + self.constraints.push(mk(13, 17, 1.0)); + self.constraints.push(mk(14, 18, 1.0)); + + self.constraints.push(mk(6, 7, 1.0)); + self.constraints.push(mk(10, 9, 1.0)); + self.constraints.push(mk(11, 12, 1.0)); + self.constraints.push(mk(15, 14, 1.0)); + self.constraints.push(mk(6, 11, 1.0)); + self.constraints.push(mk(10, 15, 1.0)); + + self.constraints.push(mk(16, 19, 1.0)); + self.constraints.push(mk(18, 20, 1.0)); + self.constraints.push(mk(19, 21, 1.0)); + self.constraints.push(mk(20, 22, 1.0)); } pub fn sync_bodies_to_center(&mut self) { diff --git a/src/physics/verlet.rs b/src/physics/verlet.rs index 97ae8d9..e87b694 100644 --- a/src/physics/verlet.rs +++ b/src/physics/verlet.rs @@ -14,6 +14,7 @@ pub struct SubBody { pub health: f32, pub on_fire: bool, pub fire_timer: u32, + pub color: [u8; 4], } impl SubBody { @@ -31,6 +32,7 @@ impl SubBody { health: 100.0, on_fire: false, fire_timer: 0, + color: [255, 255, 255, 255], } } @@ -113,7 +115,12 @@ impl VerletSolver { } } - pub fn solve_constraints(&self, bodies: &mut [SubBody], constraints: &[Constraint], iterations: u32) { + pub fn solve_constraints( + &self, + bodies: &mut [SubBody], + constraints: &[Constraint], + iterations: u32, + ) { const MAX_CORRECTION: f32 = 0.5; for _ in 0..iterations { for c in constraints { diff --git a/src/render/graphics.rs b/src/render/graphics.rs index 6ad6658..b43ab42 100644 --- a/src/render/graphics.rs +++ b/src/render/graphics.rs @@ -546,18 +546,13 @@ impl GraphicsRenderer { let sx = b.x as i32 - cam_x; let sy = b.y as i32 - cam_y; if sx >= 0 && sx < self.grid_w as i32 && sy >= 0 && sy < self.grid_h as i32 { - let fg = if e.on_fire { - [255, 160, 40, 255] - } else if !e.alive { - [100, 60, 60, 255] + let color = if e.on_fire { + let flicker = b.fire_timer % 4; + [255, 120 + flicker as u8 * 20, 20 + flicker as u8 * 10, 255] } else { - match e.kind { - EntityKind::Player => [255, 255, 100, 255], - EntityKind::Goblin => [100, 220, 100, 255], - _ => [180, 50, 50, 255], - } + b.color }; - entity_map.insert((sx, sy), fg); + entity_map.insert((sx, sy), color); } } } diff --git a/src/render/terminal.rs b/src/render/terminal.rs index 5eba7d0..d23690a 100644 --- a/src/render/terminal.rs +++ b/src/render/terminal.rs @@ -1,11 +1,16 @@ -use std::io::{self, Write, stdout}; use crossterm::{ cursor::{Hide, MoveTo, Show}, - event::{DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, PopKeyboardEnhancementFlags}, + event::{ + DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, execute, queue, - style::{Color, SetBackgroundColor, SetForegroundColor, ResetColor, Print}, - terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, size as term_size}, + 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::Renderer; @@ -64,7 +69,13 @@ impl Renderer for TerminalRenderer { Ok(()) } - fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) -> io::Result<()> { + fn render( + &mut self, + grid: &Grid, + entities: &EntityManager, + cam_x: i32, + cam_y: i32, + ) -> io::Result<()> { if !self.initialized { return Ok(()); } @@ -85,7 +96,11 @@ impl Renderer for TerminalRenderer { } let cell = grid.get(wx, wy); if cell.is_empty() { - frame[idx] = (' ', (cell.fg[0], cell.fg[1], cell.fg[2]), (cell.bg[0], cell.bg[1], cell.bg[2])); + 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 { @@ -115,14 +130,8 @@ impl Renderer for TerminalRenderer { }; 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), - } + (b.color[0], b.color[1], b.color[2]) }; frame[idx] = (ch, fg, (20, 10, 10)); } @@ -140,8 +149,16 @@ impl Renderer for TerminalRenderer { 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 }; + 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)); diff --git a/src/render/vulkan.rs b/src/render/vulkan.rs index 6f3ce27..fa2959c 100644 --- a/src/render/vulkan.rs +++ b/src/render/vulkan.rs @@ -221,14 +221,8 @@ impl VulkanRenderer { }; let fg = if e.on_fire { [255, 160, 40, 255] - } else if !e.alive { - [100, 60, 60, 255] } else { - match e.kind { - EntityKind::Player => [255, 255, 100, 255], - EntityKind::Goblin => [100, 220, 100, 255], - _ => [180, 50, 50, 255], - } + b.color }; entity_map.insert((sx, sy), (ch, fg)); } diff --git a/tests/edge_cases.rs b/tests/edge_cases.rs index 178ad3d..f7bec2c 100644 --- a/tests/edge_cases.rs +++ b/tests/edge_cases.rs @@ -1,34 +1,60 @@ -use verbatim::ai::GameSession; use verbatim::ai::AiAction; +use verbatim::ai::GameSession; fn setup() -> GameSession { let mut s = GameSession::new_seeded(42); s.init_empty(); s.clear_area(90, 90, 50, 50); - s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { + x: 80, + y: 130, + w: 80, + h: 15, + material: "stone".into(), + }); s } #[test] fn entity_at_world_boundary_stays_in_bounds() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 5.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 5.0, + y: 120.0, + }); s.step(60); let entities = s.get_entities(); if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) { - assert!(g.pos[0] >= 0.0 && g.pos[0] <= 250.0, "goblin should stay in bounds: x={}", g.pos[0]); - assert!(g.pos[1] >= 0.0 && g.pos[1] <= 250.0, "goblin should stay in bounds: y={}", g.pos[1]); + assert!( + g.pos[0] >= 0.0 && g.pos[0] <= 250.0, + "goblin should stay in bounds: x={}", + g.pos[0] + ); + assert!( + g.pos[1] >= 0.0 && g.pos[1] <= 250.0, + "goblin should stay in bounds: y={}", + g.pos[1] + ); } } #[test] fn entity_at_right_boundary() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 245.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 245.0, + y: 120.0, + }); s.step(60); let entities = s.get_entities(); if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) { - assert!(g.pos[0] <= 248.0, "goblin should not exit right boundary: x={}", g.pos[0]); + assert!( + g.pos[0] <= 248.0, + "goblin should not exit right boundary: x={}", + g.pos[0] + ); } } @@ -37,11 +63,18 @@ fn many_entities_simulate_without_crash() { let mut s = setup(); for i in 0..10 { let x = 100.0 + (i as f32 * 5.0); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x, y: 110.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x, + y: 110.0, + }); } s.step(60); let alive = s.get_entities().into_iter().filter(|e| e.alive).count(); - assert!(alive > 0, "at least some entities should survive 60 ticks with 10 goblins"); + assert!( + alive > 0, + "at least some entities should survive 60 ticks with 10 goblins" + ); } #[test] @@ -49,10 +82,20 @@ fn spawn_many_goblins_stress() { let mut s = GameSession::new_seeded(42); s.init_empty(); s.clear_area(0, 0, 250, 250); - s.perform_action(&AiAction::FillRect { x: 0, y: 200, w: 250, h: 50, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { + x: 0, + y: 200, + w: 250, + h: 50, + material: "stone".into(), + }); for i in 0..20 { let x = 20.0 + (i as f32 * 10.0); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x, y: 180.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x, + y: 180.0, + }); } s.step(30); let entities = s.get_entities(); @@ -74,13 +117,20 @@ fn player_bodies_count_matches_layout() { let mut s = GameSession::new_seeded(42); s.init(); let p = s.get_player().unwrap(); - assert_eq!(p.body_count, 27, "player should have 27 bodies (5x5 + 2 arms)"); + assert_eq!( + p.body_count, 23, + "player should have 23 bodies (humanoid shape)" + ); } #[test] fn goblin_has_correct_max_health() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 130.0, + y: 120.0, + }); let entities = s.get_entities(); let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); assert_eq!(g.max_health, 40.0, "goblin max health should be 40"); @@ -97,16 +147,37 @@ fn player_has_correct_max_health() { #[test] fn fill_rect_creates_correct_material_count() { let mut s = setup(); - s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 5, h: 3, material: "wood".into() }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 100, + w: 5, + h: 3, + material: "wood".into(), + }); let count = s.count_material_in_region(100, 100, 5, 3, "wood"); - assert_eq!(count, 15, "5x3 fill_rect should create 15 wood cells, got {}", count); + assert_eq!( + count, 15, + "5x3 fill_rect should create 15 wood cells, got {}", + count + ); } #[test] fn clear_region_removes_all_materials() { let mut s = setup(); - s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 5, h: 5, material: "stone".into() }); - s.perform_action(&AiAction::ClearRegion { x: 100, y: 100, w: 5, h: 5 }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 100, + w: 5, + h: 5, + material: "stone".into(), + }); + s.perform_action(&AiAction::ClearRegion { + x: 100, + y: 100, + w: 5, + h: 5, + }); let count = s.count_material_in_region(100, 100, 5, 5, "stone"); assert_eq!(count, 0, "clear_region should remove all materials"); } @@ -114,17 +185,32 @@ fn clear_region_removes_all_materials() { #[test] fn set_cell_overwrites_existing() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "stone".into(), + }); assert_eq!(s.get_cell(105, 110).material, "stone"); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "water".into() }); - assert_eq!(s.get_cell(105, 110).material, "water", "set_cell should overwrite"); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "water".into(), + }); + assert_eq!( + s.get_cell(105, 110).material, + "water", + "set_cell should overwrite" + ); } #[test] fn out_of_bounds_cell_returns_error() { let s = setup(); let cell = s.get_cell(-1, -1); - assert_eq!(cell.material, "out_of_bounds", "out of bounds cell should report correctly"); + assert_eq!( + cell.material, "out_of_bounds", + "out of bounds cell should report correctly" + ); let cell2 = s.get_cell(999, 999); assert_eq!(cell2.material, "out_of_bounds"); } @@ -132,10 +218,19 @@ fn out_of_bounds_cell_returns_error() { #[test] fn paint_creates_material_in_radius() { let mut s = setup(); - s.perform_action(&AiAction::Paint { x: 120, y: 110, material: "sand".into(), radius: 3 }); + s.perform_action(&AiAction::Paint { + x: 120, + y: 110, + material: "sand".into(), + radius: 3, + }); let count = s.count_material_in_region(116, 106, 8, 8, "sand"); assert!(count > 0, "paint should create sand cells in radius"); - assert!(count < 50, "paint should not create too many cells: {}", count); + assert!( + count < 50, + "paint should not create too many cells: {}", + count + ); } #[test] @@ -149,7 +244,12 @@ fn set_gravity_affects_player() { let y_up = p1.pos[1]; s.step(30); let p2 = s.get_player().unwrap(); - assert!(p2.pos[1] <= y_up + 2.0, "with zero gravity, player should not fall: y_up={} y_after={}", y_up, p2.pos[1]); + assert!( + p2.pos[1] <= y_up + 2.0, + "with zero gravity, player should not fall: y_up={} y_after={}", + y_up, + p2.pos[1] + ); } #[test] @@ -166,14 +266,26 @@ fn center_camera_on_player() { s.perform_action(&AiAction::SetCamera { x: 0, y: 0 }); s.step(30); s.perform_action(&AiAction::CenterCamera); - assert!(s.game.cam_x > 0, "camera should move from 0 toward player: got {}", s.game.cam_x); - assert!(s.game.cam_y > 0, "camera should move from 0 toward player: got {}", s.game.cam_y); + assert!( + s.game.cam_x > 0, + "camera should move from 0 toward player: got {}", + s.game.cam_x + ); + assert!( + s.game.cam_y > 0, + "camera should move from 0 toward player: got {}", + s.game.cam_y + ); } #[test] fn kill_entity_removes_alive_status() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 130.0, + y: 120.0, + }); s.step(10); s.perform_action(&AiAction::KillEntity { id: 1 }); s.step(1); @@ -185,7 +297,11 @@ fn kill_entity_removes_alive_status() { #[test] fn find_material_locates_existing() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 123, y: 115, material: "lava".into() }); + s.perform_action(&AiAction::SetCell { + x: 123, + y: 115, + material: "lava".into(), + }); let found = s.find_material("lava"); assert!(found.is_some(), "find_material should locate lava"); let (fx, fy) = found.unwrap(); @@ -197,5 +313,8 @@ fn find_material_locates_existing() { fn find_material_returns_none_for_absent() { let s = setup(); let found = s.find_material("lava"); - assert!(found.is_none(), "find_material should return None for absent material"); + assert!( + found.is_none(), + "find_material should return None for absent material" + ); }