Files
2026-08-03 06:43:21 -05:00

313 lines
12 KiB
JavaScript

import * as THREE from 'three';
import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
import { createBodyProxy } from '../src/physics/bodyProxy.js';
import { CAT } from '../src/physics/bridge.js';
import { createSkater } from '../src/character/skater.js';
import { spawnLineup } from '../shared/ai.js';
import { SKATE, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
import { RINK, insideRink } from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
/**
* Box3D integration.
*
* The claim these tests exist to check is the one the spike rests on: that
* board contact and skater-on-skater contact are solved by the physics engine
* and come back into the sim as momentum, rather than being faked by a clamp.
* Everything else about the skating is covered headlessly in skaterSim.mjs.
*/
const DT = 1 / 120;
await initPhysics();
/** A world plus `n` skaters wired the way the match loop wires them. */
function makeWorld(spawns) {
const physics = createPhysicsWorld();
const states = spawns.map((sp, i) => createSkaterState(i, sp));
const proxies = spawns.map((sp, i) => {
const p = createBodyProxy(physics, { index: i, position: sp });
p.teleport(sp.x, sp.z);
return p;
});
return { physics, states, proxies };
}
/** Step the match loop's inner cycle for `seconds`. */
function run(w, seconds, drive) {
const steps = Math.round(seconds / DT);
for (let n = 0; n < steps; n++) {
for (let i = 0; i < w.states.length; i++) {
w.proxies[i].read(w.states[i]);
if (drive) drive(w.states[i], i, n * DT);
stepSkater(w.states[i], DT, { clampBoards: false });
w.proxies[i].write(w.states[i]);
}
w.physics.step(DT);
}
}
section('the world builds');
{
const w = makeWorld([{ x: 0, z: 0, yaw: 0 }]);
ok(w.physics.boardBodies.length > 30, `the boards are a real ring (${w.physics.boardBodies.length} segments)`);
ok(w.proxies[0].mass > 60 && w.proxies[0].mass < 120, `a skater weighs something plausible (${w.proxies[0].mass.toFixed(0)} kg)`);
w.physics.destroy();
}
section('the proxy carries the skater and stays upright');
{
const w = makeWorld([{ x: -20, z: 0, yaw: Math.PI / 2 }]);
run(w, 3, (s) => {
s.ix = 1;
s.iz = 0;
});
const t = w.physics.api.b3Body_GetTransform(w.proxies[0].body);
ok(t.p.x > -18, `the body actually moved down the ice (x=${t.p.x.toFixed(1)})`);
near(t.p.y, 0, 1e-3, 'and never left the ice');
near(t.q.v.x, 0, 1e-4, 'and never tipped over (x)');
near(t.q.v.z, 0, 1e-4, 'and never tipped over (z)');
near(w.states[0].x, t.p.x, 1e-6, 'the sim reads its position straight out of Box3D');
w.physics.destroy();
}
section('the boards stop a skater at full speed');
{
// Straight at the end boards from centre ice, sprinting, for long enough to
// be well past them if nothing were there.
const w = makeWorld([{ x: 0, z: 0, yaw: Math.PI / 2 }]);
run(w, 12, (s) => {
s.ix = 1;
s.iz = 0;
s.sprint = true;
});
const s = w.states[0];
ok(insideRink(s.x, s.z, SKATE.radius * 0.9), `stopped by the end boards (x=${s.x.toFixed(2)} of ${RINK.halfX})`);
ok(s.x > RINK.halfX - 2, 'and got all the way to them');
w.physics.destroy();
}
section('the corners hold too');
{
// The corners are the interesting case: they are a chain of short boxes, and
// a body driven into the seam between two of them is exactly how a skater
// escapes a rink.
for (const heading of [0.5, 1.0, 2.2, -0.8, -2.5]) {
const w = makeWorld([{ x: 0, z: 0, yaw: heading }]);
run(w, 14, (s) => {
s.ix = Math.sin(heading);
s.iz = Math.cos(heading);
s.sprint = true;
});
const s = w.states[0];
ok(
insideRink(s.x, s.z, SKATE.radius * 0.9),
`heading ${heading.toFixed(1)} stayed inside (${s.x.toFixed(1)}, ${s.z.toFixed(1)})`,
);
w.physics.destroy();
}
}
section('a board hit costs speed');
{
// Started far enough out that four seconds of sprinting is a run-up, not a
// collision — the measurement below is the speed *arriving* at the boards.
const w = makeWorld([{ x: -8, z: 0, yaw: Math.PI / 2 }]);
run(w, 4, (s) => {
s.ix = 1;
s.iz = 0;
s.sprint = true;
});
const entry = speedOf(w.states[0]);
ok(w.states[0].x < RINK.halfX - 3, `still short of the boards after the run-up (x=${w.states[0].x.toFixed(1)})`);
ok(entry > 5, `carrying real speed into them (${entry.toFixed(1)} m/s)`);
run(w, 3, (s) => {
s.ix = 1;
s.iz = 0;
s.sprint = true;
});
// Still pushing into the wall, so speed should be near nothing, not bouncing
// around the rink.
ok(speedOf(w.states[0]) < 1.5, `pinned against the boards (${speedOf(w.states[0]).toFixed(2)} m/s)`);
w.physics.destroy();
}
section('two skaters cannot occupy the same ice');
{
// The worst case the engine will ever see: both at full sprint, dead head
// on, both still pushing after contact for several seconds.
//
// They settle around 0.53 m apart rather than at two capsule radii (0.72 m).
// That is not a solver failure — raising the substep count does not move it
// by a millimetre — it is the equilibrium of two bodies whose velocity is
// *commanded* by the sim each step leaning on each other. The proxy radius
// is deliberately larger than the body it carries (torso half-width is about
// 0.22 m), so at that separation the two torsos still have ~10 cm of daylight
// between them and nothing visibly intersects.
//
// What would be a real failure is passing through, so that is checked too.
const w = makeWorld([
{ x: -8, z: 0, yaw: Math.PI / 2 },
{ x: 8, z: 0, yaw: -Math.PI / 2 },
]);
const TORSO_HALF_WIDTH = 0.22;
let minGap = Infinity;
let crossed = false;
const steps = Math.round(6 / DT);
for (let n = 0; n < steps; n++) {
for (let i = 0; i < 2; i++) {
w.proxies[i].read(w.states[i]);
w.states[i].ix = i === 0 ? 1 : -1;
w.states[i].iz = 0;
w.states[i].sprint = true;
stepSkater(w.states[i], DT, { clampBoards: false });
w.proxies[i].write(w.states[i]);
}
w.physics.step(DT);
const gap = Math.hypot(w.states[0].x - w.states[1].x, w.states[0].z - w.states[1].z);
minGap = Math.min(minGap, gap);
if (w.states[0].x > w.states[1].x) crossed = true;
}
ok(!crossed, 'neither skater ever passed through the other');
ok(
minGap > TORSO_HALF_WIDTH * 2,
`torsos never intersected (closest ${minGap.toFixed(2)}m, two torso widths is ${(TORSO_HALF_WIDTH * 2).toFixed(2)}m)`,
);
ok(minGap < SKATE.radius * 2, 'and they did genuinely make contact');
w.physics.destroy();
}
section('a bump transfers momentum into the sim');
{
// One skater flying, one standing still directly in the way.
const w = makeWorld([
{ x: -12, z: 0, yaw: Math.PI / 2 },
{ x: 4, z: 0, yaw: Math.PI / 2 },
]);
run(w, 5, (s, i) => {
if (i === 0) {
s.ix = 1;
s.iz = 0;
s.sprint = true;
} else {
s.ix = 0;
s.iz = 0;
}
});
const victim = w.states[1];
ok(victim.x > 4.05, `the stationary skater was shoved down the ice (x ${victim.x.toFixed(2)} from 4.00)`);
ok(speedOf(victim) > 0.3, `and carried real speed away from it (${speedOf(victim).toFixed(2)} m/s)`);
ok(speedOf(victim) < SKATE.speedCeiling, 'without being launched');
w.physics.destroy();
}
section('a glancing hit knocks a skater off their line');
{
// Passing shoulder to shoulder rather than head on.
const w = makeWorld([
{ x: -10, z: 0.3, yaw: Math.PI / 2 },
{ x: 10, z: -0.3, yaw: -Math.PI / 2 },
]);
run(w, 6, (s, i) => {
s.ix = i === 0 ? 1 : -1;
s.iz = 0;
s.sprint = true;
});
ok(
Math.abs(w.states[0].z) > 0.4 || Math.abs(w.states[1].z) > 0.4,
`contact pushed someone off their line (z ${w.states[0].z.toFixed(2)} / ${w.states[1].z.toFixed(2)})`,
);
w.physics.destroy();
}
section('the ragdoll is built and follows the animated skeleton');
{
// Nothing in spike 1 pushes the rig, but it has to be there and correct or
// the first hit in spike 2 will land on a rig that was never wired up.
const physics = createPhysicsWorld();
const scene = new THREE.Group();
const sk = createSkater({ seed: 5, scene, physics, index: 0, team: 0, position: { x: 3, z: -2 }, facing: 0.4 });
ok(sk.ragdoll, 'a skater has a ragdoll');
ok(sk.ragdoll.order.length === 18, `18 capsules (${sk.ragdoll.order.length})`);
ok(sk.ragdoll.joints.length === 17, `17 joints (${sk.ragdoll.joints.length})`);
ok(sk.ragdoll.mode === 'driven', 'and starts kinematic, chasing the animation');
const mass = sk.ragdoll.totalMass();
ok(mass > 70 && mass < 100, `the rig weighs a person (${mass.toFixed(0)} kg)`);
// Drive it the way the match loop does, then check the physics bodies ended
// up on the bones rather than at the origin.
const state = createSkaterState(0, { x: 3, z: -2, yaw: 0.4 });
for (let n = 0; n < 120; n++) {
state.ix = 1;
state.iz = 0;
stepSkater(state, DT, { clampBoards: false });
sk.applyState(state, 0);
sk.update(DT);
physics.step(DT, (fixedDt) => sk.ragdoll.syncFromSkeleton(fixedDt));
}
const api = physics.api;
const bone = new THREE.Vector3();
let worst = 0;
for (const part of sk.ragdoll.order) {
part.bone.getWorldPosition(bone);
const p = api.b3Body_GetPosition(part.body);
worst = Math.max(worst, Math.hypot(p.x - bone.x, p.y - bone.y, p.z - bone.z));
}
ok(worst < 0.05, `every capsule sits on its bone (worst gap ${worst.toFixed(4)}m)`);
// And the whole rig travelled with the skater rather than staying at spawn.
const pelvis = api.b3Body_GetPosition(sk.ragdoll.parts.pelvis.body);
ok(Math.abs(pelvis.x - state.x) < 0.4, `the rig moved with the skater (${pelvis.x.toFixed(2)} vs ${state.x.toFixed(2)})`);
ok(pelvis.y > 0.6 && pelvis.y < 1.1, `and its hips are at hip height (${pelvis.y.toFixed(2)}m)`);
sk.dispose();
physics.destroy();
}
section('a full 3-on-3 runs without anything escaping');
{
// Six bodies, all sprinting at centre ice at once, for twenty-five seconds.
// This is the pile-up case: every proxy in contact with several others while
// the sim keeps commanding velocity into the middle of the heap.
const w = makeWorld(spawnLineup(3, 2));
ok(w.states.length === 6, 'six skaters on the ice');
run(w, 25, (s, i, t) => {
const dx = -s.x;
const dz = -s.z;
const len = Math.hypot(dx, dz) || 1;
s.ix = (dx / len) * Math.sin(t * 0.7 + i);
s.iz = (dz / len) * Math.cos(t * 0.5 + i);
s.sprint = true;
});
for (let i = 0; i < w.states.length; i++) {
const s = w.states[i];
ok(Number.isFinite(s.x) && Number.isFinite(s.z), `skater ${i} stayed finite`);
ok(insideRink(s.x, s.z, SKATE.radius * 0.9), `skater ${i} stayed on the ice`);
ok(speedOf(s) <= SKATE.speedCeiling, `skater ${i} never exceeded the speed ceiling`);
}
// Nobody ends up standing inside anybody, even after a sustained pile-up.
for (let i = 0; i < w.states.length; i++) {
for (let j = i + 1; j < w.states.length; j++) {
const d = Math.hypot(w.states[i].x - w.states[j].x, w.states[i].z - w.states[j].z);
ok(d > 0.44, `skaters ${i} and ${j} are not inside each other (${d.toFixed(2)}m)`);
}
}
w.physics.destroy();
}
section('every skater in a 3-on-3 gets its own collision layer');
{
// Ragdoll categories are one bit per skater from bit 1 up, and the proxy
// layer sits at bit 15. Six a side would still fit; this checks the two do
// not collide at the roster sizes we actually intend to reach.
for (let i = 0; i < 10; i++) {
ok(CAT.skater(i) !== CAT.PROXY, `skater ${i}'s ragdoll bit is not the proxy bit`);
ok((CAT.skater(i) & CAT.RINK) === 0n, `skater ${i}'s ragdoll bit is not the rink bit`);
}
}
done('physics');