animations

This commit is contained in:
ryanfitzpatrickio
2026-08-03 16:04:46 -05:00
parent 8b28e299e6
commit fb1ebeed05
28 changed files with 5246 additions and 105 deletions
+144
View File
@@ -0,0 +1,144 @@
import * as THREE from 'three';
import { buildSkeleton } from '../src/character/skeleton.js';
import { buildStick } from '../src/character/stick.js';
import {
applyTiltAnimation,
applyTiltClip,
captureSkeletonKeyframe,
clipAsModule,
createTiltClip,
deleteClipKeyframe,
frameSpan,
sanitizeTiltClip,
sampleTiltStick,
setClipKeyframe,
smoothTiltClip,
} from '../src/anim/clip.js';
import { done, ok, section } from './harness.mjs';
section('Tilt clips capture and interpolate the native skeleton');
{
const skel = buildSkeleton();
const clip = createTiltClip({ name: 'test-motion', loop: false, shotSide: 'left' });
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0);
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
skel.bones.upperArmL.quaternion.identity();
ok(applyTiltClip(skel, clip, 0.5), 'a populated clip applies');
const angle = 2 * Math.acos(skel.bones.upperArmL.quaternion.w);
ok(Math.abs(angle - Math.PI / 4) < 1e-5, 'quaternions slerp halfway');
ok(frameSpan(clip, 2).a.time === 1, 'non-looping clips clamp at the end');
ok(sanitizeTiltClip(clip).shotSide === 'left', 'clip preserves its authored stick socket side');
}
section('clip validation, replacement, smoothing, and export');
{
const skel = buildSkeleton();
const clip = createTiltClip({ name: 'hip check' });
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
ok(clip.keyframes.length === 1, 'setting the same time replaces a key');
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0.5));
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
smoothTiltClip(clip);
const copy = sanitizeTiltClip(JSON.parse(JSON.stringify(clip)));
ok(copy.keyframes.length === 3 && copy.rig === 'tilt-23', 'serialized clips validate');
const module = clipAsModule(copy);
ok(module.includes('export const hip_check') && module.includes('applyTiltAnimation'), 'module export is drop-in runtime JavaScript');
ok(deleteClipKeyframe(copy, 0.5) && copy.keyframes.length === 2, 'keys can be deleted');
}
section('stick landmarks survive storage and interpolate with the pose');
{
const skel = buildSkeleton();
const clip = createTiltClip({ loop: false });
const a = captureSkeletonKeyframe(skel, 0);
a.stick = { butt: [0.4, 0.2], grip: [0.45, 0.4], blade: [0.5, 0.8], target: [-0.2, 0.03, 0.6], roll: 0, confidence: 0.8 };
const b = captureSkeletonKeyframe(skel, 1);
b.stick = { butt: [0.6, 0.2], grip: [0.6, 0.4], blade: [0.8, 0.8], target: [0.4, 0.2, 1.0], roll: 0.4, confidence: 1 };
setClipKeyframe(clip, a);
setClipKeyframe(clip, b);
const stick = sampleTiltStick(clip, 0.5);
ok(Math.abs(stick.blade[0] - 0.65) < 1e-6, 'blade landmark interpolates');
ok(Math.abs(stick.target[0] - 0.1) < 1e-6, 'rig-local blade target interpolates');
ok(Number.isFinite(stick.angle), 'legacy butt/blade marks derive a stable shaft angle');
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.confidence === 0.8, 'stick metadata serializes');
}
section('applied stick guides constrain the 3D shaft through both hands');
{
const skel = buildSkeleton();
const mover = new THREE.Group();
mover.add(skel.rootBone);
const stick = buildStick(null, null, 0);
stick.attachTo(skel.bones.handR);
const clip = createTiltClip({ loop: false });
const frame = captureSkeletonKeyframe(skel, 0);
frame.stick = {
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
target: [0, 0, 0], angle: 0.5, roll: 0.2, alignHands: true, confidence: 1,
};
setClipKeyframe(clip, frame);
const skater = { skelData: skel, mover, stick };
ok(applyTiltAnimation(skater, clip, 0), 'two-hand guide applies');
const shaftA = new THREE.Vector3();
const shaftB = new THREE.Vector3();
const lowerHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
stick.shaftSegment(shaftA, shaftB);
const shaft = shaftB.clone().sub(shaftA);
const t = THREE.MathUtils.clamp(lowerHand.clone().sub(shaftA).dot(shaft) / shaft.lengthSq(), 0, 1);
const closest = shaftA.clone().addScaledVector(shaft, t);
ok(shaftA.distanceTo(skel.bones.handR.getWorldPosition(new THREE.Vector3())) < 1e-6, 'shaft starts at the socket hand');
ok(closest.distanceTo(lowerHand) < 1e-6, 'shaft crosses the lower hand in depth as well as screen space');
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.alignHands, 'two-hand constraint survives save/load');
}
section('equivalent stick-line directions do not bake a false half-turn');
{
const skel = buildSkeleton();
const clip = createTiltClip({ loop: false });
for (const [time, angle] of [[0, 0.2], [0.5, Math.PI - 0.1], [1, 0.3]]) {
const frame = captureSkeletonKeyframe(skel, time);
frame.stick = {
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
target: [0, 0, 0], angle, roll: 0, confidence: 1,
};
setClipKeyframe(clip, frame);
}
const clean = sanitizeTiltClip(clip);
const delta = Math.abs(clean.keyframes[1].stick.angle - clean.keyframes[0].stick.angle);
ok(delta < Math.PI / 2, 'a PI-flipped detector result stays on the nearest shaft-line branch');
}
section('stick playback follows the actual socket bone');
{
const skel = buildSkeleton();
const mover = new THREE.Group();
mover.add(skel.rootBone);
const stickGroup = new THREE.Group();
skel.bones.handL.add(stickGroup);
const clip = createTiltClip({ loop: false, shotSide: 'left' });
const frame = captureSkeletonKeyframe(skel, 0);
frame.stick = {
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
target: [0, 0, 0], angle: -0.4, roll: 0, confidence: 1,
};
setClipKeyframe(clip, frame);
let actualHand = null;
const skater = {
skelData: skel,
mover,
stick: {
group: stickGroup,
aimAt(_target, hand) { actualHand = hand.clone(); },
},
};
applyTiltAnimation(skater, clip, 0);
const expectedHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
ok(actualHand?.distanceTo(expectedHand) < 1e-6, 'left socket playback anchors at handL instead of handR');
}
done('animation clip');
+43
View File
@@ -0,0 +1,43 @@
import { parseFreeMoCapCsv } from '../src/studio/freemocapImport.js';
import { done, ok, section } from './harness.mjs';
const points = [
'left_ear', 'right_ear', 'left_shoulder', 'right_shoulder',
'left_elbow', 'right_elbow', 'left_wrist', 'right_wrist',
'left_hip', 'right_hip', 'left_knee', 'right_knee',
'left_ankle', 'right_ankle', 'left_heel', 'right_heel',
'left_foot_index', 'right_foot_index',
];
section('FreeMoCap tidy XYZ data imports into MediaPipe landmark order');
{
const lines = ['frame,keypoint,x,y,z,model,trajectory,reprojection_error'];
for (const trajectory of ['3d_xyz', 'rigid_3d_xyz']) {
for (const frame of [0, 1]) {
for (const [index, point] of points.entries()) {
const base = trajectory === 'rigid_3d_xyz' ? 200 : 100;
lines.push(`${frame},${point},${base + index},20,300,mediapipe_body,${trajectory},0.5`);
}
}
}
// Non-body rows in the tidy all-data file must not contaminate the pose.
lines.push('0,left_wrist,999,999,999,mediapipe_left_hand,rigid_3d_xyz,0.5');
const result = parseFreeMoCapCsv(lines.join('\n'));
ok(result.frames.length === 2, 'all complete body frames import');
ok(result.trajectory === 'rigid_3d_xyz', 'rigid body trajectory is preferred when available');
ok(result.tracker === 'MediaPipe', 'tracker metadata is reported');
const leftEar = result.frames[0].landmarks[7];
ok(leftEar.x === 200 && leftEar.y === -300 && leftEar.z === -20, 'FreeMoCap Z-up axes convert for the Tilt retargeter');
}
section('FreeMoCap per-trajectory CSV and RTMPose toe names are accepted');
{
const rtmposePoints = points.map((point) => point.replace('foot_index', 'big_toe'));
const lines = ['frame,keypoint,x,y,z'];
for (const [index, point] of rtmposePoints.entries()) lines.push(`4,${point},${index},10,20`);
const result = parseFreeMoCapCsv(lines.join('\r\n'));
ok(result.frames[0].frame === 4, 'source frame numbers survive import');
ok(result.frames[0].landmarks[31].visibility === 1, 'RTMPose big toe maps to the Tilt foot-index landmark');
}
done('FreeMoCap import');
+117 -29
View File
@@ -1,8 +1,11 @@
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';
/**
@@ -16,7 +19,7 @@ import { done, ok, section } from './harness.mjs';
const DT = 1 / 60;
function rig() {
function rig(shotSide = 'right') {
const skelData = buildSkeleton();
const mover = new THREE.Group();
// Same hierarchy as createSkater: skeleton rides on the mover so body yaw
@@ -24,10 +27,12 @@ function rig() {
// 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.
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.handR);
stick.attachTo(skelData.bones[`hand${topHandFor(shotSide)}`]);
anim.stick = stick;
return { skelData, mover, anim, stick };
}
@@ -262,26 +267,28 @@ 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)`);
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 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)`);
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 = 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)})`);
// 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');
@@ -297,13 +304,14 @@ section('the stick stays in the socket when the body turns');
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);
r.skelData.bones.handR.getWorldQuaternion(handQ);
topBone.getWorldQuaternion(handQ);
r.stick.group.getWorldQuaternion(stickQ);
local.copy(handQ).invert().multiply(stickQ);
if (i === 0) local0.copy(local);
@@ -313,6 +321,26 @@ section('the stick stays in the socket when the body turns');
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']) {
@@ -334,9 +362,9 @@ section('stick actions run and finish');
}
}
section('a wind-up lifts the blade off the ice and holds');
section('a wind-up uses the first half of saved shot1 and holds');
{
const r = rig();
const r = rig('left');
drive(r, 1, { ...GLIDE, hasPuck: true });
const flat = new THREE.Vector3();
r.stick.bladeWorld(flat);
@@ -353,11 +381,71 @@ section('a wind-up lifts the blade off the ice and holds');
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)})`);
// 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');
+28
View File
@@ -1,5 +1,8 @@
import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
import { RINK, insideRink } from '../shared/rink.js';
import {
normalizePlayer, normalizeShotSide, packPlayer, rollShotSide, shotSign, unpackPlayer,
} from '../shared/player.js';
import { done, near, ok, section } from './harness.mjs';
const DT = 1 / 120;
@@ -234,6 +237,7 @@ section('intent from a controller is clamped before the sim sees it');
// 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');
}
@@ -243,4 +247,28 @@ section('rink dimensions are the ones we think they are');
near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide');
}
section('player definition carries shot side');
{
ok(normalizeShotSide('left') === 'left', 'left stays left');
ok(normalizeShotSide('L') === 'left', 'L is left');
ok(normalizeShotSide(-1) === 'left', '-1 is left');
ok(normalizeShotSide('right') === 'right', 'right stays right');
ok(normalizeShotSide('R') === 'right', 'R is right');
ok(normalizeShotSide(undefined) === 'right', 'missing defaults to right (authored side)');
ok(shotSign('left') === -1 && shotSign('right') === 1, 'shotSign is ±1');
ok(rollShotSide(0.1) === 'left' && rollShotSide(0.9) === 'right', 'roll respects the NHL-ish split');
const packed = packPlayer({ shotSide: 'left' });
ok(packed.ss === 'L', 'pack uses a short wire form');
ok(unpackPlayer(packed).shotSide === 'left', 'unpack restores shot side');
ok(normalizePlayer({ shotSide: 'left' }).shotSide === 'left', 'normalizePlayer keeps shot side');
const lefty = createSkaterState(0, START, { shotSide: 'left', name: 'Lefty' });
const righty = createSkaterState(1, START, { shotSide: 'right' });
const plain = createSkaterState(2, START);
ok(lefty.shotSide === 'left', 'state stores left shot');
ok(righty.shotSide === 'right', 'state stores right shot');
ok(plain.shotSide === 'right', 'state defaults to right shot');
}
done('skaterSim');