450 lines
17 KiB
JavaScript
450 lines
17 KiB
JavaScript
import * as THREE from 'three';
|
|
import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
|
|
import { createMatch } from '../src/game/match.js';
|
|
import { closestLimbs, describeHit } from '../src/game/hits.js';
|
|
import { REGION } from '../src/character/skeleton.js';
|
|
import { segSegDistance } from '../src/core/math.js';
|
|
import { insideRink } from '../shared/rink.js';
|
|
import { done, near, ok, section } from './harness.mjs';
|
|
|
|
/**
|
|
* Body checks, end to end and headless.
|
|
*
|
|
* The thing under test is the handoff: while upright the proxy capsule owns
|
|
* position and the ragdoll is a kinematic passenger; a knockdown inverts that,
|
|
* and getting up inverts it back. That round trip is the part of the design
|
|
* with nowhere to hide, so most of this file is about proving it does not leak
|
|
* a disabled proxy, a stranded sim position, or a skater who never gets up.
|
|
*/
|
|
|
|
const DT = 1 / 60;
|
|
|
|
await initPhysics();
|
|
|
|
/** A match with everyone parked, so only the skaters under test move. */
|
|
function arena(perTeam = 1) {
|
|
const physics = createPhysicsWorld();
|
|
const match = createMatch({ scene: new THREE.Group(), physics, perTeam, teams: 2 });
|
|
return { physics, match };
|
|
}
|
|
|
|
/**
|
|
* Drive skater 0 into skater 1 head on and run until something happens.
|
|
* Returns the hits that landed.
|
|
*/
|
|
function collide({ closing = 'full', seconds = 6, gap = 16 } = {}) {
|
|
const { physics, match } = arena(1);
|
|
const [a, b] = match.states;
|
|
const landed = [];
|
|
const seen = new Set();
|
|
|
|
a.x = -gap / 2; a.z = 0; a.yaw = Math.PI / 2; a.vx = 0; a.vz = 0;
|
|
b.x = gap / 2; b.z = 0; b.yaw = -Math.PI / 2; b.vx = 0; b.vz = 0;
|
|
match.skaters[0].proxy.teleport(a.x, a.z);
|
|
match.skaters[1].proxy.teleport(b.x, b.z);
|
|
|
|
// Drive both directly, so the AI's avoidance steering cannot politely
|
|
// sidestep the collision this test exists to cause. With cameraYaw 0 the
|
|
// stick's x maps straight to world +X.
|
|
match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
|
|
match.setControl(1, closing === 'full'
|
|
? { x: -1, y: 0, sprint: true, brake: false, cameraYaw: 0 }
|
|
: { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
|
|
|
|
const steps = Math.round(seconds / DT);
|
|
for (let n = 0; n < steps; n++) {
|
|
match.update(DT);
|
|
for (const h of match.recentHits) {
|
|
const id = `${h.at}|${h.attacker}|${h.victim}`;
|
|
if (!seen.has(id)) {
|
|
seen.add(id);
|
|
landed.push(h);
|
|
}
|
|
}
|
|
}
|
|
return { physics, match, landed };
|
|
}
|
|
|
|
section('segment distance is correct');
|
|
{
|
|
const A = new THREE.Vector3();
|
|
const B = new THREE.Vector3();
|
|
// Two parallel segments one metre apart.
|
|
let d = segSegDistance(
|
|
new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0),
|
|
new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 1, 0), A, B,
|
|
);
|
|
near(d, 1, 1e-9, 'parallel segments');
|
|
|
|
// Crossing segments touch.
|
|
d = segSegDistance(
|
|
new THREE.Vector3(-1, 0, 0), new THREE.Vector3(1, 0, 0),
|
|
new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 1, 0), A, B,
|
|
);
|
|
near(d, 0, 1e-9, 'crossing segments');
|
|
|
|
// Endpoint to endpoint, no overlap in parameter space.
|
|
d = segSegDistance(
|
|
new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0),
|
|
new THREE.Vector3(3, 0, 0), new THREE.Vector3(4, 0, 0), A, B,
|
|
);
|
|
near(d, 2, 1e-9, 'collinear, disjoint');
|
|
near(A.x, 1, 1e-9, 'closest point on the first segment is its end');
|
|
near(B.x, 3, 1e-9, 'closest point on the second is its start');
|
|
|
|
// Degenerate: both segments are points.
|
|
d = segSegDistance(
|
|
new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0),
|
|
new THREE.Vector3(3, 4, 0), new THREE.Vector3(3, 4, 0), A, B,
|
|
);
|
|
near(d, 5, 1e-9, 'two points');
|
|
}
|
|
|
|
section('the closest limb pair is found between two posed rigs');
|
|
{
|
|
const { physics, match } = arena(1);
|
|
const [a, b] = match.skaters;
|
|
// Stand them shoulder to shoulder.
|
|
match.states[0].x = 0; match.states[0].z = 0;
|
|
match.states[1].x = 0.75; match.states[1].z = 0;
|
|
a.applyState(match.states[0], 0);
|
|
b.applyState(match.states[1], 0);
|
|
a.update(DT);
|
|
b.update(DT);
|
|
|
|
const pair = closestLimbs(a.ragdoll, b.ragdoll);
|
|
ok(pair, 'a pair was found');
|
|
ok(pair.attackerPart && pair.victimPart, 'both sides identified');
|
|
ok(pair.distance < 0.6, `and they are genuinely close (${pair.distance.toFixed(3)}m)`);
|
|
// Side by side, the nearest parts must be on the facing sides, i.e. arms or
|
|
// torso — never a foot to a head.
|
|
ok(
|
|
pair.attackerPart.name !== 'footL' && pair.attackerPart.name !== 'footR',
|
|
`a shoulder-to-shoulder stance does not resolve to a foot (${pair.attackerPart.name})`,
|
|
);
|
|
physics.destroy();
|
|
}
|
|
|
|
section('a full-speed head-on check lands and knocks someone down');
|
|
{
|
|
const { physics, match, landed } = collide({ closing: 'full' });
|
|
ok(landed.length > 0, `a hit was registered (${landed.length})`);
|
|
const hit = landed[0];
|
|
ok(hit.speed > 4, `with real closing speed (${hit.speed.toFixed(1)} m/s)`);
|
|
ok(hit.outcome !== 'bump', `and it was more than a bump (${hit.outcome})`);
|
|
ok(hit.attacker !== hit.victim, 'attacker and victim are different skaters');
|
|
ok(typeof hit.by === 'string' && hit.by.length > 0, `it was delivered by something (${hit.by})`);
|
|
ok(typeof describeHit(hit) === 'string', `and describes itself: "${describeHit(hit)}"`);
|
|
physics.destroy();
|
|
}
|
|
|
|
section('a skater who is run over goes down, then gets back up');
|
|
{
|
|
const { physics, match } = collide({ closing: 'stationary', seconds: 5 });
|
|
const downed = match.skaters.filter((s) => s.limp);
|
|
// Either someone is still down, or they already got up — both mean the path
|
|
// ran. Keep simulating until nobody is down, and check it terminates.
|
|
let steps = 0;
|
|
while (match.skaters.some((s) => s.limp) && steps < 60 / DT) {
|
|
match.update(DT);
|
|
steps++;
|
|
}
|
|
ok(steps < 60 / DT, `everyone got back up (took ${(steps * DT).toFixed(1)}s)`);
|
|
for (let i = 0; i < match.skaters.length; i++) {
|
|
const sk = match.skaters[i];
|
|
ok(!sk.limp, `skater ${i} is upright`);
|
|
ok(sk.proxy.enabled, `skater ${i}'s proxy is switched back on`);
|
|
ok(sk.ragdoll.mode === 'driven', `skater ${i}'s rig is back under animation`);
|
|
ok(insideRink(match.states[i].x, match.states[i].z, 0.3), `skater ${i} is on the ice`);
|
|
ok(Number.isFinite(match.states[i].x), `skater ${i}'s position is finite`);
|
|
}
|
|
physics.destroy();
|
|
}
|
|
|
|
section('a knockdown puts a skater on the ice, not in the air');
|
|
{
|
|
// The failure this catches is specific and very visible: applying the whole
|
|
// impulse at the contact point, which sits well above the centre of mass,
|
|
// cartwheels the victim up over the hitter's head instead of driving them
|
|
// down and back.
|
|
const { physics, match } = collide({ closing: 'full', gap: 24, seconds: 3 });
|
|
const victim = match.skaters.find((s) => s.limp);
|
|
ok(victim, 'somebody went down');
|
|
|
|
const pelvis = new THREE.Vector3();
|
|
const head = new THREE.Vector3();
|
|
let peakPelvis = 0;
|
|
let peakHead = 0;
|
|
for (let n = 0; n < 2.5 / DT; n++) {
|
|
match.update(DT);
|
|
victim.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
|
|
victim.ragdoll.parts.head.bone.getWorldPosition(head);
|
|
peakPelvis = Math.max(peakPelvis, pelvis.y);
|
|
peakHead = Math.max(peakHead, head.y);
|
|
}
|
|
// Standing hip height is ~1.0m and standing head height ~1.6m. Going above
|
|
// those while being knocked over means they were launched.
|
|
ok(peakPelvis < 1.35, `the hips never went above standing height (peak ${peakPelvis.toFixed(2)}m)`);
|
|
ok(peakHead < 2.0, `and neither did the head (peak ${peakHead.toFixed(2)}m)`);
|
|
physics.destroy();
|
|
}
|
|
|
|
section('a knockdown drives the victim away from the hit, not back into it');
|
|
{
|
|
// Run until contact rather than for a fixed time: how long the run-up takes
|
|
// depends on the acceleration curve, and a test that silently ends before
|
|
// the collision proves nothing.
|
|
const { physics, match } = collide({ closing: 'stationary', gap: 22, seconds: 3 });
|
|
let waited = 0;
|
|
while (!match.skaters.some((s) => s.limp) && waited < 6) {
|
|
match.update(DT);
|
|
waited += DT;
|
|
}
|
|
const victimIndex = match.skaters.findIndex((s) => s.limp);
|
|
ok(victimIndex >= 0, `somebody went down (after ${waited.toFixed(1)}s of extra run-up)`);
|
|
const pelvis = new THREE.Vector3();
|
|
match.skaters[victimIndex].ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
|
|
const startX = pelvis.x;
|
|
for (let n = 0; n < 1.2 / DT; n++) match.update(DT);
|
|
match.skaters[victimIndex].ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
|
|
// The attacker was travelling +X, so the victim has to end up further +X.
|
|
ok(pelvis.x > startX, `the body carried on down the ice (${startX.toFixed(2)} → ${pelvis.x.toFixed(2)})`);
|
|
physics.destroy();
|
|
}
|
|
|
|
section('getting up does not teleport the body');
|
|
{
|
|
// The bug this pins down: while limp the ragdoll writes the body's
|
|
// displacement into the *root bone*, because the mover stays parked where
|
|
// they fell. Moving the mover onto the pelvis without re-expressing that
|
|
// offset applies the displacement twice — the skater visibly flies out by
|
|
// however far they slid and the crossfade then drags them back.
|
|
//
|
|
// Measured on the rendered bones, not on the sim state, because the sim
|
|
// state was always right; it was the drawn pose that jumped.
|
|
const { physics, match } = arena(1);
|
|
const sk = match.skaters[0];
|
|
const st = match.states[0];
|
|
st.x = -4;
|
|
st.z = 3;
|
|
sk.proxy.teleport(st.x, st.z);
|
|
for (let n = 0; n < 20; n++) match.update(DT);
|
|
|
|
sk.goDown({ severity: 9, direction: new THREE.Vector3(1, 0, 0), victimPart: 'spine2' });
|
|
// Send them sliding so the fall position and the resting position differ by
|
|
// a long way — with them equal the bug cannot show.
|
|
sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(300, 30, 90), null);
|
|
|
|
const sample = new THREE.Vector3();
|
|
const bones = ['pelvis', 'head', 'footL', 'handR'];
|
|
const before = new Map();
|
|
let slid = 0;
|
|
|
|
while (sk.limp) {
|
|
// Remember the last frame before the handoff.
|
|
for (const b of bones) {
|
|
sk.ragdoll.parts[b].bone.getWorldPosition(sample);
|
|
before.set(b, sample.clone());
|
|
}
|
|
sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample);
|
|
slid = Math.hypot(sample.x - st.x, sample.z - st.z);
|
|
match.update(DT);
|
|
}
|
|
|
|
ok(slid > 0.5, `the body really did slide away from where it fell (${slid.toFixed(2)}m)`);
|
|
|
|
// First frame back under animation: every bone must be where it just was.
|
|
let worst = 0;
|
|
let worstBone = '';
|
|
for (const b of bones) {
|
|
sk.ragdoll.parts[b].bone.getWorldPosition(sample);
|
|
const moved = sample.distanceTo(before.get(b));
|
|
if (moved > worst) {
|
|
worst = moved;
|
|
worstBone = b;
|
|
}
|
|
}
|
|
ok(worst < 0.12, `no bone jumped across the handoff (worst ${worstBone} ${worst.toFixed(3)}m)`);
|
|
|
|
// And the whole get-up should be a pose change, not a journey.
|
|
sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample);
|
|
const riseStart = sample.clone();
|
|
let drift = 0;
|
|
while (sk.rising > 0) {
|
|
match.update(DT);
|
|
sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample);
|
|
drift = Math.max(drift, Math.hypot(sample.x - riseStart.x, sample.z - riseStart.z));
|
|
}
|
|
ok(drift < 0.6, `they stood up roughly where they lay (drifted ${drift.toFixed(2)}m)`);
|
|
physics.destroy();
|
|
}
|
|
|
|
section('a skater who gets up faces the way they were lying');
|
|
{
|
|
const { physics, match } = arena(1);
|
|
const sk = match.skaters[0];
|
|
const st = match.states[0];
|
|
st.x = 0;
|
|
st.z = 0;
|
|
st.yaw = 0;
|
|
sk.proxy.teleport(0, 0);
|
|
for (let n = 0; n < 20; n++) match.update(DT);
|
|
sk.goDown(null);
|
|
sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(0, 20, 260), null);
|
|
while (sk.limp) match.update(DT);
|
|
|
|
const pelvis = new THREE.Vector3();
|
|
const chest = new THREE.Vector3();
|
|
sk.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
|
|
sk.ragdoll.parts.spine3.bone.getWorldPosition(chest);
|
|
const bodyYaw = Math.atan2(chest.x - pelvis.x, chest.z - pelvis.z);
|
|
const off = Math.abs(Math.atan2(Math.sin(st.yaw - bodyYaw), Math.cos(st.yaw - bodyYaw)));
|
|
ok(off < 0.9, `facing follows the sprawled body rather than a stale yaw (${off.toFixed(2)} rad off)`);
|
|
ok(Number.isFinite(st.yaw), 'and is a real number');
|
|
physics.destroy();
|
|
}
|
|
|
|
section('the sim follows the body across a knockdown');
|
|
{
|
|
const { physics, match } = arena(1);
|
|
const sk = match.skaters[0];
|
|
const st = match.states[0];
|
|
st.x = -5; st.z = 2; st.vx = 0; st.vz = 0;
|
|
sk.proxy.teleport(st.x, st.z);
|
|
for (let n = 0; n < 20; n++) match.update(DT);
|
|
|
|
sk.goDown({ severity: 9, direction: new THREE.Vector3(1, 0, 0), victimPart: 'spine2' });
|
|
ok(sk.limp, 'they are down');
|
|
ok(!sk.proxy.enabled, 'the proxy switched off — no invisible bollard left behind');
|
|
|
|
// Shove the rig so it ends up somewhere other than where it fell.
|
|
sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(260, 40, 0), null);
|
|
for (let n = 0; n < 90; n++) match.update(DT);
|
|
|
|
const pelvis = new THREE.Vector3();
|
|
sk.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
|
|
while (sk.limp) match.update(DT);
|
|
|
|
ok(sk.proxy.enabled, 'the proxy came back');
|
|
const gap = Math.hypot(st.x - pelvis.x, st.z - pelvis.z);
|
|
ok(gap < 1.2, `the sim was moved to where the body actually ended up (${gap.toFixed(2)}m off)`);
|
|
ok(Math.hypot(st.vx, st.vz) < 2, 'and starts from rest rather than inheriting the slide');
|
|
physics.destroy();
|
|
}
|
|
|
|
section('a downed skater is not driven around by the sim');
|
|
{
|
|
const { physics, match } = arena(1);
|
|
const sk = match.skaters[0];
|
|
const st = match.states[0];
|
|
st.x = 0; st.z = 0;
|
|
sk.proxy.teleport(0, 0);
|
|
for (let n = 0; n < 10; n++) match.update(DT);
|
|
sk.goDown(null);
|
|
const at = { x: st.x, z: st.z };
|
|
// Hold full sprint intent for a second while down.
|
|
for (let n = 0; n < 60; n++) {
|
|
st.ix = 1;
|
|
st.iz = 0;
|
|
st.sprint = true;
|
|
match.update(DT);
|
|
}
|
|
const moved = Math.hypot(st.x - at.x, st.z - at.z);
|
|
near(moved, 0, 1e-6, 'the frozen sim position did not skate off without the body');
|
|
physics.destroy();
|
|
}
|
|
|
|
section('ragdoll limbs join the collision world only while dynamic');
|
|
{
|
|
const { physics, match } = arena(1);
|
|
const sk = match.skaters[0];
|
|
const api = physics.api;
|
|
const shape = sk.ragdoll.parts.spine2.shape;
|
|
|
|
const drivenMask = api.b3Shape_GetFilter(shape).maskBits;
|
|
sk.goDown(null);
|
|
const limpMask = api.b3Shape_GetFilter(shape).maskBits;
|
|
ok(limpMask !== drivenMask, 'the filter changed when the rig went dynamic');
|
|
ok(limpMask > drivenMask, 'and it got wider, not narrower');
|
|
|
|
while (sk.limp) match.update(DT);
|
|
const backMask = api.b3Shape_GetFilter(shape).maskBits;
|
|
near(Number(backMask), Number(drivenMask), 0, 'and went back on standing up');
|
|
physics.destroy();
|
|
}
|
|
|
|
section('a 3-on-3 with hits enabled stays sane');
|
|
{
|
|
const { physics, match } = arena(3);
|
|
for (let n = 0; n < 90 / DT; n++) match.update(DT);
|
|
for (let i = 0; i < match.states.length; i++) {
|
|
const s = match.states[i];
|
|
ok(Number.isFinite(s.x) && Number.isFinite(s.z), `skater ${i} finite after 90s`);
|
|
ok(insideRink(s.x, s.z, 0.3), `skater ${i} still on the ice`);
|
|
}
|
|
ok(match.recentHits.length >= 0, 'the hit list did not blow up');
|
|
physics.destroy();
|
|
}
|
|
|
|
section('hit severity is graded, not binary');
|
|
{
|
|
// Different run-ups must produce different outcomes, or "varied hits" is a
|
|
// lie. A short approach is a shove; a long one puts someone on the ice.
|
|
const outcomes = new Set();
|
|
const byShortRun = [];
|
|
const byLongRun = [];
|
|
for (const [gap, into] of [[2.5, byShortRun], [22, byLongRun]]) {
|
|
const { physics, landed } = collide({ closing: 'stationary', gap, seconds: 6 });
|
|
for (const h of landed) {
|
|
outcomes.add(h.outcome);
|
|
into.push(h);
|
|
}
|
|
physics.destroy();
|
|
}
|
|
ok(outcomes.size >= 2, `run-up length changes the outcome (${[...outcomes].join(', ')})`);
|
|
ok(byShortRun.length > 0 && byLongRun.length > 0, 'both approaches landed something');
|
|
ok(
|
|
byLongRun[0].severity > byShortRun[0].severity,
|
|
`a longer run-up hits harder (${byLongRun[0].severity.toFixed(1)} vs ${byShortRun[0].severity.toFixed(1)})`,
|
|
);
|
|
}
|
|
|
|
section('the kind of hit follows the pose, not a coin flip');
|
|
{
|
|
// Two geometries that should produce genuinely different checks: running
|
|
// down a stationary skater leads with the shoulder, while a head-on between
|
|
// two skaters both crouched low at speed is a hip check.
|
|
const kinds = new Set();
|
|
const seen = [];
|
|
for (const closing of ['stationary', 'full']) {
|
|
const { physics, landed } = collide({ closing, gap: 22, seconds: 6 });
|
|
for (const h of landed) {
|
|
kinds.add(h.by);
|
|
seen.push(`${closing}: ${describeHit(h)}`);
|
|
}
|
|
physics.destroy();
|
|
}
|
|
ok(kinds.size >= 2, `more than one kind of hit is reachable (${[...kinds].join(', ')})`);
|
|
ok(kinds.has('shoulder') || kinds.has('hip'), `and they are real checks (${seen.join(' | ')})`);
|
|
}
|
|
|
|
section('nobody delivers a check with their head');
|
|
{
|
|
// A skater at speed is pitched forward, which makes the head the leading
|
|
// part of the body geometrically. Without the delivering-part restriction
|
|
// almost every hit resolves to a headbutt.
|
|
const delivered = new Set();
|
|
for (const closing of ['stationary', 'full']) {
|
|
for (const gap of [5, 14, 22]) {
|
|
const { physics, landed } = collide({ closing, gap, seconds: 6 });
|
|
for (const h of landed) delivered.add(h.attackerPart);
|
|
physics.destroy();
|
|
}
|
|
}
|
|
ok(!delivered.has('head'), `no hit was credited to a head (${[...delivered].join(', ')})`);
|
|
ok(!delivered.has('neck'), 'nor to a neck');
|
|
ok(delivered.size > 0, 'and hits did land');
|
|
}
|
|
|
|
done('hits');
|