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
+138 -25
View File
@@ -1,11 +1,15 @@
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,
poseCarry, posePass, posePoke, poseShot, poseWindup,
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';
/**
@@ -133,18 +137,68 @@ export function buildAnimator(skelData, mover) {
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();
@@ -256,6 +310,7 @@ export function buildAnimator(skelData, mover) {
* 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;
@@ -276,7 +331,10 @@ export function buildAnimator(skelData, mover) {
if (anim.action === 'windup') {
// Held. Blends in over ACTION_BLEND and then stays until released.
const w = Math.min(1, anim.actionTime / ACTION_BLEND);
poseWindup(overlay, { phase: anim.charge, aim: anim.actionAim });
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;
}
@@ -288,14 +346,23 @@ export function buildAnimator(skelData, mover) {
}
// 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 w = t > 1 - ACTION_BLEND / duration
const fading = t > 1 - ACTION_BLEND / duration;
const w = fading
? Math.max(0, (1 - t) * duration / ACTION_BLEND)
: Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5));
// 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') poseShot(overlay, args);
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;
}
@@ -303,8 +370,13 @@ export function buildAnimator(skelData, mover) {
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);
return ['windup', 'follow', Math.min(1, t / 0.45)];
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];
@@ -530,7 +602,9 @@ export function buildAnimator(skelData, mover) {
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;
@@ -574,6 +648,8 @@ export function buildAnimator(skelData, mover) {
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]);
@@ -602,40 +678,77 @@ export function buildAnimator(skelData, mover) {
mover.updateMatrixWorld(true);
// ---- the stick, last ---------------------------------------------------
// Socket first, because it hangs off the right hand and the arm has only
// just been posed. Then the lower hand is pulled onto the shaft, which
// needs the stick already placed — hence the second matrix refresh.
// 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 [from, to, t] = gripFor();
const roll = anim.stick.stanceTarget(from, to, t, _stickTarget);
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.
if (anim.hasPuck) {
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
// 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);
}
_stickTarget.applyMatrix4(mover.matrixWorld);
B.handR.getWorldPosition(_handPos);
B.handR.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.
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1);
if (twoHanded > 0.05) {
//
// 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.upperArmL.getWorldPosition(_H);
B[`upperArm${lower}`].getWorldPosition(_H);
_shaftDir.subVectors(_shaftB, _shaftA);
const len = _shaftDir.length() || 1;
const armReach = ARM.upper + ARM.fore - 0.03;
@@ -661,7 +774,7 @@ export function buildAnimator(skelData, mover) {
_shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
}
}
solveArm('L', _shaftPoint);
solveArm(lower, _shaftPoint);
mover.updateMatrixWorld(true);
}
}