Initial commit
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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');
|
||||
Reference in New Issue
Block a user