import * as THREE from 'three'; import { buildSkeleton } from '../src/character/skeleton.js'; import { buildAnimator } from '../src/anim/skateAnimator.js'; import { shot1 } from '../src/anim/clips/shot1.js'; import { frameSpan } from '../src/anim/clip.js'; import { buildStick } from '../src/character/stick.js'; import { segDist } from '../src/core/math.js'; import { topHandFor } from '../shared/player.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(shotSide = 'right') { 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); anim.setShotSide(shotSide); // The stick is part of the pose now — it hangs off the top hand for this // shot side 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[`hand${topHandFor(shotSide)}`]); 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. for (const side of ['right', 'left']) { const r = drive(rig(side), 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 top = new THREE.Vector3(); r.skelData.bones[`hand${r.anim.topHand}`].getWorldPosition(top); ok(top.distanceTo(butt) < 0.12, `${side}: top hand is on the butt (${top.distanceTo(butt).toFixed(3)}m)`); const lower = new THREE.Vector3(); const closest = new THREE.Vector3(); r.skelData.bones[`hand${r.anim.lowerHand}`].getWorldPosition(lower); const gap = segDist(lower, butt, heel, closest); ok(gap < 0.08, `${side}: 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 = top.clone().applyMatrix4(inv); ok(Math.abs(handLocal.x) < 0.28, `${side}: top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`); ok(handLocal.z > 0.25, `${side}: 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; const topBone = r.skelData.bones[`hand${r.anim.topHand}`]; 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); topBone.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('shot side puts the blade on the matching forehand'); { // Right shot: blade on the skater's right (−X). Left shot: mirrored to +X. const right = drive(rig('right'), 3, { ...GLIDE, hasPuck: true }); const left = drive(rig('left'), 3, { ...GLIDE, hasPuck: true }); const invR = new THREE.Matrix4().copy(right.mover.matrixWorld).invert(); const invL = new THREE.Matrix4().copy(left.mover.matrixWorld).invert(); const br = new THREE.Vector3(); const bl = new THREE.Vector3(); right.stick.bladeWorld(br); left.stick.bladeWorld(bl); br.applyMatrix4(invR); bl.applyMatrix4(invL); ok(br.x < -0.05, `right shot carries on the right (x=${br.x.toFixed(2)})`); ok(bl.x > 0.05, `left shot carries on the left (x=${bl.x.toFixed(2)})`); ok(right.anim.topHand === 'R' && right.anim.lowerHand === 'L', 'right shot: top R, lower L'); ok(left.anim.topHand === 'L' && left.anim.lowerHand === 'R', 'left shot: top L, lower R'); ok(right.anim.shotSign === 1 && left.anim.shotSign === -1, 'shotSign tracks the side'); } 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 uses the first half of saved shot1 and holds'); { const r = rig('left'); 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); // The saved midpoint is the held load pose. ok(backLocal.y > 0.28, `the blade is lifted (y=${backLocal.y.toFixed(2)})`); ok(backLocal.y > flatLocal.y + 0.2, `well above the carry (${flatLocal.y.toFixed(2)} → ${backLocal.y.toFixed(2)})`); ok(backLocal.z < flatLocal.z - 0.2, `and drawn back from the carry (z ${flatLocal.z.toFixed(2)} → ${backLocal.z.toFixed(2)})`); const savedMidpoint = shot1.keyframes.find((frame) => frame.time === shot1.duration * 0.5); const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo( new THREE.Quaternion().fromArray(savedMidpoint.rotations.upperArmL), ); ok(poseDelta < 1e-6, `held wind-up lands exactly on shot1's midpoint (Δ=${poseDelta.toFixed(6)})`); ok(r.anim.action === 'windup', 'and the wind-up is held, not played once'); // Both hands stay on the shaft through the load. 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); const onShaft = segDist(handR, butt, heel, new THREE.Vector3()); ok(onShaft < 0.1, `lower hand stays on the stick (dist ${onShaft.toFixed(3)})`); } section('a regular shot plays the whole saved shot1 clip'); { const r = rig('left'); drive(r, 1, { ...GLIDE, hasPuck: true }); r.anim.playAction('shoot', { power: 1 }); // The 6-second reference is compressed into the 0.33-second motion window; // the remaining 0.09 seconds are the existing blend back to skating. r.anim.actionTime = 0.33 - DT; Object.assign(r.anim, { ...GLIDE, hasPuck: true }); r.anim.update(DT); const savedEnd = shot1.keyframes.at(-1); const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo( new THREE.Quaternion().fromArray(savedEnd.rotations.upperArmL), ); ok(poseDelta < 1e-5, `regular shot reaches shot1's final key before fading (Δ=${poseDelta.toFixed(6)})`); const butt = new THREE.Vector3(); const heel = new THREE.Vector3(); const lowerHand = r.skelData.bones.handR.getWorldPosition(new THREE.Vector3()); r.stick.shaftSegment(butt, heel); const onShaft = segDist(lowerHand, butt, heel, new THREE.Vector3()); ok(onShaft < 1e-5, `saved shot keeps the guide through both hands (dist ${onShaft.toFixed(6)})`); } section('releasing a held wind-up continues through shot1 second half'); { const r = rig('left'); drive(r, 1, { ...GLIDE, hasPuck: true }); r.anim.action = 'windup'; drive(r, 0.2, { ...GLIDE, hasPuck: true, charge: 1 }); r.anim.playAction('shoot', { power: 1 }); Object.assign(r.anim, { ...GLIDE, hasPuck: true }); r.anim.update(DT); const referenceTime = shot1.duration * (0.5 + 0.5 * DT / 0.33); const span = frameSpan(shot1, referenceTime); const expected = new THREE.Quaternion().slerpQuaternions( new THREE.Quaternion().fromArray(span.a.rotations.upperArmL), new THREE.Quaternion().fromArray(span.b.rotations.upperArmL), span.alpha, ); const poseDelta = r.skelData.bones.upperArmL.quaternion.angleTo(expected); ok(r.anim.actionFromWindup, 'release remembers that the first half was already held'); ok(poseDelta < 1e-5, `release continues from the midpoint instead of replaying the load (Δ=${poseDelta.toFixed(6)})`); } 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');