makepad/libs/game/sim/tests/mover_separation.rs
Admin 8dadda7f4b Movers push each other apart instead of clipping through
A POST-PASS, not a sweep change. The sweep carries the 0.55 step-up,
CONTACT_SKIN and the terrain-cliff logic, and every existing contract was
written against it, so it is untouched. separate_movers runs after the whole
integration loop — which also makes the result independent of who stepped
first — and before rider pinning, which stays authoritative.

HORIZONTAL ONLY, resolving the least-penetration axis of x/z. Resolving
vertically is exactly how characters end up standing on each other's heads; an
overlapping pair is pushed apart on the ground plane and a stack unpicks
itself.

Three FIXED relaxation passes, deliberately not convergence-based: an
early-exit on "nothing moved" makes the result depend on iteration order, and
this has to be bit-reproducible. Broad phase is a uniform grid sized 2x the
widest half, with buckets as a sorted (cell_key, index) array rather than a
hash map — allocation-light AND ordered without a second sort. That replaced a
hash map of per-cell Vecs and took allocations from 617/tick to ~15.

Each shove is clamped by sweep_axis against the solid world. Without that, a
crowd pressed against a wall squeezes its outermost members straight through.

push_mass weights the split by the OTHER body's mass, so equals each give half
and a player at 4.0 shoulders through NPCs at 1.0. 0.0 — the Default — READS
AS 1.0, not as weightless: a literal zero would make every default-constructed
mover infinitely shovable and divide by zero when two met. Same discipline as
`hidden` over `visible`.

Projectiles are excluded, and that is CORRECTNESS not taste: collect_touches
reports a strike from the overlap itself, so separating projectiles would mean
a bullet could never touch anyone. Sensors, collide:false decor and attached
riders are skipped too.

  50 packed movers               0.023 ms/tick
  200 packed movers              0.123
  12 villagers + 500 static      0.107
  200 movers + 500 static        2.020
Packed crowds where everyone overlaps a neighbour — the honest worst case. The
200-among-500-statics figure is dominated by the per-shove static clamp; at the
realistic 12-50 NPCs it is 0.1-0.25 ms. The fix if 200+ becomes normal is
accumulating pushes and clamping once per mover per pass, deliberately not done
because it changes Gauss-Seidel to Jacobi and the numbers don't justify it.

THE GOLDEN HASHES DID NOT CHANGE, and that is genuine rather than lucky:
mover_scene's walkers start 1.7 apart with 0.4 halves and diverge, and its only
other mover is an attached rider, so no pair ever overlaps and the pass is
inert. Nine new tests prove separation works; the unchanged goldens prove it
does nothing where movers never meet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:20:26 +02:00

344 lines
11 KiB
Rust

