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); }