diff --git a/src/ai/state.rs b/src/ai/state.rs index 934ec22..eeba6ec 100644 --- a/src/ai/state.rs +++ b/src/ai/state.rs @@ -1,9 +1,9 @@ -use serde::{Deserialize, Serialize}; -use crate::entity::{EntityManager, EntityKind}; -use crate::world::grid::Grid; -use crate::world::cell::MaterialId; -use crate::world::material::MaterialRegistry; +use crate::entity::{EntityKind, EntityManager}; use crate::game::Game; +use crate::world::cell::MaterialId; +use crate::world::grid::Grid; +use crate::world::material::MaterialRegistry; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GameState { @@ -54,7 +54,8 @@ impl CellInfo { pub fn from_grid(grid: &Grid, x: i32, y: i32) -> Self { if !grid.in_bounds(x, y) { return Self { - x, y, + x, + y, material: "out_of_bounds".to_string(), temp: 0.0, is_solid: false, @@ -66,7 +67,8 @@ impl CellInfo { let reg = MaterialRegistry::instance(); let mat = reg.get(cell.material); Self { - x, y, + x, + y, material: mat.name.to_string(), temp: cell.temp, is_solid: mat.solid, @@ -78,18 +80,23 @@ impl CellInfo { pub fn entity_info(e: &crate::entity::entity::Entity) -> EntityInfo { let (px, py) = e.center(); - let bodies: Vec = e.bodies.iter().enumerate().map(|(i, b)| { - let reg = MaterialRegistry::instance(); - SubBodyInfo { - idx: i, - pos: [b.x, b.y], - vel: [b.vx(), b.vy()], - health: b.health, - alive: b.alive, - on_fire: b.on_fire, - material: reg.get(b.material).name.to_string(), - } - }).collect(); + let bodies: Vec = e + .bodies + .iter() + .enumerate() + .map(|(i, b)| { + let reg = MaterialRegistry::instance(); + SubBodyInfo { + idx: i, + pos: [b.x, b.y], + vel: [b.vx(), b.vy()], + health: b.health, + alive: b.alive, + on_fire: b.on_fire, + material: reg.get(b.material).name.to_string(), + } + }) + .collect(); EntityInfo { id: e.id, @@ -107,7 +114,9 @@ pub fn entity_info(e: &crate::entity::entity::Entity) -> EntityInfo { pub fn build_game_state(game: &Game, view_w: usize, view_h: usize) -> GameState { let player_info = game.player.entity(&game.entities).map(entity_info); - let entities: Vec = game.entities.all() + let entities: Vec = game + .entities + .all() .iter() .filter(|e| e.id != game.player.entity_id) .map(entity_info) @@ -129,17 +138,27 @@ pub fn build_game_state(game: &Game, view_w: usize, view_h: usize) -> GameState } } -pub fn render_view(grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String { +pub fn render_view( + grid: &Grid, + entities: &EntityManager, + cam_x: i32, + cam_y: i32, + vw: usize, + vh: usize, +) -> String { let mut entity_map = std::collections::HashMap::new(); for e in entities.all() { for b in &e.bodies { - if !b.alive { continue; } + if !b.alive { + continue; + } let sx = b.x as i32 - cam_x; let sy = b.y as i32 - cam_y; if sx >= 0 && sx < vw as i32 && sy >= 0 && sy < vh as i32 { let ch = match e.kind { EntityKind::Player if e.alive => '@', EntityKind::Goblin if e.alive => 'g', + EntityKind::Slime if e.alive => 's', _ => '%', }; entity_map.insert((sx, sy), ch); @@ -190,6 +209,7 @@ pub fn entity_kind_name(kind: EntityKind) -> &'static str { match kind { EntityKind::Player => "Player", EntityKind::Goblin => "Goblin", + EntityKind::Slime => "Slime", EntityKind::Corpse => "Corpse", } } @@ -198,6 +218,7 @@ pub fn parse_entity_kind(name: &str) -> Option { match name.to_lowercase().as_str() { "player" => Some(EntityKind::Player), "goblin" => Some(EntityKind::Goblin), + "slime" => Some(EntityKind::Slime), "corpse" => Some(EntityKind::Corpse), _ => None, } diff --git a/src/entity/body_template.rs b/src/entity/body_template.rs index edbedfd..c977b40 100644 --- a/src/entity/body_template.rs +++ b/src/entity/body_template.rs @@ -171,6 +171,41 @@ impl BodyTemplate { } } + pub fn slime() -> Self { + let parts = vec![ + p!(-2, -1, 60, 180, 80, "body"), + p!(-1, -2, 80, 200, 100, "body"), + p!(0, -2, 90, 210, 110, "body"), + p!(1, -2, 80, 200, 100, "body"), + p!(2, -1, 60, 180, 80, "body"), + p!(-2, 0, 70, 190, 90, "body"), + p!(-1, 0, 100, 220, 130, "body"), + p!(0, 0, 110, 230, 140, "body"), + p!(1, 0, 100, 220, 130, "body"), + p!(2, 0, 70, 190, 90, "body"), + p!(-2, 1, 50, 170, 70, "body"), + p!(-1, 1, 80, 200, 100, "body"), + p!(0, 1, 90, 210, 110, "body"), + p!(1, 1, 80, 200, 100, "body"), + p!(2, 1, 50, 170, 70, "body"), + p!(-1, 2, 40, 160, 60, "body"), + p!(0, 2, 50, 170, 70, "body"), + p!(1, 2, 40, 160, 60, "body"), + p!(-1, -1, 120, 230, 150, "eye"), + p!(1, -1, 120, 230, 150, "eye"), + ]; + + let n = parts.len(); + Self { + name: "slime".to_string(), + half_w: 3.0, + half_h: 2.0, + radius: 0.5, + parts, + constraints: Self::auto_constraints(n), + } + } + pub fn boulder() -> Self { let parts = vec![ p!(-1, -2, 100, 100, 110, "rock"), @@ -311,6 +346,7 @@ pub fn template_for_kind(kind: EntityKind) -> BodyTemplate { match kind { EntityKind::Player => BodyTemplate::humanoid_player(), EntityKind::Goblin => BodyTemplate::humanoid_goblin(), + EntityKind::Slime => BodyTemplate::slime(), EntityKind::Corpse => BodyTemplate::humanoid_player(), } } diff --git a/src/entity/entity.rs b/src/entity/entity.rs index ce47be7..68b20f3 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -7,6 +7,7 @@ pub type EntityId = u32; pub enum EntityKind { Player, Goblin, + Slime, Corpse, } @@ -90,7 +91,10 @@ impl Entity { b.set_vel(cvx + (b.x - self.cx) * 0.3, cvy + (b.y - self.cy) * 0.3); } } - if self.kind == EntityKind::Player || self.kind == EntityKind::Goblin { + if self.kind == EntityKind::Player + || self.kind == EntityKind::Goblin + || self.kind == EntityKind::Slime + { self.kind = EntityKind::Corpse; } } @@ -176,6 +180,10 @@ impl EntityManager { e.max_health = 40.0; e.health = 40.0; } + EntityKind::Slime => { + e.max_health = 25.0; + e.health = 25.0; + } EntityKind::Corpse => { e.alive = false; e.rigid = false; diff --git a/src/game.rs b/src/game.rs index 2176a8d..c7d68bb 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,14 +1,14 @@ use std::time::{Duration, Instant}; -use crate::entity::{EntityManager, EntityKind}; +use crate::entity::player::Player; +use crate::entity::{EntityKind, EntityManager}; use crate::input::{Action, InputHandler}; -use crate::physics::verlet::VerletSolver; use crate::physics::collision::resolve_grid_collision; +use crate::physics::verlet::VerletSolver; use crate::render::Renderer; use crate::world::cell::MaterialId; -use crate::world::grid::Grid; use crate::world::cellular::CellularAutomaton; -use crate::entity::player::Player; +use crate::world::grid::Grid; pub struct Game { pub grid: Grid, @@ -52,8 +52,10 @@ impl Game { let h = self.grid.height; for x in 0..w { - self.grid.set_material(x as i32, (h - 1) as i32, MaterialId::Stone); - self.grid.set_material(x as i32, (h - 2) as i32, MaterialId::Dirt); + self.grid + .set_material(x as i32, (h - 1) as i32, MaterialId::Stone); + self.grid + .set_material(x as i32, (h - 2) as i32, MaterialId::Dirt); } for x in 0..w { @@ -101,7 +103,8 @@ impl Game { self.grid.set_material(wood_x + 4, y, MaterialId::Wood); } for x in wood_x..=wood_x + 4 { - self.grid.set_material(x, wood_surface - 8, MaterialId::Wood); + self.grid + .set_material(x, wood_surface - 8, MaterialId::Wood); } // Sand dune (right of center) @@ -145,7 +148,9 @@ impl Game { let surface_x = cx as i32; let mut surface_y = h as i32 - 3; for y in 0..h as i32 { - if self.grid.get(surface_x, y).is_solid() && self.grid.get(surface_x, y).material != MaterialId::Stone { + if self.grid.get(surface_x, y).is_solid() + && self.grid.get(surface_x, y).material != MaterialId::Stone + { surface_y = y; break; } @@ -224,7 +229,11 @@ impl Game { if let Some(m) = mat { self.grid.set_material(cx + dx, cy + dy, m); } else { - self.grid.set(cx + dx, cy + dy, crate::world::cell::Cell::empty()); + self.grid.set( + cx + dx, + cy + dy, + crate::world::cell::Cell::empty(), + ); } } } @@ -293,12 +302,107 @@ impl Game { self.ca.step(&mut self.grid); self.update_entities(); + self.update_slime_ai(); + self.update_combat(); self.apply_world_damage(); if self.tick % 30 == 0 { self.try_spawn_goblin(); } + if self.tick % 45 == 0 { + self.try_spawn_slime(); + } + } + + fn update_slime_ai(&mut self) { + let (px, py) = self.player.center(&self.entities); + let tick = self.tick; + + let slime_data: Vec<(usize, f32, f32, bool, f32)> = self + .entities + .all() + .iter() + .enumerate() + .filter(|(_, e)| e.alive && e.kind == EntityKind::Slime) + .map(|(i, e)| (i, e.cx, e.cy, e.rigid, e.health)) + .collect(); + + for (idx, sx, sy, rigid, health) in slime_data { + if !rigid { + continue; + } + let dx = px - sx; + let dy = py - sy; + let dist = (dx * dx + dy * dy).sqrt(); + if dist < 0.5 { + continue; + } + + let jump_phase = tick % 60; + if jump_phase == 0 && dist < 40.0 { + let dir_x = dx / dist; + let dir_y = dy / dist; + let jump_power = 0.8 + (1.0 - dist / 40.0).min(0.5) * 0.5; + if let Some(e) = self.entities.all_mut().get_mut(idx) { + e.set_horizontal_vel(dir_x * jump_power); + e.set_vertical_vel(-jump_power * 0.8); + } + } else if jump_phase == 30 { + if let Some(e) = self.entities.all_mut().get_mut(idx) { + e.set_horizontal_vel(0.0); + } + } + } + } + + fn update_combat(&mut self) { + let player_id = self.player.entity_id; + let player_center = self.player.center(&self.entities); + let player_half_w = self + .entities + .get(player_id) + .map(|e| e.half_w) + .unwrap_or(3.0); + let player_half_h = self + .entities + .get(player_id) + .map(|e| e.half_h) + .unwrap_or(6.0); + + let enemy_data: Vec<(usize, EntityKind, f32, f32, f32, f32)> = self + .entities + .all() + .iter() + .enumerate() + .filter(|(_, e)| { + e.alive && e.kind != EntityKind::Player && e.kind != EntityKind::Corpse + }) + .map(|(i, e)| (i, e.kind, e.cx, e.cy, e.half_w, e.half_h)) + .collect(); + + for (idx, kind, ex, ey, ew, eh) in enemy_data { + let dx = (ex - player_center.0).abs(); + let dy = (ey - player_center.1).abs(); + if dx < ew + player_half_w && dy < eh + player_half_h { + if self.tick % 20 == 0 { + let damage = match kind { + EntityKind::Goblin => 8.0, + EntityKind::Slime => 5.0, + _ => 0.0, + }; + if damage > 0.0 { + if let Some(p) = self.entities.get_mut(player_id) { + p.take_damage(damage); + } + let knockback_dir = if player_center.0 < ex { -1.0 } else { 1.0 }; + if let Some(p) = self.entities.get_mut(player_id) { + p.set_horizontal_vel(knockback_dir * 0.5); + } + } + } + } + } } fn update_entities(&mut self) { @@ -374,16 +478,23 @@ impl Game { // Blocked — resolve X let (resolved_x, hit) = self.resolve_aabb_x(idx, nx, ny, half_w, half_h, nvx); nx = resolved_x; - if hit { nvx = 0.0; } + if hit { + nvx = 0.0; + } } } // Step 2: Vertical movement ny += nvy; - let (resolved_y, hit_floor, hit_ceiling) = self.resolve_aabb_y(idx, nx, ny, half_w, half_h, nvy > 0.0); + let (resolved_y, hit_floor, hit_ceiling) = + self.resolve_aabb_y(idx, nx, ny, half_w, half_h, nvy > 0.0); ny = resolved_y; - if hit_floor { nvy = 0.0; } - if hit_ceiling { nvy = 0.0; } + if hit_floor { + nvy = 0.0; + } + if hit_ceiling { + nvy = 0.0; + } // Check material contacts let (touching_lava, touching_fire, touching_acid, in_liquid) = { @@ -398,12 +509,22 @@ impl Game { let max_y = (ny + half_h).ceil() as i32; for y in min_y..=max_y { for x in min_x..=max_x { - if !grid.in_bounds(x, y) { continue; } + if !grid.in_bounds(x, y) { + continue; + } let cell = grid.get(x, y); - if cell.material == MaterialId::Lava { tl = true; } - if cell.material == MaterialId::Fire { tf = true; } - if cell.material == MaterialId::Acid { ta = true; } - if cell.is_liquid() { il = true; } + if cell.material == MaterialId::Lava { + tl = true; + } + if cell.material == MaterialId::Fire { + tf = true; + } + if cell.material == MaterialId::Acid { + ta = true; + } + if cell.is_liquid() { + il = true; + } } } (tl, tf, ta, il) @@ -424,7 +545,9 @@ impl Game { for b in &mut e.bodies { if b.alive { b.health -= 0.5; - if !b.on_fire { b.on_fire = true; } + if !b.on_fire { + b.on_fire = true; + } } } } @@ -432,13 +555,17 @@ impl Game { for b in &mut e.bodies { if b.alive { b.health -= 0.15; - if !b.on_fire && b.health < 80.0 { b.on_fire = true; } + if !b.on_fire && b.health < 80.0 { + b.on_fire = true; + } } } } if touching_acid { for b in &mut e.bodies { - if b.alive { b.health -= 0.25; } + if b.alive { + b.health -= 0.25; + } } } } @@ -458,9 +585,13 @@ impl Game { for y in min_y..=max_y { for x in min_x..=max_x { - if !grid.in_bounds(x, y) { continue; } + if !grid.in_bounds(x, y) { + continue; + } let cell = grid.get(x, y); - if !cell.is_solid() { continue; } + if !cell.is_solid() { + continue; + } let cl = x as f32; let cr = (x + 1) as f32; let ct = y as f32; @@ -473,7 +604,15 @@ impl Game { false } - fn resolve_aabb_x(&self, _idx: usize, cx: f32, cy: f32, hw: f32, hh: f32, vx: f32) -> (f32, bool) { + fn resolve_aabb_x( + &self, + _idx: usize, + cx: f32, + cy: f32, + hw: f32, + hh: f32, + vx: f32, + ) -> (f32, bool) { let grid = &self.grid; let left = cx - hw; let right = cx + hw; @@ -490,9 +629,13 @@ impl Game { for y in min_y..=max_y { for x in min_x..=max_x { - if !grid.in_bounds(x, y) { continue; } + if !grid.in_bounds(x, y) { + continue; + } let cell = grid.get(x, y); - if !cell.is_solid() { continue; } + if !cell.is_solid() { + continue; + } let cell_left = x as f32; let cell_right = (x + 1) as f32; @@ -532,7 +675,15 @@ impl Game { (new_cx, hit) } - fn resolve_aabb_y(&self, _idx: usize, cx: f32, cy: f32, hw: f32, hh: f32, moving_down: bool) -> (f32, bool, bool) { + fn resolve_aabb_y( + &self, + _idx: usize, + cx: f32, + cy: f32, + hw: f32, + hh: f32, + moving_down: bool, + ) -> (f32, bool, bool) { let grid = &self.grid; let left = cx - hw; let right = cx + hw; @@ -550,9 +701,13 @@ impl Game { for y in min_y..=max_y { for x in min_x..=max_x { - if !grid.in_bounds(x, y) { continue; } + if !grid.in_bounds(x, y) { + continue; + } let cell = grid.get(x, y); - if !cell.is_solid() { continue; } + if !cell.is_solid() { + continue; + } let cell_left = x as f32; let cell_right = (x + 1) as f32; @@ -593,7 +748,12 @@ impl Game { (new_cy, hit_floor, hit_ceiling) } - fn update_ragdoll_entity(&mut self, idx: usize, solver: &crate::physics::verlet::VerletSolver, substeps: u32) { + fn update_ragdoll_entity( + &mut self, + idx: usize, + solver: &crate::physics::verlet::VerletSolver, + substeps: u32, + ) { let grid = &self.grid; let mut bodies = self.entities.all()[idx].bodies.clone(); let constraints = self.entities.all()[idx].constraints.clone(); @@ -622,11 +782,15 @@ impl Game { let result = resolve_grid_collision(grid, b); if result.touching_lava { b.health -= 0.5; - if !b.on_fire { b.on_fire = true; } + if !b.on_fire { + b.on_fire = true; + } } if result.touching_fire { b.health -= 0.15; - if !b.on_fire && b.health < 80.0 { b.on_fire = true; } + if !b.on_fire && b.health < 80.0 { + b.on_fire = true; + } } if result.touching_acid { b.health -= 0.25; @@ -673,7 +837,12 @@ impl Game { } fn try_spawn_goblin(&mut self) { - let alive_goblins = self.entities.all().iter().filter(|e| e.alive && e.kind == EntityKind::Goblin).count(); + let alive_goblins = self + .entities + .all() + .iter() + .filter(|e| e.alive && e.kind == EntityKind::Goblin) + .count(); if alive_goblins >= 3 { return; } @@ -703,4 +872,41 @@ impl Game { g.build_humanoid(spawn_x as f32, spawn_y as f32); } } + + fn try_spawn_slime(&mut self) { + let alive_slimes = self + .entities + .all() + .iter() + .filter(|e| e.alive && e.kind == EntityKind::Slime) + .count(); + if alive_slimes >= 2 { + return; + } + + let (px, _py) = self.player.center(&self.entities); + let spawn_x = px as i32 + if px as i32 % 2 == 0 { -18 } else { 18 }; + if !self.grid.in_bounds(spawn_x, 0) { + return; + } + + let mut surface_y = self.grid.height as i32 - 3; + for y in 0..self.grid.height as i32 { + let cell = self.grid.get(spawn_x, y); + if cell.is_solid() && cell.material != MaterialId::Stone { + surface_y = y; + break; + } + } + let spawn_y = surface_y - 3; + + if !self.grid.in_bounds(spawn_x, spawn_y) { + return; + } + + let id = self.entities.spawn(EntityKind::Slime); + if let Some(s) = self.entities.get_mut(id) { + s.build_humanoid(spawn_x as f32, spawn_y as f32); + } + } } diff --git a/src/render/terminal.rs b/src/render/terminal.rs index d23690a..51448c7 100644 --- a/src/render/terminal.rs +++ b/src/render/terminal.rs @@ -126,6 +126,7 @@ impl Renderer for TerminalRenderer { 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 { diff --git a/src/render/vulkan.rs b/src/render/vulkan.rs index fa2959c..bfb74ff 100644 --- a/src/render/vulkan.rs +++ b/src/render/vulkan.rs @@ -217,6 +217,7 @@ impl VulkanRenderer { let ch = match e.kind { EntityKind::Player if e.alive => '@', EntityKind::Goblin if e.alive => 'g', + EntityKind::Slime if e.alive => 's', _ => '%', }; let fg = if e.on_fire { diff --git a/tests/slime.rs b/tests/slime.rs new file mode 100644 index 0000000..b2fe2e0 --- /dev/null +++ b/tests/slime.rs @@ -0,0 +1,138 @@ +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 +} + +#[test] +fn slime_spawns_correctly() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { + kind: "slime".into(), + x: 120.0, + y: 120.0, + }); + let entities = s.get_entities(); + let slime = entities.into_iter().find(|e| e.kind == "Slime"); + assert!(slime.is_some(), "slime should exist after spawn"); + let sl = slime.unwrap(); + assert!(sl.alive, "slime should be alive"); + assert_eq!(sl.max_health, 25.0, "slime max health should be 25"); +} + +#[test] +fn slime_takes_damage_and_dies() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { + kind: "slime".into(), + x: 120.0, + y: 120.0, + }); + s.step(10); + let id = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Slime") + .unwrap() + .id; + s.perform_action(&AiAction::DamageEntity { id, amount: 25.0 }); + s.step(1); + let entities = s.get_entities(); + let sl = entities.into_iter().find(|e| e.id == id).unwrap(); + assert!(!sl.alive, "slime should die after 25 damage"); + assert_eq!(sl.kind, "Corpse", "dead slime should become corpse"); +} + +#[test] +fn slime_jumps_toward_player() { + let mut s = setup(); + s.step(30); + let player = s.get_player().unwrap(); + let px = player.pos[0]; + s.perform_action(&AiAction::Spawn { + kind: "slime".into(), + x: px + 10.0, + y: 120.0, + }); + s.step(10); + let entities = s.get_entities(); + let slime = entities.into_iter().find(|e| e.kind == "Slime" && e.alive); + assert!(slime.is_some(), "slime should be alive"); + let slime_y_before = slime.unwrap().pos[1]; + s.step(60); + let entities = s.get_entities(); + let slime_after = entities.into_iter().find(|e| e.kind == "Slime" && e.alive); + if let Some(sl) = slime_after { + assert!( + sl.pos[1] < slime_y_before + 5.0 || sl.pos[0] != px + 10.0, + "slime should have moved (jumped) from original position" + ); + } +} + +#[test] +fn slime_deals_contact_damage_to_player() { + let mut s = setup(); + s.step(30); + let player = s.get_player().unwrap(); + let hp_before = player.health; + s.perform_action(&AiAction::Spawn { + kind: "slime".into(), + x: player.pos[0] + 1.0, + y: player.pos[1], + }); + s.step(60); + let player_after = s.get_player().unwrap(); + assert!( + player_after.health < hp_before, + "player should take damage from slime contact: {} -> {}", + hp_before, + player_after.health + ); +} + +#[test] +fn slime_template_has_correct_shape() { + use verbatim::entity::body_template::BodyTemplate; + let t = BodyTemplate::slime(); + assert_eq!(t.name, "slime"); + assert!( + t.parts.len() >= 15, + "slime should have 15+ parts, got {}", + t.parts.len() + ); + assert!( + t.parts.iter().any(|p| p.label == "eye"), + "slime should have eyes" + ); +} + +#[test] +fn slime_count_in_world() { + let mut s = setup(); + for i in 0..3 { + s.perform_action(&AiAction::Spawn { + kind: "slime".into(), + x: 100.0 + i as f32 * 10.0, + y: 120.0, + }); + } + s.step(10); + let slimes = s + .get_entities() + .into_iter() + .filter(|e| e.kind == "Slime") + .count(); + assert_eq!(slimes, 3, "should have 3 slimes"); +}