//! Movers shove each other apart (task #16).
//!
//! Before this, `step_world`'s solid snapshot filtered to
//! `Static | Kinematic | Rigid` and movers passed straight through one
//! another — an inherited Godot-parity rule that was defensible with one
//! player character and reads as broken in a village of NPCs.
use makepad_game_sim::*;
use makepad_math::*;
fn ent(id: u64, kind: BodyKind, pos: Vec3f, half: Vec3f) -> Entity {
Entity {
id,
kind,
pos,
half,
collide: true,
gravity_scale: 1.0,
density: 1.0,
friction: 0.6,
scale: vec3f(1.0, 1.0, 1.0),
scale_target: vec3f(1.0, 1.0, 1.0),
speed_mult: 1.0,
..Default::default()
}
}
/// Flat ground as a static slab, so gravity has something to rest on.
fn ground(w: &mut GameWorld, id: u64) {
w.push_entity(ent(
id,
BodyKind::Static,
vec3f(0.0, -0.5, 0.0),
vec3f(60.0, 0.5, 60.0),
));
}
fn horizontal_gap(a: &Entity, b: &Entity) -> f32 {
let dx = (a.pos.x - b.pos.x).abs() - (a.half.x + b.half.x);
let dz = (a.pos.z - b.pos.z).abs() - (a.half.z + b.half.z);
dx.max(dz)
}
#[test]
fn two_movers_walking_together_do_not_end_up_inside_each_other() {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
w.push_entity(ent(2, BodyKind::Mover, vec3f(-1.0, 0.8, 0.0), vec3f(0.4, 0.8, 0.4)));
w.push_entity(ent(3, BodyKind::Mover, vec3f(1.0, 0.8, 0.0), vec3f(0.4, 0.8, 0.4)));
// Drive them into each other for a while, re-asserting velocity the way a
// walk verb does.
for _ in 0..120 {
w.entity_mut(2).unwrap().vel.x = 2.0;
w.entity_mut(3).unwrap().vel.x = -2.0;
step_world(&mut w);
}
let a = w.entity(2).unwrap();
let b = w.entity(3).unwrap();
assert!(
horizontal_gap(a, b) > -0.05,
"movers interpenetrated: a={:?} b={:?}",
a.pos,
b.pos
);
// And neither tunnelled to the far side of the other.
assert!(a.pos.x < b.pos.x, "movers swapped sides: {} vs {}", a.pos.x, b.pos.x);
}
#[test]
fn a_heavy_mover_shoulders_through_a_light_one() {
// Same collision, opposite mass ratio, run twice: the heavy body should
// hold its ground and the light one should give way.
let run = |heavy_first: bool| -> (f32, f32) {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
// Overlapping by 0.2 on x: combined halves are 0.8, centres 0.6 apart.
let mut a = ent(2, BodyKind::Mover, vec3f(-0.3, 0.8, 0.0), vec3f(0.4, 0.8, 0.4));
let mut b = ent(3, BodyKind::Mover, vec3f(0.3, 0.8, 0.0), vec3f(0.4, 0.8, 0.4));
a.push_mass = if heavy_first { 8.0 } else { 1.0 };
b.push_mass = if heavy_first { 1.0 } else { 8.0 };
w.push_entity(a);
w.push_entity(b);
for _ in 0..30 {
step_world(&mut w);
}
(
(w.entity(2).unwrap().pos.x + 0.3).abs(),
(w.entity(3).unwrap().pos.x - 0.3).abs(),
)
};
let (heavy_moved, light_moved) = run(true);
assert!(
light_moved > heavy_moved * 2.0,
"heavy should barely budge: heavy {heavy_moved}, light {light_moved}"
);
// Mirrored, so the result is about mass and not about who is listed first.
let (light_moved2, heavy_moved2) = run(false);
assert!(
light_moved2 > heavy_moved2 * 2.0,
"mirrored case failed: light {light_moved2}, heavy {heavy_moved2}"
);
}
#[test]
fn a_crowd_converging_on_one_point_settles_without_stacking_or_exploding() {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
// Twelve villagers on a ring, all walking at the middle — the bench-crowd
// case that made this visible in the first place.
for i in 0..12u64 {
let a = i as f32 * std::f32::consts::TAU / 12.0;
w.push_entity(ent(
i + 2,
BodyKind::Mover,
vec3f(a.cos() * 5.0, 0.8, a.sin() * 5.0),
vec3f(0.4, 0.8, 0.4),
));
}
for _ in 0..600 {
for i in 0..12u64 {
let e = w.entity(i + 2).unwrap();
let (dx, dz) = (-e.pos.x, -e.pos.z);
let len = (dx * dx + dz * dz).sqrt().max(1.0e-3);
let m = w.entity_mut(i + 2).unwrap();
m.vel.x = dx / len * 1.5;
m.vel.z = dz / len * 1.5;
}
step_world(&mut w);
}
let people: Vec<Entity> = (0..12u64).map(|i| w.entity(i + 2).unwrap().clone()).collect();
for p in &people {
assert!(
p.pos.x.is_finite() && p.pos.y.is_finite() && p.pos.z.is_finite(),
"crowd exploded to non-finite: {:?}",
p.pos
);
// Nobody climbed: everyone is still standing on the ground slab.
assert!(
(p.pos.y - 0.8).abs() < 0.1,
"someone was pushed off the ground plane: y={}",
p.pos.y
);
// Nobody was flung across the map by a runaway relaxation.
assert!(
p.pos.x.abs() < 12.0 && p.pos.z.abs() < 12.0,
"someone was flung out of the crowd: {:?}",
p.pos
);
}
for i in 0..people.len() {
for j in i + 1..people.len() {
assert!(
horizontal_gap(&people[i], &people[j]) > -0.12,
"crowd stacked: {} and {} at {:?} / {:?}",
people[i].id,
people[j].id,
people[i].pos,
people[j].pos
);
}
}
}
#[test]
fn separation_is_horizontal_so_nobody_is_pushed_onto_a_head() {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
// One dropped almost exactly onto another.
w.push_entity(ent(2, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.4, 0.8, 0.4)));
w.push_entity(ent(3, BodyKind::Mover, vec3f(0.05, 2.4, 0.05), vec3f(0.4, 0.8, 0.4)));
for _ in 0..180 {
step_world(&mut w);
}
let a = w.entity(2).unwrap();
let b = w.entity(3).unwrap();
// Both ended up on the floor, side by side — not one perched on the other.
assert!((a.pos.y - 0.8).abs() < 0.1, "a left the ground: {}", a.pos.y);
assert!((b.pos.y - 0.8).abs() < 0.1, "b perched on a head: {}", b.pos.y);
assert!(
horizontal_gap(a, b) > -0.05,
"coincident pair never separated: {:?} / {:?}",
a.pos,
b.pos
);
}
#[test]
fn a_rider_is_pinned_not_shoved() {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
let mut car = ent(2, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.9, 0.8, 1.6));
car.vel = vec3f(1.0, 0.0, 0.0);
w.push_entity(car);
// Rider sits inside the carrier's box — a permanent overlap that
// separation must not try to resolve.
let mut rider = ent(3, BodyKind::Mover, vec3f(0.0, 1.2, 0.0), vec3f(0.35, 0.7, 0.35));
rider.attached_to = 2;
rider.attach_offset = vec3f(0.0, 0.4, 0.0);
w.push_entity(rider);
for _ in 0..90 {
w.entity_mut(2).unwrap().vel.x = 1.0;
step_world(&mut w);
}
let car = w.entity(2).unwrap().clone();
let rider = w.entity(3).unwrap();
assert_eq!(rider.attached_to, 2, "rider was detached");
let want = car.pos + vec3f(0.0, 0.4, 0.0);
assert!(
(rider.pos - want).length() < 1.0e-4,
"rider drifted off its seat: {:?} want {:?}",
rider.pos,
want
);
}
#[test]
fn projectiles_still_pass_through_movers() {
// collect_touches reports a hit FROM the overlap, so a projectile that
// gets separated could never touch anyone.
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
w.push_entity(ent(2, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.4, 0.8, 0.4)));
let mut bullet = ent(3, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.1, 0.1, 0.1));
bullet.hits = true;
bullet.gravity_scale = 0.0;
w.push_entity(bullet);
step_world(&mut w);
let touches = collect_touches(&w);
assert!(
touches.iter().any(|(a, b)| (*a == 3 && *b == 2) || (*a == 2 && *b == 3)),
"projectile stopped reporting its hit: {touches:?}"
);
}
#[test]
fn non_colliding_and_sensor_movers_are_left_alone() {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
w.push_entity(ent(2, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.4, 0.8, 0.4)));
let mut decor = ent(3, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.4, 0.8, 0.4));
decor.collide = false;
w.push_entity(decor);
let mut sensor = ent(4, BodyKind::Mover, vec3f(0.0, 0.8, 0.0), vec3f(0.4, 0.8, 0.4));
sensor.sensor = true;
w.push_entity(sensor);
for _ in 0..60 {
step_world(&mut w);
}
// Both opted out, so both are still sitting exactly on top of the solid one.
for id in [3u64, 4] {
let e = w.entity(id).unwrap();
assert!(
e.pos.x.abs() < 1.0e-3 && e.pos.z.abs() < 1.0e-3,
"opted-out mover {id} was shoved to {:?}",
e.pos
);
}
}
#[test]
fn separation_cannot_squeeze_a_crowd_through_a_wall() {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
// A wall at x = 2, and six movers pressed into it.
w.push_entity(ent(2, BodyKind::Static, vec3f(2.5, 1.0, 0.0), vec3f(0.5, 1.0, 8.0)));
for i in 0..6u64 {
let mut m = ent(
i + 3,
BodyKind::Mover,
vec3f(1.0 - i as f32 * 0.05, 0.8, i as f32 * 0.05),
vec3f(0.4, 0.8, 0.4),
);
m.vel = vec3f(3.0, 0.0, 0.0);
w.push_entity(m);
}
for _ in 0..240 {
for i in 0..6u64 {
w.entity_mut(i + 3).unwrap().vel.x = 3.0;
}
step_world(&mut w);
}
for i in 0..6u64 {
let e = w.entity(i + 3).unwrap();
assert!(
e.pos.x + e.half.x < 2.1,
"mover {i} was squeezed into the wall: x={}",
e.pos.x
);
}
}
#[test]
fn separation_is_deterministic() {
let build = || {
let mut w = GameWorld::new();
w.reset_content();
ground(&mut w, 1);
for i in 0..10u64 {
let a = i as f32 * 0.7;
let mut m = ent(
i + 2,
BodyKind::Mover,
vec3f(a.cos() * 2.0, 0.8, a.sin() * 2.0),
vec3f(0.4, 0.8, 0.4),
);
m.vel = vec3f(-a.cos(), 0.0, -a.sin());
w.push_entity(m);
}
w
};
let (mut a, mut b) = (build(), build());
for _ in 0..300 {
step_world(&mut a);
step_world(&mut b);
}
for i in 0..10u64 {
let (pa, pb) = (a.entity(i + 2).unwrap().pos, b.entity(i + 2).unwrap().pos);
assert_eq!(pa.x.to_bits(), pb.x.to_bits(), "mover {i} x diverged");
assert_eq!(pa.y.to_bits(), pb.y.to_bits(), "mover {i} y diverged");
assert_eq!(pa.z.to_bits(), pb.z.to_bits(), "mover {i} z diverged");
}
}