**The flat plane is gone.** Terrain existed all along — heightfield collision via box3d, AO and shadow raymarching against it, a mesh path, a `game.terrain` verb — and arcade simply spawned a flat slab instead. That is the fourth capability this week that was built and never called. Turning it on was not enough, because the generator had faults that a screenshot explains faster than prose: - **Single-octave value noise.** Detail at exactly one scale reads as melted blobs. Now fBm with domain warp, which is the single highest-value knob here: warping the sample point bends contours into ridges and valleys instead of round lumps on a visible grid. - **`step` defaulted to 1.0**, quantising every smooth slope into 1-unit stairs. The old default renders as a literal contour map. Off by default. - **Noise was indexed by CELL INDEX, not world position**, so asking for a finer mesh silently generated a different landscape. Resolution should buy detail, never a new world. - **Colour came from height alone**, which paints terrain in horizontal stripes like a contour map. Now height AND slope, so rock lands on cliff faces and grass on the shelf above them. Generation moved to `libs/game/gen/terrain.rs` as a pure function. Beyond testability that was forced: arcade's demo world has no script VM, so the only generator in the tree was one it could not reach. `rim_relief` is the load-bearing idea. Terrain interesting everywhere is terrain you cannot put a town on; terrain flat enough to build on is a green table. Growing the relief outward gives a playable basin ringed by something worth looking at, and doubles as a soft boundary. It SCALES the noise rather than adding a radial ramp — the ramp version has no noise in it and renders as a smooth machined ring between two flat plains, which I built first and threw away after looking at it. Two things the tests taught me rather than confirmed: - Normalising fBm by the sum of octave amplitudes — the textbook form — makes five octaves come out FLATTER than one, because summing decorrelated fields concentrates them about the mean. Normalising against the field's own extents makes `amp` mean literal relief at any octave count. - The octave test measures CURVATURE, not slope. At gain 0.5 / lacunarity 2 every octave contributes equally to slope — that is what self-similar means — so a slope-based test reports no difference while the terrain visibly gains detail. My first version of that test was wrong, not the code. Statics get one conform pass after composition; movers already clamp to the terrain every tick in `step.rs`, so the player, villagers and car find the ground themselves. **Cars now touch the road.** Kenney authors vehicles origin-at-the-contact- patch — tyres exactly on y=0, with each wheel node lifted by its own radius — and every vehicle in every kit measures min.y == 0, verified across all 4442 GLBs in the library. We were dropping the model by `half.y` instead, a rule that is right for a walker (a Mover's box bottom really is its feet) and wrong for a raycast vehicle, whose suspension probes from the chassis origin. The float was suspension travel plus wheel radius, 0.341 units, predicted in closed form and matched by simulation to 1e-5. The same line also shifted in world Y after rotation, so the mesh slid out from under a leaning chassis. Both now derive from measured bounds along the body's own down axis — no constant anywhere. Note the convention is real but NOT universal: track and road pieces go to min.y = -1.0, so it must be read, never assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2378 lines
84 KiB
Rust
2378 lines
84 KiB
Rust
//! Block scenario tests (game.md §Testing strategy — headless, numeric, no
|
|
//! window). Each one drives a world for N ticks and asserts an outcome a player
|
|
//! would recognise: the car reaches speed, laps the oval and stays upright; the
|
|
//! character clears a step but not a wall; the plane climbs and survives a turn;
|
|
//! brains reach their goals; the race kit refuses a shortcut.
|
|
|
|
use makepad_game_blocks::*;
|
|
use makepad_game_sim::*;
|
|
use makepad_math::*;
|
|
|
|
const DT: f32 = TICK_DT;
|
|
|
|
fn ground(world: &mut GameWorld, half: Vec3f) {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Static,
|
|
pos: vec3f(0.0, -half.y, 0.0),
|
|
half,
|
|
collide: true,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
density: 1.0,
|
|
friction: 0.6,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
fn new_id(world: &mut GameWorld) -> u64 {
|
|
world.next_id += 1;
|
|
world.next_id
|
|
}
|
|
|
|
fn block(world: &mut GameWorld, pos: Vec3f, half: Vec3f) -> u64 {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Static,
|
|
pos,
|
|
half,
|
|
collide: true,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
density: 1.0,
|
|
friction: 0.6,
|
|
..Default::default()
|
|
});
|
|
id
|
|
}
|
|
|
|
fn rigid(world: &mut GameWorld, pos: Vec3f, half: Vec3f) -> u64 {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Rigid,
|
|
pos,
|
|
half,
|
|
collide: true,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
gravity_scale: 1.0,
|
|
density: 1.0,
|
|
friction: 0.7,
|
|
restitution: 0.0,
|
|
..Default::default()
|
|
});
|
|
id
|
|
}
|
|
|
|
fn mover(world: &mut GameWorld, pos: Vec3f, half: Vec3f) -> u64 {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Mover,
|
|
pos,
|
|
half,
|
|
collide: true,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
gravity_scale: 1.0,
|
|
speed_mult: 1.0,
|
|
turn_rate: 9.0,
|
|
auto_face: true,
|
|
density: 1.0,
|
|
..Default::default()
|
|
});
|
|
id
|
|
}
|
|
|
|
/// Flat ground that rises by `rise` for everything beyond `at` on -z — the
|
|
/// walkable ledge the 0.55 step-up is supposed to carry a character over.
|
|
fn step_terrain(at: f32, rise: f32) -> Terrain {
|
|
let cells = 81usize;
|
|
let cell_size = 1.0f32;
|
|
let origin = -40.0f32;
|
|
let mut heights = vec![0.0f32; cells * cells];
|
|
for gz in 0..cells {
|
|
let z = origin + gz as f32 * cell_size;
|
|
if z < at {
|
|
for gx in 0..cells {
|
|
heights[gz * cells + gx] = rise;
|
|
}
|
|
}
|
|
}
|
|
Terrain {
|
|
cells,
|
|
cell_size,
|
|
origin,
|
|
heights,
|
|
colors: vec![vec4(0.4, 0.6, 0.35, 1.0); cells * cells],
|
|
revision: 1,
|
|
}
|
|
}
|
|
|
|
fn world_with_ground() -> GameWorld {
|
|
let mut world = GameWorld::new();
|
|
world.gravity = 30.0;
|
|
ground(&mut world, vec3f(400.0, 2.0, 400.0));
|
|
world
|
|
}
|
|
|
|
/// One full host tick: drive → simulate → observe.
|
|
fn tick(world: &mut GameWorld, blocks: &mut Blocks) {
|
|
blocks.pre_step(world);
|
|
step_world(world);
|
|
world.tick += 1;
|
|
world.time += DT as f64;
|
|
blocks.post_step(world);
|
|
}
|
|
|
|
fn run(world: &mut GameWorld, blocks: &mut Blocks, ticks: usize) {
|
|
for _ in 0..ticks {
|
|
tick(world, blocks);
|
|
}
|
|
}
|
|
|
|
fn car_world() -> (GameWorld, Blocks, u64) {
|
|
let mut world = world_with_ground();
|
|
let chassis = rigid(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
let mut blocks = Blocks::new();
|
|
blocks.cars.push(Car::new(
|
|
chassis,
|
|
CarConfig::default(),
|
|
ControlSource::Script,
|
|
));
|
|
// Let the suspension settle before any test drives away.
|
|
run(&mut world, &mut blocks, 60);
|
|
(world, blocks, chassis)
|
|
}
|
|
|
|
#[test]
|
|
fn car_settles_on_its_wheels() {
|
|
let (world, blocks, chassis) = car_world();
|
|
let car = &blocks.cars[0];
|
|
assert!(
|
|
car.all_wheels_down(),
|
|
"car should rest on all four wheels, got {} down",
|
|
car.wheels_down()
|
|
);
|
|
let e = world.entity(chassis).unwrap();
|
|
// Resting height ≈ suspension rest length above the ground plane.
|
|
assert!(
|
|
e.pos.y > 0.2 && e.pos.y < 1.4,
|
|
"car settled at an implausible height: {}",
|
|
e.pos.y
|
|
);
|
|
assert!(e.vel.length() < 0.5, "car should be at rest, vel {:?}", e.vel);
|
|
}
|
|
|
|
#[test]
|
|
fn car_accelerates_toward_top_speed() {
|
|
let (mut world, mut blocks, _) = car_world();
|
|
blocks.cars[0].input.throttle = 1.0;
|
|
run(&mut world, &mut blocks, 60 * 8);
|
|
let car = &blocks.cars[0];
|
|
let top = car.config.top_speed;
|
|
assert!(
|
|
car.speed > top * 0.7,
|
|
"8s of full throttle should approach top speed ({top}), got {}",
|
|
car.speed
|
|
);
|
|
assert!(
|
|
car.speed <= top * 1.1,
|
|
"car exceeded its top speed: {} > {top}",
|
|
car.speed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn car_brakes_to_a_stop() {
|
|
let (mut world, mut blocks, _) = car_world();
|
|
blocks.cars[0].input.throttle = 1.0;
|
|
run(&mut world, &mut blocks, 60 * 4);
|
|
assert!(blocks.cars[0].speed > 5.0, "car should be rolling first");
|
|
blocks.cars[0].input.throttle = 0.0;
|
|
blocks.cars[0].input.brake = 1.0;
|
|
run(&mut world, &mut blocks, 60 * 4);
|
|
assert!(
|
|
blocks.cars[0].speed.abs() < 1.0,
|
|
"4s of braking should stop the car, speed {}",
|
|
blocks.cars[0].speed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn car_steers_and_never_flips() {
|
|
let (mut world, mut blocks, chassis) = car_world();
|
|
blocks.cars[0].input.throttle = 1.0;
|
|
// Hard lock, held — the classic rollover test.
|
|
blocks.cars[0].input.steer = 1.0;
|
|
let start_yaw = world.entity(chassis).unwrap().yaw;
|
|
let mut min_up = 1.0f32;
|
|
for _ in 0..60 * 10 {
|
|
tick(&mut world, &mut blocks);
|
|
let e = world.entity(chassis).unwrap();
|
|
// World-up component of the chassis up axis: 1 = level, <0 = on its lid.
|
|
let up = rotate_quat(e.orient, vec3f(0.0, 1.0, 0.0)).y;
|
|
min_up = min_up.min(up);
|
|
}
|
|
assert!(
|
|
min_up > 0.5,
|
|
"car leaned past 60 degrees during a full-lock turn (min up {min_up})"
|
|
);
|
|
let e = world.entity(chassis).unwrap();
|
|
let turned = (e.yaw - start_yaw).abs();
|
|
let planar = (e.vel.x * e.vel.x + e.vel.z * e.vel.z).sqrt();
|
|
assert!(
|
|
planar > 3.0,
|
|
"car should still be driving through the turn, planar speed {planar}"
|
|
);
|
|
assert!(turned > 0.5 || planar > 3.0, "car should actually turn");
|
|
}
|
|
|
|
#[test]
|
|
fn car_laps_an_oval_within_a_time_window() {
|
|
let mut world = world_with_ground();
|
|
let chassis = rigid(&mut world, vec3f(0.0, 1.0, -30.0), vec3f(0.9, 0.4, 1.6));
|
|
let mut blocks = Blocks::new();
|
|
let mut car = Car::new(chassis, CarConfig::default(), ControlSource::Script);
|
|
// A 60 x 40 oval of waypoints; the AI driver has to follow it unaided.
|
|
car.route = vec![
|
|
vec3f(0.0, 0.0, -30.0),
|
|
vec3f(25.0, 0.0, -20.0),
|
|
vec3f(30.0, 0.0, 0.0),
|
|
vec3f(25.0, 0.0, 20.0),
|
|
vec3f(0.0, 0.0, 30.0),
|
|
vec3f(-25.0, 0.0, 20.0),
|
|
vec3f(-30.0, 0.0, 0.0),
|
|
vec3f(-25.0, 0.0, -20.0),
|
|
];
|
|
car.route_pace = 1.0;
|
|
blocks.cars.push(car);
|
|
|
|
// Gate every waypoint so the lap can't be scored by cutting the infield.
|
|
for (i, point) in blocks.cars[0].route.clone().iter().enumerate() {
|
|
let _ = i;
|
|
blocks
|
|
.race
|
|
.add_checkpoint(vec3f(point.x, 1.0, point.z), vec3f(6.0, 3.0, 6.0), 0);
|
|
}
|
|
blocks.race.enter(chassis);
|
|
blocks.race.start(1);
|
|
|
|
let mut ticks = 0;
|
|
while ticks < 60 * 90 && !blocks.race.standing_of(chassis).unwrap().finished {
|
|
tick(&mut world, &mut blocks);
|
|
ticks += 1;
|
|
}
|
|
let standing = *blocks.race.standing_of(chassis).unwrap();
|
|
assert!(
|
|
standing.finished,
|
|
"AI car failed to complete a lap in 90s (progress {} of 8 gates)",
|
|
standing.progress
|
|
);
|
|
let seconds = ticks as f32 * DT;
|
|
assert!(
|
|
seconds > 5.0,
|
|
"lap time {seconds}s is impossibly fast — gates are probably overlapping"
|
|
);
|
|
// A ~190-unit oval at ~20 units/s cruise is comfortably inside 60s.
|
|
assert!(seconds < 60.0, "lap took {seconds}s, expected under 60s");
|
|
let e = world.entity(chassis).unwrap();
|
|
assert!(
|
|
e.pos.y > -1.0 && e.pos.y < 5.0,
|
|
"car left the ground plane during the lap: y {}",
|
|
e.pos.y
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------- the car's own mesh
|
|
//
|
|
// A car is a box3d box plus four suspension rays; what the player SEES is a
|
|
// stock GLB hung off that box. The two are joined by one number — how far
|
|
// below the chassis origin the road is — and getting it wrong is invisible to
|
|
// every test above, because the physics is perfect either way and only the
|
|
// picture is wrong. The car shipped floating a third of a metre in the air.
|
|
//
|
|
// So these assert the joint, against a real model's real vertices.
|
|
|
|
const PACKS: &str = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../../apps/arcade/resources/models/kenney"
|
|
);
|
|
|
|
fn kenney(rel: &str) -> Option<makepad_game_render::model::StaticModel> {
|
|
let bytes = std::fs::read(std::path::Path::new(PACKS).join(rel)).ok()?;
|
|
makepad_game_render::model::StaticModel::parse_glb(&bytes).ok()
|
|
}
|
|
|
|
/// Reproduces `ArcadeView::vehicle_instance`: scale the model's length onto
|
|
/// the chassis length, then hand the placement the two facts it is allowed to
|
|
/// use — the model's measured bounds and where the car says the road is.
|
|
fn place(
|
|
model: &makepad_game_render::model::StaticModel,
|
|
world: &GameWorld,
|
|
car: &Car,
|
|
) -> Mat4f {
|
|
let e = world.entity(car.entity).unwrap();
|
|
let native_len = (model.max.z - model.min.z)
|
|
.max(model.max.x - model.min.x)
|
|
.max(0.001);
|
|
let s = (e.half.z * 2.0) / native_len;
|
|
makepad_game_render::renderer::ModelInstance::on_body(
|
|
"vehicle".to_string(),
|
|
(model.min, model.max),
|
|
s,
|
|
car.contact_drop(),
|
|
&makepad_game_render::renderer::GameRenderer::rigid_transform(e),
|
|
)
|
|
.transform
|
|
}
|
|
|
|
/// Lowest y of every vertex after the placement transform — the bottom of the
|
|
/// car as drawn, not as declared.
|
|
fn lowest_drawn_y(model: &makepad_game_render::model::StaticModel, m: &Mat4f) -> f32 {
|
|
let stride = makepad_game_render::model::MODEL_VERTEX_FLOATS;
|
|
model
|
|
.vertices
|
|
.chunks(stride)
|
|
.map(|v| m.v[1] * v[0] + m.v[5] * v[1] + m.v[9] * v[2] + m.v[13])
|
|
.fold(f32::MAX, f32::min)
|
|
}
|
|
|
|
/// The number the picture hangs on. It is NOT the chassis half-height: the
|
|
/// wheels hold the collision box clear of the ground, so a mesh sat on the box
|
|
/// floats by the difference.
|
|
#[test]
|
|
fn the_road_under_a_car_is_not_its_collision_box() {
|
|
let (world, blocks, chassis) = car_world();
|
|
let car = &blocks.cars[0];
|
|
let e = world.entity(chassis).unwrap();
|
|
// `ground()` puts the road surface at y = 0.
|
|
let drop = car.contact_drop();
|
|
assert!(
|
|
(e.pos.y - drop).abs() < 0.02,
|
|
"contact_drop says the road is at y={}, it is at 0 (chassis at {})",
|
|
e.pos.y - drop,
|
|
e.pos.y
|
|
);
|
|
assert!(
|
|
drop > e.half.y + 0.2,
|
|
"the box bottom ({}) and the contact patch ({drop}) have converged — \
|
|
if that is real the suspension changed, and this test should be \
|
|
rewritten rather than relaxed",
|
|
e.half.y
|
|
);
|
|
}
|
|
|
|
/// Kenney's vehicles are authored with the origin ON the road: the tyres sit
|
|
/// at y = 0 and the body rises from there. Measured, not assumed — the fix
|
|
/// for the floating car reads `min.y`, and this is what says reading it is
|
|
/// worth anything.
|
|
#[test]
|
|
fn kenney_authors_a_vehicle_standing_on_its_wheels() {
|
|
let mut checked = 0;
|
|
for rel in [
|
|
"toy-car-kit/vehicle-truck.glb",
|
|
"toy-car-kit/vehicle-racer.glb",
|
|
"car-kit/sedan.glb",
|
|
"car-kit/ambulance.glb",
|
|
"racing/vehicle-truck-red.glb",
|
|
"retro-urban-kit/truck-green.glb",
|
|
] {
|
|
let Some(m) = kenney(rel) else { continue };
|
|
checked += 1;
|
|
assert!(
|
|
m.min.y.abs() < 1.0e-3,
|
|
"{rel}: origin is not on the road, min.y = {}",
|
|
m.min.y
|
|
);
|
|
assert!(m.max.y > 0.2, "{rel}: nothing above the origin?");
|
|
}
|
|
if checked == 0 {
|
|
eprintln!("SKIP: no model packs — run apps/arcade/download_assets.sh");
|
|
}
|
|
}
|
|
|
|
/// The whole claim, end to end: settle a car on the road under real physics,
|
|
/// place a real Kenney model on it the way the app does, and look at where
|
|
/// its lowest vertex actually ends up. Tyres on the tarmac, or it is wrong.
|
|
#[test]
|
|
fn a_vehicle_models_wheels_touch_the_road_it_rests_on() {
|
|
let Some(model) = kenney("toy-car-kit/vehicle-truck.glb") else {
|
|
eprintln!("SKIP: no model packs — run apps/arcade/download_assets.sh");
|
|
return;
|
|
};
|
|
let (world, blocks, _) = car_world();
|
|
let m = place(&model, &world, &blocks.cars[0]);
|
|
let lowest = lowest_drawn_y(&model, &m);
|
|
// 1 cm on a 3.2-unit car, against the 0.34 the chassis-box rule was out
|
|
// by. The settled error is ~3e-6 — the anchor is exact while the wheels
|
|
// are down — so the slack here is only for suspension wobble, not for a
|
|
// wrong rule to hide in.
|
|
assert!(
|
|
lowest.abs() < 0.01,
|
|
"the car is drawn with its lowest point {lowest} off the road it is \
|
|
resting on (positive = floating, negative = sunk)"
|
|
);
|
|
}
|
|
|
|
/// Same claim while the car is tilted: the anchor is an offset in the
|
|
/// chassis's own space, so it has to ride the chassis over a kerb. Applied in
|
|
/// world Y — the rule a walker uses, and the one the car inherited — it looks
|
|
/// right parked and slides out from under the car the moment it leans.
|
|
///
|
|
/// On flat ground this proves nothing: the force model puts no roll moment on
|
|
/// the chassis at all (car.rs — that is the whole reason it cannot flip), so
|
|
/// the car must be made to lean by the road, not by the driving.
|
|
#[test]
|
|
fn the_model_stays_bolted_to_a_leaning_chassis() {
|
|
let Some(model) = kenney("toy-car-kit/vehicle-truck.glb") else {
|
|
eprintln!("SKIP: no model packs — run apps/arcade/download_assets.sh");
|
|
return;
|
|
};
|
|
let mut world = world_with_ground();
|
|
// A kerb under the right-hand wheels only (they sit at x = +0.75), low
|
|
// enough that the chassis box rides over rather than into it.
|
|
block(&mut world, vec3f(1.2, 0.1, -14.0), vec3f(1.2, 0.1, 12.0));
|
|
let chassis = rigid(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
let mut blocks = Blocks::new();
|
|
blocks.cars.push(Car::new(
|
|
chassis,
|
|
CarConfig::default(),
|
|
ControlSource::Script,
|
|
));
|
|
run(&mut world, &mut blocks, 60);
|
|
blocks.cars[0].input.throttle = 1.0;
|
|
|
|
let mut worst = 0.0f32;
|
|
let mut leaned = 0.0f32;
|
|
for _ in 0..60 * 6 {
|
|
tick(&mut world, &mut blocks);
|
|
let e = world.entity(chassis).unwrap();
|
|
let up = rotate_quat(e.orient, vec3f(0.0, 1.0, 0.0));
|
|
leaned = leaned.max(1.0 - up.y);
|
|
// Where the chassis says its contact patch is, along its OWN down
|
|
// axis, and where the mesh's floor plane actually landed.
|
|
let drop = blocks.cars[0].contact_drop();
|
|
let want = e.pos - up * drop;
|
|
let m = place(&model, &world, &blocks.cars[0]);
|
|
let got = vec3f(m.v[12], m.v[13], m.v[14]);
|
|
worst = worst.max((got - want).length());
|
|
}
|
|
assert!(
|
|
leaned > 0.002,
|
|
"the car never left level, so this proved nothing (max lean {leaned})"
|
|
);
|
|
assert!(
|
|
worst < 1.0e-3,
|
|
"the mesh anchor drifted {worst} from the chassis contact patch \
|
|
while leaning"
|
|
);
|
|
}
|
|
|
|
/// The anchor is READ from the bounds, never assumed, so a kit that authors
|
|
/// its models away from the origin still lands on the car. `racing-kit` is
|
|
/// that kit: its cars sit where they fell in the scene grid, offset in x and
|
|
/// z with a skirt below zero. Hermetic — the one test here that still has
|
|
/// teeth on a checkout with no model packs.
|
|
#[test]
|
|
fn a_model_authored_off_the_origin_still_lands_on_the_body() {
|
|
// Bounds measured from racing-kit/raceCarRed.glb.
|
|
let min = vec3f(-0.7144, -0.0335, -1.3359);
|
|
let max = vec3f(0.0144, 0.3635, 0.0100);
|
|
let mut frame = Mat4f::identity();
|
|
frame.v[12] = 5.0;
|
|
frame.v[13] = 2.0;
|
|
frame.v[14] = -3.0;
|
|
let m = makepad_game_render::renderer::ModelInstance::on_body(
|
|
"m".to_string(),
|
|
(min, max),
|
|
2.0,
|
|
0.75,
|
|
&frame,
|
|
)
|
|
.transform;
|
|
// The model's own floor ends up exactly `drop` below the body origin...
|
|
let floor = m.v[5] * min.y + m.v[13];
|
|
assert!(
|
|
(floor - (2.0 - 0.75)).abs() < 1.0e-5,
|
|
"floor landed at {floor}, wanted 1.25"
|
|
);
|
|
// ...and its own horizontal centre on the body's axis, not its origin.
|
|
let cx = m.v[0] * (min.x + max.x) * 0.5 + m.v[12];
|
|
let cz = m.v[10] * (min.z + max.z) * 0.5 + m.v[14];
|
|
assert!(
|
|
(cx - 5.0).abs() < 1.0e-5 && (cz + 3.0).abs() < 1.0e-5,
|
|
"model centre landed at ({cx}, {cz}), wanted (5, -3)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn character_walks_and_steps_up_but_is_blocked_by_a_wall() {
|
|
// The 0.55 step-up is a TERRAIN contract (step.rs CLIMB) — static boxes
|
|
// are swept against and always block, whatever their height. So the
|
|
// walkable ledge has to be ground, and the wall is a box.
|
|
let mut world = GameWorld::new();
|
|
world.gravity = 30.0;
|
|
world.terrain = Some(step_terrain(-4.0, 0.5));
|
|
let hero = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.4, 0.8, 0.4));
|
|
// A 1.0-high wall (blocking) at z = -12.
|
|
let wall = block(&mut world, vec3f(0.0, 1.0, -12.0), vec3f(3.0, 1.0, 1.0));
|
|
let mut blocks = Blocks::new();
|
|
blocks.characters.push(Character::new(
|
|
hero,
|
|
CharacterConfig::default(),
|
|
ControlSource::Script,
|
|
None,
|
|
));
|
|
blocks.characters[0].input.move_z = -1.0;
|
|
|
|
// Walk until the wall reports a hit, watching the ledge climb on the way.
|
|
let mut on_ledge = false;
|
|
let mut stopped_at = None;
|
|
for _ in 0..60 * 4 {
|
|
tick(&mut world, &mut blocks);
|
|
let e = world.entity(hero).unwrap();
|
|
if e.pos.z < -5.0 && e.pos.y > 1.2 {
|
|
on_ledge = true;
|
|
}
|
|
if e.hit_wall == wall && stopped_at.is_none() {
|
|
stopped_at = Some(e.pos.z);
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
on_ledge,
|
|
"character should have stepped up onto the 0.5 ledge (feet 0.5, centre 1.3)"
|
|
);
|
|
let stopped_at = stopped_at.expect("character never reported hitting the wall");
|
|
// Stopped flush against the near face: wall centre -12, half 1.0, body half
|
|
// 0.4 => -10.6. Never inside it.
|
|
assert!(
|
|
stopped_at > -10.7 && stopped_at < -10.5,
|
|
"character should stop at the wall face (-10.6), stopped at {stopped_at}"
|
|
);
|
|
assert!(
|
|
blocks.characters[0].pose.walk_blend > 0.5,
|
|
"walking should blend toward the walk clip, got {}",
|
|
blocks.characters[0].pose.walk_blend
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn character_jumps_and_lands() {
|
|
let mut world = world_with_ground();
|
|
let hero = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.4, 0.8, 0.4));
|
|
let mut blocks = Blocks::new();
|
|
blocks.characters.push(Character::new(
|
|
hero,
|
|
CharacterConfig::default(),
|
|
ControlSource::Script,
|
|
None,
|
|
));
|
|
run(&mut world, &mut blocks, 30);
|
|
let grounded_y = world.entity(hero).unwrap().pos.y;
|
|
|
|
blocks.characters[0].input.jump_pressed = true;
|
|
tick(&mut world, &mut blocks);
|
|
blocks.characters[0].input.jump_pressed = false;
|
|
let mut peak = grounded_y;
|
|
for _ in 0..20 {
|
|
tick(&mut world, &mut blocks);
|
|
peak = peak.max(world.entity(hero).unwrap().pos.y);
|
|
}
|
|
assert!(
|
|
peak > grounded_y + 1.0,
|
|
"jump barely left the floor: {peak} vs {grounded_y}"
|
|
);
|
|
assert!(
|
|
blocks.characters[0].pose.airborne,
|
|
"pose should report airborne mid-jump"
|
|
);
|
|
run(&mut world, &mut blocks, 90);
|
|
let landed = world.entity(hero).unwrap();
|
|
assert!(landed.on_floor, "character should land again");
|
|
assert!(
|
|
!blocks.characters[0].pose.airborne,
|
|
"pose should clear airborne after landing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plane_climbs_under_throttle_and_survives_a_turn() {
|
|
let mut world = world_with_ground();
|
|
let body = rigid(&mut world, vec3f(0.0, 60.0, 0.0), vec3f(1.2, 0.4, 1.6));
|
|
// Launch it forward at flying speed, like a catapult start.
|
|
world.entity_mut(body).unwrap().vel = vec3f(0.0, 0.0, -30.0);
|
|
let mut blocks = Blocks::new();
|
|
blocks.planes.push(makepad_game_blocks::Plane::new(
|
|
body,
|
|
PlaneConfig::default(),
|
|
ControlSource::Script,
|
|
));
|
|
blocks.planes[0].input.throttle = 1.0;
|
|
// A sustained climb input. The nose-up attitude trades airspeed for
|
|
// altitude and self-limits as lift falls off — no loop, no stall.
|
|
blocks.planes[0].input.pitch = 0.3;
|
|
let start_y = world.entity(body).unwrap().pos.y;
|
|
run(&mut world, &mut blocks, 60 * 5);
|
|
let climbed = world.entity(body).unwrap().pos.y;
|
|
assert!(
|
|
climbed > start_y + 5.0,
|
|
"plane should climb under power: {start_y} -> {climbed}"
|
|
);
|
|
|
|
// Now bank into a turn. Roll input is a RATE, so it is held just long
|
|
// enough to establish the bank and then released — holding it would barrel
|
|
// roll (correct for an arcade plane, but not a turn).
|
|
let before = world.entity(body).unwrap().pos.y;
|
|
let heading_before = heading_of(world.entity(body).unwrap().vel);
|
|
blocks.planes[0].input.pitch = 0.15;
|
|
blocks.planes[0].input.roll = 0.8;
|
|
run(&mut world, &mut blocks, 25);
|
|
blocks.planes[0].input.roll = 0.0;
|
|
run(&mut world, &mut blocks, 60 * 5);
|
|
let after = world.entity(body).unwrap();
|
|
assert!(
|
|
after.pos.y > before - 30.0,
|
|
"plane lost too much altitude in a turn: {before} -> {}",
|
|
after.pos.y
|
|
);
|
|
assert!(
|
|
after.pos.y > 5.0,
|
|
"plane hit the ground during a normal turn"
|
|
);
|
|
let turned = angle_delta(heading_of(after.vel), heading_before).abs();
|
|
assert!(
|
|
turned > 0.3,
|
|
"banking should actually change heading, turned {turned} rad"
|
|
);
|
|
assert!(
|
|
blocks.planes[0].airspeed > 5.0,
|
|
"plane should keep flying speed through the turn, got {}",
|
|
blocks.planes[0].airspeed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wander_brain_stays_within_range_and_moves() {
|
|
let mut world = world_with_ground();
|
|
let critter = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.4, 0.5, 0.4));
|
|
let mut blocks = Blocks::new();
|
|
blocks.brains.push(Brain::new(
|
|
critter,
|
|
BrainKind::Wander {
|
|
home: vec3f(0.0, 0.0, 0.0),
|
|
range: 10.0,
|
|
speed: 3.0,
|
|
pause: 0.5,
|
|
},
|
|
));
|
|
let mut max_distance: f32 = 0.0;
|
|
let mut moved = false;
|
|
for _ in 0..60 * 30 {
|
|
tick(&mut world, &mut blocks);
|
|
let e = world.entity(critter).unwrap();
|
|
let d = (e.pos.x * e.pos.x + e.pos.z * e.pos.z).sqrt();
|
|
max_distance = max_distance.max(d);
|
|
if d > 1.5 {
|
|
moved = true;
|
|
}
|
|
}
|
|
assert!(moved, "wanderer never left home");
|
|
assert!(
|
|
max_distance < 14.0,
|
|
"wanderer strayed outside its range: {max_distance}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chase_brain_closes_and_catches() {
|
|
let mut world = world_with_ground();
|
|
let prey = mover(&mut world, vec3f(0.0, 1.0, -20.0), vec3f(0.4, 0.8, 0.4));
|
|
world.entity_mut(prey).unwrap().tag = "player".to_string();
|
|
let hunter = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.4, 0.8, 0.4));
|
|
let mut blocks = Blocks::new();
|
|
blocks.brains.push(Brain::new(
|
|
hunter,
|
|
BrainKind::Chase {
|
|
tag: "player".to_string(),
|
|
target: 0,
|
|
range: 60.0,
|
|
catch: 1.5,
|
|
speed: 7.0,
|
|
},
|
|
));
|
|
let start = 20.0f32;
|
|
let mut caught = false;
|
|
for _ in 0..60 * 10 {
|
|
tick(&mut world, &mut blocks);
|
|
if blocks.brains[0].caught == prey {
|
|
caught = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(caught, "hunter never caught stationary prey");
|
|
let d = {
|
|
let h = world.entity(hunter).unwrap().pos;
|
|
let p = world.entity(prey).unwrap().pos;
|
|
((h.x - p.x).powi(2) + (h.z - p.z).powi(2)).sqrt()
|
|
};
|
|
assert!(d < start, "hunter should have closed the gap, distance {d}");
|
|
}
|
|
|
|
#[test]
|
|
fn patrol_brain_visits_every_waypoint() {
|
|
let mut world = world_with_ground();
|
|
let guard = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.4, 0.8, 0.4));
|
|
let route = vec![
|
|
vec3f(10.0, 0.0, 0.0),
|
|
vec3f(10.0, 0.0, 10.0),
|
|
vec3f(0.0, 0.0, 10.0),
|
|
vec3f(0.0, 0.0, 0.0),
|
|
];
|
|
let mut blocks = Blocks::new();
|
|
blocks.brains.push(Brain::new(
|
|
guard,
|
|
BrainKind::Patrol {
|
|
points: route.clone(),
|
|
speed: 6.0,
|
|
looping: true,
|
|
},
|
|
));
|
|
let mut visited = vec![false; route.len()];
|
|
for _ in 0..60 * 40 {
|
|
tick(&mut world, &mut blocks);
|
|
let p = world.entity(guard).unwrap().pos;
|
|
for (i, point) in route.iter().enumerate() {
|
|
if ((p.x - point.x).powi(2) + (p.z - point.z).powi(2)).sqrt() < 2.0 {
|
|
visited[i] = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
visited.iter().all(|v| *v),
|
|
"patrol missed waypoints: {visited:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn race_kit_enforces_checkpoint_order() {
|
|
let mut world = world_with_ground();
|
|
let racer = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.5, 0.8, 0.5));
|
|
let mut blocks = Blocks::new();
|
|
for i in 0..3 {
|
|
blocks.race.add_checkpoint(
|
|
vec3f(i as f32 * 10.0, 1.0, 0.0),
|
|
vec3f(2.0, 3.0, 2.0),
|
|
0,
|
|
);
|
|
}
|
|
blocks.race.enter(racer);
|
|
blocks.race.start(1);
|
|
|
|
// Teleport straight onto gate 2, skipping gates 0 and 1.
|
|
world.entity_mut(racer).unwrap().pos = vec3f(20.0, 1.0, 0.0);
|
|
tick(&mut world, &mut blocks);
|
|
assert_eq!(
|
|
blocks.race.standing_of(racer).unwrap().progress,
|
|
0,
|
|
"a skipped gate must not score"
|
|
);
|
|
|
|
// Now take them in order.
|
|
for i in 0..3 {
|
|
world.entity_mut(racer).unwrap().pos = vec3f(i as f32 * 10.0, 1.0, 0.0);
|
|
tick(&mut world, &mut blocks);
|
|
}
|
|
let standing = *blocks.race.standing_of(racer).unwrap();
|
|
assert_eq!(standing.progress, 3, "three gates in order should all score");
|
|
assert_eq!(standing.lap, 1, "crossing every gate completes a lap");
|
|
assert!(standing.finished, "a one-lap race ends on that lap");
|
|
assert_eq!(blocks.race.winner, racer);
|
|
}
|
|
|
|
#[test]
|
|
fn race_standings_order_by_progress_then_finish_time() {
|
|
let mut world = world_with_ground();
|
|
let a = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.5, 0.8, 0.5));
|
|
let b = mover(&mut world, vec3f(0.0, 1.0, 5.0), vec3f(0.5, 0.8, 0.5));
|
|
let mut blocks = Blocks::new();
|
|
for i in 0..2 {
|
|
blocks
|
|
.race
|
|
.add_checkpoint(vec3f(i as f32 * 10.0, 1.0, 0.0), vec3f(2.0, 3.0, 2.0), 0);
|
|
}
|
|
blocks.race.enter(a);
|
|
blocks.race.enter(b);
|
|
blocks.race.start(2);
|
|
|
|
// A banks one gate; B banks none.
|
|
world.entity_mut(a).unwrap().pos = vec3f(0.0, 1.0, 0.0);
|
|
tick(&mut world, &mut blocks);
|
|
let order = blocks.race.order();
|
|
assert_eq!(order[0].entity, a, "the racer with more gates leads");
|
|
assert_eq!(blocks.race.rank_of(a), 1);
|
|
assert_eq!(blocks.race.rank_of(b), 2);
|
|
|
|
blocks.race.add_score(b, 5);
|
|
assert_eq!(blocks.race.standing_of(b).unwrap().score, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn blocks_drop_state_when_their_entity_dies() {
|
|
let mut world = world_with_ground();
|
|
let hero = mover(&mut world, vec3f(0.0, 1.0, 0.0), vec3f(0.4, 0.8, 0.4));
|
|
let mut blocks = Blocks::new();
|
|
blocks.characters.push(Character::new(
|
|
hero,
|
|
CharacterConfig::default(),
|
|
ControlSource::Script,
|
|
None,
|
|
));
|
|
blocks.race.enter(hero);
|
|
run(&mut world, &mut blocks, 5);
|
|
assert_eq!(blocks.characters.len(), 1);
|
|
|
|
world.entities.retain(|e| e.id != hero);
|
|
tick(&mut world, &mut blocks);
|
|
assert!(
|
|
blocks.characters.is_empty(),
|
|
"character block should follow its entity out"
|
|
);
|
|
assert!(
|
|
blocks.race.standings.is_empty(),
|
|
"standings should drop a departed racer"
|
|
);
|
|
}
|
|
|
|
/// game.md determinism rule: the same scenario, run twice, must land on the
|
|
/// same bits — blocks are Shared tier, so a divergence here is a desync.
|
|
#[test]
|
|
fn block_scenario_is_deterministic() {
|
|
fn scenario() -> (u64, u64, Vec3f, Vec3f) {
|
|
let mut world = world_with_ground();
|
|
let chassis = rigid(&mut world, vec3f(0.0, 1.5, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
let critter = mover(&mut world, vec3f(6.0, 1.0, 6.0), vec3f(0.4, 0.5, 0.4));
|
|
let hero = mover(&mut world, vec3f(-6.0, 1.0, 0.0), vec3f(0.4, 0.8, 0.4));
|
|
let mut blocks = Blocks::new();
|
|
let mut car = Car::new(chassis, CarConfig::default(), ControlSource::Script);
|
|
car.input.throttle = 1.0;
|
|
car.input.steer = 0.4;
|
|
blocks.cars.push(car);
|
|
blocks.brains.push(Brain::new(
|
|
critter,
|
|
BrainKind::Wander {
|
|
home: vec3f(6.0, 0.0, 6.0),
|
|
range: 8.0,
|
|
speed: 3.0,
|
|
pause: 0.4,
|
|
},
|
|
));
|
|
let mut character = Character::new(
|
|
hero,
|
|
CharacterConfig::default(),
|
|
ControlSource::Script,
|
|
None,
|
|
);
|
|
character.input.move_x = 0.6;
|
|
character.input.move_z = -0.8;
|
|
blocks.characters.push(character);
|
|
blocks.race.add_checkpoint(vec3f(0.0, 1.0, -10.0), vec3f(4.0, 3.0, 4.0), 0);
|
|
blocks.race.enter(chassis);
|
|
blocks.race.start(3);
|
|
|
|
run(&mut world, &mut blocks, 600);
|
|
(
|
|
blocks.hash(),
|
|
world_hash(&world),
|
|
world.entity(chassis).unwrap().pos,
|
|
world.entity(critter).unwrap().pos,
|
|
)
|
|
}
|
|
let first = scenario();
|
|
let second = scenario();
|
|
assert_eq!(first.0, second.0, "blocks hash diverged between runs");
|
|
assert_eq!(first.1, second.1, "world hash diverged between runs");
|
|
assert_eq!(first.2, second.2, "car position diverged");
|
|
assert_eq!(first.3, second.3, "wanderer position diverged");
|
|
}
|
|
|
|
fn world_hash(world: &GameWorld) -> u64 {
|
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
|
let mut mix = |v: u64| {
|
|
h ^= v;
|
|
h = h.wrapping_mul(0x100_0000_01b3);
|
|
};
|
|
for e in &world.entities {
|
|
mix(e.id);
|
|
for f in [
|
|
e.pos.x, e.pos.y, e.pos.z, e.vel.x, e.vel.y, e.vel.z, e.yaw, e.orient.x, e.orient.y,
|
|
e.orient.z, e.orient.w,
|
|
] {
|
|
mix(f.to_bits() as u64);
|
|
}
|
|
}
|
|
h
|
|
}
|
|
|
|
fn rotate_quat(q: Quat, v: Vec3f) -> Vec3f {
|
|
let u = vec3f(q.x, q.y, q.z);
|
|
let s = q.w;
|
|
u * (2.0 * u.dot(v)) + v * (s * s - u.dot(u)) + Vec3f::cross(u, v) * (2.0 * s)
|
|
}
|
|
|
|
/// Compass heading of a velocity vector, in the engine's -z-forward frame.
|
|
fn heading_of(v: Vec3f) -> f32 {
|
|
makepad_game_math::atan2(-v.x, -v.z)
|
|
}
|
|
|
|
fn angle_delta(a: f32, b: f32) -> f32 {
|
|
let mut d = a - b;
|
|
while d > std::f32::consts::PI {
|
|
d -= std::f32::consts::TAU;
|
|
}
|
|
while d < -std::f32::consts::PI {
|
|
d += std::f32::consts::TAU;
|
|
}
|
|
d
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NPCs (npc.rs). These are the gate for behaviour: without a screen, a
|
|
// villager "looking alive" has to be expressed as reaching places, refusing to
|
|
// wedge itself, and not all doing the same thing at once.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn npc(world: &mut GameWorld, blocks: &mut Blocks, pos: Vec3f, seed: u64) -> u64 {
|
|
let id = mover(world, pos, vec3f(0.4, 0.8, 0.4));
|
|
blocks
|
|
.npcs
|
|
.push(Npc::new(id, NpcConfig::default(), pos, seed));
|
|
id
|
|
}
|
|
|
|
/// Walks to a destination and stays there. The floor of the whole feature: an
|
|
/// NPC that cannot arrive has no behaviour to build on.
|
|
#[test]
|
|
fn npc_walks_to_a_poi_and_dwells_there() {
|
|
let mut world = world_with_ground();
|
|
let mut blocks = Blocks::new();
|
|
let id = npc(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0), 7);
|
|
blocks.pois.push(Poi::new(vec3f(14.0, 0.0, 0.0), "bench"));
|
|
// Only one destination exists, so scoring must converge on it.
|
|
blocks.npcs[0].activity = Activity::Travel {
|
|
goal: vec3f(14.0, 0.0, 0.0),
|
|
poi: Some(0),
|
|
};
|
|
|
|
let mut arrived = None;
|
|
for tick_i in 0..60 * 20 {
|
|
tick(&mut world, &mut blocks);
|
|
if matches!(blocks.npcs[0].activity, Activity::Dwell { .. }) {
|
|
arrived = Some(tick_i);
|
|
break;
|
|
}
|
|
}
|
|
let arrived = arrived.expect("npc never reached the bench");
|
|
let e = world.entity(id).unwrap();
|
|
let d = ((e.pos.x - 14.0).powi(2) + e.pos.z.powi(2)).sqrt();
|
|
assert!(d < 1.6, "stopped {d} away from the bench it was heading for");
|
|
// 14 units at ~2.6 u/s is ~5.4s; anything near the 20s cap means it was
|
|
// wandering rather than walking there.
|
|
assert!(arrived < 60 * 12, "took {arrived} ticks to walk 14 units");
|
|
}
|
|
|
|
/// Blocked by a wall it cannot jump, it goes around and still arrives. This is
|
|
/// the difference between "has a goal" and "can pursue one".
|
|
#[test]
|
|
fn npc_routes_around_a_wall_and_still_arrives() {
|
|
let mut world = world_with_ground();
|
|
// A wall across the direct path, with open ground either side.
|
|
block(&mut world, vec3f(0.0, 1.5, -8.0), vec3f(4.0, 1.5, 0.5));
|
|
let mut blocks = Blocks::new();
|
|
let id = npc(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0), 3);
|
|
blocks.npcs[0].activity = Activity::Travel {
|
|
goal: vec3f(0.0, 0.0, -16.0),
|
|
poi: None,
|
|
};
|
|
|
|
let mut best = f32::MAX;
|
|
for _ in 0..60 * 30 {
|
|
tick(&mut world, &mut blocks);
|
|
let e = world.entity(id).unwrap();
|
|
best = best.min((e.pos.z + 16.0).abs());
|
|
// Never inside the wall.
|
|
if (e.pos.z + 8.0).abs() < 0.5 {
|
|
assert!(
|
|
e.pos.x.abs() > 3.9,
|
|
"npc walked into the wall at x={} z={}",
|
|
e.pos.x,
|
|
e.pos.z
|
|
);
|
|
}
|
|
}
|
|
assert!(
|
|
best < 3.0,
|
|
"npc never got past the wall; closest approach to the goal was {best}"
|
|
);
|
|
}
|
|
|
|
/// A crate low enough to jump is jumped rather than walked around.
|
|
#[test]
|
|
fn npc_jumps_a_low_obstacle() {
|
|
let mut world = world_with_ground();
|
|
// Long, low: going around would take much longer than hopping it.
|
|
block(&mut world, vec3f(0.0, 0.4, -6.0), vec3f(14.0, 0.4, 0.5));
|
|
let mut blocks = Blocks::new();
|
|
let id = npc(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0), 11);
|
|
blocks.npcs[0].activity = Activity::Travel {
|
|
goal: vec3f(0.0, 0.0, -14.0),
|
|
poi: None,
|
|
};
|
|
|
|
let mut left_ground = false;
|
|
let mut crossed = false;
|
|
for _ in 0..60 * 25 {
|
|
tick(&mut world, &mut blocks);
|
|
let e = world.entity(id).unwrap();
|
|
if !e.on_floor {
|
|
left_ground = true;
|
|
}
|
|
if e.pos.z < -7.0 {
|
|
crossed = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(left_ground, "npc never jumped");
|
|
assert!(crossed, "npc never got over the low crate");
|
|
}
|
|
|
|
/// Converging on one destination must not stack them into a tower — movers
|
|
/// pass through each other, so the separation steering is the only thing
|
|
/// keeping a crowd looking like a crowd.
|
|
#[test]
|
|
fn npcs_converging_on_one_poi_do_not_stack() {
|
|
let mut world = world_with_ground();
|
|
let mut blocks = Blocks::new();
|
|
let mut ids = Vec::new();
|
|
for i in 0..6 {
|
|
let a = i as f32 * 1.04;
|
|
let (s, c) = (a.sin(), a.cos());
|
|
ids.push(npc(
|
|
&mut world,
|
|
&mut blocks,
|
|
vec3f(c * 10.0, 1.0, s * 10.0),
|
|
100 + i,
|
|
));
|
|
}
|
|
// One open destination they all want.
|
|
blocks.pois.push(Poi::new(vec3f(0.0, 0.0, 0.0), "well").with_capacity(8));
|
|
for n in blocks.npcs.iter_mut() {
|
|
n.activity = Activity::Travel {
|
|
goal: vec3f(0.0, 0.0, 0.0),
|
|
poi: Some(0),
|
|
};
|
|
}
|
|
|
|
for _ in 0..60 * 15 {
|
|
tick(&mut world, &mut blocks);
|
|
}
|
|
let mut closest = f32::MAX;
|
|
for (i, a) in ids.iter().enumerate() {
|
|
for b in ids.iter().skip(i + 1) {
|
|
let (pa, pb) = (
|
|
world.entity(*a).unwrap().pos,
|
|
world.entity(*b).unwrap().pos,
|
|
);
|
|
closest = closest.min(((pa.x - pb.x).powi(2) + (pa.z - pb.z).powi(2)).sqrt());
|
|
}
|
|
}
|
|
assert!(
|
|
closest > 0.55,
|
|
"npcs collapsed into each other: closest pair {closest} apart"
|
|
);
|
|
}
|
|
|
|
/// Same seed, same behaviour, tick for tick — the multiplayer and replay
|
|
/// requirement. Different seed must actually differ, or the "personality"
|
|
/// claim is decoration.
|
|
#[test]
|
|
fn npc_behaviour_is_seeded_and_reproducible() {
|
|
fn run(seed: u64) -> Vec<String> {
|
|
let mut world = world_with_ground();
|
|
let mut blocks = Blocks::new();
|
|
for i in 0..4 {
|
|
npc(
|
|
&mut world,
|
|
&mut blocks,
|
|
vec3f(i as f32 * 3.0, 1.0, 0.0),
|
|
seed + i,
|
|
);
|
|
}
|
|
blocks.pois.push(Poi::new(vec3f(12.0, 0.0, 4.0), "bench"));
|
|
blocks.pois.push(Poi::new(vec3f(-9.0, 0.0, -6.0), "well"));
|
|
let mut trace = Vec::new();
|
|
for t in 0..60 * 30 {
|
|
tick(&mut world, &mut blocks);
|
|
if t % 300 == 0 {
|
|
for n in &blocks.npcs {
|
|
trace.push(n.trace(&world));
|
|
}
|
|
}
|
|
}
|
|
trace
|
|
}
|
|
let a = run(42);
|
|
let b = run(42);
|
|
assert_eq!(a, b, "same seed produced a different run");
|
|
let c = run(43);
|
|
assert_ne!(a, c, "different seed produced an identical run");
|
|
}
|
|
|
|
/// Villagers left to their own devices must not end up doing the same thing
|
|
/// in unison, and must not freeze. This is the "looks inhabited" assertion.
|
|
#[test]
|
|
fn a_village_of_npcs_stays_busy_and_varied() {
|
|
let mut world = world_with_ground();
|
|
let mut blocks = Blocks::new();
|
|
for i in 0..10 {
|
|
let a = i as f32 * 0.63;
|
|
let (s, c) = (a.sin(), a.cos());
|
|
npc(
|
|
&mut world,
|
|
&mut blocks,
|
|
vec3f(c * 8.0, 1.0, s * 8.0),
|
|
900 + i,
|
|
);
|
|
}
|
|
for (i, (x, z, tag)) in [
|
|
(12.0, 2.0, "bench"),
|
|
(-11.0, 5.0, "well"),
|
|
(4.0, -13.0, "door"),
|
|
(-6.0, -10.0, "market"),
|
|
]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
blocks
|
|
.pois
|
|
.push(Poi::new(vec3f(*x, 0.0, *z), *tag).with_capacity(2));
|
|
let _ = i;
|
|
}
|
|
|
|
let mut moved = vec![0.0f32; blocks.npcs.len()];
|
|
let mut last: Vec<Vec3f> = blocks
|
|
.npcs
|
|
.iter()
|
|
.map(|n| world.entity(n.entity).unwrap().pos)
|
|
.collect();
|
|
let mut activities = std::collections::HashSet::new();
|
|
for t in 0..60 * 90 {
|
|
tick(&mut world, &mut blocks);
|
|
if t % 30 == 0 {
|
|
for (i, n) in blocks.npcs.iter().enumerate() {
|
|
let p = world.entity(n.entity).unwrap().pos;
|
|
moved[i] += ((p.x - last[i].x).powi(2) + (p.z - last[i].z).powi(2)).sqrt();
|
|
last[i] = p;
|
|
activities.insert(n.activity.name());
|
|
}
|
|
}
|
|
}
|
|
// Everyone got somewhere over 90 seconds.
|
|
let idle: Vec<usize> = moved
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, d)| **d < 6.0)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
assert!(
|
|
idle.is_empty(),
|
|
"npcs {idle:?} barely moved in 90s: {moved:?}"
|
|
);
|
|
// And they were not all doing one thing.
|
|
assert!(
|
|
activities.len() >= 2,
|
|
"village only ever showed activities {activities:?}"
|
|
);
|
|
// Nobody fell through the world or got launched.
|
|
for n in &blocks.npcs {
|
|
let p = world.entity(n.entity).unwrap().pos;
|
|
assert!(
|
|
p.y > -2.0 && p.y < 20.0,
|
|
"npc {} left the world at y={}",
|
|
n.entity,
|
|
p.y
|
|
);
|
|
}
|
|
}
|
|
|
|
/// An unbiased random walk has no centre, so villagers drift off the map over
|
|
/// a few minutes. Caught in a trace: one NPC 43 units out with every
|
|
/// destination inside 18.
|
|
#[test]
|
|
fn npcs_stay_in_their_village() {
|
|
let mut world = world_with_ground();
|
|
let mut blocks = Blocks::new();
|
|
for i in 0..6 {
|
|
npc(&mut world, &mut blocks, vec3f(i as f32 * 2.0, 1.0, 0.0), 77 + i);
|
|
}
|
|
blocks.pois.push(Poi::new(vec3f(9.0, 0.0, 3.0), "bench"));
|
|
// Four minutes is long enough for a drifter to be far away.
|
|
let mut furthest: f32 = 0.0;
|
|
for _ in 0..60 * 240 {
|
|
tick(&mut world, &mut blocks);
|
|
for n in &blocks.npcs {
|
|
let p = world.entity(n.entity).unwrap().pos;
|
|
let d = ((p.x - n.home.x).powi(2) + (p.z - n.home.z).powi(2)).sqrt();
|
|
furthest = furthest.max(d);
|
|
}
|
|
}
|
|
assert!(
|
|
furthest < 45.0,
|
|
"a villager wandered {furthest} units from home over four minutes"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- interiors
|
|
//
|
|
// A house you can walk into is the difference between scenery and a place. The
|
|
// generator is tested in `makepad-game-gen`; what these prove is the part only
|
|
// a running sim can show — that the room's colliders leave a passable doorway,
|
|
// that a mover can cross the floor without snagging on furniture, and that an
|
|
// NPC uses a door and comes back out again rather than vanishing.
|
|
|
|
use makepad_game_gen::interior::{interior, DoorSide, Interior, InteriorParams};
|
|
use makepad_game_gen::kit::{Kit, TileDef, TileRole};
|
|
|
|
fn shell_kit() -> Kit {
|
|
Kit::new(
|
|
"kenney/modular-buildings",
|
|
2.0,
|
|
vec![
|
|
TileDef::new("floor", TileRole::Floor, 0.1),
|
|
TileDef::new("wall", TileRole::Wall, 2.4),
|
|
TileDef::new("wall-corner", TileRole::WallCorner, 2.4),
|
|
TileDef::new("door", TileRole::Door, 2.4),
|
|
],
|
|
)
|
|
}
|
|
|
|
fn furniture_kit() -> Kit {
|
|
Kit::new(
|
|
"kenney/furniture-kit",
|
|
2.0,
|
|
vec![
|
|
TileDef::new("chair", TileRole::Prop, 0.9),
|
|
TileDef::new("table", TileRole::Prop, 0.8),
|
|
],
|
|
)
|
|
}
|
|
|
|
/// Spawn a generated room's collision into the world, exactly as the host
|
|
/// would: every box becomes a static entity.
|
|
fn materialise(world: &mut GameWorld, room: &Interior) {
|
|
for (c, h) in &room.colliders {
|
|
block(world, *c, *h);
|
|
}
|
|
}
|
|
|
|
fn walker(world: &mut GameWorld, pos: Vec3f) -> u64 {
|
|
mover(world, pos, vec3f(0.35, 0.9, 0.35))
|
|
}
|
|
|
|
/// Steer an entity toward a point for `ticks`, stopping early on arrival.
|
|
fn walk_to(world: &mut GameWorld, id: u64, goal: Vec3f, ticks: usize, speed: f32) -> bool {
|
|
for _ in 0..ticks {
|
|
let Some(e) = world.entity(id) else { return false };
|
|
let (dx, dz) = (goal.x - e.pos.x, goal.z - e.pos.z);
|
|
let d = (dx * dx + dz * dz).sqrt();
|
|
if d < 0.6 {
|
|
return true;
|
|
}
|
|
if let Some(e) = world.entity_mut(id) {
|
|
e.vel.x = dx / d * speed;
|
|
e.vel.z = dz / d * speed;
|
|
}
|
|
step_world(world);
|
|
world.tick += 1;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn room_for_test(seed: u64, shell: &Kit, fk: &Kit) -> Interior {
|
|
let mut p = InteriorParams::new(shell);
|
|
p.seed = seed;
|
|
p.cells = (5, 5);
|
|
p.door = DoorSide::South;
|
|
p.origin = vec3f(300.0, 0.0, 300.0);
|
|
p.furniture = Some(fk);
|
|
p.clutter = 0.6;
|
|
interior(&p)
|
|
}
|
|
|
|
#[test]
|
|
fn a_walker_enters_a_generated_room_crosses_it_and_leaves() {
|
|
let shell = shell_kit();
|
|
let fk = furniture_kit();
|
|
let room = room_for_test(3, &shell, &fk);
|
|
|
|
// The shared ground spans 400 units, so the pocket sits on it too.
|
|
let mut world = world_with_ground();
|
|
materialise(&mut world, &room);
|
|
|
|
// Start outside, beyond the south wall, in line with the doorway.
|
|
let outside = vec3f(room.door_pos.x, 0.9, room.door_pos.z + 4.0);
|
|
let id = walker(&mut world, outside);
|
|
for _ in 0..30 {
|
|
step_world(&mut world);
|
|
}
|
|
|
|
assert!(
|
|
walk_to(&mut world, id, room.entrance, 400, 2.5),
|
|
"walker never got through the doorway"
|
|
);
|
|
let inside_z = world.entity(id).unwrap().pos.z;
|
|
assert!(
|
|
inside_z < room.door_pos.z - 0.4,
|
|
"walker stopped in the threshold at z={inside_z}"
|
|
);
|
|
|
|
// Cross to the far side of the room, which is what furniture could block.
|
|
let far = *room
|
|
.free_points
|
|
.iter()
|
|
.max_by(|a, b| {
|
|
let da = (a.z - room.entrance.z).abs();
|
|
let db = (b.z - room.entrance.z).abs();
|
|
da.partial_cmp(&db).unwrap()
|
|
})
|
|
.unwrap();
|
|
assert!(
|
|
walk_to(&mut world, id, far, 500, 2.5),
|
|
"walker could not cross the room — furniture blocked the floor"
|
|
);
|
|
|
|
// And back out the way it came.
|
|
assert!(
|
|
walk_to(&mut world, id, outside, 700, 2.5),
|
|
"walker could not find its way back out"
|
|
);
|
|
assert!(world.entity(id).unwrap().pos.z > room.door_pos.z);
|
|
}
|
|
|
|
#[test]
|
|
fn the_walls_are_solid_everywhere_except_the_doorway() {
|
|
let shell = shell_kit();
|
|
let fk = furniture_kit();
|
|
let room = room_for_test(11, &shell, &fk);
|
|
|
|
let mut world = world_with_ground();
|
|
materialise(&mut world, &room);
|
|
|
|
// Approach the NORTH wall — the opposite side from the door — and push.
|
|
let north_outside = vec3f(room.entrance.x, 0.9, room.door_pos.z - 14.0);
|
|
let id = walker(&mut world, north_outside);
|
|
for _ in 0..30 {
|
|
step_world(&mut world);
|
|
}
|
|
let start_z = world.entity(id).unwrap().pos.z;
|
|
for _ in 0..300 {
|
|
if let Some(e) = world.entity_mut(id) {
|
|
e.vel.z = 3.0;
|
|
}
|
|
step_world(&mut world);
|
|
}
|
|
let end = world.entity(id).unwrap().pos;
|
|
assert!(
|
|
end.z > start_z,
|
|
"walker should have advanced toward the wall"
|
|
);
|
|
// It must be stopped OUTSIDE the room: the far wall is at the ring, one
|
|
// tile beyond the first floor row.
|
|
let first_floor_z = room
|
|
.free_points
|
|
.iter()
|
|
.map(|p| p.z)
|
|
.fold(f32::INFINITY, f32::min);
|
|
assert!(
|
|
end.z < first_floor_z,
|
|
"walked through the north wall: z={} reached floor at {}",
|
|
end.z,
|
|
first_floor_z
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_npc_goes_through_a_door_stays_a_while_and_comes_back_out() {
|
|
let shell = shell_kit();
|
|
let fk = furniture_kit();
|
|
let room = room_for_test(5, &shell, &fk);
|
|
|
|
let mut world = world_with_ground();
|
|
// Dusk. Doors read as an evening destination (see `tag_appeal`), so this
|
|
// is the hour the behaviour is meant to show up in.
|
|
world.tick = (DAY_SECONDS * 0.85 / DT) as u64;
|
|
|
|
let door_stand = vec3f(4.0, 0.0, 0.0);
|
|
let mut blocks = Blocks::new();
|
|
blocks.pois.push(
|
|
Poi::new(door_stand, "door")
|
|
.with_interior(room.entrance)
|
|
.with_capacity(8),
|
|
);
|
|
|
|
// A handful of personalities rather than one lucky seed: going inside is
|
|
// a scored choice, so the claim worth testing is that it happens across a
|
|
// population, not that seed N does it.
|
|
let mut ids = Vec::new();
|
|
for seed in 0..8u64 {
|
|
let a = seed as f32 * 0.8;
|
|
let (s, c) = (a.sin(), a.cos());
|
|
let id = walker(&mut world, vec3f(c * 6.0, 1.0, s * 6.0));
|
|
let mut npc = Npc::new(id, NpcConfig::default(), vec3f(c * 6.0, 0.0, s * 6.0), seed);
|
|
npc.config.visit = 3.0;
|
|
blocks.npcs.push(npc);
|
|
ids.push(id);
|
|
}
|
|
|
|
// (entered tick, exited tick, position on the way out) per NPC.
|
|
let mut trips: Vec<Option<(usize, Option<usize>, Vec3f)>> = vec![None; ids.len()];
|
|
let mut done = 0;
|
|
for t in 0..6000 {
|
|
tick(&mut world, &mut blocks);
|
|
// The host's job: perform the position write the block asked for.
|
|
// The block only ever asks — see the invariant on `DoorUse`.
|
|
for d in blocks.door_uses.drain(..) {
|
|
if let Some(e) = world.entity_mut(d.entity) {
|
|
e.pos = vec3f(d.to.x, d.to.y + e.half.y, d.to.z);
|
|
e.vel = vec3f(0.0, 0.0, 0.0);
|
|
}
|
|
let n = ids.iter().position(|&i| i == d.entity).unwrap();
|
|
match (&mut trips[n], d.entering) {
|
|
(slot @ None, true) => *slot = Some((t, None, vec3f(0.0, 0.0, 0.0))),
|
|
(Some(trip), false) if trip.1.is_none() => {
|
|
let out = world.entity(d.entity).map(|e| e.pos).unwrap_or_default();
|
|
*trip = (trip.0, Some(t), out);
|
|
done += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if done >= 3 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let full: Vec<_> = trips.iter().flatten().filter(|t| t.1.is_some()).collect();
|
|
assert!(
|
|
full.len() >= 3,
|
|
"only {} of 8 villagers used the door in 100s",
|
|
full.len()
|
|
);
|
|
for (into, out, at) in &full {
|
|
let out = out.unwrap();
|
|
assert!(out > *into, "left before it arrived");
|
|
// The visit must actually last: an NPC that bounces straight back out
|
|
// reads as a glitch rather than as someone popping indoors.
|
|
let seconds = (out - into) as f32 * DT;
|
|
assert!(
|
|
seconds > 2.0,
|
|
"visit lasted only {seconds:.1}s — too short to read as going inside"
|
|
);
|
|
// And it must come back out by the door it used, not somewhere else.
|
|
let d = ((at.x - door_stand.x).powi(2) + (at.z - door_stand.z).powi(2)).sqrt();
|
|
assert!(d < 6.0, "came out {d:.1} units from the door it went in by");
|
|
}
|
|
// Nobody may be left behind a door: leaving is unconditional, so the only
|
|
// way to still be inside is a bug in the visit countdown.
|
|
let stuck = blocks.npcs.iter().filter(|n| n.is_inside()).count();
|
|
assert!(stuck <= 1, "{stuck} villagers left behind doors");
|
|
}
|
|
|
|
#[test]
|
|
fn npcs_in_a_furnished_room_never_end_up_wedged() {
|
|
let shell = shell_kit();
|
|
let fk = furniture_kit();
|
|
let room = room_for_test(9, &shell, &fk);
|
|
|
|
let mut world = world_with_ground();
|
|
materialise(&mut world, &room);
|
|
|
|
// Four NPCs in the four corners of the room, so they have to cross it —
|
|
// and each other, and the furniture — to reach anything. Bunched together
|
|
// they would simply stand around chatting, which is correct behaviour but
|
|
// tests nothing about getting wedged.
|
|
let key = |p: &Vec3f, sx: f32, sz: f32| p.x * sx + p.z * sz;
|
|
let corners: Vec<Vec3f> = [(1.0, 1.0), (-1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)]
|
|
.iter()
|
|
.map(|&(sx, sz)| {
|
|
*room
|
|
.free_points
|
|
.iter()
|
|
.max_by(|a, b| key(a, sx, sz).total_cmp(&key(b, sx, sz)))
|
|
.unwrap()
|
|
})
|
|
.collect();
|
|
|
|
let mut blocks = Blocks::new();
|
|
let mut ids = Vec::new();
|
|
for (i, spot) in corners.iter().enumerate() {
|
|
let id = walker(&mut world, vec3f(spot.x, spot.y + 0.9, spot.z));
|
|
ids.push(id);
|
|
blocks
|
|
.npcs
|
|
.push(Npc::new(id, NpcConfig::default(), *spot, 20 + i as u64));
|
|
}
|
|
|
|
let mut moved = vec![0.0f32; ids.len()];
|
|
let mut last: Vec<Vec3f> = ids.iter().map(|&i| world.entity(i).unwrap().pos).collect();
|
|
for _ in 0..1800 {
|
|
tick(&mut world, &mut blocks);
|
|
blocks.door_uses.clear();
|
|
for (n, &id) in ids.iter().enumerate() {
|
|
let p = world.entity(id).unwrap().pos;
|
|
let d = ((p.x - last[n].x).powi(2) + (p.z - last[n].z).powi(2)).sqrt();
|
|
moved[n] += d;
|
|
last[n] = p;
|
|
}
|
|
}
|
|
|
|
let floor_min = room.free_points.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
|
|
let floor_max = room.free_points.iter().map(|p| p.x).fold(f32::NEG_INFINITY, f32::max);
|
|
for (n, &id) in ids.iter().enumerate() {
|
|
let p = world.entity(id).unwrap().pos;
|
|
assert!(
|
|
p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
|
|
"npc {n} left the world"
|
|
);
|
|
assert!(p.y > -2.0, "npc {n} fell through the floor at y={}", p.y);
|
|
// Still in the building: the doorway is the only way out, and nothing
|
|
// here has any reason to use it.
|
|
assert!(
|
|
p.x > floor_min - 3.0 && p.x < floor_max + 3.0,
|
|
"npc {n} ended up outside the room at x={}",
|
|
p.x
|
|
);
|
|
// Nobody may spend the whole run pinned. The give-up timer exists
|
|
// precisely so an NPC that cannot reach its goal abandons it rather
|
|
// than grinding into furniture forever.
|
|
assert!(
|
|
moved[n] > 2.0,
|
|
"npc {n} moved only {:.2} units in 30s — wedged",
|
|
moved[n]
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controller FEEL.
|
|
//
|
|
// These assert the SHAPE of motion, not just that it happens — a controller
|
|
// that reaches full speed is easy, one that reaches it the way a player expects
|
|
// is the job. Each test names the complaint it prevents, because the feel knobs
|
|
// are otherwise indistinguishable from arbitrary constants.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Drive a character for `ticks`, returning its horizontal speed each tick.
|
|
fn run_character(cfg: CharacterConfig, input: impl Fn(usize) -> DriveInput, ticks: usize)
|
|
-> (GameWorld, u64, Character, Vec<f32>)
|
|
{
|
|
let mut world = world_with_ground();
|
|
let id = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
let mut ch = Character::new(id, cfg, ControlSource::Player, None);
|
|
let mut speeds = Vec::with_capacity(ticks);
|
|
for t in 0..ticks {
|
|
let inp = input(t);
|
|
ch.tick(&mut world, &inp);
|
|
step_world(&mut world);
|
|
world.tick += 1;
|
|
ch.post_tick(&mut world);
|
|
let e = world.entity(id).unwrap();
|
|
speeds.push((e.vel.x * e.vel.x + e.vel.z * e.vel.z).sqrt());
|
|
}
|
|
(world, id, ch, speeds)
|
|
}
|
|
|
|
fn walk_forward() -> DriveInput {
|
|
DriveInput { move_z: -1.0, ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn speed_ramps_rather_than_stepping() {
|
|
// The complaint: "it feels like ice." Snapping to full speed on frame one
|
|
// reads as sliding, because nothing in the world accelerates instantly.
|
|
let cfg = CharacterConfig::default();
|
|
let (_, _, _, speeds) = run_character(cfg, |_| walk_forward(), 60);
|
|
assert!(speeds[0] < cfg.speed * 0.5, "instant velocity: {}", speeds[0]);
|
|
assert!(speeds[0] > 0.0, "no movement at all");
|
|
// And it must actually arrive, not creep forever.
|
|
let top = *speeds.last().unwrap();
|
|
assert!(top > cfg.speed * 0.95, "never reached speed: {top}");
|
|
// Monotonic while the stick is held — a ramp, not a wobble.
|
|
for w in speeds.windows(2).take(20) {
|
|
assert!(w[1] >= w[0] - 1e-4, "speed dipped mid-ramp: {w:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stopping_is_crisper_than_starting() {
|
|
// Deliberate asymmetry: a start should have weight, a stop should feel
|
|
// like the player let go. Sharing one constant makes one of them wrong.
|
|
let cfg = CharacterConfig::default();
|
|
assert!(cfg.decel > cfg.accel);
|
|
let (_, _, _, speeds) = run_character(
|
|
cfg,
|
|
|t| if t < 40 { walk_forward() } else { DriveInput::default() },
|
|
70,
|
|
);
|
|
assert!(speeds[45] < speeds[39] * 0.5, "did not shed speed on release");
|
|
}
|
|
|
|
#[test]
|
|
fn coyote_time_lets_a_late_jump_register() {
|
|
// The complaint players actually voice: "the jump didn't register." They
|
|
// pressed it two frames after leaving the ledge and were technically
|
|
// airborne. Being right is no comfort.
|
|
let cfg = CharacterConfig::default();
|
|
let mut world = world_with_ground();
|
|
let id = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
let mut ch = Character::new(id, cfg, ControlSource::Player, None);
|
|
// Settle on the ground, then remove it: the character is now falling and
|
|
// `on_floor` is false — the exact moment a ledge is left.
|
|
for _ in 0..10 {
|
|
ch.tick(&mut world, &DriveInput::default());
|
|
step_world(&mut world);
|
|
ch.post_tick(&mut world);
|
|
}
|
|
world.entities.retain(|e| e.kind != BodyKind::Static);
|
|
step_world(&mut world);
|
|
assert!(!world.entity(id).unwrap().on_floor, "should be airborne");
|
|
// Press two ticks late — inside the coyote window.
|
|
let jump = DriveInput { jump: true, jump_pressed: true, ..Default::default() };
|
|
ch.tick(&mut world, &jump);
|
|
assert!(
|
|
world.entity(id).unwrap().vel.y > 0.0,
|
|
"late jump was swallowed — coyote time is not working"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_jump_pressed_before_landing_fires_on_touchdown() {
|
|
// The same complaint as coyote time, from the other side: pressed a hair
|
|
// early, ignored, and the player is certain the game dropped it.
|
|
let cfg = CharacterConfig::default();
|
|
let mut world = world_with_ground();
|
|
// Start just above the floor so touchdown lands inside the buffer window.
|
|
let id = walker(&mut world, vec3f(0.0, 1.25, 0.0));
|
|
let mut ch = Character::new(id, cfg, ControlSource::Player, None);
|
|
let mut launched = false;
|
|
let mut ever_grounded = false;
|
|
for t in 0..120 {
|
|
// ONE press, while still falling; never repeated.
|
|
let inp = DriveInput { jump_pressed: t == 0, ..Default::default() };
|
|
ch.tick(&mut world, &inp);
|
|
step_world(&mut world);
|
|
world.tick += 1;
|
|
ch.post_tick(&mut world);
|
|
let e = world.entity(id).unwrap();
|
|
ever_grounded |= e.on_floor;
|
|
if ever_grounded && e.vel.y > 0.1 {
|
|
launched = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(ever_grounded, "never reached the ground");
|
|
assert!(
|
|
launched,
|
|
"a jump pressed just before landing was swallowed — buffering is not working"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn releasing_early_gives_a_lower_jump() {
|
|
// Variable height is the difference between a hop and a leap, and it is
|
|
// the single most-used expressive control a platformer has.
|
|
let cfg = CharacterConfig::default();
|
|
let apex = |hold: usize| -> f32 {
|
|
let mut world = world_with_ground();
|
|
let id = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
let mut ch = Character::new(id, cfg, ControlSource::Player, None);
|
|
let mut top = 0.0f32;
|
|
for t in 0..150 {
|
|
let held = t < hold;
|
|
let inp = DriveInput {
|
|
jump: held,
|
|
jump_pressed: t == 0,
|
|
..Default::default()
|
|
};
|
|
ch.tick(&mut world, &inp);
|
|
step_world(&mut world);
|
|
world.tick += 1;
|
|
ch.post_tick(&mut world);
|
|
top = top.max(world.entity(id).unwrap().pos.y);
|
|
}
|
|
top
|
|
};
|
|
let tapped = apex(2);
|
|
let held = apex(120);
|
|
assert!(
|
|
tapped < held - 0.25,
|
|
"tap {tapped:.2} vs hold {held:.2} — releasing early must cut the jump"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn falling_is_brisker_than_rising() {
|
|
// Symmetric gravity reads as floaty. The rise is the part the player
|
|
// steers; the fall should get on with it.
|
|
let cfg = CharacterConfig::default();
|
|
assert!(cfg.fall_gravity > 1.0);
|
|
let mut world = world_with_ground();
|
|
let id = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
let mut ch = Character::new(id, cfg, ControlSource::Player, None);
|
|
// Settle, or the first ticks are a fall onto the ground rather than a jump.
|
|
for _ in 0..20 {
|
|
ch.tick(&mut world, &DriveInput::default());
|
|
step_world(&mut world);
|
|
world.tick += 1;
|
|
ch.post_tick(&mut world);
|
|
}
|
|
assert!(world.entity(id).unwrap().on_floor, "never settled");
|
|
|
|
let (mut rise, mut fall) = (0usize, 0usize);
|
|
let mut peaked = false;
|
|
for t in 0..400 {
|
|
// Held, so the jump runs its full arc rather than being cut.
|
|
let inp = DriveInput { jump: true, jump_pressed: t == 0, ..Default::default() };
|
|
ch.tick(&mut world, &inp);
|
|
step_world(&mut world);
|
|
world.tick += 1;
|
|
ch.post_tick(&mut world);
|
|
let e = world.entity(id).unwrap();
|
|
if !peaked {
|
|
if e.vel.y > 0.0 {
|
|
rise += 1;
|
|
} else if rise > 0 {
|
|
peaked = true;
|
|
}
|
|
} else if !e.on_floor {
|
|
fall += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
assert!(rise > 0 && fall > 0, "never left the ground (rise {rise}, fall {fall})");
|
|
assert!(
|
|
fall < rise,
|
|
"fall {fall} ticks vs rise {rise} — gravity is symmetric, the jump floats"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn air_control_is_partial_not_absent_and_not_total() {
|
|
// Zero air control feels broken; full air control feels like flying. Air
|
|
// control limits the RATE, so given enough air time a player still reaches
|
|
// full speed — the claim worth asserting is that the same input builds
|
|
// speed more slowly off the ground.
|
|
let cfg = CharacterConfig::default();
|
|
assert!(cfg.air_control > 0.0 && cfg.air_control < 1.0);
|
|
|
|
let gained = |start_y: f32, ticks: usize| -> f32 {
|
|
let mut world = world_with_ground();
|
|
let id = walker(&mut world, vec3f(0.0, start_y, 0.0));
|
|
let mut ch = Character::new(id, cfg, ControlSource::Player, None);
|
|
for _ in 0..ticks {
|
|
ch.tick(&mut world, &walk_forward());
|
|
step_world(&mut world);
|
|
world.tick += 1;
|
|
ch.post_tick(&mut world);
|
|
}
|
|
let e = world.entity(id).unwrap();
|
|
(e.vel.x * e.vel.x + e.vel.z * e.vel.z).sqrt()
|
|
};
|
|
let on_ground = gained(0.9, 4);
|
|
let in_air = gained(9.0, 4);
|
|
assert!(in_air > 0.05, "no air control at all: {in_air}");
|
|
assert!(
|
|
in_air < on_ground * 0.9,
|
|
"air {in_air:.2} vs ground {on_ground:.2} — air control is not reduced"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vehicle_steering_loses_authority_with_speed() {
|
|
// Constant-rate steering at speed is the biggest "this feels like a toy"
|
|
// tell there is. The car scales steering authority by
|
|
// speed/steer_peak_speed, clamped — so authority RISES to the peak and is
|
|
// capped past it, rather than being a flat rate at every speed.
|
|
let cfg = CarConfig::default();
|
|
assert!(
|
|
cfg.steer_peak_speed > 0.0,
|
|
"no speed-sensitive steering configured"
|
|
);
|
|
let authority = |speed: f32| (speed / cfg.steer_peak_speed).clamp(0.0, 1.0);
|
|
assert!(authority(1.0) < authority(cfg.steer_peak_speed));
|
|
assert_eq!(authority(cfg.steer_peak_speed * 3.0), 1.0, "authority must cap");
|
|
// And the sign convention: steering right must lower the heading.
|
|
assert!(steer_to_yaw_rate(1.0, cfg.steer_rate) < 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn getting_in_and_out_of_a_car_puts_you_beside_it() {
|
|
// Dismounting into the seat means spawning inside a collider, and the
|
|
// separation pass would then shove the player through the nearest wall.
|
|
let mut world = world_with_ground();
|
|
let ch_id = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
let car_id = rigid(&mut world, vec3f(2.0, 1.0, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
if let Some(e) = world.entity_mut(car_id) {
|
|
e.tag = "car".to_string();
|
|
}
|
|
let mut mount = Mount::new(ch_id);
|
|
assert_eq!(mount.seat, Seat::OnFoot);
|
|
let got_in = mount.toggle(&mut world);
|
|
assert_eq!(got_in, Some(Seat::Driving(car_id)), "did not board a car in reach");
|
|
assert!(world.entity(ch_id).unwrap().hidden, "driver should be parked");
|
|
assert_eq!(mount.subject(), car_id, "camera should follow the car");
|
|
|
|
let got_out = mount.toggle(&mut world);
|
|
assert_eq!(got_out, Some(Seat::OnFoot));
|
|
let (c, car) = (world.entity(ch_id).unwrap(), world.entity(car_id).unwrap());
|
|
assert!(!c.hidden, "driver should be visible again");
|
|
let (dx, dz) = (c.pos.x - car.pos.x, c.pos.z - car.pos.z);
|
|
let d = (dx * dx + dz * dz).sqrt();
|
|
assert!(d > 1.0, "stepped out INSIDE the car: {d:.2} units away");
|
|
assert!(d < 4.0, "stepped out absurdly far: {d:.2}");
|
|
}
|
|
|
|
#[test]
|
|
fn mounting_blends_the_camera_instead_of_cutting() {
|
|
// A cut between rigs reads as a glitch. The camera must travel.
|
|
let mut cam = FollowCamera::new(CameraConfig::on_foot());
|
|
let before = cam.config.distance;
|
|
cam.transition_to(CameraConfig::in_vehicle(), 0.35);
|
|
// Mid-blend the camera is neither rig.
|
|
let mut world = world_with_ground();
|
|
let id = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
cam.tick(&world, id, 0.0, 0.0);
|
|
assert!(cam.boom >= before - 0.01, "camera jumped in on mount");
|
|
}
|
|
|
|
#[test]
|
|
fn the_camera_eases_back_out_after_an_obstruction_clears() {
|
|
// Pulling IN against a wall must be immediate — easing in spends those
|
|
// frames inside the wall. Easing OUT must not, or the shot pops the
|
|
// instant the player rounds a corner.
|
|
let mut world = world_with_ground();
|
|
let id = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
let mut cam = FollowCamera::new(CameraConfig::on_foot());
|
|
for _ in 0..60 {
|
|
cam.tick(&world, id, 0.0, 0.0);
|
|
}
|
|
let open = cam.boom;
|
|
assert!(open > 1.0, "camera never extended: {open}");
|
|
// Drop it hard, as an obstruction would, then let it recover.
|
|
cam.boom = 1.0;
|
|
cam.tick(&world, id, 0.0, 0.0);
|
|
let after_one = cam.boom;
|
|
assert!(after_one > 1.0, "did not recover at all");
|
|
assert!(
|
|
after_one < open * 0.5,
|
|
"snapped back out in one tick ({after_one} of {open}) — that is the pop"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The player prefab end to end (game.md §"Building blocks").
|
|
//
|
|
// These are the tests that decide whether the prefab is finished. The claim
|
|
// being defended is not "the pieces work" but "a game gets a playable character
|
|
// in and out of a car in a few lines and never touches a camera" — so the
|
|
// setup below is exactly what a generated game would write, and if it grows,
|
|
// the prefab has failed rather than the test.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The whole player setup a game performs. If this helper needs to grow, the
|
|
/// prefab is the thing that should change.
|
|
fn player_world() -> (GameWorld, Blocks, u64, u64) {
|
|
let mut world = world_with_ground();
|
|
let ch = walker(&mut world, vec3f(0.0, 0.9, 0.0));
|
|
let car = rigid(&mut world, vec3f(2.5, 1.0, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
if let Some(e) = world.entity_mut(car) {
|
|
e.tag = "car".to_string();
|
|
}
|
|
let mut blocks = Blocks::new();
|
|
blocks.characters.push(Character::new(
|
|
ch,
|
|
CharacterConfig::default(),
|
|
ControlSource::Player,
|
|
None,
|
|
));
|
|
blocks
|
|
.cars
|
|
.push(Car::new(car, CarConfig::default(), ControlSource::Player));
|
|
blocks.player_rigs.insert(PlayerId::LOCAL, PlayerRig::new(ch));
|
|
(world, blocks, ch, car)
|
|
}
|
|
|
|
/// One frame: rigs, then blocks, then the sim. The ordering the host owes.
|
|
fn player_tick(world: &mut GameWorld, blocks: &mut Blocks, raw: RawInput) {
|
|
let mut inputs = std::collections::HashMap::new();
|
|
inputs.insert(PlayerId::LOCAL, raw);
|
|
blocks.tick_player_rigs(world, &inputs);
|
|
blocks.pre_step(world);
|
|
step_world(world);
|
|
world.tick += 1;
|
|
blocks.post_step(world);
|
|
}
|
|
|
|
#[test]
|
|
fn a_player_walks_gets_in_drives_and_gets_out() {
|
|
// The headline journey, in the order a player performs it. Every leg
|
|
// asserts the thing that leg is for; a pass means the feature works
|
|
// end to end, not that its parts compile together.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
|
|
// --- walk toward the car -------------------------------------------
|
|
for _ in 0..40 {
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { move_y: 1.0, ..Default::default() },
|
|
);
|
|
}
|
|
let walked = world.entity(ch).unwrap().pos;
|
|
let moved = (walked.x * walked.x + walked.z * walked.z).sqrt();
|
|
assert!(moved > 2.0, "the character barely walked: {moved:.2}");
|
|
assert_eq!(blocks.player_rigs[&PlayerId::LOCAL].seat(), Seat::OnFoot);
|
|
|
|
// --- get in ---------------------------------------------------------
|
|
// Stand the player next to the car rather than relying on where the walk
|
|
// ended: this test is about the seat, not about pathing.
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
}
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
let rig = &blocks.player_rigs[&PlayerId::LOCAL];
|
|
assert_eq!(rig.seat(), Seat::Driving(car), "did not get into the car");
|
|
assert!(world.entity(ch).unwrap().hidden, "the driver is still standing there");
|
|
|
|
// --- drive ----------------------------------------------------------
|
|
let before = world.entity(car).unwrap().pos;
|
|
for _ in 0..90 {
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { throttle: 1.0, ..Default::default() },
|
|
);
|
|
}
|
|
let after = world.entity(car).unwrap().pos;
|
|
let drove = ((after.x - before.x).powi(2) + (after.z - before.z).powi(2)).sqrt();
|
|
assert!(drove > 5.0, "the car did not drive: {drove:.2}");
|
|
|
|
// --- get out --------------------------------------------------------
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
assert_eq!(blocks.player_rigs[&PlayerId::LOCAL].seat(), Seat::OnFoot);
|
|
let (c, k) = (world.entity(ch).unwrap(), world.entity(car).unwrap());
|
|
assert!(!c.hidden, "the driver never reappeared");
|
|
let gap = ((c.pos.x - k.pos.x).powi(2) + (c.pos.z - k.pos.z).powi(2)).sqrt();
|
|
assert!(gap > 1.0 && gap < 4.0, "stepped out {gap:.2} units from the car");
|
|
// And the camera came with them, rather than staying on the abandoned car.
|
|
assert_eq!(blocks.player_rigs[&PlayerId::LOCAL].mount.subject(), ch);
|
|
}
|
|
|
|
#[test]
|
|
fn driving_does_not_also_walk_your_parked_character() {
|
|
// The modality bug: a player owns both a character and a car, `pre_step`
|
|
// matches blocks by owner, so the same stick that steers you would also
|
|
// walk the body you left in the driver's seat — and then you get out
|
|
// somewhere you never went. The seat pin now carries the body with the
|
|
// car, so the property to check is that it stays WITH the car rather than
|
|
// that it stays put.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
}
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
for _ in 0..90 {
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { throttle: 1.0, move_y: 1.0, move_x: 1.0, ..Default::default() },
|
|
);
|
|
}
|
|
let (c, k) = (world.entity(ch).unwrap(), world.entity(car).unwrap());
|
|
let apart = ((c.pos.x - k.pos.x).powi(2) + (c.pos.z - k.pos.z).powi(2)).sqrt();
|
|
assert!(apart < 1.0, "the parked driver walked {apart:.2} units off the car");
|
|
// And getting out puts them beside THIS car, not back where they boarded.
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
let (c, k) = (world.entity(ch).unwrap(), world.entity(car).unwrap());
|
|
let gap = ((c.pos.x - k.pos.x).powi(2) + (c.pos.z - k.pos.z).powi(2)).sqrt();
|
|
assert!(gap < 4.0, "got out {gap:.2} units from the car they were driving");
|
|
}
|
|
|
|
#[test]
|
|
fn driving_one_car_does_not_also_drive_your_other_one() {
|
|
// The modality gate where it is directly observable. Nothing pins a second
|
|
// vehicle, so if `pre_step` handed this player's throttle to every block
|
|
// they own, the spare car drives itself off across the map while they are
|
|
// sitting in the first one.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
let spare = rigid(&mut world, vec3f(-8.0, 1.0, 0.0), vec3f(0.9, 0.4, 1.6));
|
|
if let Some(e) = world.entity_mut(spare) {
|
|
e.tag = "car".to_string();
|
|
}
|
|
blocks
|
|
.cars
|
|
.push(Car::new(spare, CarConfig::default(), ControlSource::Player));
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
}
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
assert_eq!(blocks.player_rigs[&PlayerId::LOCAL].seat(), Seat::Driving(car));
|
|
let parked_at = world.entity(spare).unwrap().pos;
|
|
for _ in 0..90 {
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { throttle: 1.0, move_x: 0.4, ..Default::default() },
|
|
);
|
|
}
|
|
let now = world.entity(spare).unwrap().pos;
|
|
let drift = ((now.x - parked_at.x).powi(2) + (now.z - parked_at.z).powi(2)).sqrt();
|
|
assert!(drift < 0.5, "the spare car drove itself {drift:.2} units");
|
|
// And the one being driven definitely moved, or this proves nothing.
|
|
let driven = world.entity(car).unwrap().pos;
|
|
let d = ((driven.x - 2.5).powi(2) + driven.z.powi(2)).sqrt();
|
|
assert!(d > 5.0, "the driven car did not move either: {d:.2}");
|
|
}
|
|
|
|
#[test]
|
|
fn analog_deflection_gives_intermediate_speed() {
|
|
// A pad has to be able to amble. Snapping from walk to run past a
|
|
// threshold is the clearest "toy" tell in a character controller, and a
|
|
// bool `run` cannot express anything else.
|
|
let cfg = CharacterConfig::default();
|
|
let top = |run: f32| {
|
|
let (_, _, _, speeds) = run_character(
|
|
cfg,
|
|
move |_| DriveInput { move_z: -1.0, run, ..Default::default() },
|
|
90,
|
|
);
|
|
*speeds.last().unwrap()
|
|
};
|
|
let (walk, half, sprint) = (top(0.0), top(0.5), top(1.0));
|
|
assert!(sprint > walk * 1.3, "run does nothing: {walk} -> {sprint}");
|
|
// The point of the test: half deflection is genuinely in between, not
|
|
// rounded to one of the two ends.
|
|
let midpoint = (walk + sprint) * 0.5;
|
|
assert!(
|
|
(half - midpoint).abs() < walk * 0.1,
|
|
"half stick gave {half}, not the {midpoint} between {walk} and {sprint}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_reload_keeps_you_in_the_car_with_the_camera_where_it_was() {
|
|
// Task #24 in the place it is most visible. An edit to the game's script
|
|
// must not eject the player mid-corner and snap the view — where you are
|
|
// sitting and where you are looking are the player's state, not the
|
|
// game's content.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
}
|
|
for _ in 0..8 {
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { look_dx: 40.0, ..Default::default() },
|
|
);
|
|
}
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
let (seat, angles) = {
|
|
let r = &blocks.player_rigs[&PlayerId::LOCAL];
|
|
(r.seat(), r.view_angles())
|
|
};
|
|
assert_eq!(seat, Seat::Driving(car));
|
|
|
|
blocks.clear();
|
|
|
|
let r = blocks
|
|
.player_rigs
|
|
.get(&PlayerId::LOCAL)
|
|
.expect("the reload threw the player out of the world");
|
|
assert_eq!(r.seat(), seat, "a reload ejected the driver");
|
|
assert_eq!(r.view_angles(), angles, "a reload snapped the camera");
|
|
}
|
|
|
|
#[test]
|
|
fn losing_the_car_you_are_driving_puts_you_back_on_your_feet() {
|
|
// A despawn, a rollback or a re-eval that does not recreate the car leaves
|
|
// the player driving a ghost: no camera subject, no block taking their
|
|
// input, and no way out, because the get-out reads the car to find a door.
|
|
// Dead controls are worse than any reset.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
}
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
assert_eq!(blocks.player_rigs[&PlayerId::LOCAL].seat(), Seat::Driving(car));
|
|
|
|
world.entities.retain(|e| e.id != car);
|
|
player_tick(&mut world, &mut blocks, RawInput::default());
|
|
|
|
let rig = &blocks.player_rigs[&PlayerId::LOCAL];
|
|
assert_eq!(rig.seat(), Seat::OnFoot, "still driving a car that is gone");
|
|
assert_eq!(rig.mount.subject(), ch, "the camera has nothing to follow");
|
|
assert!(!world.entity(ch).unwrap().hidden, "the player stayed invisible");
|
|
|
|
// And the controls work again.
|
|
for _ in 0..40 {
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { move_y: 1.0, ..Default::default() },
|
|
);
|
|
}
|
|
let e = world.entity(ch).unwrap();
|
|
let speed = (e.vel.x * e.vel.x + e.vel.z * e.vel.z).sqrt();
|
|
assert!(speed > 1.0, "the player cannot move after being ejected: {speed}");
|
|
}
|
|
|
|
#[test]
|
|
fn stepping_out_against_a_wall_uses_the_other_door() {
|
|
// Parking tight against something is normal. Preferring one side
|
|
// unconditionally drops the player inside it, and the separation pass then
|
|
// shoves them somewhere arbitrary — the most broken-feeling thing a
|
|
// get-out can do.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
}
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
// Wall hard against the driver's side of the car's resting heading.
|
|
let cpos = world.entity(car).unwrap().pos;
|
|
let right = heading_to_right(world.entity(car).unwrap().yaw);
|
|
block(
|
|
&mut world,
|
|
vec3f(cpos.x - right.x * 2.2, cpos.y, cpos.z - right.z * 2.2),
|
|
vec3f(1.0, 1.5, 1.0),
|
|
);
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
let c = world.entity(ch).unwrap().pos;
|
|
// Came out on the far side instead — dot against `right` says which.
|
|
let side = (c.x - cpos.x) * right.x + (c.z - cpos.z) * right.z;
|
|
assert!(side > 0.5, "stepped out into the wall (side {side:.2})");
|
|
}
|
|
|
|
#[test]
|
|
fn the_prompt_and_the_button_always_agree() {
|
|
// An affordance prompt that runs its own search drifts from the button it
|
|
// describes, and "press E to get in" that does nothing is worse than no
|
|
// prompt at all — it teaches the player the game is broken at the moment
|
|
// they are learning it. Walking the whole approach checks agreement at
|
|
// every distance, including right at the reach boundary where a duplicated
|
|
// search would differ first.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
for step in 0..24 {
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 12.0 - step as f32 * 0.5);
|
|
}
|
|
let rig = &blocks.player_rigs[&PlayerId::LOCAL];
|
|
let promised = rig.mount.candidate(&world);
|
|
let mut probe = rig.mount;
|
|
let got = match probe.toggle(&mut world) {
|
|
Some(Seat::Driving(id)) => Some(id),
|
|
_ => None,
|
|
};
|
|
assert_eq!(promised, got, "prompt and button disagreed at step {step}");
|
|
// Undo whatever the probe did to the world before the next step.
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.hidden = false;
|
|
}
|
|
}
|
|
// And it did become available somewhere along that approach, or the test
|
|
// proved only that both agree on "never".
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.0);
|
|
}
|
|
assert_eq!(
|
|
blocks.player_rigs[&PlayerId::LOCAL].mount.candidate(&world),
|
|
Some(car),
|
|
"the car was never offerable"
|
|
);
|
|
// Once seated there is nothing to get into, so no prompt should appear.
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
let rig = &blocks.player_rigs[&PlayerId::LOCAL];
|
|
assert_eq!(rig.seat(), Seat::Driving(car));
|
|
assert_eq!(rig.mount.candidate(&world), None, "offered a car while driving");
|
|
}
|
|
|
|
#[test]
|
|
fn getting_out_does_not_unhide_a_model_based_player() {
|
|
// `hidden` is the HOST's field: "my appearance is a mesh, don't also draw
|
|
// my collider box". Every game that uses a model rather than a coloured box
|
|
// sets it, which is every real game. Mount must restore it, not clear it —
|
|
// otherwise getting out pops a grey collision slab into the world beside
|
|
// the car, and the only host-side fix is re-asserting `hidden` every tick
|
|
// forever.
|
|
for host_hidden in [false, true] {
|
|
let (mut world, mut blocks, ch, _car) = player_world();
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
e.hidden = host_hidden;
|
|
}
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
assert!(world.entity(ch).unwrap().hidden, "the driver was still drawn");
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
assert_eq!(
|
|
world.entity(ch).unwrap().hidden,
|
|
host_hidden,
|
|
"getting out overwrote the host's `hidden` (was {host_hidden})"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_driver_rides_along_instead_of_being_left_as_an_invisible_wall() {
|
|
// `hidden` means "solid to everything, drawn by nothing", so hiding the
|
|
// driver in place leaves their collider standing where they boarded: drive
|
|
// back past it later and you hit a wall that is not there. The seat pin
|
|
// takes the body out of physics and carries it with the car.
|
|
let (mut world, mut blocks, ch, car) = player_world();
|
|
if let Some(e) = world.entity_mut(ch) {
|
|
e.pos = vec3f(2.5, 0.9, 1.2);
|
|
}
|
|
let boarded_at = world.entity(ch).unwrap().pos;
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { use_pressed: true, ..Default::default() },
|
|
);
|
|
for _ in 0..90 {
|
|
player_tick(
|
|
&mut world,
|
|
&mut blocks,
|
|
RawInput { throttle: 1.0, ..Default::default() },
|
|
);
|
|
}
|
|
let (c, k) = (world.entity(ch).unwrap(), world.entity(car).unwrap());
|
|
let left_behind = ((c.pos.x - boarded_at.x).powi(2) + (c.pos.z - boarded_at.z).powi(2)).sqrt();
|
|
assert!(left_behind > 3.0, "the body stayed at the kerb: {left_behind:.2}");
|
|
let to_car = ((c.pos.x - k.pos.x).powi(2) + (c.pos.z - k.pos.z).powi(2)).sqrt();
|
|
assert!(to_car < 1.0, "the rider is not travelling with the car: {to_car:.2}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_new_world_falls_without_being_told_to() {
|
|
// Gravity was set only by `reset_content`, which script evaluation calls
|
|
// and nothing else does — so every world built through the sim API floated
|
|
// until its caller happened to know. The symptom is not "physics looks
|
|
// wrong", it is a character that silently never reports `on_floor` and so
|
|
// refuses to jump. Four test files had each grown their own
|
|
// `world.gravity = 30.0`; a workaround that gets copy-pasted means the
|
|
// default is the bug.
|
|
let mut world = GameWorld::new();
|
|
assert!(world.gravity > 0.0, "a fresh world has no gravity");
|
|
ground(&mut world, vec3f(40.0, 1.0, 40.0));
|
|
let id = walker(&mut world, vec3f(0.0, 6.0, 0.0));
|
|
for _ in 0..120 {
|
|
step_world(&mut world);
|
|
world.tick += 1;
|
|
}
|
|
let e = world.entity(id).unwrap();
|
|
assert!(e.on_floor, "never landed, so a jump would never fire");
|
|
assert!(e.pos.y < 3.0, "did not fall: y={}", e.pos.y);
|
|
}
|
|
|
|
#[test]
|
|
fn reverse_moves_the_car_but_the_brake_still_wins() {
|
|
// Two separate claims, because conflating them is what makes "reverse
|
|
// barely works" hard to diagnose.
|
|
//
|
|
// 1. Reverse alone must actually reverse. Half engine authority is the
|
|
// arcade convention and is fine; being immobile is not.
|
|
// 2. Brake AND reverse together must stay near-immobile. That is correct
|
|
// car behaviour — a foot on the brake wins — so it is pinned here
|
|
// rather than "fixed". Any input path that maps one control to BOTH
|
|
// brake and negative throttle will therefore read as broken reverse,
|
|
// and the bug is in that mapping, not in this force model.
|
|
let drive_for = |input: DriveInput, ticks: usize| -> f32 {
|
|
let (mut world, mut blocks, chassis) = car_world();
|
|
if let Some(c) = blocks.car_mut(chassis) {
|
|
c.control = ControlSource::Script;
|
|
c.input = input;
|
|
}
|
|
let start = world.entity(chassis).unwrap().pos;
|
|
run(&mut world, &mut blocks, ticks);
|
|
let e = world.entity(chassis).unwrap();
|
|
((e.pos.x - start.x).powi(2) + (e.pos.z - start.z).powi(2)).sqrt()
|
|
};
|
|
let secs = (2.0 / DT) as usize;
|
|
let forward = drive_for(DriveInput { throttle: 1.0, ..Default::default() }, secs);
|
|
let reverse = drive_for(DriveInput { throttle: -1.0, ..Default::default() }, secs);
|
|
let braked = drive_for(
|
|
DriveInput { throttle: -1.0, brake: 1.0, ..Default::default() },
|
|
secs,
|
|
);
|
|
assert!(forward > 10.0, "the car did not drive forward at all: {forward:.2}");
|
|
assert!(
|
|
reverse > forward * 0.4,
|
|
"reverse is not usable: {reverse:.2}m vs {forward:.2}m forward"
|
|
);
|
|
assert!(
|
|
braked < reverse * 0.2,
|
|
"the brake did not hold the car: {braked:.2}m vs {reverse:.2}m free"
|
|
);
|
|
}
|