makepad/libs/sim/tests/capsule_mover.rs
Admin 6d708d9391 Clean the public tree for a checkout other people can use.
Drop the archived old/ tree, root AI notes, splashgame.md (Arcade lives
in the private sandbox), widgets-dll leftovers, and LAN/oracle helper
scripts. Move download_*.sh into tools/, and move sim/math out of
libs/game/ so the public repo no longer has a game/ directory.

Also quiet first-party compile noise and skip asset-ui tests that need
sandbox kits or uncommitted fixtures.
2026-08-18 15:23:19 +02:00

221 lines
7.9 KiB
Rust

//! P3: the opt-in capsule mover. `capsule_collider: true` routes a mover's
//! motion through box3d `world_collide_mover` + `solve_planes` — real wall
//! sliding on angled geometry, smooth corners, true contact normals — while
//! flag-off movers keep the AABB sweep byte-for-byte (mover_golden.rs is the
//! proof: its hash did not move when this landed).
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()
}
}
fn capsule_walker(id: u64, pos: Vec3f) -> Entity {
let mut m = ent(id, BodyKind::Mover, pos, vec3f(0.35, 0.9, 0.35));
m.capsule_collider = true;
m
}
fn ground(w: &mut GameWorld) {
w.push_entity(ent(
1,
BodyKind::Static,
vec3f(0.0, -0.5, 0.0),
vec3f(60.0, 0.5, 60.0),
));
}
fn fnv(h: u64, v: u32) -> u64 {
(h ^ v as u64).wrapping_mul(0x100000001b3)
}
fn hash_world(w: &GameWorld) -> u64 {
let mut h = 0xcbf29ce484222325;
for e in &w.entities {
for f in [
e.pos.x,
e.pos.y,
e.pos.z,
e.vel.x,
e.vel.y,
e.vel.z,
e.floor_normal.x,
e.floor_normal.y,
e.floor_normal.z,
e.wall_normal.x,
e.wall_normal.z,
] {
h = fnv(h, f.to_bits());
}
h = fnv(h, e.on_floor as u32);
h = fnv(h, e.hit_wall as u32);
}
h
}
#[test]
fn a_capsule_mover_settles_on_the_ground_and_reports_a_floor() {
let mut w = GameWorld::new();
w.gravity = 30.0;
ground(&mut w);
w.push_entity(capsule_walker(2, vec3f(0.0, 3.0, 0.0)));
for _ in 0..90 {
step_world(&mut w);
}
let e = w.entity(2).unwrap();
assert!(e.on_floor, "capsule never grounded (y {})", e.pos.y);
assert_eq!(e.floor_id, 1);
assert!(e.floor_normal.y > 0.99, "flat floor normal {:?}", e.floor_normal);
// Resting roughly a half-height above the slab's top face.
assert!(
(e.pos.y - 0.9).abs() < 0.1,
"resting at y {} instead of ~0.9",
e.pos.y
);
}
/// The gate: wall-slide along an ANGLED wall. The AABB sweep collides a
/// rotated wall as its unrotated bounding box and kills the whole axis; the
/// capsule meets the true rotated face and keeps its tangential speed —
/// walking square into a 0.4-rad wall must carry the mover sideways.
#[test]
fn walking_into_an_angled_wall_slides_along_it() {
let mut w = GameWorld::new();
w.gravity = 30.0;
ground(&mut w);
let mut wall = ent(2, BodyKind::Static, vec3f(4.0, 1.5, 0.0), vec3f(0.3, 1.5, 10.0));
wall.yaw = 0.4; // the box3d mirror rotates the body; the AABB sweep never would
w.push_entity(wall);
w.push_entity(capsule_walker(3, vec3f(0.0, 0.9, 0.0)));
for _ in 0..150 {
let e = w.entity_mut(3).unwrap();
// Walk straight at the wall, the full intent re-asserted each tick
// exactly as a character block would (both components, so the slide
// rate is the clip's answer, not an accumulator).
e.vel.x = 4.0;
e.vel.z = 0.0;
step_world(&mut w);
}
let e = w.entity(3).unwrap();
// Slid along the face: the wall's world normal is (-cos .4, 0, sin .4),
// so a +x walk deflects toward +z.
assert!(
e.pos.z > 1.0,
"no slide along the angled wall (z {})",
e.pos.z
);
// And the wall was reported with its TRUE normal, not an axis guess.
assert!(e.hit_wall != 0, "wall contact never reported");
let n = e.wall_normal;
assert!(n.x < -0.8 && n.z > 0.2, "wall normal {:?} is not the rotated face", n);
// Never through it: every point on the mover's side of the face satisfies
// dot(p - face_point, n) > 0. Check the capsule centre against the plane
// through the wall's near face.
let face = vec3f(4.0 - 0.3 * (0.4f32).cos(), 0.0, 0.3 * (0.4f32).sin());
let d = (e.pos.x - face.x) * n.x + (e.pos.z - face.z) * n.z;
assert!(d > -0.05, "capsule penetrated the wall ({d})");
}
/// A corner: sliding along one wall into a second must stop dead without
/// jitter or tunneling — the plane solver holds both constraints at once.
#[test]
fn a_corner_holds_the_capsule_without_penetration() {
let mut w = GameWorld::new();
w.gravity = 30.0;
ground(&mut w);
w.push_entity(ent(2, BodyKind::Static, vec3f(5.0, 1.5, 0.0), vec3f(0.4, 1.5, 8.0)));
w.push_entity(ent(3, BodyKind::Static, vec3f(0.0, 1.5, 5.0), vec3f(8.0, 1.5, 0.4)));
w.push_entity(capsule_walker(4, vec3f(0.0, 0.9, 0.0)));
for _ in 0..240 {
let e = w.entity_mut(4).unwrap();
e.vel.x = 3.0;
e.vel.z = 3.0;
step_world(&mut w);
}
let e = w.entity(4).unwrap();
// Wedged into the corner, a capsule-radius short of both faces.
assert!(e.pos.x < 5.0 - 0.4 + 0.01, "through wall A: x {}", e.pos.x);
assert!(e.pos.z < 5.0 - 0.4 + 0.01, "through wall B: z {}", e.pos.z);
assert!(e.pos.x > 3.8 && e.pos.z > 3.8, "never reached the corner ({}, {})", e.pos.x, e.pos.z);
assert!(e.on_floor, "lost the floor while cornering");
}
/// The wedge mirrors as a true prism now, so the capsule path can walk the
/// ramp it sees — the same contract the AABB sweep gets from its wedge
/// special-case.
#[test]
fn a_capsule_mover_walks_up_a_wedge_ramp() {
let mut w = GameWorld::new();
w.gravity = 30.0;
ground(&mut w);
let mut ramp = ent(2, BodyKind::Static, vec3f(0.0, 2.0, 0.0), vec3f(6.0, 2.0, 7.0));
ramp.shape = Shape::Wedge;
w.push_entity(ramp);
w.push_entity(capsule_walker(3, vec3f(0.0, 0.9, -9.0)));
for _ in 0..240 {
let e = w.entity_mut(3).unwrap();
e.vel.x = 0.0;
e.vel.z = 4.0;
step_world(&mut w);
}
let e = w.entity(3).unwrap();
assert!(e.pos.z > -2.0, "stuck at the ramp's low edge (z {})", e.pos.z);
assert!(e.pos.y > 1.5, "at z {} but only y {} — through the ramp, not up it", e.pos.z, e.pos.y);
// On the slope the floor normal tilts toward the low edge.
if e.on_floor {
assert!(e.floor_normal.z < -0.1, "slope normal {:?}", e.floor_normal);
}
}
#[test]
fn the_capsule_path_is_run_to_run_deterministic() {
fn scene() -> GameWorld {
let mut w = GameWorld::new();
w.gravity = 30.0;
ground(&mut w);
let mut wall = ent(2, BodyKind::Static, vec3f(4.0, 1.5, 0.0), vec3f(0.3, 1.5, 10.0));
wall.yaw = 0.35;
w.push_entity(wall);
let mut ramp = ent(3, BodyKind::Static, vec3f(-6.0, 1.0, 4.0), vec3f(4.0, 1.0, 5.0));
ramp.shape = Shape::Wedge;
w.push_entity(ramp);
// A light crate the capsule can shove (the sweep-push half).
let mut crate_e = ent(4, BodyKind::Rigid, vec3f(2.0, 0.4, -3.0), vec3f(0.4, 0.4, 0.4));
crate_e.density = 0.4;
w.push_entity(crate_e);
w.push_entity(capsule_walker(5, vec3f(0.0, 0.9, -3.0)));
w.push_entity(capsule_walker(6, vec3f(-6.0, 0.9, -2.0)));
w
}
let mut a = scene();
let mut b = scene();
for t in 0..300 {
for (id, vx, vz) in [(5u64, 3.0f32, 0.6f32), (6, 0.0, 3.0)] {
let steer = if (t / 60) % 2 == 0 { 1.0 } else { -0.4 };
a.entity_mut(id).unwrap().vel.x = vx * steer;
a.entity_mut(id).unwrap().vel.z = vz;
b.entity_mut(id).unwrap().vel.x = vx * steer;
b.entity_mut(id).unwrap().vel.z = vz;
}
step_world(&mut a);
step_world(&mut b);
}
assert_eq!(hash_world(&a), hash_world(&b), "capsule path diverged between runs");
// And it actually did something: both walkers moved.
assert!((a.entity(5).unwrap().pos - vec3f(0.0, 0.9, -3.0)).length() > 1.0);
}