makepad/libs/game/render/tests/prop_collision.rs
Admin 5266625cb6 The Knight was never in the world — plus variety, fog, shadows, ranking
THE WALK-THROUGH BUG WAS NOT THE COLLIDERS. Knight::tick wrote a triangle wave
straight into self.pos, and find(|e| e.tag == "knight") returned None — no
mover, no sweep, no Character block. He passed through benches, houses and
trees alike because there was nothing to collide WITH. The collider maths was
right all along, which is exactly why the house-wall and tree-trunk tests
passed while the user kept reporting walk-through three times over.

Dumping the bench's real collider before changing anything confirmed it:
graveyard-kit/bench yields 1.36 x 0.9 x 0.78 at ground level — a perfectly good
obstacle that nothing was ever tested against.

The Knight is now a BodyKind::Mover (hidden, so the mesh stays his appearance).
tick sets a HEADING; desired_velocity feeds entity.vel; his rendered position
is read back AFTER the sweep. Intent goes in, physics decides where he ends up.
Two things that fell out: his half-extents would have been 1.4 m wide and 3.6 m
tall, because spawn takes FULL size; and his patrol line ran straight through
the bench row, which — now that he genuinely collides — would jam him against
the first bench forever, so he walks the pavement between road edge and
furniture. Walking AROUND obstacles is NPC behaviour, not layout.

prop_collision.rs loads the real bench GLB, reproduces compose_village's
scaling, and walks a Knight-sized mover into it. spawn() now routes through
push_entity rather than entities.push, so the sorted-id invariant is asserted
rather than assumed.

VARIETY WIRED: 5 house designs instead of one model five times, 4 distinct
pines, a real lamp post instead of a CACTUS, two real benches instead of a
coaster-train carriage and a park entrance. Two genuine bugs in the variety
layer, both making find()'s correct answer worse:
- dominant_pack SUMMED 60 hits, so mass beat quality: nature-kit's incidental
  "tall" matches out-summed racing-kit's three lightPosts, and a lamp query
  returned a cactus. Only hits within 25% of the top score count now
- Spread::Mixed wanders on multi-word queries — "park bench wooden" let bench,
  coaster-train-wooden and park-entrance each pass on one word

RANKING: whole_query_bonus tested only the ENTIRE query, so "fence" scored the
real fence 28 while "wooden fence" scored it 8 — tied with everything and
decided alphabetically, which is how asking for a fence returned arena/wall.

STATIC SHADOWS 15 -> 69 CASTERS: base_y was the MAXIMUM static top, and the
per-prop colliders are static entities, so the receiver plane sat at roof
height and every prop projected onto a plane above itself. Each prop now uses
its own lowest point — also correct on a slope.

Fog 0.004 -> 0.0015 (24% -> ~10% wash at the treeline), set on the demo rather
than SkyConfig::default() which gamemaker also reads. Fence spacing derived
from the panel's own scaled width. Crate stack is a 3-2-1 pyramid, not a
six-high chimney. Aspect guard so a short wide model can't explode sideways
into a coloured slab — any library picked by description eventually returns
something oddly proportioned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:23:53 +02:00

155 lines
5.1 KiB
Rust

