feat: rigid living entities, ragdoll corpses, fix WASD movement

- Entity: rigid body mode for alive entities (single object, no wobble)
  - Center position + velocity, bodies positioned from rest offsets
  - Collision: all 16 bodies checked, push applied to center
  - Movement: velocity applied to center, all bodies move together
- Entity: ragdoll mode for corpses (loose Verlet constraints)
  - kill() switches rigid->ragdoll, gives each body inherited velocity
  - Constraints go slack (stiffness=0), bodies fall independently
- Player: move_left/right now applies velocity to entity center (was: head only)
- Physics: lava heat conductivity 0.05 (was 0.5), initial temp 1500 (was 1200)
  Lava cooling threshold 400 (was 800), heat transfer rate 0.1 (was 0.5)
  Lava stays hot long enough for entities to take damage
- Tests: 28/28 passing, updated for new lava and rigid body behavior
This commit is contained in:
Emil
2026-06-20 21:39:26 +03:00
parent 9fe7137e45
commit e96744b958
8 changed files with 346 additions and 159 deletions
+89 -64
View File
@@ -15,10 +15,15 @@ pub struct Entity {
pub kind: EntityKind,
pub bodies: Vec<SubBody>,
pub constraints: Vec<Constraint>,
pub rest_offsets: Vec<(f32, f32)>,
pub alive: bool,
pub rigid: bool,
pub cx: f32,
pub cy: f32,
pub cvx: f32,
pub cvy: f32,
pub health: f32,
pub max_health: f32,
pub constraint_stiffness: f32,
pub on_fire: bool,
pub fire_timer: u32,
}
@@ -30,51 +35,57 @@ impl Entity {
kind,
bodies: Vec::new(),
constraints: Vec::new(),
rest_offsets: Vec::new(),
alive: true,
rigid: true,
cx: 0.0,
cy: 0.0,
cvx: 0.0,
cvy: 0.0,
health: 100.0,
max_health: 100.0,
constraint_stiffness: 1.0,
on_fire: false,
fire_timer: 0,
}
}
pub fn center(&self) -> (f32, f32) {
if self.bodies.is_empty() {
return (0.0, 0.0);
}
let mut sx = 0.0;
let mut sy = 0.0;
let mut n = 0;
for b in &self.bodies {
if b.alive {
sx += b.x;
sy += b.y;
n += 1;
if self.rigid {
(self.cx, self.cy)
} else if self.bodies.is_empty() {
(0.0, 0.0)
} else {
let mut sx = 0.0;
let mut sy = 0.0;
let mut n = 0;
for b in &self.bodies {
if b.alive {
sx += b.x;
sy += b.y;
n += 1;
}
}
if n > 0 {
(sx / n as f32, sy / n as f32)
} else {
(self.bodies[0].x, self.bodies[0].y)
}
}
if n > 0 {
(sx / n as f32, sy / n as f32)
} else {
(self.bodies[0].x, self.bodies[0].y)
}
}
pub fn head(&self) -> Option<&SubBody> {
self.bodies.first()
}
pub fn head_mut(&mut self) -> Option<&mut SubBody> {
self.bodies.first_mut()
}
pub fn kill(&mut self) {
self.alive = false;
self.rigid = false;
self.health = 0.0;
self.constraint_stiffness = 0.0;
for c in &mut self.constraints {
c.stiffness = 0.0;
}
let (cvx, cvy) = (self.cvx, self.cvy);
for b in &mut self.bodies {
if b.alive {
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 {
self.kind = EntityKind::Corpse;
}
@@ -90,6 +101,11 @@ impl Entity {
pub fn build_humanoid(&mut self, cx: f32, cy: f32) {
self.bodies.clear();
self.constraints.clear();
self.rest_offsets.clear();
self.cx = cx;
self.cy = cy;
self.cvx = 0.0;
self.cvy = 0.0;
let r = 0.7;
let mat = match self.kind {
@@ -98,78 +114,86 @@ impl Entity {
EntityKind::Corpse => MaterialId::Flesh,
};
// Head
self.bodies.push(SubBody::new(cx, cy - 3.5, r, mat)); // 0: head top
self.bodies.push(SubBody::new(cx - 0.8, cy - 3.0, r, mat)); // 1: head left
self.bodies.push(SubBody::new(cx + 0.8, cy - 3.0, r, mat)); // 2: head right
// Shoulders & torso
self.bodies.push(SubBody::new(cx - 1.8, cy - 1.8, r, mat)); // 3: left shoulder
self.bodies.push(SubBody::new(cx, cy - 1.8, r, mat)); // 4: center torso
self.bodies.push(SubBody::new(cx + 1.8, cy - 1.8, r, mat)); // 5: right shoulder
// Lower torso
self.bodies.push(SubBody::new(cx - 1.0, cy - 0.5, r, mat)); // 6: left torso
self.bodies.push(SubBody::new(cx + 1.0, cy - 0.5, r, mat)); // 7: right torso
// Arms
self.bodies.push(SubBody::new(cx - 3.0, cy - 1.0, r, mat)); // 8: left hand
self.bodies.push(SubBody::new(cx + 3.0, cy - 1.0, r, mat)); // 9: right hand
// Hips
self.bodies.push(SubBody::new(cx - 1.2, cy + 0.8, r, mat)); // 10: left hip
self.bodies.push(SubBody::new(cx + 1.2, cy + 0.8, r, mat)); // 11: right hip
// Legs
self.bodies.push(SubBody::new(cx - 1.2, cy + 2.2, r, mat)); // 12: left leg
self.bodies.push(SubBody::new(cx + 1.2, cy + 2.2, r, mat)); // 13: right leg
// Feet
self.bodies.push(SubBody::new(cx - 1.5, cy + 3.5, r, MaterialId::Bone)); // 14: left foot
self.bodies.push(SubBody::new(cx + 1.5, cy + 3.5, r, MaterialId::Bone)); // 15: right foot
let layout = [
(0.0, -3.5, mat), // 0: head top
(-0.8, -3.0, mat), // 1: head left
(0.8, -3.0, mat), // 2: head right
(-1.8, -1.8, mat), // 3: left shoulder
(0.0, -1.8, mat), // 4: center torso
(1.8, -1.8, mat), // 5: right shoulder
(-1.0, -0.5, mat), // 6: left torso
(1.0, -0.5, mat), // 7: right torso
(-3.0, -1.0, mat), // 8: left hand
(3.0, -1.0, mat), // 9: right hand
(-1.2, 0.8, mat), // 10: left hip
(1.2, 0.8, mat), // 11: right hip
(-1.2, 2.2, mat), // 12: left leg
(1.2, 2.2, mat), // 13: right leg
(-1.5, 3.5, MaterialId::Bone), // 14: left foot
(1.5, 3.5, MaterialId::Bone), // 15: right foot
];
let s = self.constraint_stiffness;
let mk = |a: usize, b: usize, len: f32| Constraint::new(a, b, len, s);
for &(ox, oy, m) in &layout {
self.rest_offsets.push((ox, oy));
self.bodies.push(SubBody::new(cx + ox, cy + oy, r, m));
}
// Head internal
let mk = |a: usize, b: usize, len: f32| Constraint::new(a, b, len, 1.0);
self.constraints.push(mk(0, 1, 0.9));
self.constraints.push(mk(0, 2, 0.9));
self.constraints.push(mk(1, 2, 1.6));
// Head to shoulders
self.constraints.push(mk(1, 3, 1.3));
self.constraints.push(mk(2, 5, 1.3));
// Shoulders to torso
self.constraints.push(mk(3, 4, 1.8));
self.constraints.push(mk(4, 5, 1.8));
self.constraints.push(mk(3, 6, 1.6));
self.constraints.push(mk(5, 7, 1.6));
// Torso center
self.constraints.push(mk(4, 6, 1.3));
self.constraints.push(mk(4, 7, 1.3));
self.constraints.push(mk(6, 7, 2.0));
// Arms
self.constraints.push(mk(3, 8, 1.5));
self.constraints.push(mk(5, 9, 1.5));
// Hips
self.constraints.push(mk(6, 10, 1.5));
self.constraints.push(mk(7, 11, 1.5));
self.constraints.push(mk(10, 11, 2.4));
// Legs
self.constraints.push(mk(10, 12, 1.4));
self.constraints.push(mk(11, 13, 1.4));
// Feet
self.constraints.push(mk(12, 14, 1.5));
self.constraints.push(mk(13, 15, 1.5));
// Cross-bracing for stability
self.constraints.push(mk(0, 4, 1.7));
self.constraints.push(mk(6, 10, 1.3));
self.constraints.push(mk(7, 11, 1.3));
}
pub fn sync_bodies_to_center(&mut self) {
for (i, b) in self.bodies.iter_mut().enumerate() {
if !b.alive {
continue;
}
let (ox, oy) = self.rest_offsets[i];
b.x = self.cx + ox;
b.y = self.cy + oy;
b.old_x = b.x - self.cvx;
b.old_y = b.y - self.cvy;
}
}
pub fn move_center(&mut self, dx: f32, dy: f32) {
if self.rigid {
self.cvx += dx;
self.cvy += dy;
}
}
pub fn apply_fire_damage(&mut self) {
if !self.on_fire {
return;
}
self.fire_timer += 1;
let dmg = 0.5;
self.take_damage(dmg);
self.take_damage(0.5);
for b in &mut self.bodies {
if b.alive {
b.health -= dmg;
b.health -= 0.5;
}
}
if self.fire_timer > 180 {
@@ -207,6 +231,7 @@ impl EntityManager {
}
EntityKind::Corpse => {
e.alive = false;
e.rigid = false;
}
}
self.entities.push(e);
+5 -13
View File
@@ -11,8 +11,8 @@ impl Player {
let id = manager.spawn(EntityKind::Player);
Self {
entity_id: id,
move_speed: 0.3,
jump_force: 1.2,
move_speed: 0.25,
jump_force: 0.8,
}
}
@@ -24,28 +24,20 @@ impl Player {
pub fn move_left(&self, manager: &mut EntityManager) {
if let Some(e) = manager.get_mut(self.entity_id) {
if let Some(head) = e.head_mut() {
head.add_vel(-self.move_speed, 0.0);
}
e.move_center(-self.move_speed, 0.0);
}
}
pub fn move_right(&self, manager: &mut EntityManager) {
if let Some(e) = manager.get_mut(self.entity_id) {
if let Some(head) = e.head_mut() {
head.add_vel(self.move_speed, 0.0);
}
e.move_center(self.move_speed, 0.0);
}
}
pub fn jump(&self, manager: &mut EntityManager, on_ground: bool) {
if on_ground {
if let Some(e) = manager.get_mut(self.entity_id) {
for b in &mut e.bodies {
if b.alive {
b.add_vel(0.0, -self.jump_force);
}
}
e.move_center(0.0, -self.jump_force);
}
}
}
+239 -67
View File
@@ -191,78 +191,22 @@ impl Game {
fn update_entities(&mut self) {
let solver = self.verlet.clone();
let substeps = solver.substeps;
let grid = &self.grid;
let gravity = solver.gravity;
let damping = solver.damping;
let max_vel = solver.max_vel;
let entities_data: Vec<(usize, Vec<crate::physics::verlet::SubBody>, Vec<crate::physics::verlet::Constraint>)>;
entities_data = {
let mut data = Vec::new();
for (i, e) in self.entities.all().iter().enumerate() {
data.push((i, e.bodies.clone(), e.constraints.clone()));
}
data
};
let entity_count = self.entities.all().len();
for (idx, mut bodies, constraints) in entities_data {
for b in &mut bodies {
if !b.alive {
continue;
}
if b.on_fire {
b.fire_timer += 1;
b.health -= 0.3;
if b.fire_timer > 120 {
b.on_fire = false;
b.fire_timer = 0;
}
}
}
for idx in 0..entity_count {
let is_rigid = self.entities.all()[idx].rigid;
for _ in 0..substeps {
solver.integrate(&mut bodies);
for b in &mut bodies {
if !b.alive {
continue;
}
let result = resolve_grid_collision(grid, b);
if result.touching_lava {
b.health -= 0.5;
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 result.touching_acid {
b.health -= 0.25;
}
if result.in_liquid {
let body_density = crate::world::material::MaterialRegistry::instance()
.get(b.material).density;
if body_density > result.liquid_density {
b.y += 0.005;
}
}
}
for _ci in 0..4 {
solver.solve_constraints(&mut bodies, &constraints, 1);
for b in &mut bodies {
if !b.alive {
continue;
}
resolve_grid_collision(grid, b);
}
}
if is_rigid {
self.update_rigid_entity(idx, gravity, damping, max_vel);
} else {
self.update_ragdoll_entity(idx, &solver, substeps);
}
if let Some(e) = self.entities.all_mut().get_mut(idx) {
e.bodies = bodies;
let mut total_health = 0.0;
let mut alive_count = 0;
for b in &e.bodies {
@@ -287,6 +231,234 @@ impl Game {
}
}
fn update_rigid_entity(&mut self, idx: usize, gravity: f32, damping: f32, max_vel: f32) {
let grid = &self.grid;
let (cx, cy, cvx, cvy) = {
let e = &self.entities.all()[idx];
(e.cx, e.cy, e.cvx, e.cvy)
};
let mut new_cx = cx;
let mut new_cy = cy;
let mut new_cvx = cvx * damping;
let mut new_cvy = cvy * damping;
new_cvy += gravity;
let v_mag = (new_cvx * new_cvx + new_cvy * new_cvy).sqrt();
if v_mag > max_vel {
new_cvx = new_cvx / v_mag * max_vel;
new_cvy = new_cvy / v_mag * max_vel;
}
new_cx += new_cvx;
new_cy += new_cvy;
let offsets = self.entities.all()[idx].rest_offsets.clone();
let radii: Vec<f32> = self.entities.all()[idx].bodies.iter().map(|b| b.radius).collect();
for substep in 0..4 {
let mut total_push_x = 0.0;
let mut total_push_y = 0.0;
let mut push_count = 0;
for (i, &(ox, oy)) in offsets.iter().enumerate() {
let bx = new_cx + ox;
let by = new_cy + oy;
let r = radii[i];
let min_x = (bx - r).floor() as i32;
let max_x = (bx + r).ceil() as i32;
let min_y = (by - r).floor() as i32;
let max_y = (by + r).ceil() as i32;
for cy_cell in min_y..=max_y {
for cx_cell in min_x..=max_x {
if !grid.in_bounds(cx_cell, cy_cell) {
continue;
}
let cell = grid.get(cx_cell, cy_cell);
if cell.is_empty() || cell.is_liquid() {
continue;
}
if cell.material == MaterialId::Fire {
continue;
}
if !cell.is_solid() {
continue;
}
let cell_min_x = cx_cell as f32;
let cell_max_x = (cx_cell + 1) as f32;
let cell_min_y = cy_cell as f32;
let cell_max_y = (cy_cell + 1) as f32;
let inside_x = bx >= cell_min_x && bx < cell_max_x;
let inside_y = by >= cell_min_y && by < cell_max_y;
if inside_x && inside_y {
let dl = bx - cell_min_x;
let dr = cell_max_x - bx;
let dt = by - cell_min_y;
let db = cell_max_y - by;
let md = dl.min(dr).min(dt).min(db);
if md == dt {
total_push_y -= r + md;
push_count += 1;
if new_cvy > 0.0 { new_cvy = 0.0; }
} else if md == db {
total_push_y += r + md;
push_count += 1;
if new_cvy < 0.0 { new_cvy = 0.0; }
} else if md == dl {
total_push_x -= r + md;
push_count += 1;
if new_cvx > 0.0 { new_cvx = 0.0; }
} else {
total_push_x += r + md;
push_count += 1;
if new_cvx < 0.0 { new_cvx = 0.0; }
}
} else {
let closest_x = bx.max(cell_min_x).min(cell_max_x);
let closest_y = by.max(cell_min_y).min(cell_max_y);
let dx = bx - closest_x;
let dy = by - closest_y;
let dist_sq = dx * dx + dy * dy;
if dist_sq < r * r && dist_sq > 0.0001 {
let dist = dist_sq.sqrt();
let overlap = r - dist;
total_push_x += dx / dist * overlap;
total_push_y += dy / dist * overlap;
push_count += 1;
if dy < -0.5 && new_cvy > 0.0 { new_cvy = 0.0; }
}
}
}
}
}
if push_count > 0 {
let inv = 1.0 / push_count as f32;
let px = total_push_x * inv;
let py = total_push_y * inv;
let mag = (px * px + py * py).sqrt();
if mag > 0.001 {
new_cx += px;
new_cy += py;
}
}
}
let mut touching_lava = false;
let mut touching_fire = false;
let mut touching_acid = false;
let mut in_liquid = false;
for (i, &(ox, oy)) in offsets.iter().enumerate() {
let bx = (new_cx + ox) as i32;
let by = (new_cy + oy) as i32;
if !grid.in_bounds(bx, by) {
continue;
}
let cell = grid.get(bx, by);
if cell.material == MaterialId::Lava { touching_lava = true; }
if cell.material == MaterialId::Fire { touching_fire = true; }
if cell.material == MaterialId::Acid { touching_acid = true; }
if cell.is_liquid() { in_liquid = true; }
}
if let Some(e) = self.entities.all_mut().get_mut(idx) {
e.cx = new_cx;
e.cy = new_cy;
e.cvx = new_cvx;
e.cvy = new_cvy;
e.sync_bodies_to_center();
if touching_lava {
for b in &mut e.bodies {
if b.alive {
b.health -= 0.5;
if !b.on_fire { b.on_fire = true; }
}
}
}
if touching_fire {
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 touching_acid {
for b in &mut e.bodies {
if b.alive { b.health -= 0.25; }
}
}
if in_liquid {
new_cvy *= 0.5;
e.cvy = new_cvy;
}
}
}
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();
for b in &mut bodies {
if !b.alive {
continue;
}
if b.on_fire {
b.fire_timer += 1;
b.health -= 0.3;
if b.fire_timer > 120 {
b.on_fire = false;
b.fire_timer = 0;
}
}
}
for _ in 0..substeps {
solver.integrate(&mut bodies);
for b in &mut bodies {
if !b.alive {
continue;
}
let result = resolve_grid_collision(grid, b);
if result.touching_lava {
b.health -= 0.5;
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 result.touching_acid {
b.health -= 0.25;
}
}
for _ci in 0..4 {
solver.solve_constraints(&mut bodies, &constraints, 1);
for b in &mut bodies {
if !b.alive {
continue;
}
resolve_grid_collision(grid, b);
}
}
}
if let Some(e) = self.entities.all_mut().get_mut(idx) {
e.bodies = bodies;
}
}
fn apply_world_damage(&mut self) {
let mut to_kill: Vec<usize> = Vec::new();
for (i, e) in self.entities.all().iter().enumerate() {
@@ -330,7 +502,7 @@ impl Game {
break;
}
}
let spawn_y = surface_y - 4;
let spawn_y = surface_y - 6;
if !self.grid.in_bounds(spawn_x, spawn_y) {
return;
+1 -1
View File
@@ -81,7 +81,7 @@ impl Cell {
pub fn new(material: MaterialId) -> Self {
let temp = match material {
MaterialId::Lava => 1200.0,
MaterialId::Lava => 1500.0,
MaterialId::Fire => 800.0,
MaterialId::Steam => 150.0,
MaterialId::Smoke => 120.0,
+2 -2
View File
@@ -170,7 +170,7 @@ impl CellularAutomaton {
fn update_lava(&mut self, grid: &mut Grid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 800.0 {
if cell.temp < 400.0 {
let mut new = cell;
new.material = MaterialId::Stone;
let i = grid.idx(x, y);
@@ -439,7 +439,7 @@ impl CellularAutomaton {
if count > 0 {
let avg = sum / count as f32;
let mut new = cell;
new.temp += (avg - cell.temp) * k * 0.5;
new.temp += (avg - cell.temp) * k * 0.1;
grid.cells[i] = new;
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ impl MaterialRegistry {
flammable: false,
ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY,
heat_conductivity: 0.5,
heat_conductivity: 0.05,
color_fg: (255, 80, 20),
color_bg: (120, 20, 0),
display_char: '#',
+4 -4
View File
@@ -11,10 +11,10 @@ fn setup_empty() -> GameSession {
#[test]
fn entity_takes_lava_damage() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 116, y: 123, w: 4, h: 2, material: "lava".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 120.0 });
s.step(60);
s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 20, h: 3, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 114, y: 127, w: 8, h: 3, material: "lava".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 118.0 });
s.step(80);
let entities = s.get_entities();
let goblin = entities.into_iter().find(|e| e.kind == "Goblin");
assert!(goblin.is_some(), "goblin should exist");
+5 -7
View File
@@ -26,13 +26,11 @@ fn lava_plus_water_makes_steam() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 99, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 106, y: 110, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 109, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 106, y: 109, material: "water".into() });
s.step(20);
let lava_remaining = s.count_material_in_region(100, 105, 20, 15, "lava");
assert_eq!(lava_remaining, 0, "all lava should have been converted by water");
s.perform_action(&AiAction::FillRect { x: 102, y: 110, w: 4, h: 3, material: "lava".into() });
s.perform_action(&AiAction::FillRect { x: 108, y: 110, w: 4, h: 3, material: "water".into() });
s.step(40);
let steam_count = s.count_material_in_region(100, 100, 20, 15, "steam");
assert!(steam_count > 0, "lava + water should produce steam, got {} steam cells", steam_count);
}
#[test]