Files
tilt/test/hits.mjs
T
2026-08-03 06:43:21 -05:00

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