Files
tilt/src/anim/skateAnimator.js
T
2026-08-03 16:04:46 -05:00

788 lines
31 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 { E, clamp, segDist, smooth } from '../core/math.js';
import { lerp, lerpAngle } from '../../shared/scalar.js';
import { lowerHandFor, normalizeShotSide, shotSign, topHandFor } from '../../shared/player.js';
import { poseSkate, poseStop } from './poses/skate.js';
import {
STICK_ARMS, STICK_BONES, STICK_SPINE,
mirrorStickwork,
poseCarry, posePass, posePoke,
} from './poses/stickwork.js';
import { frameSpan, sampleTiltStick } from './clip.js';
import { shot1 } from './clips/shot1.js';
import { STICK } from '../character/stick.js';
/**
* Skating animator.
*
* Same architecture as the Ludus fighter animator — a pose buffer that states
* write into, crossfaded on state changes, with two-bone analytic leg IK
* resolving world-space foot targets — with two deliberate differences.
*
* 1. It does not integrate movement. In Ludus the animator owned the fighter's
* position; here the sim plus the Box3D proxy own it, and the animator is
* told where the body ended up (`setTransform`). Anything else would have
* the pose fighting the collision response.
*
* 2. The feet are authored in *mover-local* space rather than planted in world
* space. That is not a shortcut: a walking foot is stationary while it bears
* weight, but a skate is gliding the entire time, including through the
* push. Planting it would be the thing that made this read as running on
* ice, which is exactly the failure mode we are trying to avoid.
*
* States exist so the next spike can add `shoot` / `stickhandle` and get the
* crossfade for free. Today there are two: `skate` and `stop`.
*/
/** How far the Skill Stick can push the blade around the carrier, metres. */
const STICK_REACH = { side: 0.5, fwd: 0.34 };
/** Foot joint height above the ice — boot plus blade. */
const FOOT_SOLE = 0.085;
const STRIDE = {
/** Fraction of the cycle the leg spends pushing rather than recovering. */
pushFrac: 0.55,
/** Half the width of a neutral glide stance, metres. */
narrow: 0.105,
/** How far out to the side a full push extends the blade. */
reachSide: 0.3,
/** Fore/aft travel of the blade through a push. */
reachFwd: 0.16,
reachAft: 0.26,
/** Blade clearance on the recovery. Skates barely leave the ice. */
lift: 0.07,
/** Toe flare — the V a skater's blades make as the leg extends. */
toeOut: 0.55,
toeGlide: 0.12,
/** Stride cycle at a standstill and at top speed, seconds. */
cycleSlow: 1.15,
cycleFast: 0.6,
};
export function buildAnimator(skelData, mover) {
const B = skelData.bones;
const LEN = { thigh: B.shinL.position.length(), shin: B.footL.position.length() };
const restThighDir = { L: B.shinL.position.clone().normalize(), R: B.shinR.position.clone().normalize() };
const restShinDir = { L: B.footL.position.clone().normalize(), R: B.footR.position.clone().normalize() };
const UPPER = [
'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head',
'clavicleL', 'upperArmL', 'forearmL', 'handL',
'clavicleR', 'upperArmR', 'forearmR', 'handR',
];
const LEGS = ['thighL', 'shinL', 'footL', 'toeL', 'thighR', 'shinR', 'footR', 'toeR'];
function newPose() {
const p = {
q: {},
rootOffset: new THREE.Vector3(),
rootQuat: new THREE.Quaternion(),
foot: { L: { pos: new THREE.Vector3(), yaw: 0 }, R: { pos: new THREE.Vector3(), yaw: 0 } },
};
for (const n of UPPER.concat(LEGS)) p.q[n] = new THREE.Quaternion();
return p;
}
const cur = newPose();
const frozen = newPose();
const anim = {
state: 'skate',
blend: 1,
BLEND_TIME: 0.22,
transitionTime: 0.22,
time: 0,
stateTime: 0,
/** Playback rate, for slow motion later. */
speed: 1,
// ---- written by the rig each frame, read by the poses -----------------
origin: new THREE.Vector3(),
originYaw: 0,
/** Planar speed, m/s. */
moveSpeed: 0,
/** Signed speed along the blade — negative means gliding backwards. */
bladeSpeed: 0,
/** How hard the skater is pushing, 0..1, straight off the sim. */
effort: 0,
/** Rate the velocity vector is turning, rad/s. Drives the bank. */
yawRate: 0,
braking: false,
// ---- derived, smoothed --------------------------------------------------
/** Stride amplitude, 0 (pure glide) .. 1 (digging in). */
gait: 0,
/** Lean into the turn, radians. Signed: positive is turning right. */
bank: 0,
stridePhase: 0,
/** Which shoulder leads a hockey stop; latched when the stop starts. */
stopDir: 1,
/** Called on each blade bite, for ice spray and audio later. */
onStride: null,
// ---- stickwork ---------------------------------------------------------
/** True while this skater has the puck. Decides the resting grip. */
hasPuck: false,
/** Skill Stick, -1..1. Moves the hands, which moves the blade. */
handling: { x: 0, y: 0 },
/** Held wind-up charge from the Skill Stick, 0..1. */
charge: 0,
/** The stick, so the animator can drive its socket and IK onto its shaft. */
stick: null,
/**
* Current stick action: null, 'windup', 'shoot', 'pass' or 'poke'.
* Wind-up is held; the other three run once and blend out.
*/
action: null,
actionTime: 0,
actionPower: 1,
actionAim: 0,
/** A released held wind-up starts shot1 at its midpoint, not frame zero. */
actionFromWindup: false,
/** Eased 0..1 between the settled grip and the one-handed dangle. */
hustleGrip: 0,
/**
* Shot side — which hand is on top of the stick, which side the blade
* lives on, and whether stickwork poses are mirrored. Authored content is
* for `'right'`; `'left'` flips grips and arms across the body.
*/
shotSide: 'right',
/** `+1` right (as authored), `-1` left (mirrored). */
shotSign: 1,
/** Top hand bone suffix for this shot side: `'R'` or `'L'`. */
topHand: 'R',
/** Lower hand bone suffix — the one IK pins to the shaft. */
lowerHand: 'L',
};
/** Apply a player shot side. Call once at create (or if a roster swaps it). */
anim.setShotSide = function setShotSide(side) {
anim.shotSide = normalizeShotSide(side);
anim.shotSign = shotSign(anim.shotSide);
anim.topHand = topHandFor(anim.shotSide);
anim.lowerHand = lowerHandFor(anim.shotSide);
};
/** How long each one-shot action runs, seconds. */
const ACTION_TIME = { shoot: 0.42, pass: 0.3, poke: 0.34 };
/** Seconds to blend the override in and out over the skating pose. */
const ACTION_BLEND = 0.09;
/** shot1 reaches its final key before the normal action fade begins. */
const SHOT_MOTION_TIME = ACTION_TIME.shoot - ACTION_BLEND;
/** Scratch pose the action layer writes into before being blended over. */
const overlay = newPose();
const _actionSpine = new THREE.Quaternion();
const _clipQa = new THREE.Quaternion();
const _clipQb = new THREE.Quaternion();
/** Map game action time onto the saved reference motion. */
function shot1Time() {
if (anim.action === 'windup') return clamp(anim.charge, 0, 1) * shot1.duration * 0.5;
if (anim.action === 'shoot') {
const phase = clamp(anim.actionTime / SHOT_MOTION_TIME, 0, 1);
const start = anim.actionFromWindup ? 0.5 : 0;
return (start + phase * (1 - start)) * shot1.duration;
}
return null;
}
/** Sample shot1 into the upper-body action layer without replacing skating legs. */
function poseShot1(P, time) {
const span = frameSpan(shot1, time);
if (!span) return false;
for (const name of STICK_BONES) {
const a = span.a.rotations[name] ?? [0, 0, 0, 1];
const b = span.b.rotations[name] ?? a;
P.q[name].slerpQuaternions(_clipQa.fromArray(a), _clipQb.fromArray(b), span.alpha);
}
return true;
}
const _localFoot = new THREE.Vector3();
function applyMover() {
mover.position.copy(anim.origin);
mover.rotation.set(0, anim.originYaw, 0);
}
/** Place the skater. Position and yaw come from the sim, never from here. */
anim.setTransform = function setTransform(position, yaw) {
anim.origin.copy(position);
anim.originYaw = yaw;
};
/**
* Mover-local foot target for one leg at cycle position `p`.
*
* The path is a flattened loop: out and back through the push, then in and
* forward through the recovery. Scaling the whole thing by `amp` means a
* glide collapses it to a pair of feet sitting under the hips, with no
* separate "glide" authoring to keep in sync.
*/
function strideLocal(side, p, amp, out) {
const sign = side === 'L' ? 1 : -1;
const S = STRIDE;
let x;
let z;
let y;
let toe;
if (p < S.pushFrac) {
const u = smooth(p / S.pushFrac);
x = sign * (S.narrow + S.reachSide * amp * u);
z = lerp(S.reachFwd * amp, -S.reachAft * amp, u);
y = 0;
toe = sign * (S.toeGlide + S.toeOut * amp * u);
} else {
const u = smooth((p - S.pushFrac) / (1 - S.pushFrac));
x = sign * lerp(S.narrow + S.reachSide * amp, S.narrow * 0.8, u);
z = lerp(-S.reachAft * amp, S.reachFwd * amp, u);
y = S.lift * amp * Math.sin(Math.PI * u);
toe = sign * lerp(S.toeGlide + S.toeOut * amp, S.toeGlide, u);
}
out.set(x, FOOT_SOLE + y, z);
return toe;
}
/** Local foot placement for a hockey stop: blades thrown across the travel. */
function stopLocal(side, dir, bite, out) {
const lead = side === 'L' ? 1 : -1;
out.set(
dir * (0.06 + 0.12 * bite) * (side === 'L' ? 1 : 0.4),
FOOT_SOLE,
lead * (0.19 + 0.06 * bite),
);
return dir * (0.3 + 0.9 * bite);
}
const _worldFoot = new THREE.Vector3();
/** Write a local foot target into the pose buffer as a world-space target. */
function writeFoot(P, side, local, toeYaw) {
_worldFoot.copy(local).applyMatrix4(mover.matrixWorld);
// The ice is flat, so the sole height authored locally is the world height;
// re-pin anyway so a future heightfield only has to change this line.
_worldFoot.y = local.y;
P.foot[side].pos.copy(_worldFoot);
P.foot[side].yaw = anim.originYaw + toeYaw;
}
/**
* Advance the derived, smoothed values every state shares.
*
* Smoothing lives here rather than in the sim because these are presentation
* quantities: the sim's `effort` is allowed to change instantly when the AI
* changes its mind, but a skater's legs cannot.
*/
function advanceCommon(dt) {
// Gait chases effort quickly on the way up (a push starts now) and decays
// slowly (the leg finishes its stroke).
const target = clamp(anim.effort, 0, 1);
const rate = target > anim.gait ? 5.5 : 2.2;
anim.gait = lerp(anim.gait, target, Math.min(1, rate * dt));
// Bank: the lean that balances the centripetal force of the current turn.
// atan(v·ω / g) is the real thing, and it behaves correctly at low speed —
// spinning on the spot produces no lean, which is what you want.
const bankTarget = clamp(
Math.atan2(anim.moveSpeed * anim.yawRate, 9.81),
-0.45,
0.45,
);
anim.bank = lerp(anim.bank, bankTarget, Math.min(1, 6 * dt));
// Stride rate rises with speed; a standing skater shuffles slowly.
const fast = clamp(anim.moveSpeed / 7.5, 0, 1);
const cycle = lerp(STRIDE.cycleSlow, STRIDE.cycleFast, fast);
const before = anim.stridePhase;
// Only advance while there is a stride to throw, so a long glide holds the
// legs where the last push left them instead of pedalling in mid-air.
anim.stridePhase = (anim.stridePhase + (dt / cycle) * Math.max(anim.gait, 0.06)) % 1;
// Blade bite: each leg starts its push half a cycle apart.
if (anim.onStride) {
if (before > anim.stridePhase) anim.onStride('L', anim.moveSpeed);
else if (before < 0.5 && anim.stridePhase >= 0.5) anim.onStride('R', anim.moveSpeed);
}
}
/**
* Fire a one-shot stick action. Wind-up is started and stopped explicitly
* instead, because it is held for as long as the stick is pulled back.
*/
anim.playAction = function playAction(name, { power = 1, aim = 0 } = {}) {
anim.actionFromWindup = name === 'shoot' && anim.action === 'windup';
anim.action = name;
anim.actionTime = 0;
anim.actionPower = power;
anim.actionAim = aim;
};
/**
* Advance the stick action clock and write the override pose.
*
* Returns the blend weight, 0 when nothing is happening. Kept separate from
* the states because these are *layers*: a skater keeps striding through a
* shot, so the action owns the arms and some spine and nothing else.
*/
function advanceAction(dt) {
if (!anim.action) return 0;
anim.actionTime += dt;
if (anim.action === 'windup') {
// Held. Blends in over ACTION_BLEND and then stays until released.
const w = Math.min(1, anim.actionTime / ACTION_BLEND);
poseShot1(overlay, shot1Time());
// shot1 was authored as a left shot. Mirror only when the runtime
// skater uses the opposite socket side.
if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay);
return w;
}
const duration = ACTION_TIME[anim.action] ?? 0.3;
const t = anim.actionTime / duration;
if (t >= 1) {
anim.action = null;
return 0;
}
// Snap in, ease out — a shot should look like it started the instant the
// button did, and a slow blend in front of it steals that.
const fading = t > 1 - ACTION_BLEND / duration;
const w = fading
? Math.max(0, (1 - t) * duration / ACTION_BLEND)
// A released held wind-up is already fully blended in. Dropping its
// weight back to zero for the shoot action caused a one-frame snap to
// carry before the second half of shot1 began.
: anim.action === 'shoot' && anim.actionFromWindup
? 1
: Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5));
const args = { phase: t, power: anim.actionPower, aim: anim.actionAim };
if (anim.action === 'shoot') poseShot1(overlay, shot1Time());
else if (anim.action === 'pass') posePass(overlay, args);
else posePoke(overlay, args);
if (anim.action === 'shoot') {
if (anim.shotSide !== shot1.shotSide) mirrorStickwork(overlay);
} else if (anim.shotSign < 0) mirrorStickwork(overlay);
return w;
}
/** Which socket grip the stick should be using right now, and the blend. */
function gripFor() {
if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)];
if (anim.action === 'shoot') {
// Three beats matching poseShot: loaded → blade square on the ice →
// follow-through. Contact is short and early so the bottom of the blade
// is flush when the puck leaves, not halfway through a blend to high.
const t = anim.actionTime / (ACTION_TIME.shoot);
if (t < 0.28) return ['windup', 'contact', Math.min(1, t / 0.28)];
if (t < 0.55) return ['contact', 'follow', (t - 0.28) / 0.27];
return ['follow', 'follow', 1];
}
if (anim.action === 'poke') return ['carry', 'poke', Math.min(1, anim.actionTime / 0.1)];
if (anim.action === 'pass') return ['carry', 'follow', Math.min(1, anim.actionTime / 0.2) * 0.5];
// Resting: hustling pushes the stick out in front on one hand.
return ['carry', 'hustle', anim.hustleGrip];
}
const states = {
skate: {
pre(dt) {
advanceCommon(dt);
applyMover();
mover.updateMatrixWorld(true);
},
pose(P, t) {
poseSkate(P, {
gait: anim.gait,
speed: anim.moveSpeed,
bank: anim.bank,
phase: anim.stridePhase,
t,
});
for (const side of ['L', 'R']) {
const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1;
const toe = strideLocal(side, p, anim.gait, _localFoot);
writeFoot(P, side, _localFoot, toe);
}
},
},
stop: {
blendTime: 0.12,
enter() {
// Which way the skater turns to plant depends on which edge is already
// loaded, so a stop out of a right-hand turn continues that rotation.
anim.stopDir = anim.bank >= 0 ? 1 : -1;
},
pre(dt) {
advanceCommon(dt);
applyMover();
mover.updateMatrixWorld(true);
},
pose(P, t) {
poseStop(P, { speed: anim.moveSpeed, dir: anim.stopDir, t });
const bite = clamp(anim.moveSpeed / 6, 0.25, 1);
for (const side of ['L', 'R']) {
const toe = stopLocal(side, anim.stopDir, bite, _localFoot);
writeFoot(P, side, _localFoot, toe);
}
},
},
};
function snapshot() {
for (const n of UPPER.concat(LEGS)) frozen.q[n].copy(B[n].quaternion);
frozen.rootOffset.copy(B.root.position);
frozen.rootQuat.copy(B.root.quaternion);
frozen.foot.L.pos.copy(cur.foot.L.pos);
frozen.foot.L.yaw = cur.foot.L.yaw;
frozen.foot.R.pos.copy(cur.foot.R.pos);
frozen.foot.R.yaw = cur.foot.R.yaw;
}
const _footWorld = new THREE.Vector3();
/**
* Restart the crossfade from whatever pose the skeleton is currently in.
*
* Used when physics hands the skeleton back after a knockdown: the bones are
* wherever the ragdoll left them, and the animator would otherwise snap to a
* skating pose on the next frame. Snapshotting the collapsed pose and easing
* out of it is the get-up.
*/
anim.rebase = function rebase(blendTime = 0.6) {
snapshot();
// `snapshot` takes the foot targets from `cur`, which for a skater who has
// been lying on the ice still holds wherever their blades were before the
// hit. Blending the IK out of a stale target drags the legs across the rink
// to catch up. Read the feet where they actually are instead.
mover.updateMatrixWorld(true);
for (const side of ['L', 'R']) {
B[`foot${side}`].getWorldPosition(_footWorld);
frozen.foot[side].pos.copy(_footWorld);
frozen.foot[side].yaw = anim.originYaw;
}
anim.blend = 0;
anim.transitionTime = Math.max(0.05, blendTime);
// The feet are wherever the body fell, not where the last stride put them,
// so start the stride cycle from a planted stance rather than mid-push.
anim.stridePhase = 0;
anim.gait = 0;
anim.bank = 0;
};
anim.setState = function setState(name, blendTime = null) {
if (name === anim.state || !states[name]) return;
snapshot();
anim.state = name;
anim.stateTime = 0;
anim.blend = 0;
anim.transitionTime = blendTime ?? states[name].blendTime ?? anim.BLEND_TIME;
if (states[name].enter) states[name].enter();
};
// ---- two-bone analytic IK ------------------------------------------------
// Lifted from Ludus unchanged. The knee pole is the one skating-specific
// detail: it points forward and *outward*, because a skater's knees track
// over the outside of the blade rather than straight ahead.
const _H = new THREE.Vector3();
const _d = new THREE.Vector3();
const _pole = new THREE.Vector3();
const _e2 = new THREE.Vector3();
const _knee = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _f = new THREE.Vector3();
const _r = new THREE.Vector3();
const _qP = new THREE.Quaternion();
const _q1 = new THREE.Quaternion();
const _q2 = new THREE.Quaternion();
const _qF = new THREE.Quaternion();
const _qInv = new THREE.Quaternion();
const fwdOf = (yaw, out) => out.set(Math.sin(yaw), 0, Math.cos(yaw));
const rightOf = (yaw, out) => out.set(Math.cos(yaw), 0, -Math.sin(yaw));
function solveLeg(side, targetPos, targetYaw) {
const thigh = B['thigh' + side];
const shin = B['shin' + side];
const foot = B['foot' + side];
thigh.getWorldPosition(_H);
_d.subVectors(targetPos, _H);
let d = _d.length();
const a = LEN.thigh;
const b = LEN.shin;
d = clamp(d, 0.12, a + b - 0.003);
_d.normalize();
// Cosine rule for the angle between the thigh axis and the hip->target line.
const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1);
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
fwdOf(anim.originYaw, _f);
rightOf(anim.originYaw, _r);
_pole.copy(_f).addScaledVector(_r, side === 'L' ? 0.34 : -0.34);
_pole.y -= 0.2;
_e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
if (_e2.lengthSq() < 1e-8) _e2.copy(_f);
_e2.normalize();
_knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
_dir.subVectors(_knee, _H).normalize();
_q1.setFromUnitVectors(restThighDir[side], _dir);
thigh.parent.getWorldQuaternion(_qP);
_qInv.copy(_qP).invert();
thigh.quaternion.copy(_qInv).multiply(_q1);
_dir.subVectors(targetPos, _knee).normalize();
_q2.setFromUnitVectors(restShinDir[side], _dir);
_qInv.copy(_q1).invert();
shin.quaternion.copy(_qInv).multiply(_q2);
E(_qF, 0, targetYaw, 0, 'YXZ');
_qInv.copy(_q2).invert();
foot.quaternion.copy(_qInv).multiply(_qF);
B['toe' + side].quaternion.identity();
}
// ---- two-bone arm IK ----------------------------------------------------
// Same solver as the legs, different pole. Used only to pin the lower hand
// onto the shaft: a two-handed grip where the second hand merely hovers near
// the stick is worse than not showing it at all, and no amount of authored
// shoulder angle keeps a hand on a pole that the other arm is swinging.
const ARM = {
upper: B.forearmL.position.length(),
fore: B.handL.position.length(),
};
const restUpperArmDir = {
L: B.forearmL.position.clone().normalize(),
R: B.forearmR.position.clone().normalize(),
};
const restForearmDir = {
L: B.handL.position.clone().normalize(),
R: B.handR.position.clone().normalize(),
};
function solveArm(side, targetPos) {
const upper = B[`upperArm${side}`];
const fore = B[`forearm${side}`];
upper.getWorldPosition(_H);
_d.subVectors(targetPos, _H);
let d = _d.length();
const a = ARM.upper;
const b = ARM.fore;
// Never fully lock the elbow — a straight arm reads as a mannequin.
d = clamp(d, 0.12, a + b - 0.02);
_d.normalize();
const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1);
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
// Elbow hangs below the shoulder and a little outside the ribs.
rightOf(anim.originYaw, _r);
_pole.set(0, -1, 0).addScaledVector(_r, side === 'L' ? 0.34 : -0.34);
_e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
if (_e2.lengthSq() < 1e-8) _e2.set(0, -1, 0);
_e2.normalize();
_knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
_dir.subVectors(_knee, _H).normalize();
_q1.setFromUnitVectors(restUpperArmDir[side], _dir);
upper.parent.getWorldQuaternion(_qP);
_qInv.copy(_qP).invert();
upper.quaternion.copy(_qInv).multiply(_q1);
_dir.subVectors(targetPos, _knee).normalize();
_q2.setFromUnitVectors(restForearmDir[side], _dir);
_qInv.copy(_q1).invert();
fore.quaternion.copy(_qInv).multiply(_q2);
}
// ---- per-frame update ---------------------------------------------------
const _blendFoot = new THREE.Vector3();
const _shaftPoint = new THREE.Vector3();
const _stickTarget = new THREE.Vector3();
const _shaftA = new THREE.Vector3();
const _shaftB = new THREE.Vector3();
const _shaftDir = new THREE.Vector3();
const _handPos = new THREE.Vector3();
const _lowerHandPos = new THREE.Vector3();
const _handQuat = new THREE.Quaternion();
const _shot1Stick = {};
anim.update = function update(dt) {
dt *= anim.speed;
anim.time += dt;
anim.stateTime += dt;
anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime);
// A hockey stop is worth its own state; everything else is one pose driven
// by continuous parameters.
anim.setState(anim.braking && anim.moveSpeed > 1.2 ? 'stop' : 'skate');
const st = states[anim.state];
if (st.pre) st.pre(dt);
for (const n of UPPER) cur.q[n].identity();
cur.rootOffset.set(0, 0, 0);
cur.rootQuat.identity();
st.pose(cur, anim.stateTime);
// ---- stickwork layer ---------------------------------------------------
// The resting grip: hustling pushes the stick out in front on one hand,
// and it eases rather than switching, so half-throttle is half-dangled.
// With the puck, both hands stay on — the reference carry is two-handed
// even at speed; only a real one-handed dangle (no puck) opens the grip.
const hustleTarget = anim.state === 'skate' && !anim.hasPuck
? clamp(anim.effort * 0.6 + clamp(anim.moveSpeed / 7, 0, 1) * 0.6, 0, 1)
: anim.hasPuck
? clamp(anim.effort * 0.08, 0, 0.2)
: clamp(anim.effort * 0.25, 0, 1);
anim.hustleGrip = lerp(anim.hustleGrip, hustleTarget, Math.min(1, 4 * dt));
// Carry pose first — the arms holding the stick at all — then any action
// over the top of it.
//
// Arms are replaced and spine is *multiplied*. The spine already carries
// the skating lean and the bank; a shot's coil is a twist on top of that.
// Overwriting it was what stood everybody upright the moment they picked up
// a stick.
for (const n of STICK_BONES) overlay.q[n].identity();
poseCarry(overlay, {
hustle: anim.hustleGrip,
reach: anim.handling.y,
lateral: anim.handling.x,
});
// Authored for a right shot; left shots run the mirrored pose set.
if (anim.shotSign < 0) mirrorStickwork(overlay);
for (const n of STICK_ARMS) cur.q[n].copy(overlay.q[n]);
for (const n of STICK_SPINE) cur.q[n].multiply(overlay.q[n]);
for (const n of STICK_BONES) overlay.q[n].identity();
const actionWeight = advanceAction(dt);
if (actionWeight > 0.001) {
for (const n of STICK_ARMS) cur.q[n].slerp(overlay.q[n], actionWeight);
for (const n of STICK_SPINE) {
_actionSpine.identity().slerp(overlay.q[n], actionWeight);
cur.q[n].multiply(_actionSpine);
}
}
const w = smooth(anim.blend);
for (const n of UPPER) B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w);
B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w);
B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w);
// Feet are solved after the spine is posed and the matrices refreshed, or
// the hip the IK measures from is a frame stale and the legs trail.
mover.updateMatrixWorld(true);
_blendFoot.lerpVectors(frozen.foot.L.pos, cur.foot.L.pos, w);
solveLeg('L', _blendFoot, lerpAngle(frozen.foot.L.yaw, cur.foot.L.yaw, w));
_blendFoot.lerpVectors(frozen.foot.R.pos, cur.foot.R.pos, w);
solveLeg('R', _blendFoot, lerpAngle(frozen.foot.R.yaw, cur.foot.R.yaw, w));
mover.updateMatrixWorld(true);
// ---- the stick, last ---------------------------------------------------
// Socket first: it hangs off the top hand for this shot side. Then the
// lower hand is pulled onto the shaft, which needs the stick already
// placed — hence the second matrix refresh.
if (anim.stick) {
const top = anim.topHand;
const lower = anim.lowerHand;
const clipTime = shot1Time();
const referenceStick = clipTime === null ? null : sampleTiltStick(shot1, clipTime, _shot1Stick);
let referenceHandsAligned = false;
// The applied studio guide carries a real 3D constraint: the shaft must
// cross both saved hand sockets. Use that instead of reconstructing
// camera depth from the 2D line, and keep the saved arm pose untouched.
if (referenceStick?.alignHands && typeof anim.stick.aimThroughHands === 'function') {
B[`hand${top}`].getWorldPosition(_handPos);
B[`hand${lower}`].getWorldPosition(_lowerHandPos);
B[`hand${top}`].getWorldQuaternion(_handQuat).invert();
referenceHandsAligned = anim.stick.aimThroughHands(
_handPos,
_lowerHandPos,
_handQuat,
referenceStick.roll,
);
mover.updateMatrixWorld(true);
} else {
const [from, to, t] = gripFor();
let roll = anim.stick.stanceTarget(from, to, t, _stickTarget);
// GRIP targets are authored for a right shot (forehand at X). Flip the
// blade across the body for a left shot, and the roll with it so the
// face stays open the same way relative to the forehand.
if (anim.shotSign < 0) {
_stickTarget.x *= -1;
roll = -roll;
}
// Stickhandling moves the *target*, not just the arm pose. Nudging only
// the shoulders moved the blade by centimetres; the puck follows the
// blade now, so the Skill Stick has to move the blade to mean anything.
//
// Lateral is *subtracted*: skater local +X is the left side, but the Skill
// Stick's +X is "push right". Adding them lined the deke up mirrored —
// stick right sent the puck to the skater's left. Screen-right stays
// skater-right for both shot sides.
if (anim.hasPuck) {
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
}
_stickTarget.applyMatrix4(mover.matrixWorld);
B[`hand${top}`].getWorldPosition(_handPos);
B[`hand${top}`].getWorldQuaternion(_handQuat);
_handQuat.invert();
anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll);
mover.updateMatrixWorld(true);
}
// Two hands on it whenever the stick is being used for something, and
// not while it is being dangled out on one.
//
// Skip the lower-hand IK while a pose crossfade is still running (get-up
// from a knockdown, state change). IK overwrites the bone fully, so
// applying it on top of a blend from a limp pose yanks the lower hand
// onto the shaft mid-rise — measured as a ~0.9 m jump on handR for a
// left shot, where the lower hand *is* the right.
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1)
* (anim.blend >= 1 ? 1 : 0);
if (twoHanded > 0.05 && !referenceHandsAligned) {
// Preferred lower-hand grip is a bit down the shaft (hands apart, the
// way the reference draws a carry). If that point is past the arm's
// reach, slide up toward the butt until it is — never leave the hand
// waving short of the stick, and never stack both hands on the butt.
anim.stick.shaftSegment(_shaftA, _shaftB);
B[`upperArm${lower}`].getWorldPosition(_H);
_shaftDir.subVectors(_shaftB, _shaftA);
const len = _shaftDir.length() || 1;
const armReach = ARM.upper + ARM.fore - 0.03;
// ~quarter of the way down when we can; closer when we must.
let gripT = 0.28;
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
if (_H.distanceTo(_shaftPoint) > armReach) {
gripT = 0.28;
while (gripT > 0.12) {
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
if (_H.distanceTo(_shaftPoint) <= armReach) break;
gripT -= 0.02;
}
// Last resort: nearest point on the reachable band of the shaft.
if (_H.distanceTo(_shaftPoint) > armReach) {
segDist(_H, _shaftA, _shaftB, _shaftPoint);
const tNear = clamp(
_shaftPoint.clone().sub(_shaftA).dot(_shaftDir) / (len * len),
0.12,
0.55,
);
gripT = tNear;
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
}
}
solveArm(lower, _shaftPoint);
mover.updateMatrixWorld(true);
}
}
};
anim.states = states;
anim.stateNames = Object.keys(states);
applyMover();
return anim;
}