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::{EntityKind, EntityManager};
|
||||||
use crate::entity::{EntityManager, EntityKind};
|
|
||||||
use crate::world::grid::Grid;
|
|
||||||
use crate::world::cell::MaterialId;
|
|
||||||
use crate::world::material::MaterialRegistry;
|
|
||||||
use crate::game::Game;
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
@@ -54,7 +54,8 @@ impl CellInfo {
|
|||||||
pub fn from_grid(grid: &Grid, x: i32, y: i32) -> Self {
|
pub fn from_grid(grid: &Grid, x: i32, y: i32) -> Self {
|
||||||
if !grid.in_bounds(x, y) {
|
if !grid.in_bounds(x, y) {
|
||||||
return Self {
|
return Self {
|
||||||
x, y,
|
x,
|
||||||
|
y,
|
||||||
material: "out_of_bounds".to_string(),
|
material: "out_of_bounds".to_string(),
|
||||||
temp: 0.0,
|
temp: 0.0,
|
||||||
is_solid: false,
|
is_solid: false,
|
||||||
@@ -66,7 +67,8 @@ impl CellInfo {
|
|||||||
let reg = MaterialRegistry::instance();
|
let reg = MaterialRegistry::instance();
|
||||||
let mat = reg.get(cell.material);
|
let mat = reg.get(cell.material);
|
||||||
Self {
|
Self {
|
||||||
x, y,
|
x,
|
||||||
|
y,
|
||||||
material: mat.name.to_string(),
|
material: mat.name.to_string(),
|
||||||
temp: cell.temp,
|
temp: cell.temp,
|
||||||
is_solid: mat.solid,
|
is_solid: mat.solid,
|
||||||
@@ -78,18 +80,23 @@ impl CellInfo {
|
|||||||
|
|
||||||
pub fn entity_info(e: &crate::entity::entity::Entity) -> EntityInfo {
|
pub fn entity_info(e: &crate::entity::entity::Entity) -> EntityInfo {
|
||||||
let (px, py) = e.center();
|
let (px, py) = e.center();
|
||||||
let bodies: Vec<SubBodyInfo> = e.bodies.iter().enumerate().map(|(i, b)| {
|
let bodies: Vec<SubBodyInfo> = e
|
||||||
let reg = MaterialRegistry::instance();
|
.bodies
|
||||||
SubBodyInfo {
|
.iter()
|
||||||
idx: i,
|
.enumerate()
|
||||||
pos: [b.x, b.y],
|
.map(|(i, b)| {
|
||||||
vel: [b.vx(), b.vy()],
|
let reg = MaterialRegistry::instance();
|
||||||
health: b.health,
|
SubBodyInfo {
|
||||||
alive: b.alive,
|
idx: i,
|
||||||
on_fire: b.on_fire,
|
pos: [b.x, b.y],
|
||||||
material: reg.get(b.material).name.to_string(),
|
vel: [b.vx(), b.vy()],
|
||||||
}
|
health: b.health,
|
||||||
}).collect();
|
alive: b.alive,
|
||||||
|
on_fire: b.on_fire,
|
||||||
|
material: reg.get(b.material).name.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
EntityInfo {
|
EntityInfo {
|
||||||
id: e.id,
|
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 {
|
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 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()
|
.iter()
|
||||||
.filter(|e| e.id != game.player.entity_id)
|
.filter(|e| e.id != game.player.entity_id)
|
||||||
.map(entity_info)
|
.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();
|
let mut entity_map = std::collections::HashMap::new();
|
||||||
for e in entities.all() {
|
for e in entities.all() {
|
||||||
for b in &e.bodies {
|
for b in &e.bodies {
|
||||||
if !b.alive { continue; }
|
if !b.alive {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let sx = b.x as i32 - cam_x;
|
let sx = b.x as i32 - cam_x;
|
||||||
let sy = b.y as i32 - cam_y;
|
let sy = b.y as i32 - cam_y;
|
||||||
if sx >= 0 && sx < vw as i32 && sy >= 0 && sy < vh as i32 {
|
if sx >= 0 && sx < vw as i32 && sy >= 0 && sy < vh as i32 {
|
||||||
let ch = match e.kind {
|
let ch = match e.kind {
|
||||||
EntityKind::Player if e.alive => '@',
|
EntityKind::Player if e.alive => '@',
|
||||||
EntityKind::Goblin if e.alive => 'g',
|
EntityKind::Goblin if e.alive => 'g',
|
||||||
|
EntityKind::Slime if e.alive => 's',
|
||||||
_ => '%',
|
_ => '%',
|
||||||
};
|
};
|
||||||
entity_map.insert((sx, sy), ch);
|
entity_map.insert((sx, sy), ch);
|
||||||
@@ -190,6 +209,7 @@ pub fn entity_kind_name(kind: EntityKind) -> &'static str {
|
|||||||
match kind {
|
match kind {
|
||||||
EntityKind::Player => "Player",
|
EntityKind::Player => "Player",
|
||||||
EntityKind::Goblin => "Goblin",
|
EntityKind::Goblin => "Goblin",
|
||||||
|
EntityKind::Slime => "Slime",
|
||||||
EntityKind::Corpse => "Corpse",
|
EntityKind::Corpse => "Corpse",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +218,7 @@ pub fn parse_entity_kind(name: &str) -> Option<EntityKind> {
|
|||||||
match name.to_lowercase().as_str() {
|
match name.to_lowercase().as_str() {
|
||||||
"player" => Some(EntityKind::Player),
|
"player" => Some(EntityKind::Player),
|
||||||
"goblin" => Some(EntityKind::Goblin),
|
"goblin" => Some(EntityKind::Goblin),
|
||||||
|
"slime" => Some(EntityKind::Slime),
|
||||||
"corpse" => Some(EntityKind::Corpse),
|
"corpse" => Some(EntityKind::Corpse),
|
||||||
_ => None,
|
_ => 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 {
|
pub fn boulder() -> Self {
|
||||||
let parts = vec![
|
let parts = vec![
|
||||||
p!(-1, -2, 100, 100, 110, "rock"),
|
p!(-1, -2, 100, 100, 110, "rock"),
|
||||||
@@ -311,6 +346,7 @@ pub fn template_for_kind(kind: EntityKind) -> BodyTemplate {
|
|||||||
match kind {
|
match kind {
|
||||||
EntityKind::Player => BodyTemplate::humanoid_player(),
|
EntityKind::Player => BodyTemplate::humanoid_player(),
|
||||||
EntityKind::Goblin => BodyTemplate::humanoid_goblin(),
|
EntityKind::Goblin => BodyTemplate::humanoid_goblin(),
|
||||||
|
EntityKind::Slime => BodyTemplate::slime(),
|
||||||
EntityKind::Corpse => BodyTemplate::humanoid_player(),
|
EntityKind::Corpse => BodyTemplate::humanoid_player(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub type EntityId = u32;
|
|||||||
pub enum EntityKind {
|
pub enum EntityKind {
|
||||||
Player,
|
Player,
|
||||||
Goblin,
|
Goblin,
|
||||||
|
Slime,
|
||||||
Corpse,
|
Corpse,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +91,10 @@ impl Entity {
|
|||||||
b.set_vel(cvx + (b.x - self.cx) * 0.3, cvy + (b.y - self.cy) * 0.3);
|
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;
|
self.kind = EntityKind::Corpse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +180,10 @@ impl EntityManager {
|
|||||||
e.max_health = 40.0;
|
e.max_health = 40.0;
|
||||||
e.health = 40.0;
|
e.health = 40.0;
|
||||||
}
|
}
|
||||||
|
EntityKind::Slime => {
|
||||||
|
e.max_health = 25.0;
|
||||||
|
e.health = 25.0;
|
||||||
|
}
|
||||||
EntityKind::Corpse => {
|
EntityKind::Corpse => {
|
||||||
e.alive = false;
|
e.alive = false;
|
||||||
e.rigid = false;
|
e.rigid = false;
|
||||||
|
|||||||
+239
-33
@@ -1,14 +1,14 @@
|
|||||||
use std::time::{Duration, Instant};
|
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::input::{Action, InputHandler};
|
||||||
use crate::physics::verlet::VerletSolver;
|
|
||||||
use crate::physics::collision::resolve_grid_collision;
|
use crate::physics::collision::resolve_grid_collision;
|
||||||
|
use crate::physics::verlet::VerletSolver;
|
||||||
use crate::render::Renderer;
|
use crate::render::Renderer;
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
use crate::world::grid::Grid;
|
|
||||||
use crate::world::cellular::CellularAutomaton;
|
use crate::world::cellular::CellularAutomaton;
|
||||||
use crate::entity::player::Player;
|
use crate::world::grid::Grid;
|
||||||
|
|
||||||
pub struct Game {
|
pub struct Game {
|
||||||
pub grid: Grid,
|
pub grid: Grid,
|
||||||
@@ -52,8 +52,10 @@ impl Game {
|
|||||||
let h = self.grid.height;
|
let h = self.grid.height;
|
||||||
|
|
||||||
for x in 0..w {
|
for x in 0..w {
|
||||||
self.grid.set_material(x as i32, (h - 1) as i32, MaterialId::Stone);
|
self.grid
|
||||||
self.grid.set_material(x as i32, (h - 2) as i32, MaterialId::Dirt);
|
.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 {
|
for x in 0..w {
|
||||||
@@ -101,7 +103,8 @@ impl Game {
|
|||||||
self.grid.set_material(wood_x + 4, y, MaterialId::Wood);
|
self.grid.set_material(wood_x + 4, y, MaterialId::Wood);
|
||||||
}
|
}
|
||||||
for x in wood_x..=wood_x + 4 {
|
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)
|
// Sand dune (right of center)
|
||||||
@@ -145,7 +148,9 @@ impl Game {
|
|||||||
let surface_x = cx as i32;
|
let surface_x = cx as i32;
|
||||||
let mut surface_y = h as i32 - 3;
|
let mut surface_y = h as i32 - 3;
|
||||||
for y in 0..h as i32 {
|
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;
|
surface_y = y;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -224,7 +229,11 @@ impl Game {
|
|||||||
if let Some(m) = mat {
|
if let Some(m) = mat {
|
||||||
self.grid.set_material(cx + dx, cy + dy, m);
|
self.grid.set_material(cx + dx, cy + dy, m);
|
||||||
} else {
|
} 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.ca.step(&mut self.grid);
|
||||||
|
|
||||||
self.update_entities();
|
self.update_entities();
|
||||||
|
self.update_slime_ai();
|
||||||
|
self.update_combat();
|
||||||
|
|
||||||
self.apply_world_damage();
|
self.apply_world_damage();
|
||||||
|
|
||||||
if self.tick % 30 == 0 {
|
if self.tick % 30 == 0 {
|
||||||
self.try_spawn_goblin();
|
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) {
|
fn update_entities(&mut self) {
|
||||||
@@ -374,16 +478,23 @@ impl Game {
|
|||||||
// Blocked — resolve X
|
// Blocked — resolve X
|
||||||
let (resolved_x, hit) = self.resolve_aabb_x(idx, nx, ny, half_w, half_h, nvx);
|
let (resolved_x, hit) = self.resolve_aabb_x(idx, nx, ny, half_w, half_h, nvx);
|
||||||
nx = resolved_x;
|
nx = resolved_x;
|
||||||
if hit { nvx = 0.0; }
|
if hit {
|
||||||
|
nvx = 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Vertical movement
|
// Step 2: Vertical movement
|
||||||
ny += nvy;
|
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;
|
ny = resolved_y;
|
||||||
if hit_floor { nvy = 0.0; }
|
if hit_floor {
|
||||||
if hit_ceiling { nvy = 0.0; }
|
nvy = 0.0;
|
||||||
|
}
|
||||||
|
if hit_ceiling {
|
||||||
|
nvy = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
// Check material contacts
|
// Check material contacts
|
||||||
let (touching_lava, touching_fire, touching_acid, in_liquid) = {
|
let (touching_lava, touching_fire, touching_acid, in_liquid) = {
|
||||||
@@ -398,12 +509,22 @@ impl Game {
|
|||||||
let max_y = (ny + half_h).ceil() as i32;
|
let max_y = (ny + half_h).ceil() as i32;
|
||||||
for y in min_y..=max_y {
|
for y in min_y..=max_y {
|
||||||
for x in min_x..=max_x {
|
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);
|
let cell = grid.get(x, y);
|
||||||
if cell.material == MaterialId::Lava { tl = true; }
|
if cell.material == MaterialId::Lava {
|
||||||
if cell.material == MaterialId::Fire { tf = true; }
|
tl = true;
|
||||||
if cell.material == MaterialId::Acid { ta = true; }
|
}
|
||||||
if cell.is_liquid() { il = 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)
|
(tl, tf, ta, il)
|
||||||
@@ -424,7 +545,9 @@ impl Game {
|
|||||||
for b in &mut e.bodies {
|
for b in &mut e.bodies {
|
||||||
if b.alive {
|
if b.alive {
|
||||||
b.health -= 0.5;
|
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 {
|
for b in &mut e.bodies {
|
||||||
if b.alive {
|
if b.alive {
|
||||||
b.health -= 0.15;
|
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 {
|
if touching_acid {
|
||||||
for b in &mut e.bodies {
|
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 y in min_y..=max_y {
|
||||||
for x in min_x..=max_x {
|
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);
|
let cell = grid.get(x, y);
|
||||||
if !cell.is_solid() { continue; }
|
if !cell.is_solid() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let cl = x as f32;
|
let cl = x as f32;
|
||||||
let cr = (x + 1) as f32;
|
let cr = (x + 1) as f32;
|
||||||
let ct = y as f32;
|
let ct = y as f32;
|
||||||
@@ -473,7 +604,15 @@ impl Game {
|
|||||||
false
|
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 grid = &self.grid;
|
||||||
let left = cx - hw;
|
let left = cx - hw;
|
||||||
let right = cx + hw;
|
let right = cx + hw;
|
||||||
@@ -490,9 +629,13 @@ impl Game {
|
|||||||
|
|
||||||
for y in min_y..=max_y {
|
for y in min_y..=max_y {
|
||||||
for x in min_x..=max_x {
|
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);
|
let cell = grid.get(x, y);
|
||||||
if !cell.is_solid() { continue; }
|
if !cell.is_solid() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let cell_left = x as f32;
|
let cell_left = x as f32;
|
||||||
let cell_right = (x + 1) as f32;
|
let cell_right = (x + 1) as f32;
|
||||||
@@ -532,7 +675,15 @@ impl Game {
|
|||||||
(new_cx, hit)
|
(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 grid = &self.grid;
|
||||||
let left = cx - hw;
|
let left = cx - hw;
|
||||||
let right = cx + hw;
|
let right = cx + hw;
|
||||||
@@ -550,9 +701,13 @@ impl Game {
|
|||||||
|
|
||||||
for y in min_y..=max_y {
|
for y in min_y..=max_y {
|
||||||
for x in min_x..=max_x {
|
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);
|
let cell = grid.get(x, y);
|
||||||
if !cell.is_solid() { continue; }
|
if !cell.is_solid() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let cell_left = x as f32;
|
let cell_left = x as f32;
|
||||||
let cell_right = (x + 1) as f32;
|
let cell_right = (x + 1) as f32;
|
||||||
@@ -593,7 +748,12 @@ impl Game {
|
|||||||
(new_cy, hit_floor, hit_ceiling)
|
(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 grid = &self.grid;
|
||||||
let mut bodies = self.entities.all()[idx].bodies.clone();
|
let mut bodies = self.entities.all()[idx].bodies.clone();
|
||||||
let constraints = self.entities.all()[idx].constraints.clone();
|
let constraints = self.entities.all()[idx].constraints.clone();
|
||||||
@@ -622,11 +782,15 @@ impl Game {
|
|||||||
let result = resolve_grid_collision(grid, b);
|
let result = resolve_grid_collision(grid, b);
|
||||||
if result.touching_lava {
|
if result.touching_lava {
|
||||||
b.health -= 0.5;
|
b.health -= 0.5;
|
||||||
if !b.on_fire { b.on_fire = true; }
|
if !b.on_fire {
|
||||||
|
b.on_fire = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if result.touching_fire {
|
if result.touching_fire {
|
||||||
b.health -= 0.15;
|
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 {
|
if result.touching_acid {
|
||||||
b.health -= 0.25;
|
b.health -= 0.25;
|
||||||
@@ -673,7 +837,12 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn try_spawn_goblin(&mut self) {
|
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 {
|
if alive_goblins >= 3 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -703,4 +872,41 @@ impl Game {
|
|||||||
g.build_humanoid(spawn_x as f32, spawn_y as f32);
|
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 {
|
let ch = match e.kind {
|
||||||
crate::entity::EntityKind::Player if e.alive => '@',
|
crate::entity::EntityKind::Player if e.alive => '@',
|
||||||
crate::entity::EntityKind::Goblin if e.alive => 'g',
|
crate::entity::EntityKind::Goblin if e.alive => 'g',
|
||||||
|
crate::entity::EntityKind::Slime if e.alive => 's',
|
||||||
_ => '%',
|
_ => '%',
|
||||||
};
|
};
|
||||||
let fg = if e.on_fire {
|
let fg = if e.on_fire {
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ impl VulkanRenderer {
|
|||||||
let ch = match e.kind {
|
let ch = match e.kind {
|
||||||
EntityKind::Player if e.alive => '@',
|
EntityKind::Player if e.alive => '@',
|
||||||
EntityKind::Goblin if e.alive => 'g',
|
EntityKind::Goblin if e.alive => 'g',
|
||||||
|
EntityKind::Slime if e.alive => 's',
|
||||||
_ => '%',
|
_ => '%',
|
||||||
};
|
};
|
||||||
let fg = if e.on_fire {
|
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