//! A walker must be stopped by a stock prop's real collider.
//!
//! The existing collision tests pass a house wall and a tree trunk, both of
//! which are large parts of large models. A park bench is small, thin and
//! close to the ground, and the user reported walking straight through one
//! three times — so the only test worth having here uses the ACTUAL bench
//! model, scaled and placed the way the demo places it, rather than a
//! synthetic box that would have passed all along.
//!
//! Skips (rather than fails) when the asset packs are absent, so a fresh
//! checkout without `apps/arcade/download_assets.sh` still runs green.
use makepad_game_render::model::StaticModel;
use makepad_game_sim::*;
use makepad_draw::*;
const PACKS: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../apps/arcade/resources/models/kenney"
);
fn load(rel: &str) -> Option<StaticModel> {
let bytes = std::fs::read(std::path::Path::new(PACKS).join(rel)).ok()?;
StaticModel::parse_glb(&bytes).ok()
}
fn ent(id: u64, kind: BodyKind, pos: Vec3f, half: Vec3f) -> Entity {
Entity {
id,
kind,
shape: Shape::Box,
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()
}
}
/// Reproduces `ArcadeView::compose_village`'s placement maths: scale the model
/// to a target height, then turn each collider part into a world-space box.
fn prop_collider_boxes(m: &StaticModel, target_h: f32, at: Vec3f) -> Vec<(Vec3f, Vec3f)> {
let native_h = (m.max.y - m.min.y).max(0.001);
let s = target_h / native_h;
m.collider_parts()
.iter()
.map(|(a, b)| {
let c = vec3f(
(a.x + b.x) * 0.5 * s,
(a.y + b.y) * 0.5 * s,
(a.z + b.z) * 0.5 * s,
);
let e = vec3f(
(b.x - a.x) * 0.5 * s,
(b.y - a.y) * 0.5 * s,
(b.z - a.z) * 0.5 * s,
);
(
vec3f(at.x + c.x, c.y.max(e.y), at.z + c.z),
vec3f(e.x.max(0.08), e.y.max(0.08), e.z.max(0.08)),
)
})
.collect()
}
/// The bench must produce a collider that is actually in the walker's way:
/// tall enough to intersect a person and wide enough to be hit. A bench that
/// collapses to the 0.08 minimum in any axis is scenery, not furniture.
#[test]
fn a_bench_produces_a_collider_a_person_can_hit() {
let Some(m) = load("graveyard-kit/bench.glb").or_else(|| load("coaster-kit/bench.glb")) else {
eprintln!("skip: no bench model — run apps/arcade/download_assets.sh");
return;
};
let boxes = prop_collider_boxes(&m, 0.9, vec3f(0.0, 0.0, 0.0));
assert!(!boxes.is_empty(), "bench produced no collider at all");
let (pos, half) = boxes[0];
// Reaches from the ground up to roughly seat height...
assert!(
pos.y - half.y < 0.15,
"bench collider floats: base at {}",
pos.y - half.y
);
assert!(
pos.y + half.y > 0.55,
"bench collider too short to stop anyone: top at {}",
pos.y + half.y
);
// ...and is a real obstacle in plan, not a sliver.
assert!(
half.x.min(half.z) > 0.1,
"bench collider is a sliver: half = {half:?}"
);
}
/// The user's actual complaint: walking the pavement into a bench and passing
/// through it. Walks a person-sized mover along the demo's patrol line into a
/// bench placed on that line, and requires that he stops short of it.
#[test]
fn a_walker_is_stopped_by_a_real_bench() {
let Some(m) = load("graveyard-kit/bench.glb").or_else(|| load("coaster-kit/bench.glb")) else {
eprintln!("skip: no bench model — run apps/arcade/download_assets.sh");
return;
};
let mut w = GameWorld::new();
w.reset_content();
w.push_entity(ent(
1,
BodyKind::Static,
vec3f(0.0, -0.5, 0.0),
vec3f(30.0, 0.5, 30.0),
));
// A bench on the walking line, five units along.
let bench_x = 5.0;
let mut next_id = 2;
for (pos, half) in prop_collider_boxes(&m, 0.9, vec3f(bench_x, 0.0, 0.0)) {
w.push_entity(ent(next_id, BodyKind::Static, pos, half));
next_id += 1;
}
let bench_near_face = w
.entities
.iter()
.filter(|e| e.id >= 2)
.map(|e| e.pos.x - e.half.x)
.fold(f32::MAX, f32::min);
// Person-sized mover, same half-extents the demo gives the Knight.
let walker_half = vec3f(0.7, 1.8, 0.7);
w.push_entity(ent(
next_id,
BodyKind::Mover,
vec3f(0.0, walker_half.y, 0.0),
walker_half,
));
// Walk into it for two seconds: unobstructed he would reach x = 6.4.
for _ in 0..120 {
if let Some(e) = w.entity_mut(next_id) {
e.vel.x = 3.2;
}
step_world(&mut w);
}
let x = w.entity(next_id).unwrap().pos.x;
assert!(
x < bench_near_face - walker_half.x + 0.05,
"walked through the bench: stopped at {x}, bench face at {bench_near_face}"
);
}