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