Files
tilt/src/anim/poses/stickwork.js
T
2026-08-03 19:20:00 -05:00

386 lines
14 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 { E } from '../../core/math.js';
import { clamp, lerp } from '../../../shared/scalar.js';
import * as THREE from 'three';
/**
* Upper-body authoring for everything done with the stick.
*
* These are *override* poses, not whole-body states. They write the arms and
* some spine, and the animator blends them over the skating pose by a weight —
* because you keep skating while you shoot, and a shot that stopped the legs
* would read as a cutscene.
*
* Each one is a function of a single phase 0..1 so the animator can drive it
* from a timer, hold it (wind-up), or run it once and blend out (shoot, pass,
* poke). Poses are authored for a **right** shot (top hand right, lower hand
* left, forehand at X). A left shot runs the same functions and then
* `mirrorStickwork` swaps the arms and flips the coil across the midline.
*/
/**
* Bones the stickwork layer *replaces*. The arms belong to the stick whenever
* it is being used — there is no meaningful blend between "swinging with the
* stride" and "holding a stick", they are different arms.
*/
export const STICK_ARMS = [
'clavicleR', 'upperArmR', 'forearmR', 'handR',
'clavicleL', 'upperArmL', 'forearmL', 'handL',
];
/**
* Bones the layer *adds to*. The spine is already carrying the skating lean and
* the bank; a shot's coil is a twist on top of that, not instead of it.
* Replacing these was what flattened the forward lean the moment a stick
* appeared, and stood everybody up.
*/
export const STICK_SPINE = ['spine1', 'spine2', 'spine3', 'neck', 'head'];
export const STICK_BONES = STICK_ARMS.concat(STICK_SPINE);
/** Left/right arm pairs the mirror swaps. */
const ARM_PAIRS = [
['clavicleL', 'clavicleR'],
['upperArmL', 'upperArmR'],
['forearmL', 'forearmR'],
['handL', 'handR'],
];
const _mirrorL = new THREE.Euler();
const _mirrorR = new THREE.Euler();
/**
* Mirror a right-shot stickwork pose onto a left shot.
*
* Arms swap sides with yaw/roll flipped; spine coil flips the same way. Call
* after any stickwork writer when `shotSign < 0`. Idempotent only if you do
* not call it twice — the animator applies it once per layer write.
*/
export function mirrorStickwork(P) {
for (const [l, r] of ARM_PAIRS) {
if (!P.q[l] || !P.q[r]) continue;
_mirrorL.setFromQuaternion(P.q[l], 'XYZ');
_mirrorR.setFromQuaternion(P.q[r], 'XYZ');
E(P.q[l], _mirrorR.x, -_mirrorR.y, -_mirrorR.z);
E(P.q[r], _mirrorL.x, -_mirrorL.y, -_mirrorL.z);
}
for (const n of STICK_SPINE) {
if (!P.q[n]) continue;
_mirrorL.setFromQuaternion(P.q[n], 'XYZ');
E(P.q[n], _mirrorL.x, -_mirrorL.y, -_mirrorL.z);
}
}
/**
* The neutral carry, and the hustle variant.
*
* `hustle` 0..1 slides between two-hands-ready and the one-handed dangle a
* skater falls into when they are just trying to move: the stick goes out in
* front, the left arm leaves it and swings with the stride.
*
* Carry is authored from the motion-reference sheet (ready stance / puck carry):
* both hands in front of the torso, shaft angled down to the ice, blade a
* little to the forehand side — not parked out on the hip with the off-hand
* floating. The right arm has to sit close enough that the left can actually
* reach the shaft: the arm is only ~0.54 m long, so a top hand 40 cm off
* centre puts the stick out of reach no matter what the IK does.
*/
export function poseCarry(P, { hustle = 0, reach = 0, lateral = 0 }) {
const h = clamp(hustle, 0, 1);
// Skill Stick +X is "push right"; bone +X is the skater's left. Negate so
// the arms lean the same way the blade goes.
const side = -lateral;
// Right arm: top hand. Across the body and out in front at about waist /
// lower-chest height. Hustle extends it forward and frees the left side.
// Roll signs are mirrored: right arm tucks on *positive* Z, left on negative.
E(P.q.clavicleR, 0.03, lerp(-0.04, -0.1, h), 0.06);
E(
P.q.upperArmR,
lerp(-0.42, -0.7, h) + reach * 0.2,
lerp(0.42, 0.05, h) + side * 0.28,
lerp(0.68, 0.38, h),
);
E(
P.q.forearmR,
lerp(-1.35, -0.75, h) - reach * 0.15,
lerp(0.02, 0.12, h),
lerp(0.32, 0.16, h),
);
E(P.q.handR, lerp(-0.12, -0.08, h), lerp(0.12, 0.04, h), lerp(0.04, -0.12, h));
// Left arm: lower hand on the shaft when settled. Seeded near the stick so
// the IK only has to finish the last few centimetres, not haul it across the
// body. At full hustle it leaves the stick and opens for the stride swing.
E(P.q.clavicleL, 0.03, lerp(0.06, 0.02, h), lerp(-0.06, -0.02, h));
E(
P.q.upperArmL,
lerp(-0.38, -0.66, h) + reach * 0.12,
lerp(0.1, 0.16, h) + side * 0.18,
lerp(-0.48, -0.2, h),
);
E(
P.q.forearmL,
lerp(-1.32, -0.72, h),
lerp(-0.08, 0, h),
lerp(0.18, 0.08, h),
);
E(P.q.handL, -0.1, 0, 0.08 * (1 - h));
// Soft coil over the stick when both hands are on it; opens up when hustling.
E(P.q.spine1, 0.02 * h, lerp(-0.04, 0.02, h) + side * 0.05, 0);
E(P.q.spine2, 0.02 * h, lerp(-0.05, 0.02, h) + side * 0.05, 0);
E(P.q.spine3, 0.02, lerp(-0.04, 0, h), 0);
}
/**
* Wind-up. `phase` 0..1 is how loaded the shot is, and it is *held* — the
* animator parks here for as long as the Skill Stick is pulled back.
*
* Both hands stay on the stick, relatively square, and the whole grip draws
* back and *up* as a unit from the carry — not a golf swing that parks the
* stick behind the head. The torso coils open so the downswing has something
* to spend.
*/
export function poseWindup(P, { phase = 0, aim = 0 }) {
const w = clamp(phase, 0, 1);
// Aim on the Skill Stick is "push right"; bone +Y twist toward the skater's
// left is the opposite sign.
const side = -aim;
// Torso coils open on the shot side — enough load, not a full pirouette.
E(P.q.spine1, -0.03 - 0.04 * w, -0.1 - 0.18 * w + side * 0.08, -0.02 * w);
E(P.q.spine2, -0.04 - 0.05 * w, -0.12 - 0.22 * w + side * 0.1, -0.03 * w);
E(P.q.spine3, -0.02 - 0.04 * w, -0.08 - 0.16 * w + side * 0.08, -0.02 * w);
// Eyes stay on the target while the body turns away from it.
E(P.q.neck, 0.02, 0.12 + 0.18 * w - side * 0.16, 0);
E(P.q.head, 0.02, 0.1 + 0.14 * w - side * 0.18, 0);
// Carry-like arms that lift and pull back *together* on the forehand side.
// Keeping the seed close to the two-handed carry means the lower-hand IK only
// finishes the last few centimetres, and the hands stay square on the shaft.
E(P.q.clavicleR, -0.02 * w, -0.05 * w, -0.05);
E(
P.q.upperArmR,
lerp(-0.42, -0.3, w) + side * 0.03,
lerp(0.42, 0.22, w) + side * 0.1,
lerp(0.68, 0.62, w),
);
E(
P.q.forearmR,
lerp(-1.35, -1.2, w),
lerp(0.02, 0.05, w),
lerp(0.32, 0.28, w),
);
E(P.q.handR, lerp(-0.12, -0.09, w), lerp(0.12, 0.13, w), lerp(0.04, 0.06, w));
E(P.q.clavicleL, 0.03, lerp(0.06, 0.07, w), lerp(-0.06, -0.03, w));
E(
P.q.upperArmL,
lerp(-0.38, -0.26, w),
lerp(0.1, 0.16, w) + side * 0.06,
lerp(-0.48, -0.4, w),
);
E(
P.q.forearmL,
lerp(-1.32, -1.2, w),
lerp(-0.08, -0.09, w),
lerp(0.18, 0.15, w),
);
E(P.q.handL, -0.08, 0, lerp(0.08, 0.02, w));
}
/**
* Shot swing. `phase` 0..1 runs once, fast.
*
* Three beats, matching the grip path: still loaded → square through the puck
* with the blade on the ice → follow-through high across the body. Front-loaded
* easing so contact reads early, not in the middle of a slow blend.
*/
export function poseShot(P, { phase = 0, power = 1, aim = 0 }) {
const t = clamp(phase, 0, 1);
// Fast out of the coil, then settle into the follow.
const s = 1 - (1 - t) * (1 - t);
const p = clamp(power, 0.2, 1);
// 0..1 through the "square on the ice" window, then 0..1 into the follow.
// Contact lands around t≈0.28 so the blade is flush when the puck leaves.
const down = clamp(t / 0.28, 0, 1);
const through = clamp((t - 0.28) / 0.55, 0, 1);
const square = 1 - (1 - down) * (1 - down);
const twist = lerp(-0.28 * p, 0.38 * p, s);
E(P.q.spine1, -0.04 + 0.1 * s, twist * 0.9, 0.03 * s);
E(P.q.spine2, -0.05 + 0.12 * s, twist, 0.04 * s);
E(P.q.spine3, -0.03 + 0.08 * s, twist * 0.8, 0.03 * s);
E(P.q.neck, 0.02, -twist * 0.5 + aim * 0.2, 0);
E(P.q.head, 0.02, -twist * 0.4 + aim * 0.25, 0);
// Arms: wind-up square → contact square (carry-like) → follow high.
// The early half is deliberately close to the carry pose so both hands stay
// on the shaft and the stick reads flat through the ice, not rotating off it.
E(
P.q.clavicleR,
lerp(lerp(-0.02, 0.02, square), 0.04, through),
lerp(lerp(-0.06, -0.04, square), 0.1, through),
-0.05,
);
E(
P.q.upperArmR,
lerp(lerp(-0.3, -0.42, square), -1.0 * p, through),
lerp(lerp(0.22, 0.4, square), 0.28, through),
lerp(lerp(0.62, 0.68, square), -0.08, through),
);
E(
P.q.forearmR,
lerp(lerp(-1.2, -1.34, square), -0.4, through),
lerp(0.05, 0.12, through),
lerp(0.28, -0.08, through),
);
E(P.q.handR, -0.1, 0.1, 0.12);
E(
P.q.clavicleL,
0.03,
lerp(lerp(0.07, 0.06, square), -0.04, through),
lerp(-0.03, 0.05, through),
);
E(
P.q.upperArmL,
lerp(lerp(-0.26, -0.38, square), -0.28, through),
lerp(lerp(0.16, 0.12, square), 0.1, through),
lerp(lerp(-0.4, -0.48, square), 0.48, through),
);
E(
P.q.forearmL,
lerp(lerp(-1.2, -1.32, square), -0.55, through),
lerp(-0.09, -0.2, through),
lerp(0.15, -0.14, through),
);
E(P.q.handL, -0.08, 0, -0.08);
}
/**
* Pass: a flat sweep, no coil and no lift. Shorter and lower than a shot,
* because a pass that looks like a shot makes the two impossible to read apart
* at a glance — which matters more for a teammate watching than for the passer.
*/
export function posePass(P, { phase = 0, aim = 0 }) {
const t = clamp(phase, 0, 1);
const s = Math.sin(t * Math.PI); // out and back
const sweep = lerp(-0.18, 0.34, 1 - (1 - t) * (1 - t));
E(P.q.spine1, 0.03 * s, sweep * 0.6, 0);
E(P.q.spine2, 0.04 * s, sweep * 0.7, 0);
E(P.q.spine3, 0.03 * s, sweep * 0.5, 0);
E(P.q.neck, 0, -sweep * 0.4 + aim * 0.2, 0);
E(P.q.head, 0, -sweep * 0.3 + aim * 0.2, 0);
E(P.q.clavicleR, 0.02, -0.04, -0.05);
E(P.q.upperArmR, -0.34 - 0.3 * s, -0.34 + sweep * 0.5, -0.4 - 0.12 * s);
E(P.q.forearmR, -0.72 - 0.24 * s, 0.12, -0.12);
E(P.q.handR, -0.12, 0.06, 0.18);
E(P.q.clavicleL, 0.02, 0.05, 0.05);
E(P.q.upperArmL, -0.58 - 0.18 * s, 0.36 + sweep * 0.3, 0.3);
E(P.q.forearmL, -1.06 - 0.16 * s, -0.24, -0.18);
E(P.q.handL, -0.1, 0, -0.12);
}
/**
* Stick spread (RB hold): dominant arm drives the blade on a *frontal* cone —
* centre is out in front of the chest, Skill Stick X (`waggle` 1..1) sweeps
* left↔right up to ±90°. Never behind the hips. Non-dominant arm is off the
* shaft, out to the side in a ready stance. Slight crouch.
*
* Authored for a right shot (top hand R). Left shots mirror.
*/
export function poseSpread(P, { waggle = 0, phase = 1 } = {}) {
const w = clamp(phase, 0, 1);
// Skill Stick +X = screen-right. Match the stick target's sign flip so the
// arm and blade travel the same way (see skateAnimator SPREAD_ARC).
const a = -clamp(waggle, -1, 1);
const ang = a * (Math.PI / 2);
const fwd = Math.cos(ang); // 1 centre, 0 at full left/right
const lat = Math.sin(ang); // body-right after the stick-sign flip
// Bone space: +Y twist that opens the right arm across the body is opposite
// of body-right, so lateral into the arm uses lat.
const side = -lat;
// Sit into it — eyes up the ice; torso opens with the lateral sweep only.
E(P.q.spine1, 0.1 * w, -0.04 * w + side * 0.12, 0);
E(P.q.spine2, 0.12 * w, -0.05 * w + side * 0.14, 0);
E(P.q.spine3, 0.08 * w, -0.04 * w + side * 0.12, 0);
E(P.q.neck, -0.08 * w, 0.06 * w - side * 0.14, 0);
E(P.q.head, -0.06 * w, 0.06 * w - side * 0.16, 0);
// Dominant (right) arm: extended low. Centre = straight out in front;
// full stick swings the arm across the body front to each side (not
// forward/back along the midline).
E(P.q.clavicleR, 0.04 * w, -0.06 * w + side * 0.22, 0.05 * w);
E(
P.q.upperArmR,
// Pitch: reach low toward the ice; a bit more extended when out front.
lerp(-0.42, -0.28, w) - fwd * 0.12,
// Yaw: centre ≈ forward of the shoulder; lat swings ±90° in front.
lerp(0.42, 0.12, w) + side * 1.05,
lerp(0.68, 0.0, w) + Math.abs(lat) * 0.1,
);
E(
P.q.forearmR,
lerp(-1.35, -0.2, w) - fwd * 0.08,
lerp(0.02, 0.04, w) + side * 0.25,
lerp(0.32, 0.0, w),
);
E(
P.q.handR,
lerp(-0.12, -0.2, w),
lerp(0.12, 0.0, w) + side * 0.22,
lerp(0.04, 0.06, w),
);
// Non-dominant (left) arm: detached, out to the side and slightly forward —
// ready stance, does not follow the waggle.
E(P.q.clavicleL, 0.05 * w, 0.12 * w, -0.06);
E(
P.q.upperArmL,
lerp(-0.38, -0.26, w),
lerp(0.1, 0.68, w), // open wide to the left
lerp(-0.48, 0.55, w), // rolled out, clear of the shaft
);
E(
P.q.forearmL,
lerp(-1.32, -0.92, w),
-0.05,
lerp(0.18, -0.14, w),
);
E(P.q.handL, -0.05, 0.04, 0.02);
}
/**
* Poke check: a stab. One hand, the whole arm extending forward with the body
* reaching after it, back almost as fast as it went out.
*/
export function posePoke(P, { phase = 0 }) {
const t = clamp(phase, 0, 1);
// Out fast, back slower.
const s = t < 0.35 ? t / 0.35 : 1 - (t - 0.35) / 0.65;
const jab = clamp(s, 0, 1);
E(P.q.spine1, 0.06 * jab, -0.14 * jab, 0);
E(P.q.spine2, 0.07 * jab, -0.18 * jab, 0);
E(P.q.spine3, 0.05 * jab, -0.14 * jab, 0);
E(P.q.neck, -0.04 * jab, 0.1 * jab, 0);
E(P.q.head, -0.04 * jab, 0.1 * jab, 0);
// Right arm thrusts out and down toward the ice.
E(P.q.clavicleR, 0.02 + 0.06 * jab, -0.06 - 0.16 * jab, -0.05);
E(P.q.upperArmR, -0.34 - 0.5 * jab, -0.28 - 0.12 * jab, -0.36 + 0.14 * jab);
E(P.q.forearmR, -0.78 + 0.66 * jab, 0.1, -0.12);
E(P.q.handR, -0.12, 0.06, 0.18);
// Left arm comes off the stick and back for balance.
E(P.q.clavicleL, 0.02, 0.04, 0.04);
E(P.q.upperArmL, -0.5 + 0.2 * jab, 0.28 - 0.2 * jab, 0.34 + 0.16 * jab);
E(P.q.forearmL, -0.9 + 0.3 * jab, -0.16, -0.14);
E(P.q.handL, -0.1, 0, -0.1);
}