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:
Emil
2026-06-21 10:42:16 +03:00
parent e287b0d22a
commit 2078e3f11e
7 changed files with 467 additions and 56 deletions
+43 -22
View File
@@ -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,
}
+36
View File
@@ -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(),
}
}
+9 -1
View File
@@ -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
View File
@@ -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);
}
}
}
+1
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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");
}