Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
import { createBrain, spawnLineup, steer } 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';
const DT = 1 / 120;
/** Deterministic PRNG so a failure here is reproducible. */
function rng(seed) {
let a = seed | 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* A whole match's worth of skaters and brains, stepped headlessly.
* Board contact is the sim's clamp here rather than Box3D's, which is the
* point: the AI must not need the physics world to behave.
*/
function simulate(perTeam, seconds, seed = 7, teams = 2) {
const rand = rng(seed);
const spawns = spawnLineup(perTeam, teams);
const count = spawns.length;
const states = spawns.map((sp, i) => createSkaterState(i, sp, { team: sp.team }));
const brains = spawns.map(() => createBrain(rand));
const trace = states.map(() => ({ minSpeed: Infinity, maxSpeed: 0, offIce: 0, touches: 0, distance: 0 }));
const steps = Math.round(seconds / DT);
for (let n = 0; n < steps; n++) {
for (let i = 0; i < count; i++) {
steer(brains[i], states[i], states, DT);
const x0 = states[i].x;
const z0 = states[i].z;
stepSkater(states[i], DT);
const t = trace[i];
t.distance += Math.hypot(states[i].x - x0, states[i].z - z0);
const v = speedOf(states[i]);
if (v < t.minSpeed) t.minSpeed = v;
if (v > t.maxSpeed) t.maxSpeed = v;
if (!insideRink(states[i].x, states[i].z, SKATE.radius)) t.offIce++;
}
// Count how often two bodies are actually overlapping. The proxies resolve
// this in the browser; here it measures whether the *steering* alone keeps
// them roughly apart.
for (let i = 0; i < count; i++) {
for (let j = i + 1; j < count; j++) {
const d = Math.hypot(states[i].x - states[j].x, states[i].z - states[j].z);
if (d < SKATE.radius * 2) {
trace[i].touches++;
trace[j].touches++;
}
}
}
}
return { states, brains, trace, steps, count };
}
section('the 3-on-3 lineup is legal, split by half, and faces centre ice');
{
const spawns = spawnLineup(3, 2);
ok(spawns.length === 6, `six skaters on the ice (${spawns.length})`);
ok(spawns.filter((s) => s.team === 0).length === 3, 'three a side, home');
ok(spawns.filter((s) => s.team === 1).length === 3, 'three a side, away');
for (const sp of spawns) {
ok(insideRink(sp.x, sp.z, SKATE.radius + 1), `spawn (${sp.x.toFixed(1)}, ${sp.z.toFixed(1)}) is on the ice`);
// Facing should point back toward the middle of the rink.
near(sp.yaw, Math.atan2(-sp.x, -sp.z), 1e-9, 'spawn faces centre ice');
// Each team starts in its own half, the way a lineup does.
const ownHalf = sp.team === 0 ? sp.x < 0 : sp.x > 0;
ok(ownHalf, `team ${sp.team} lines up in its own half (x=${sp.x.toFixed(1)})`);
}
// Index order has to agree with the team field, because the match builds
// skaters and materials off the index.
for (let i = 0; i < spawns.length; i++) {
ok(spawns[i].team === Math.floor(i / 3), `index ${i} belongs to team ${Math.floor(i / 3)}`);
}
for (let i = 0; i < spawns.length; i++) {
for (let j = i + 1; j < spawns.length; j++) {
const d = Math.hypot(spawns[i].x - spawns[j].x, spawns[i].z - spawns[j].z);
ok(d > 2, `spawns ${i} and ${j} are not on top of each other (${d.toFixed(1)}m)`);
}
}
// Nobody starts inside the far team, and nobody starts in a corner.
for (const sp of spawns) {
ok(Math.abs(sp.x) < RINK.halfX - 4, `spawn is clear of the end boards (x=${sp.x.toFixed(1)})`);
}
}
section('a 3-on-3 skates a full minute without leaving the ice');
{
const { trace, states, count } = simulate(3, 60);
ok(count === 6, 'six skaters simulated');
for (let i = 0; i < count; i++) {
ok(trace[i].offIce === 0, `skater ${i} never went through the boards`);
ok(Number.isFinite(states[i].x) && Number.isFinite(states[i].z), `skater ${i} stayed finite`);
ok(trace[i].distance > 120, `skater ${i} actually covered ground (${trace[i].distance.toFixed(0)}m in 60s)`);
ok(trace[i].maxSpeed > 4, `skater ${i} got up to a real speed (${trace[i].maxSpeed.toFixed(1)} m/s)`);
ok(trace[i].maxSpeed <= SKATE.speedCeiling, `skater ${i} never exceeded the ceiling`);
}
}
section('bots keep out of each other\'s way on their own');
{
const { trace, steps, count } = simulate(3, 60);
for (let i = 0; i < count; i++) {
const overlapFraction = trace[i].touches / steps;
ok(
overlapFraction < 0.06,
`skater ${i} spends almost no time inside another body (${(overlapFraction * 100).toFixed(1)}%)`,
);
}
}
section('bots reach their waypoints rather than circling forever');
{
const rand = rng(19);
const s = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
const brain = createBrain(rand);
let arrivals = 0;
let last = null;
for (let n = 0; n < 60 * 120; n++) {
steer(brain, s, [s], DT);
if (brain.target !== last) {
if (last !== null) arrivals++;
last = brain.target;
}
stepSkater(s, DT);
}
ok(arrivals >= 5, `a lone bot got through several waypoints in a minute (${arrivals})`);
}
section('a full 5-on-5 still behaves');
{
// Not a spike-1 requirement, but the cheapest possible check that the
// steering does not fall over the moment there is a full side on the ice.
const { trace, states, count } = simulate(5, 30, 3);
ok(count === 10, 'ten skaters simulated');
for (let i = 0; i < count; i++) {
ok(trace[i].offIce === 0, `skater ${i} of ten stayed on the ice`);
ok(Number.isFinite(states[i].x), `skater ${i} of ten stayed finite`);
}
}
section('the whole match is deterministic');
{
const a = simulate(3, 20, 42);
const b = simulate(3, 20, 42);
for (let i = 0; i < a.count; i++) {
near(a.states[i].x, b.states[i].x, 0, `skater ${i} replays to the same x`);
near(a.states[i].z, b.states[i].z, 0, `skater ${i} replays to the same z`);
}
}
done('ai');
+103
View File
@@ -0,0 +1,103 @@
import * as THREE from 'three';
import { createGoalie } from '../src/character/goalie.js';
import { done, ok, section } from './harness.mjs';
/**
* Goalie presentation: skeleton, gear, and stance selection.
*
* Save logic and angle play are covered by shootout.mjs — this file pins the
* things that used to be a capsule-and-box placeholder.
*/
const DT = 1 / 60;
function make() {
const scene = new THREE.Group();
const goalie = createGoalie(null, scene, { end: 1, team: 1, seed: 42 });
return { scene, goalie };
}
section('goalie is a skinned skeleton, not a capsule');
{
const { goalie } = make();
ok(goalie.skelData.list.length >= 20, `has a full bone list (${goalie.skelData.list.length})`);
ok(goalie.bodyMesh?.isSkinnedMesh, 'body is a SkinnedMesh');
ok(goalie.gear.padL.parent === goalie.skelData.bones.shinL, 'left pad is on the left shin');
ok(goalie.gear.padR.parent === goalie.skelData.bones.shinR, 'right pad is on the right shin');
ok(goalie.gear.trapper.parent === goalie.skelData.bones.handL, 'trapper is on the left hand');
ok(goalie.gear.blocker.parent === goalie.skelData.bones.handR, 'blocker is on the right hand');
ok(goalie.gear.mask.parent === goalie.skelData.bones.head, 'mask is on the head');
ok(goalie.gear.stick.parent === goalie.skelData.bones.handR, 'paddle is in the blocker hand');
goalie.destroy();
}
section('nothing produces NaN while tracking');
{
const { goalie } = make();
let bad = false;
for (let i = 0; i < 180; i++) {
goalie.update(DT, {
x: 15 + i * 0.08,
y: 0.1 + 0.5 * Math.sin(i * 0.1),
z: Math.sin(i * 0.07) * 2,
});
for (const b of goalie.skelData.list) {
for (const e of b.matrixWorld.elements) {
if (!Number.isFinite(e)) bad = true;
}
}
}
ok(!bad, 'bone matrices stay finite');
goalie.destroy();
}
section('stances respond to the puck');
{
const { goalie } = make();
// Far out: ready.
for (let i = 0; i < 60; i++) goalie.update(DT, { x: 10, y: 0.5, z: 0 });
ok(goalie.animator.state === 'ready', `idle crease is ready (${goalie.animator.state})`);
// Low and closing: butterfly.
for (let i = 0; i < 90; i++) {
goalie.update(DT, { x: 12 + i * 0.15, y: 0.1, z: 0.2 });
}
ok(
goalie.animator.state === 'butterfly',
`low attack draws a butterfly (${goalie.animator.state})`,
);
// High and close: reach.
for (let i = 0; i < 45; i++) {
goalie.update(DT, { x: goalie.pos.x + 2, y: 1.25, z: goalie.pos.z });
}
ok(goalie.animator.state === 'reach', `high puck draws a reach (${goalie.animator.state})`);
goalie.destroy();
}
section('goalie drops low in the butterfly');
{
const { goalie } = make();
for (let i = 0; i < 40; i++) goalie.update(DT, { x: 18, y: 0.5, z: 0 });
const readyY = goalie.skelData.bones.root.position.y;
const foot = new THREE.Vector3();
goalie.skelData.bones.footL.getWorldPosition(foot);
ok(Math.abs(foot.y - 0.085) < 0.04, `ready feet are on the ice (y=${foot.y.toFixed(3)})`);
for (let i = 0; i < 80; i++) {
goalie.update(DT, { x: goalie.pos.x + 1.2, y: 0.08, z: 0 });
}
const flyY = goalie.skelData.bones.root.position.y;
ok(flyY < readyY - 0.1, `butterfly drops the hips (${readyY.toFixed(2)}${flyY.toFixed(2)})`);
goalie.skelData.bones.footL.getWorldPosition(foot);
ok(Math.abs(foot.y - 0.085) < 0.04, `butterfly feet stay on the ice (y=${foot.y.toFixed(3)})`);
// Pads flare wider than the ready stance.
const inv = new THREE.Matrix4().copy(goalie.mover.matrixWorld).invert();
const fL = foot.clone().applyMatrix4(inv);
goalie.skelData.bones.footR.getWorldPosition(foot);
const fR = foot.clone().applyMatrix4(inv);
ok(Math.abs(fL.x - fR.x) > 0.9, `butterfly opens the stance (width ${(fL.x - fR.x).toFixed(2)})`);
goalie.destroy();
}
done('goalie');
+28
View File
@@ -0,0 +1,28 @@
/** The smallest test harness that gives a useful failure message. */
let failures = 0;
let checks = 0;
export function ok(cond, msg) {
checks++;
if (!cond) {
failures++;
console.error(' FAIL ' + msg);
}
}
export function near(actual, expected, tol, msg) {
ok(Math.abs(actual - expected) <= tol, `${msg} (got ${actual}, want ${expected} ±${tol})`);
}
export function section(name) {
console.log('· ' + name);
}
export function done(name) {
if (failures) {
console.error(`\n${name}: ${failures} of ${checks} checks failed`);
process.exit(1);
}
console.log(`${name}: ${checks} checks passed`);
}
+449
View File
@@ -0,0 +1,449 @@
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');
+361
View File
@@ -0,0 +1,361 @@
import { PAD, createInput, stickToWorld } from '../src/game/input.js';
import { createSkaterState, stepSkater } from '../shared/skaterSim.js';
import { done, near, ok, section } from './harness.mjs';
/**
* A fake window and a fake gamepad, so the pad layer can be tested without a
* browser or a pad. The Gamepad API is polled, not evented, which makes it
* unusually easy to stand in for.
*/
function fakePad(overrides = {}) {
const buttons = Array.from({ length: 17 }, () => ({ pressed: false, value: 0 }));
return {
index: 0,
id: 'Xbox Wireless Controller (STANDARD GAMEPAD)',
connected: true,
mapping: 'standard',
axes: [0, 0, 0, 0],
buttons,
...overrides,
};
}
/**
* Node exposes `navigator` as a getter-only global, so it has to be replaced
* with defineProperty rather than assigned. Both globals are restored after
* each case so one test cannot leak a fake pad into the next.
*/
function stubGlobal(name, value) {
const had = Object.getOwnPropertyDescriptor(globalThis, name);
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
return () => {
if (had) Object.defineProperty(globalThis, name, had);
else delete globalThis[name];
};
}
function harness() {
const listeners = new Map();
const fakeWindow = {
addEventListener: (t, fn) => listeners.set(t, fn),
removeEventListener: () => {},
};
const pad = fakePad();
const restoreNav = stubGlobal('navigator', { getGamepads: () => [pad] });
const restoreWin = stubGlobal('window', fakeWindow);
const input = createInput(fakeWindow);
return {
input,
pad,
listeners,
press: (i, value = 1) => { pad.buttons[i] = { pressed: value > 0.5, value }; },
release: (i) => { pad.buttons[i] = { pressed: false, value: 0 }; },
restore: () => {
restoreWin();
restoreNav();
},
};
}
/**
* Camera-relative steering.
*
* Worth its own file because the failure mode is silent and infuriating:
* a sign flip here means pushing the stick forward sends the skater backwards
* only when the camera happens to be on a particular side, which is very easy
* to mistake for a physics bug.
*
* The convention under test: the camera orbits at `cameraYaw`, sitting at
* +(sin, cos) from its target, so "away from the camera" is -(sin, cos).
*/
const DT = 1 / 120;
/** Angle between two XZ directions, radians. */
function angleBetween(ax, az, bx, bz) {
const dot = (ax * bx + az * bz) / (Math.hypot(ax, az) * Math.hypot(bx, bz));
return Math.acos(Math.max(-1, Math.min(1, dot)));
}
section('pushing forward always means away from the camera');
{
for (const yaw of [0, 0.7, Math.PI / 2, 2.5, Math.PI, -1.2, -Math.PI / 2]) {
const w = stickToWorld({ x: 0, y: 1 }, yaw);
// The camera sits at +(sin, cos) * distance from its target, so away from
// it is the negative of that.
near(w.ix, -Math.sin(yaw), 1e-12, `yaw ${yaw.toFixed(2)}: forward is away from the camera (x)`);
near(w.iz, -Math.cos(yaw), 1e-12, `yaw ${yaw.toFixed(2)}: forward is away from the camera (z)`);
}
}
section('the four directions are square to each other');
{
for (const yaw of [0, 1.1, -2.2, Math.PI]) {
const f = stickToWorld({ x: 0, y: 1 }, yaw);
const b = stickToWorld({ x: 0, y: -1 }, yaw);
const r = stickToWorld({ x: 1, y: 0 }, yaw);
const l = stickToWorld({ x: -1, y: 0 }, yaw);
near(angleBetween(f.ix, f.iz, r.ix, r.iz), Math.PI / 2, 1e-9, `yaw ${yaw.toFixed(1)}: right is 90° from forward`);
near(angleBetween(f.ix, f.iz, b.ix, b.iz), Math.PI, 1e-9, `yaw ${yaw.toFixed(1)}: back is opposite forward`);
near(angleBetween(r.ix, r.iz, l.ix, l.iz), Math.PI, 1e-9, `yaw ${yaw.toFixed(1)}: left is opposite right`);
// Right must be to the camera's right, not its left. Cross product of
// forward x right about +Y is negative for a correct right-handed frame.
const cross = f.ix * r.iz - f.iz * r.ix;
ok(cross > 0, `yaw ${yaw.toFixed(1)}: "right" is on the camera's right, not its left`);
}
}
section('magnitude survives the transform');
{
for (const yaw of [0, 0.9, -1.7]) {
for (const stick of [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: 0.6, y: 0.8 }, { x: 0.3, y: -0.2 }]) {
const w = stickToWorld(stick, yaw);
near(
Math.hypot(w.ix, w.iz),
Math.hypot(stick.x, stick.y),
1e-12,
`yaw ${yaw.toFixed(1)}: a rotation does not change stick magnitude`,
);
}
}
}
section('a centred stick produces no intent');
{
for (const yaw of [0, 1.4, -2.9]) {
const w = stickToWorld({ x: 0, y: 0 }, yaw);
near(w.ix, 0, 1e-12, 'centred stick, no x');
near(w.iz, 0, 1e-12, 'centred stick, no z');
}
}
section('holding forward drives the skater away from the camera');
{
// The end-to-end claim: stick + sim together move the body where the player
// expects, from any camera angle and any starting facing.
for (const cameraYaw of [0, 1.0, -2.0, Math.PI]) {
const s = createSkaterState(0, { x: 0, z: 0, yaw: 2.3 }); // facing anywhere
const w = stickToWorld({ x: 0, y: 1 }, cameraYaw);
for (let n = 0; n < 3 / DT; n++) {
s.ix = w.ix;
s.iz = w.iz;
s.sprint = true;
stepSkater(s, DT, { clampBoards: false });
}
const travelled = angleBetween(s.x, s.z, w.ix, w.iz);
ok(
travelled < 0.2,
`camera ${cameraYaw.toFixed(1)}: skater ended up where the stick pointed (${travelled.toFixed(3)} rad off)`,
);
ok(Math.hypot(s.x, s.z) > 8, 'and actually covered ground');
}
}
section('the skater turns to face the stick regardless of where they started');
{
for (const startYaw of [0, 2.0, -2.0, Math.PI]) {
const s = createSkaterState(0, { x: 0, z: 0, yaw: startYaw });
const w = stickToWorld({ x: 0, y: 1 }, 0); // away from a camera at yaw 0
for (let n = 0; n < 2 / DT; n++) {
s.ix = w.ix;
s.iz = w.iz;
stepSkater(s, DT, { clampBoards: false });
}
const want = Math.atan2(w.ix, w.iz);
const off = Math.abs(Math.atan2(Math.sin(s.yaw - want), Math.cos(s.yaw - want)));
ok(off < 0.25, `from yaw ${startYaw.toFixed(1)}: came round to face the stick (${off.toFixed(3)} rad off)`);
}
}
section('the pad reads as an Xbox controller');
{
const h = harness();
const s = h.input.read(1 / 60);
ok(h.input.connected, 'a connected pad is found even without a connect event');
ok(s.padId.includes('Xbox'), `and identifies itself (${s.padId})`);
near(s.x, 0, 1e-9, 'a resting stick is centred (x)');
near(s.y, 0, 1e-9, 'a resting stick is centred (y)');
ok(!s.sprint && !s.brake, 'and nothing is pressed');
h.restore();
}
section('sticks have a radial deadzone and correct signs');
{
const h = harness();
h.pad.axes = [0.1, -0.1, 0, 0];
let s = h.input.read(1 / 60);
near(s.x, 0, 1e-9, 'a small drift is inside the deadzone');
near(s.y, 0, 1e-9, 'on both axes');
// Pad Y is positive *downward*, so pushing up must come out positive.
h.pad.axes = [0, -1, 0, 0];
s = h.input.read(1 / 60);
ok(s.y > 0.9, `pushing the stick up is positive y (${s.y.toFixed(2)})`);
near(s.x, 0, 1e-9, 'and no x');
h.pad.axes = [1, 0, 0, 0];
s = h.input.read(1 / 60);
ok(s.x > 0.9, `pushing right is positive x (${s.x.toFixed(2)})`);
// Full diagonal must not exceed unit length, or diagonals are faster.
h.pad.axes = [1, -1, 0, 0];
s = h.input.read(1 / 60);
ok(Math.hypot(s.x, s.y) <= 1.0001, `a full diagonal stays on the unit circle (${Math.hypot(s.x, s.y).toFixed(3)})`);
// The right stick is axes 2/3 and must not be confused with the left.
h.pad.axes = [0, 0, 0, -1];
s = h.input.read(1 / 60);
near(s.x, 0, 1e-9, 'the right stick does not move the skater');
ok(s.skill.y > 0.9, `and lands on the Skill Stick (${s.skill.y.toFixed(2)})`);
h.restore();
}
section('triggers are analog, not boolean');
{
const h = harness();
h.press(PAD.RT, 0.3);
let s = h.input.read(1 / 60);
ok(s.hustle > 0.2 && s.hustle < 0.4, `a light pull is a light hustle (${s.hustle.toFixed(2)})`);
ok(!s.sprint, 'and does not trip the sprint stride');
h.press(PAD.RT, 1);
s = h.input.read(1 / 60);
near(s.hustle, 1, 1e-9, 'a full pull is full hustle');
ok(s.sprint, 'and does trip the sprint stride');
h.press(PAD.LT, 1);
s = h.input.read(1 / 60);
ok(s.brake, 'the left trigger stops');
ok(s.protect > 0.9, `and reports analog (${s.protect.toFixed(2)})`);
h.restore();
}
section('buttons report as actions, and only on the edge');
{
const h = harness();
h.input.read(1 / 60);
h.press(PAD.A);
let s = h.input.read(1 / 60);
ok(s.pressed.pass, 'A is a pass');
ok(s.held.pass, 'and is held');
s = h.input.read(1 / 60);
ok(!s.pressed.pass, 'holding it does not re-fire the press');
ok(s.held.pass, 'but it is still held');
h.release(PAD.A);
h.press(PAD.B);
s = h.input.read(1 / 60);
ok(!s.held.pass, 'releasing clears held');
ok(s.pressed.poke, 'B is a poke check');
h.release(PAD.B);
h.press(PAD.LB);
s = h.input.read(1 / 60);
ok(s.pressed.switchPlayer, 'LB switches player');
h.restore();
}
section('the Skill Stick fires a shot on pull-back-and-push');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
// Pull back and hold, which should charge but not fire.
h.pad.axes = [0, 0, 0, 1]; // pad Y down = stick pulled back
let s;
for (let i = 0; i < 20; i++) s = h.input.read(dt);
ok(s.shot === null, 'holding the stick back does not fire');
ok(s.charge > 0.4, `it winds up instead (${s.charge.toFixed(2)})`);
// Push forward: release.
h.pad.axes = [0, 0, 0, -1];
s = h.input.read(dt);
ok(s.shot, 'pushing forward releases the shot');
ok(s.shot.power > 0.5, `with real power after a long wind-up (${s.shot.power.toFixed(2)})`);
near(s.charge, 0, 1e-9, 'and the wind-up is spent');
s = h.input.read(dt);
ok(s.shot === null, 'the shot fires once, not every frame after');
h.restore();
}
section('a quick flick is a weaker shot than a full wind-up');
{
function fire(windFrames) {
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.pad.axes = [0, 0, 0, 1];
for (let i = 0; i < windFrames; i++) h.input.read(dt);
h.pad.axes = [0, 0, 0, -1];
const s = h.input.read(dt);
h.restore();
return s.shot;
}
const flick = fire(2);
const loaded = fire(40);
ok(flick, 'a flick still fires');
ok(loaded, 'and so does a full wind-up');
ok(loaded.power > flick.power, `holding longer hits harder (${loaded.power.toFixed(2)} vs ${flick.power.toFixed(2)})`);
ok(flick.power >= 0.25, `but a snap shot is never nothing (${flick.power.toFixed(2)})`);
}
section('an abandoned wind-up is forgotten, not banked');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.pad.axes = [0, 0, 0, 1];
for (let i = 0; i < 8; i++) h.input.read(dt);
// Let go back to centre and wait it out.
h.pad.axes = [0, 0, 0, 0];
let s;
for (let i = 0; i < 150; i++) s = h.input.read(dt);
ok(s.shot === null, 'nothing fired from a wind-up left to rot');
near(s.charge, 0, 1e-9, 'and the charge decayed away');
h.restore();
}
section('the shot carries aim from the stick');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.pad.axes = [0, 0, 0.8, 1]; // wound back, stick held to the right
for (let i = 0; i < 20; i++) h.input.read(dt);
h.pad.axes = [0, 0, 0.8, -1];
const s = h.input.read(dt);
ok(s.shot, 'the shot fired');
ok(s.shot.aim > 0.5, `and remembers it was aimed right (${s.shot.aim.toFixed(2)})`);
h.restore();
}
section('the shoot button works for anyone who never learns the Skill Stick');
{
const h = harness();
h.input.read(1 / 60);
h.press(PAD.X);
const s = h.input.read(1 / 60);
ok(s.shot, 'X shoots');
ok(s.shot.power > 0 && s.shot.power <= 1, `at a sensible power (${s.shot.power.toFixed(2)})`);
h.restore();
}
section('rumble never throws, whatever the pad supports');
{
const h = harness();
ok(h.input.rumble(1, 1, 100) === false, 'a pad without haptics reports no rumble rather than crashing');
h.pad.vibrationActuator = { playEffect: () => Promise.resolve('complete') };
ok(h.input.rumble(1, 1, 100) === true, 'and a pad with them reports success');
h.pad.vibrationActuator = { playEffect: () => { throw new Error('nope'); } };
ok(h.input.rumble(1, 1, 100) === false, 'a throwing actuator is swallowed');
h.restore();
}
done('input');
+312
View File
@@ -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');
+394
View File
@@ -0,0 +1,394 @@
import * as THREE from 'three';
import { buildSkeleton } from '../src/character/skeleton.js';
import { buildAnimator } from '../src/anim/skateAnimator.js';
import { buildStick } from '../src/character/stick.js';
import { segDist } from '../src/core/math.js';
import { done, ok, section } from './harness.mjs';
/**
* Animator checks, run headlessly.
*
* Nothing here needs a GPU: the skeleton is three.js Bones and the animator is
* maths. That makes the pose the one part of the render path that can be
* regression-tested, which is worth doing because "the skater looks wrong" is
* otherwise only ever caught by a human squinting at a screenshot.
*/
const DT = 1 / 60;
function rig() {
const skelData = buildSkeleton();
const mover = new THREE.Group();
// Same hierarchy as createSkater: skeleton rides on the mover so body yaw
// carries the bones. Leaving the root unparented made every yaw test a lie —
// the stick target orbited in world space while the hand sat still.
mover.add(skelData.rootBone);
const anim = buildAnimator(skelData, mover);
// The stick is part of the pose now — it hangs off the hand and the animator
// aims it, so a rig without one is not the rig the game runs.
const stick = buildStick(null, null, 0);
stick.attachTo(skelData.bones.handR);
anim.stick = stick;
return { skelData, mover, anim, stick };
}
/** Drive the animator for `seconds` under a fixed set of inputs. */
function drive(r, seconds, inputs) {
const steps = Math.round(seconds / DT);
for (let i = 0; i < steps; i++) {
Object.assign(r.anim, inputs);
r.anim.update(DT);
}
return r;
}
const _v = new THREE.Vector3();
/** World position of a bone, relative to the mover's own frame. */
function bonePos(r, name) {
r.mover.updateMatrixWorld(true);
r.skelData.bones[name].getWorldPosition(_v);
return _v.clone().sub(r.mover.position);
}
/** How far a limb sticks out sideways, as an angle from straight down. */
function spread(hip, hand) {
const dx = Math.abs(hand.x - hip.x);
const dy = hip.y - hand.y;
return Math.atan2(dx, Math.max(1e-6, dy));
}
const GLIDE = { moveSpeed: 6, bladeSpeed: 6, effort: 0, yawRate: 0, braking: false, originYaw: 0 };
const STRIDE = { moveSpeed: 6, bladeSpeed: 6, effort: 1, yawRate: 0, braking: false, originYaw: 0 };
const STAND = { moveSpeed: 0, bladeSpeed: 0, effort: 0, yawRate: 0, braking: false, originYaw: 0 };
const CARVE = { moveSpeed: 7, bladeSpeed: 7, effort: 0.8, yawRate: 1.2, braking: false, originYaw: 0 };
section('nothing produces NaN');
{
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
const r = drive(rig(), 4, inputs);
let bad = 0;
for (const b of r.skelData.list) {
for (const e of b.matrixWorld.elements) if (!Number.isFinite(e)) bad++;
}
ok(bad === 0, `${name} leaves every bone matrix finite`);
}
}
section('the skater stands on the ice, not in it or above it');
{
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
const r = drive(rig(), 4, inputs);
for (const side of ['L', 'R']) {
const foot = bonePos(r, `foot${side}`);
ok(foot.y > -0.02, `${name}: ${side} foot is not through the ice (y=${foot.y.toFixed(3)})`);
ok(foot.y < 0.35, `${name}: ${side} foot is not floating (y=${foot.y.toFixed(3)})`);
}
}
}
section('the skater is crouched, and more so under a stride');
{
const glide = drive(rig(), 4, GLIDE);
const stride = drive(rig(), 4, STRIDE);
const hipG = bonePos(glide, 'pelvis').y;
const hipS = bonePos(stride, 'pelvis').y;
// Rest pelvis height is 1.0; a hockey stance sits well under that.
ok(hipG < 0.95, `gliding hips are below rest height (${hipG.toFixed(3)})`);
ok(hipS < hipG, `a stride sits deeper than a glide (${hipS.toFixed(3)} vs ${hipG.toFixed(3)})`);
ok(hipS > 0.6, `but not folded in half (${hipS.toFixed(3)})`);
const head = bonePos(stride, 'head');
ok(head.y > hipS + 0.35, `the head is still well above the hips (${head.y.toFixed(3)})`);
}
section('the torso is pitched forward, but not folded over');
{
/** Angle of the pelvis→neck line from vertical, degrees. */
function torsoAngle(inputs) {
const r = drive(rig(), 4, inputs);
const hips = bonePos(r, 'pelvis');
const neck = bonePos(r, 'neck');
const up = neck.clone().sub(hips);
return Math.atan2(Math.hypot(up.x, up.z), up.y) * 57.3;
}
const stand = torsoAngle(STAND);
const stride = torsoAngle(STRIDE);
ok(stand > 3 && stand < 22, `a standing skater is slightly forward (${stand.toFixed(0)}°)`);
ok(stride > 25, `at speed they are properly over their skates (${stride.toFixed(0)}°)`);
ok(stride < 55, `but not bent double (${stride.toFixed(0)}°)`);
ok(stride > stand + 8, 'and more folded moving than standing');
// The head has to come back up, or they are skating looking at their boots.
// Measured off the head bone's own forward axis rather than off a bone
// offset: the head is a leaf, so there is no child position to read a
// direction from.
const r = drive(rig(), 4, STRIDE);
r.mover.updateMatrixWorld(true);
const gazeDir = new THREE.Vector3(0, 0, 1)
.applyQuaternion(r.skelData.bones.head.getWorldQuaternion(new THREE.Quaternion()));
const gaze = Math.asin(-gazeDir.y) * 57.3;
ok(gaze < stride - 8, `the eyes are up the ice, not on the boots (${gaze.toFixed(0)}° down vs a ${stride.toFixed(0)}° torso)`);
ok(gaze > -20, 'and not craned back at the roof');
}
section('arms hang by the body, not out in a T-pose');
{
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND })) {
const r = drive(rig(), 4, inputs);
for (const side of ['L', 'R']) {
const shoulder = bonePos(r, `upperArm${side}`);
const hand = bonePos(r, `hand${side}`);
const angle = spread(shoulder, hand);
// Wider than the old free-arm limit on purpose: these hands are holding
// a stick out in front, which is not the same silhouette as a skater
// swinging their arms.
ok(
angle < 1.15,
`${name}: ${side} arm is within 66° of the body (${(angle * 57.3).toFixed(0)}°)`,
);
ok(hand.y < shoulder.y, `${name}: ${side} hand is below the shoulder`);
// Hands carried in front, the way a skater carries them.
ok(hand.z > -0.15, `${name}: ${side} hand is not trailing behind the back (z=${hand.z.toFixed(2)})`);
}
}
}
section('the elbows are bent');
{
const r = drive(rig(), 4, STRIDE);
for (const side of ['L', 'R']) {
const shoulder = bonePos(r, `upperArm${side}`);
const elbow = bonePos(r, `forearm${side}`);
const hand = bonePos(r, `hand${side}`);
const upper = elbow.clone().sub(shoulder).normalize();
const fore = hand.clone().sub(elbow).normalize();
const bend = Math.acos(Math.max(-1, Math.min(1, upper.dot(fore))));
ok(bend > 0.35, `${side} elbow is bent (${(bend * 57.3).toFixed(0)}°)`);
ok(bend < 2.2, `${side} elbow is not folded shut (${(bend * 57.3).toFixed(0)}°)`);
}
}
section('a stride moves the legs, a glide does not');
{
function footTravel(inputs) {
const r = rig();
let minZ = Infinity;
let maxZ = -Infinity;
for (let i = 0; i < 240; i++) {
Object.assign(r.anim, inputs);
r.anim.update(DT);
const f = bonePos(r, 'footL');
minZ = Math.min(minZ, f.z);
maxZ = Math.max(maxZ, f.z);
}
return maxZ - minZ;
}
const strideTravel = footTravel(STRIDE);
const glideTravel = footTravel(GLIDE);
ok(strideTravel > 0.3, `a stride swings the blade fore and aft (${strideTravel.toFixed(2)}m)`);
ok(glideTravel < 0.08, `a glide holds it still (${glideTravel.toFixed(2)}m)`);
}
section('the skater banks into a turn');
{
const straight = drive(rig(), 3, { ...CARVE, yawRate: 0 });
const right = drive(rig(), 3, { ...CARVE, yawRate: 1.4 });
const left = drive(rig(), 3, { ...CARVE, yawRate: -1.4 });
ok(Math.abs(straight.anim.bank) < 0.02, 'no bank on a straight line');
ok(right.anim.bank > 0.3, `a right-hand turn banks right (${right.anim.bank.toFixed(2)} rad)`);
ok(left.anim.bank < -0.3, `a left-hand turn banks left (${left.anim.bank.toFixed(2)} rad)`);
// The lean has to show up in the body, not just in the number.
const headR = bonePos(right, 'head');
const headS = bonePos(straight, 'head');
ok(headR.x > headS.x + 0.1, `the head leads into the turn (${headR.x.toFixed(2)} vs ${headS.x.toFixed(2)})`);
}
section('a hockey stop is a different pose');
{
const skate = drive(rig(), 3, { ...STRIDE, braking: false });
const stop = drive(rig(), 3, { ...STRIDE, braking: true });
ok(stop.anim.state === 'stop', 'braking at speed enters the stop state');
ok(skate.anim.state === 'skate', 'and not braking does not');
// Blades across the travel: the toes should be turned well off the body's
// forward axis, which is what actually scrapes the ice.
const l = stop.skelData.bones.footL.getWorldQuaternion(new THREE.Quaternion());
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(l);
const off = Math.abs(Math.atan2(fwd.x, fwd.z));
ok(off > 0.7, `the blades are thrown across the travel (${(off * 57.3).toFixed(0)}°)`);
}
section('a slow skater does not enter the stop state');
{
const r = drive(rig(), 3, { ...STAND, braking: true });
ok(r.anim.state === 'skate', 'braking from a standstill is not a hockey stop');
}
section('feet stay under the body');
{
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, CARVE })) {
const r = drive(rig(), 4, inputs);
for (const side of ['L', 'R']) {
const foot = bonePos(r, `foot${side}`);
ok(Math.abs(foot.x) < 0.75, `${name}: ${side} blade is not splayed out (x=${foot.x.toFixed(2)})`);
ok(Math.abs(foot.z) < 0.6, `${name}: ${side} blade is not stretched out (z=${foot.z.toFixed(2)})`);
}
}
}
section('the blade is on the ice, ahead of the skater');
{
// The failure this pins: a socket rotation authored in hand space composes
// with whatever the arm is doing, so a grip tuned for one gait floats the
// blade half a metre up in another. Checked across every skating stance.
for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
const r = drive(rig(), 4, { ...inputs, hasPuck: true });
const blade = new THREE.Vector3();
r.stick.bladeWorld(blade);
const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
const local = blade.clone().applyMatrix4(inv);
ok(local.y > -0.02 && local.y < 0.16, `${name}: blade is on the ice (y=${local.y.toFixed(3)})`);
ok(local.z > 0.5, `${name}: and out in front (z=${local.z.toFixed(2)})`);
// Carry keeps the blade near the body midline, slightly forehand — not
// parked a metre off the hip.
ok(Math.abs(local.x) < 0.75, `${name}: not flung out sideways (x=${local.x.toFixed(2)})`);
}
}
section('the stick is held, not floating');
{
// Puck carry must be two-handed: top hand on the butt, lower hand on the
// shaft. The old pose parked the stick on the hip and left the off-hand
// ~25 cm short — the failure the motion-reference carry frame calls out.
const r = drive(rig(), 4, { ...GLIDE, effort: 0.2, hasPuck: true });
r.mover.updateMatrixWorld(true);
const butt = new THREE.Vector3();
const heel = new THREE.Vector3();
r.stick.shaftSegment(butt, heel);
const handR = new THREE.Vector3();
r.skelData.bones.handR.getWorldPosition(handR);
ok(handR.distanceTo(butt) < 0.12, `the top hand is on the butt of the stick (${handR.distanceTo(butt).toFixed(3)}m)`);
const handL = new THREE.Vector3();
const closest = new THREE.Vector3();
r.skelData.bones.handL.getWorldPosition(handL);
const gap = segDist(handL, butt, heel, closest);
ok(gap < 0.08, `the lower hand is on the shaft (${gap.toFixed(3)}m)`);
// Stick sits in front of the body, not parked out on the hip.
const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
const handLocal = handR.clone().applyMatrix4(inv);
ok(Math.abs(handLocal.x) < 0.28, `top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`);
ok(handLocal.z > 0.25, `top hand is out in front (z=${handLocal.z.toFixed(2)})`);
}
section('the stick stays in the socket when the body turns');
{
// Failure this pins: aiming with setFromUnitVectors in *world* space leaves a
// free twist around the shaft that does not cancel under parent yaw. The stick
// then rolls with every body turn instead of holding a fixed grip in the hand.
const r = rig();
// Settle derived quantities first so the spin only changes yaw.
drive(r, 2, { ...GLIDE, hasPuck: true, yawRate: 0 });
const local0 = new THREE.Quaternion();
const local = new THREE.Quaternion();
const handQ = new THREE.Quaternion();
const stickQ = new THREE.Quaternion();
let maxDelta = 0;
for (let i = 0; i < 48; i++) {
const yaw = (i / 48) * Math.PI * 2;
r.anim.setTransform(r.mover.position, yaw);
Object.assign(r.anim, { ...GLIDE, hasPuck: true, originYaw: yaw, yawRate: 0 });
r.anim.update(DT);
r.skelData.bones.handR.getWorldQuaternion(handQ);
r.stick.group.getWorldQuaternion(stickQ);
local.copy(handQ).invert().multiply(stickQ);
if (i === 0) local0.copy(local);
// 1 - |dot| is 0 for identical orientations (including double-cover).
maxDelta = Math.max(maxDelta, 1 - Math.abs(local0.dot(local)));
}
ok(maxDelta < 0.02, `stick local pose is stable across a full spin (delta ${maxDelta.toFixed(4)})`);
}
section('stick actions run and finish');
{
for (const action of ['shoot', 'pass', 'poke']) {
const r = rig();
drive(r, 1, GLIDE);
r.anim.playAction(action, { power: 1 });
ok(r.anim.action === action, `${action} started`);
// Halfway through it must still be running.
for (let i = 0; i < 8; i++) {
Object.assign(r.anim, GLIDE);
r.anim.update(DT);
}
ok(r.anim.action === action, `${action} is still running mid-way`);
for (let i = 0; i < 60; i++) {
Object.assign(r.anim, GLIDE);
r.anim.update(DT);
}
ok(r.anim.action === null, `${action} finished and cleared`);
}
}
section('a wind-up lifts the blade off the ice and holds');
{
const r = rig();
drive(r, 1, { ...GLIDE, hasPuck: true });
const flat = new THREE.Vector3();
r.stick.bladeWorld(flat);
const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
const flatLocal = flat.clone().applyMatrix4(inv);
r.anim.action = 'windup';
r.anim.actionTime = 0;
for (let i = 0; i < 90; i++) {
Object.assign(r.anim, { ...GLIDE, hasPuck: true, charge: 1 });
r.anim.action = 'windup';
r.anim.update(DT);
}
const back = new THREE.Vector3();
r.stick.bladeWorld(back);
const backLocal = back.clone().applyMatrix4(inv);
// High and back behind the head — not hanging blade-down at hip height.
ok(backLocal.y > 1.1, `the blade is up high (y=${backLocal.y.toFixed(2)})`);
ok(backLocal.y > flatLocal.y + 0.8, `well above the carry (${flatLocal.y.toFixed(2)}${backLocal.y.toFixed(2)})`);
ok(backLocal.z < -0.15, `and back behind the body (z=${backLocal.z.toFixed(2)})`);
ok(r.anim.action === 'windup', 'and the wind-up is held, not played once');
}
section('Skill Stick right moves the blade to the skater\'s right');
{
// Local +X is the skater's *left*. Skill Stick +X is pad-right. Getting the
// sign wrong mirrored every deke.
const right = drive(rig(), 3, { ...GLIDE, hasPuck: true, handling: { x: 1, y: 0 } });
const left = drive(rig(), 3, { ...GLIDE, hasPuck: true, handling: { x: -1, y: 0 } });
const inv = new THREE.Matrix4().copy(right.mover.matrixWorld).invert();
const br = new THREE.Vector3();
const bl = new THREE.Vector3();
right.stick.bladeWorld(br);
left.stick.bladeWorld(bl);
br.applyMatrix4(inv);
bl.applyMatrix4(new THREE.Matrix4().copy(left.mover.matrixWorld).invert());
// Skater's right is X: stick-right must land more negative than stick-left.
ok(br.x < bl.x - 0.3, `stick-right is on the right (x ${br.x.toFixed(2)} vs ${bl.x.toFixed(2)})`);
}
section('hustling changes the grip');
{
const settled = drive(rig(), 4, { ...GLIDE, effort: 0, moveSpeed: 1, hasPuck: true });
const flatOut = drive(rig(), 4, { ...STRIDE, hasPuck: false });
ok(settled.anim.hustleGrip < 0.35, `a settled skater keeps two hands on it (${settled.anim.hustleGrip.toFixed(2)})`);
ok(flatOut.anim.hustleGrip > 0.7, `a skater at full stride dangles it (${flatOut.anim.hustleGrip.toFixed(2)})`);
const a = new THREE.Vector3();
const b = new THREE.Vector3();
settled.stick.bladeWorld(a);
flatOut.stick.bladeWorld(b);
ok(b.z > a.z + 0.1, `and pushes the blade further out front (${a.z.toFixed(2)}${b.z.toFixed(2)})`);
}
done('pose');
+324
View File
@@ -0,0 +1,324 @@
import * as THREE from 'three';
import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
import { PUCK, createPuck } from '../src/physics/puck.js';
import { createMatch } from '../src/game/match.js';
import { CARRY } from '../src/game/possession.js';
import { RINK, insideRink } from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
/**
* Puck, stick and possession.
*
* The two things worth testing hard are the ones that are hard to see: that a
* 45 m/s shot does not tunnel through the boards (it moves ten times its own
* radius per step, so it will unless it is a bullet), and that possession
* behaves sanely at both ends of the magnetism dial — because that dial is a
* feel decision that has not been made yet, and the code has to survive
* wherever it lands.
*/
const DT = 1 / 60;
await initPhysics();
function arena(perTeam = 1) {
const physics = createPhysicsWorld();
const match = createMatch({ scene: new THREE.Group(), physics, perTeam, teams: 2 });
return { physics, match };
}
/** Park everyone far from the play so they cannot interfere. */
function clearIce(match, keep = []) {
for (let i = 0; i < match.states.length; i++) {
if (keep.includes(i)) continue;
const x = -RINK.halfX * 0.8 + i * 3;
match.states[i].x = x;
match.states[i].z = -RINK.halfZ * 0.75;
match.states[i].vx = 0;
match.states[i].vz = 0;
match.skaters[i].proxy.teleport(x, -RINK.halfZ * 0.75);
match.setControl(i, { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
}
}
section('the puck is a regulation puck');
{
const physics = createPhysicsWorld();
const puck = createPuck(physics);
near(PUCK.radius * 2, 0.0762, 1e-4, 'three inches across');
near(PUCK.thickness, 0.0254, 1e-4, 'one inch thick');
near(puck.mass, 0.170, 0.005, `and 170 grams (got ${puck.mass.toFixed(3)} kg)`);
puck.destroy();
physics.destroy();
}
section('the puck settles flat on the ice and stays there');
{
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: 0, y: 1.5, z: 0 } });
for (let n = 0; n < 3 / DT; n++) physics.step(DT);
const p = puck.position();
ok(p.y > 0 && p.y < 0.05, `it lands on the surface (y=${p.y.toFixed(4)})`);
// Angular X and Z are locked, so it can never be standing on its edge.
const q = puck.rotation();
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q);
ok(up.y > 0.999, `and lies flat rather than rolling on its edge (up.y=${up.y.toFixed(4)})`);
puck.destroy();
physics.destroy();
}
section('a hard shot does not tunnel through the boards');
{
// The headline risk. At 45 m/s the puck covers 0.37 m per 1/120 s step,
// roughly ten times its own radius, so without continuous collision it goes
// straight through the wall and is never seen again.
for (const speed of [20, 45, 55]) {
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } });
puck.setVelocity(speed, 0, 0);
for (let n = 0; n < 4 / DT; n++) physics.step(DT);
const p = puck.position();
ok(
insideRink(p.x, p.z, 0),
`a ${speed} m/s shot stayed in the rink (ended at x=${p.x.toFixed(2)}, z=${p.z.toFixed(2)})`,
);
ok(Math.abs(p.y) < 1.5, `and did not go over the glass (y=${p.y.toFixed(2)})`);
puck.destroy();
physics.destroy();
}
}
section('a shot into the corner stays in the corner');
{
// Corners are a chain of short board segments; the seams between them are
// where a fast small body escapes if anything is going to.
for (const angle of [0.6, 1.1, -0.7, 2.4]) {
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } });
puck.setVelocity(Math.sin(angle) * 48, 0, Math.cos(angle) * 48);
for (let n = 0; n < 5 / DT; n++) physics.step(DT);
const p = puck.position();
ok(insideRink(p.x, p.z, 0), `a shot at ${angle.toFixed(1)} rad stayed inside`);
puck.destroy();
physics.destroy();
}
}
section('a dumped puck slides a long way but does stop');
{
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: -RINK.halfX * 0.9, y: 0.02, z: 0 } });
puck.setVelocity(14, 0, 0);
let travelled = 0;
const start = puck.position().x;
for (let n = 0; n < 6 / DT; n++) physics.step(DT);
travelled = Math.abs(puck.position().x - start);
ok(travelled > 15, `it carries down the ice (${travelled.toFixed(1)}m in 6s)`);
ok(puck.speed() < 14, `and does lose speed (${puck.speed().toFixed(1)} m/s left)`);
puck.destroy();
physics.destroy();
}
section('a skater picks up a loose puck');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = 0;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(0, 0);
// Drop the puck right where skater 0's blade is.
match.puck.place(0.8, 0.02, 0.2);
for (let n = 0; n < 1 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, `skater 0 picked it up (carrier=${match.possession.carrier})`);
physics.destroy();
}
section('a carried puck stays with the skater');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -20;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-20, 0);
match.puck.place(-20 + 0.8, 0.02, 0);
match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'possession established');
let maxGap = 0;
for (let n = 0; n < 2.5 / DT; n++) {
match.update(DT);
if (match.possession.carrier !== 0) break;
const p = match.puck.position();
maxGap = Math.max(maxGap, Math.hypot(p.x - match.states[0].x, p.z - match.states[0].z));
}
ok(match.possession.carrier === 0, 'and survived a full-speed rush');
ok(maxGap < CARRY.breakRadius + 1, `the puck stayed with the stick (worst ${maxGap.toFixed(2)}m)`);
ok(match.states[0].x > -12, `while actually covering ground (x=${match.states[0].x.toFixed(1)})`);
physics.destroy();
}
section('the dial does what it says at both ends');
{
function carryWander(magnetism) {
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -20;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-20, 0);
match.puck.place(-20 + 0.8, 0.02, 0);
match.possession.tuning.magnetism = magnetism;
match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
const had = match.possession.carrier === 0;
let worst = 0;
let held = 0;
for (let n = 0; n < 2 / DT; n++) {
match.update(DT);
if (match.possession.carrier === 0) {
held++;
const p = match.puck.position();
const c = match.possession.carryPoint(new THREE.Vector3());
if (c) worst = Math.max(worst, p.distanceTo(c));
}
}
physics.destroy();
return { had, worst, held: held / (2 / DT) };
}
const glued = carryWander(1);
const loose = carryWander(0.15);
ok(glued.had && loose.had, 'both settings pick the puck up');
ok(
glued.worst < loose.worst,
`high magnetism keeps the puck tighter to the blade (${glued.worst.toFixed(3)}m vs ${loose.worst.toFixed(3)}m)`,
);
ok(glued.held > 0.9, `and holds possession through the rush (${(glued.held * 100).toFixed(0)}% of frames)`);
}
section('shooting sends the puck away and gives up possession');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -10;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-10, 0);
match.puck.place(-10 + 0.8, 0.02, 0);
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'carrying first');
const fired = match.possession.shoot(1, Math.PI / 2);
ok(fired, 'the shot fired');
ok(match.possession.carrier === null, 'and possession was given up');
ok(match.puck.speed() > 25, `the puck is moving like a shot (${match.puck.speed().toFixed(1)} m/s)`);
// And it must not be instantly re-captured by the shooter.
for (let n = 0; n < 0.1 / DT; n++) match.update(DT);
ok(match.possession.carrier !== 0, 'the shooter cannot immediately vacuum it back up');
physics.destroy();
}
section('a harder shot travels faster than a soft one');
{
function fire(power) {
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -10;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-10, 0);
match.puck.place(-10 + 0.8, 0.02, 0);
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
match.possession.shoot(power, Math.PI / 2);
const speed = match.puck.speed();
physics.destroy();
return speed;
}
const soft = fire(0.2);
const hard = fire(1);
ok(hard > soft * 2, `full power is far harder than a soft one (${hard.toFixed(1)} vs ${soft.toFixed(1)} m/s)`);
ok(hard < PUCK.maxSpeed, 'and stays under the ceiling');
}
section('a knockdown loses the puck');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = 0;
match.states[0].z = 0;
match.skaters[0].proxy.teleport(0, 0);
match.puck.place(0.8, 0.02, 0.2);
for (let n = 0; n < 1 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'carrying first');
match.skaters[0].goDown(null);
match.update(DT);
ok(match.possession.carrier === null, 'going down gives the puck up');
physics.destroy();
}
section('the Skill Stick moves the puck around the carrier');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = 0;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(0, 0);
match.puck.place(0.8, 0.02, 0);
const control = { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0, skill: { x: 0, y: 0 }, pressed: {} };
match.setControl(0, control);
for (let n = 0; n < 1 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'carrying first');
control.skill.x = 1;
for (let n = 0; n < 0.6 / DT; n++) match.update(DT);
const right = match.puck.position().clone();
control.skill.x = -1;
for (let n = 0; n < 0.6 / DT; n++) match.update(DT);
const left = match.puck.position().clone();
// Skater faces +X (yaw = π/2). Mesh-right is local X (handR side), which
// is world +Z at that yaw — not the sim's up×forward "right", which is
// mirrored from the skeleton. Stick-right must follow the mesh.
ok(
right.z > left.z + 0.25,
`stick-right moves the puck to the skater's right (${right.z.toFixed(2)} vs ${left.z.toFixed(2)})`,
);
physics.destroy();
}
section('a 3-on-3 with a puck stays sane and produces contact');
{
// This is the payoff: give six skaters one thing to want and they converge,
// which is what finally exercises the hit system under normal play instead
// of only in staged collisions.
const { physics, match } = arena(3);
let hits = 0;
const seen = new Set();
for (let n = 0; n < 60 / DT; 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);
hits++;
}
}
}
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 60s`);
ok(insideRink(s.x, s.z, 0.3), `skater ${i} still on the ice`);
}
const p = match.puck.position();
ok(Number.isFinite(p.x) && Number.isFinite(p.z), 'the puck is finite');
ok(insideRink(p.x, p.z, 0), `the puck is still on the ice (${p.x.toFixed(1)}, ${p.z.toFixed(1)})`);
ok(hits > 0, `chasing a puck produced contact without staging it (${hits} hits in 60s)`);
physics.destroy();
}
done('puck');
+77
View File
@@ -0,0 +1,77 @@
import { RINK, clampToRink, insideRink, randomIcePoint, rinkOutline, rinkPenetration } from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
section('penetration on the straights');
{
ok(insideRink(0, 0), 'centre ice is on the ice');
ok(!insideRink(RINK.halfX + 1, 0), 'past the end boards is not');
ok(!insideRink(0, RINK.halfZ + 1), 'past the side boards is not');
const p = rinkPenetration(0, RINK.halfZ + 0.5);
near(p.dist, 0.5, 1e-9, 'side board penetration');
near(p.nz, -1, 1e-9, 'side board normal points back to centre');
}
section('penetration in the corners');
{
// The corner arc centre, pushed out along the diagonal by exactly the radius,
// has to land on the boards.
const cx = RINK.halfX - RINK.cornerR;
const cz = RINK.halfZ - RINK.cornerR;
const d = RINK.cornerR / Math.SQRT2;
const p = rinkPenetration(cx + d, cz + d);
near(p.dist, 0, 1e-9, 'diagonal from the corner centre lands on the boards');
near(Math.hypot(p.nx, p.nz), 1, 1e-9, 'corner normal is unit length');
ok(p.nx < 0 && p.nz < 0, 'corner normal points inward');
// A point in the corner quadrant but inside the arc is on the ice, even
// though it is outside neither straight wall — this is the case a plain
// rectangle test gets wrong.
ok(insideRink(cx + 1, cz + 1), 'inside the corner arc is on the ice');
ok(!insideRink(RINK.halfX - 0.5, RINK.halfZ - 0.5), 'the clipped corner is off the ice');
}
section('radius is respected');
{
ok(!insideRink(0, RINK.halfZ - 0.2, 0.36), 'a body wider than its gap does not fit');
ok(insideRink(0, RINK.halfZ - 1, 0.36), 'the same body fits with room to spare');
}
section('clamping kills inward velocity');
{
const s = { x: 0, z: RINK.halfZ + 0.2, vx: 1, vz: 3 };
const hit = clampToRink(s, 0.36, 0);
ok(hit, 'a body past the boards reports a hit');
ok(insideRink(s.x, s.z, 0.36), 'and is put back on the ice');
near(s.vz, 0, 1e-9, 'velocity into the boards is removed');
near(s.vx, 1, 1e-9, 'velocity along them is kept');
const bouncy = { x: 0, z: RINK.halfZ + 0.2, vx: 0, vz: 4 };
clampToRink(bouncy, 0.36, 0.5);
near(bouncy.vz, -2, 1e-9, 'restitution reverses half the closing speed');
const clear = { x: 0, z: 0, vx: 5, vz: 0 };
ok(!clampToRink(clear, 0.36), 'centre ice is not clamped');
near(clear.vx, 5, 1e-9, 'and keeps its speed');
}
section('outline follows the boards');
{
const outline = rinkOutline(8);
ok(outline.length === 36, 'four arcs of nine points');
let maxOff = 0;
for (const p of outline) maxOff = Math.max(maxOff, Math.abs(rinkPenetration(p.x, p.z).dist));
near(maxOff, 0, 1e-9, 'every outline point sits exactly on the boards');
}
section('random points land on the ice');
{
let n = 0;
const rand = () => ((n = (n * 1103515245 + 12345) % 2147483648) / 2147483648);
for (let i = 0; i < 500; i++) {
const p = randomIcePoint(rand, 3);
ok(insideRink(p.x, p.z, 3), `waypoint ${i} is 3m clear of the boards`);
}
}
done('rink');
+237
View File
@@ -0,0 +1,237 @@
import * as THREE from 'three';
import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
import { createMatch } from '../src/game/match.js';
import { createShootout } from '../src/game/shootout.js';
import { NET, goalLineX, goalieSpot, isGoal } from '../shared/net.js';
import { PUCK } from '../src/physics/puck.js';
import { done, near, ok, section } from './harness.mjs';
/**
* The shootout: net, goalie, and the loop that turns them into a result.
*
* The thing worth testing hard is that it *terminates*. A shootout that can
* hang — a puck asleep in a corner, a goalie who never lets go of it, an
* attempt with no way to end — is worse than one that scores wrongly, because
* nothing tells you it has happened.
*/
const DT = 1 / 60;
await initPhysics();
function arena() {
const physics = createPhysicsWorld();
const match = createMatch({ scene: new THREE.Group(), physics, perTeam: 3, teams: 2 });
const shootout = createShootout({ scene: new THREE.Group(), physics, match });
match.addSubstepSync((dt) => {
shootout.goalies[1].syncPhysics(dt);
shootout.goalies[-1].syncPhysics(dt);
});
shootout.reset();
return { physics, match, shootout };
}
function run(w, seconds) {
for (let n = 0; n < seconds / DT; n++) {
w.match.update(DT);
w.shootout.update(DT);
}
}
section('goal detection follows the rule');
{
const end = 1;
const line = goalLineX(end);
const r = PUCK.radius;
ok(!isGoal({ x: line, y: 0.02, z: 0 }, end, r), 'a puck on the line is not a goal');
ok(!isGoal({ x: line + r * 0.5, y: 0.02, z: 0 }, end, r), 'nor one only half across');
ok(isGoal({ x: line + r * 2, y: 0.02, z: 0 }, end, r), 'fully across and between the posts is');
ok(!isGoal({ x: line + r * 2, y: 0.02, z: NET.width }, end, r), 'wide of the post is not');
ok(!isGoal({ x: line + r * 2, y: NET.height + 0.2, z: 0 }, end, r), 'over the bar is not');
ok(!isGoal({ x: line + NET.depth + 0.5, y: 0.02, z: 0 }, end, r), 'behind the net is not');
// And the same at the other end, where every sign flips.
const l2 = goalLineX(-1);
ok(isGoal({ x: l2 - r * 2, y: 0.02, z: 0 }, -1, r), 'the far end scores too');
ok(!isGoal({ x: l2 + r * 2, y: 0.02, z: 0 }, -1, r), 'and not from in front of it');
}
section('the goalie plays the angle');
{
const end = 1;
const line = goalLineX(end);
const spot = { x: 0, z: 0 };
goalieSpot({ x: 0, z: 0 }, end, 0.6, spot);
near(spot.z, 0, 1e-9, 'a puck dead centre puts them dead centre');
ok(spot.x < line && spot.x > line - 1, `and out in front of the line (${spot.x.toFixed(2)})`);
// Puck to one side: the goalie shifts the same way, but less.
goalieSpot({ x: line - 8, z: 4 }, end, 0.6, spot);
ok(spot.z > 0, 'a puck to the left moves them left');
ok(spot.z < 4, 'but they do not chase it out there');
ok(Math.abs(spot.z) <= NET.width / 2 + 0.25, `and never past the post (${spot.z.toFixed(2)})`);
// Extreme angle: still covering the post, never abandoning the net.
goalieSpot({ x: line, z: 12 }, end, 0.6, spot);
ok(Math.abs(spot.z) <= NET.width / 2 + 0.25, 'even from the goal line corner');
}
section('a shootout sets itself up');
{
const w = arena();
const so = w.shootout.state;
ok(so.phase === 'ready', 'it starts in the ready phase');
ok(so.score[0] === 0 && so.score[1] === 0, 'nil-nil');
ok(w.match.possession.carrier === null, 'nobody starts holding the puck');
const s = w.match.states[so.shooter];
const puck = w.match.puck.position();
near(Math.hypot(puck.x, puck.z), 0, 0.3, 'the puck is on the dot at centre ice');
ok(Math.hypot(s.x - puck.x, s.z - puck.z) > 3, `and the shooter starts back from it (${Math.hypot(s.x - puck.x, s.z - puck.z).toFixed(1)}m)`);
w.physics.destroy();
}
section('the shooter skates onto the puck rather than spawning on it');
{
const w = arena();
ok(w.match.possession.carrier === null, 'loose at the start');
// Give them time to get released and reach it.
let gained = false;
for (let n = 0; n < 6 / DT; n++) {
w.match.update(DT);
w.shootout.update(DT);
if (w.match.possession.carrier === w.shootout.state.shooter) {
gained = true;
break;
}
}
ok(gained, 'the shooter picked the puck up on the way through');
w.physics.destroy();
}
section('losing the handle does not end the attempt');
{
const w = arena();
// Run to live, then knock the puck away from whoever has it.
for (let n = 0; n < 5 / DT; n++) {
w.match.update(DT);
w.shootout.update(DT);
if (w.shootout.state.phase === 'live' && w.match.possession.carrier !== null) break;
}
ok(w.shootout.state.phase === 'live', 'the attempt is live');
const before = w.shootout.state.attempts.slice();
w.match.possession.release('test', 0.2);
w.match.puck.setVelocity(0, 0, 0);
// Sit on a dead loose puck for well over the old two-second dead timeout.
for (let n = 0; n < 3 / DT; n++) {
w.match.update(DT);
w.shootout.update(DT);
}
ok(
w.shootout.state.attempts[0] === before[0] && w.shootout.state.attempts[1] === before[1],
'a dead loose puck did not end the attempt',
);
w.physics.destroy();
}
section('the AI takes attempts and they all resolve');
{
const w = arena();
const results = [];
let lastRound = null;
for (let n = 0; n < 120 / DT; n++) {
w.match.update(DT);
w.shootout.update(DT);
const last = w.shootout.state.last;
if (last && last !== lastRound) {
results.push(last);
lastRound = last;
}
}
ok(results.length >= 4, `several attempts completed in two minutes (${results.length})`);
for (const r of results) {
ok(r.result === 'goal' || r.result === 'save', `every attempt resolved (${r.result} ${r.detail})`);
}
const so = w.shootout.state;
ok(so.attempts[0] > 0 && so.attempts[1] > 0, 'both teams got to shoot');
ok(Math.abs(so.attempts[0] - so.attempts[1]) <= 1, 'and the sides alternate');
w.physics.destroy();
}
section('bots actually shoot');
{
// This is the gap that made the whole shooting layer human-only: a bot with
// the puck used to carry it forever.
const w = arena();
let shots = 0;
const seen = new Set();
for (let n = 0; n < 90 / DT; n++) {
w.match.update(DT);
w.shootout.update(DT);
for (const p of w.match.recentPlays) {
const id = `${p.at}|${p.type}|${p.skater}`;
if (!seen.has(id)) {
seen.add(id);
if (p.type === 'shot') shots++;
}
}
}
ok(shots > 0, `bots put shots on net without a human driving (${shots})`);
w.physics.destroy();
}
section('the goalie makes saves and the shooter sometimes scores');
{
// Over enough attempts both outcomes have to be reachable, or the goalie is
// either a wall or a turnstile and neither is a game.
const w = arena();
let goals = 0;
let saves = 0;
let lastRound = null;
for (let n = 0; n < 240 / DT; n++) {
w.match.update(DT);
w.shootout.update(DT);
const last = w.shootout.state.last;
if (last && last !== lastRound) {
lastRound = last;
if (last.result === 'goal') goals++;
else saves++;
}
}
ok(goals + saves >= 8, `plenty of attempts to judge on (${goals + saves})`);
ok(saves > 0, `the goalie stops some (${saves} saves)`);
// Not asserted the other way round: a goalie who is currently unbeatable is
// a tuning problem, and the number is reported so it can be tuned.
console.log(` ${goals} goals / ${saves} saves`);
w.physics.destroy();
}
section('nothing escapes and nothing hangs');
{
const w = arena();
run(w, 120);
const p = w.match.puck.position();
ok(Number.isFinite(p.x) && Number.isFinite(p.z), 'the puck is finite');
ok(Math.abs(p.x) < 40 && Math.abs(p.z) < 20, `and still in the building (${p.x.toFixed(1)}, ${p.z.toFixed(1)})`);
for (let i = 0; i < w.match.states.length; i++) {
ok(Number.isFinite(w.match.states[i].x), `skater ${i} is finite`);
}
ok(w.shootout.state.round > 1, `rounds advanced (round ${w.shootout.state.round})`);
w.physics.destroy();
}
section('the goalie is not knocked around by the puck');
{
const w = arena();
run(w, 3);
const g = w.shootout.goalies[1];
const before = { x: g.pos.x, z: g.pos.z };
// Fire a puck straight into them at full pace.
w.match.puck.place(goalLineX(1) - 4, 0.3, 0);
w.match.puck.setVelocity(45, 0, 0);
run(w, 0.6);
// They may have shuffled to track it, but not been shoved into the net.
ok(Math.abs(g.pos.x - before.x) < 1.2, `the goalie held their ground (${(g.pos.x - before.x).toFixed(2)}m)`);
w.physics.destroy();
}
done('shootout');
+246
View File
@@ -0,0 +1,246 @@
import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
import { RINK, insideRink } from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
const DT = 1 / 120;
/**
* Run the sim for `seconds`, optionally editing the state each step.
*
* Board clamping is off by default so a test about acceleration is not
* secretly a test about the end boards. The containment section turns it back
* on, which is the only place it is the subject.
*/
function run(s, seconds, edit = null, opts = { clampBoards: false }) {
const steps = Math.round(seconds / DT);
for (let i = 0; i < steps; i++) {
if (edit) edit(s, i * DT);
stepSkater(s, DT, opts);
}
return s;
}
/** Down the ice: the rink's long axis is +X, which is yaw = PI/2. */
const START = { x: 0, z: 0, yaw: Math.PI / 2 };
const forward = (s) => {
s.ix = 1;
s.iz = 0;
};
const coast = (s) => {
s.ix = 0;
s.iz = 0;
};
section('the stride reaches a speed and holds it');
{
const s = createSkaterState(0, START);
run(s, 8, forward);
const cruise = speedOf(s);
ok(cruise > 4.5, `cruise settles above 4.5 m/s (got ${cruise.toFixed(2)})`);
ok(cruise <= SKATE.cruiseSpeed, `and never exceeds the cruise ceiling (${cruise.toFixed(2)})`);
// Another four seconds must not keep adding speed.
const before = speedOf(s);
run(s, 4, forward);
near(speedOf(s), before, 0.05, 'top speed is stable, not creeping');
}
section('acceleration takes time — you cannot jump to top speed');
{
const s = createSkaterState(0, START);
run(s, 0.5, forward);
const half = speedOf(s);
ok(half > 0.8, `half a second of pushing gets you moving (${half.toFixed(2)} m/s)`);
ok(half < 4, 'but nowhere near cruise');
}
section('sprinting is meaningfully faster');
{
const cruiser = createSkaterState(0, START);
run(cruiser, 8, forward);
const sprinter = createSkaterState(1, START);
run(sprinter, 8, (s) => {
forward(s);
s.sprint = true;
});
ok(
speedOf(sprinter) > speedOf(cruiser) + 1.5,
`sprint beats cruise by more than 1.5 m/s (${speedOf(sprinter).toFixed(2)} vs ${speedOf(cruiser).toFixed(2)})`,
);
ok(speedOf(sprinter) <= SKATE.sprintSpeed, 'and stays under the sprint ceiling');
}
section('a glide keeps its momentum');
{
const s = createSkaterState(0, START);
run(s, 8, forward);
const entry = speedOf(s);
run(s, 3, coast);
const after = speedOf(s);
ok(after > entry * 0.6, `three seconds of glide keeps most of the speed (${after.toFixed(2)} of ${entry.toFixed(2)})`);
ok(after < entry, 'but not all of it');
}
section('braking is much faster than gliding');
{
const glide = createSkaterState(0, START);
run(glide, 8, forward);
const brake = createSkaterState(1, START);
run(brake, 8, forward);
near(speedOf(glide), speedOf(brake), 0.01, 'both start from the same speed');
run(glide, 1, coast);
run(brake, 1, (s) => {
coast(s);
s.brake = true;
});
ok(speedOf(brake) < 0.6, `a hockey stop is done inside a second (${speedOf(brake).toFixed(2)} m/s left)`);
ok(speedOf(glide) > speedOf(brake) * 4, 'a glide over the same second is nowhere near stopped');
}
section('the blade kills sideways drift');
{
const s = createSkaterState(0, START);
// Thrown across the blade at 4 m/s: body pointing +X, momentum along +Z.
s.vx = 0;
s.vz = 4;
run(s, 1.5, coast);
const velYaw = Math.atan2(s.vx, s.vz);
const offBlade = Math.abs(Math.abs(velYaw) - Math.PI / 2);
ok(offBlade < 0.25, `momentum ends up along the blade, not across it (${offBlade.toFixed(3)} rad off)`);
}
section('a carve redirects momentum instead of destroying it');
{
const s = createSkaterState(0, START);
run(s, 6, forward);
const entry = speedOf(s);
ok(Math.abs(Math.atan2(s.vx, s.vz) - Math.PI / 2) < 0.05, 'travelling straight down the ice first');
// Ninety degrees of turn: the stick swings from +X to -Z.
run(s, 1.2, (st) => {
st.ix = 0;
st.iz = -1;
});
const velYaw = Math.atan2(s.vx, s.vz);
ok(velYaw > 2.2, `the velocity vector followed the turn round (${velYaw.toFixed(2)} rad, want ~PI)`);
ok(speedOf(s) > entry * 0.4, `and kept real speed through it (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`);
ok(speedOf(s) < entry, 'a hard carve is not free');
}
section('momentum resists an instant reversal');
{
const s = createSkaterState(0, START);
run(s, 6, forward);
const entryX = s.vx;
ok(entryX > 3, 'moving down the ice to begin with');
// A tenth of a second of "go back the other way" must not flip the velocity.
run(s, 0.1, (st) => {
st.ix = -1;
st.iz = 0;
});
ok(s.vx > 0, 'still travelling the original way a tenth of a second later');
ok(s.vx < entryX, 'but already losing speed to the edges');
}
section('a turn on the spot costs nothing');
{
const s = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
run(s, 0.6, (st) => {
st.ix = 1;
st.iz = 0;
});
ok(Math.abs(s.yaw - Math.PI / 2) < 0.35, `a standing skater can pivot (yaw ${s.yaw.toFixed(2)})`);
}
section('turning is harder at speed than at rest');
{
const slow = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
run(slow, 0.3, (st) => {
st.ix = 1;
st.iz = 0;
});
const fast = createSkaterState(1, { x: 0, z: 0, yaw: 0 });
run(fast, 6, (st) => {
st.ix = 0;
st.iz = 1;
st.sprint = true;
});
const before = fast.yaw;
run(fast, 0.3, (st) => {
st.ix = 1;
st.iz = 0;
st.sprint = true;
});
ok(
Math.abs(fast.yaw - before) < Math.abs(slow.yaw),
`a flying skater turns slower than a standing one (${(fast.yaw - before).toFixed(3)} vs ${slow.yaw.toFixed(3)} rad)`,
);
}
section('nothing leaves the rink');
{
// Point skaters at the boards from centre ice and hold it for ten seconds.
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const s = createSkaterState(i, { x: 0, z: 0, yaw: a });
run(s, 10, (st) => {
st.ix = Math.sin(a);
st.iz = Math.cos(a);
st.sprint = true;
}, { clampBoards: true });
ok(insideRink(s.x, s.z, SKATE.radius), `skater driving at heading ${a.toFixed(2)} stayed on the ice`);
ok(Number.isFinite(s.x) && Number.isFinite(s.z), 'and its position stayed finite');
}
}
section('the sim is deterministic');
{
const drive = (st, t) => {
st.ix = Math.sin(t * 1.3);
st.iz = Math.cos(t * 0.7);
st.sprint = t > 3;
};
const a = createSkaterState(0, { x: 4, z: -6, yaw: 1 });
const b = createSkaterState(0, { x: 4, z: -6, yaw: 1 });
run(a, 12, drive, { clampBoards: true });
run(b, 12, drive, { clampBoards: true });
near(a.x, b.x, 0, 'same inputs, same x');
near(a.z, b.z, 0, 'same inputs, same z');
near(a.yaw, b.yaw, 0, 'same inputs, same yaw');
}
section('intent from a controller is clamped before the sim sees it');
{
// This is the seam a gamepad or a network message will come in through, so
// it has to survive garbage without the sim ever seeing it.
const s = createSkaterState(0, START);
applyIntent(s, { ix: 1, iz: 1 });
near(Math.hypot(s.ix, s.iz), 1, 1e-9, 'a diagonal stick is normalised, not sqrt(2) fast');
applyIntent(s, { ix: 0.3, iz: -0.4 });
near(s.ix, 0.3, 1e-9, 'a stick inside the deadzone circle is left alone (x)');
near(s.iz, -0.4, 1e-9, 'a stick inside the deadzone circle is left alone (z)');
applyIntent(s, { ix: 40, iz: -40 });
ok(Math.hypot(s.ix, s.iz) <= 1 + 1e-9, 'an out-of-range stick is clamped');
applyIntent(s, { ix: NaN, iz: undefined, sprint: 'yes', brake: 0 });
ok(s.ix === 0 && s.iz === 0, 'NaN and undefined become a centred stick');
ok(s.sprint === true && s.brake === false, 'and the flags come through as booleans');
// The clamped state must still step without producing garbage.
stepSkater(s, DT);
ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards');
}
section('rink dimensions are the ones we think they are');
{
near(RINK.halfX * 2, 60.96, 0.01, 'the rink is 200 feet long');
near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide');
}
done('skaterSim');