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');