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

395 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');