feat: slime enemies — jump AI, contact damage, combat system
New entity: Slime (EntityKind::Slime) - 19 parts: blobby body (3x5) + 2 glowing eyes - Green translucent colors, brighter center - HP: 25, spawns every 45 ticks (max 2 alive) Slime AI (update_slime_ai): - Jumps toward player every 60 ticks when within 40 cells - Jump power scales with proximity (closer = stronger) - Pauses horizontally between jumps (50/50 hop-stop cycle) - Uses set_horizontal_vel + set_vertical_vel (vector movement) Combat system (update_combat): - AABB overlap check between player and all alive enemies - Goblin: 8 damage per 20 ticks on contact - Slime: 5 damage per 20 ticks on contact - Knockback: player pushed away from enemy on hit - Player.take_damage() called, death possible from enemies World gen: try_spawn_slime() spawns at surface, 18 cells from player Renderers: slime rendered as 's' in ascii/terminal, green blob in graphics 6 new tests (122 total, 0 failures)
This commit is contained in:
+43
-22
@@ -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<SubBodyInfo> = 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<SubBodyInfo> = 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<EntityInfo> = game.entities.all()
|
||||
let entities: Vec<EntityInfo> = 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<EntityKind> {
|
||||
match name.to_lowercase().as_str() {
|
||||
"player" => Some(EntityKind::Player),
|
||||
"goblin" => Some(EntityKind::Goblin),
|
||||
"slime" => Some(EntityKind::Slime),
|
||||
"corpse" => Some(EntityKind::Corpse),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+239
-33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+138
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user