import { E } from '../../core/math.js'; import { clamp, lerp } from '../../../shared/scalar.js'; /** * Upper-body authoring for skating. * * Split out from the animator for the same reason Ludus splits its stance * poses: the runtime concerns (foot path, IK, blending) are fiddly and stable, * while these numbers are pure feel and get tuned constantly. * * Everything keys off four scalars the sim already produces: * gait 0..1 how much of a stride is being thrown (from effort + speed) * speed m/s planar * bank rad lean into the current turn, signed (+ = turning right) * phase 0..1 stride cycle position */ export const SKATE_POSE = { /** Knee bend at a standstill and at a full stride, in metres of root drop. */ crouchIdle: 0.1, crouchStride: 0.26, /** * Forward pitch, radians, at a standstill and at speed. * * This is the *root* pitch; the spine adds roughly another half of it on top * as it stacks up the chain, so the finished torso angle is around 1.5x these * numbers. Authoring the final angle here instead would mean re-tuning every * time a spine joint changed. */ leanIdle: 0.08, leanFast: 0.3, /** How much of the bank the torso takes; the rest is absorbed by the legs. */ bankTorso: 0.7, /** Head stays closer to level than the body — a skater looks up the ice. */ bankHeadCounter: 0.55, /** Arm swing amplitude, radians, at a full stride. */ armSwing: 0.72, /** Elbow bend: skaters carry their hands, they don't run with straight arms. */ elbow: -0.62, /** Roll that pulls the arms in from the skeleton's rest A-pose. */ armTuck: 0.34, /** Hip / shoulder counter-rotation with the stride. */ hipTwist: 0.2, shoulderTwist: 0.26, }; /** * The moving pose: crouched, pitched forward, twisting against the stride. * * `P` is the animator's pose buffer — quaternions per bone plus a root offset. * Foot targets are not written here; they are world-space and belong to the * stepper. */ export function poseSkate(P, { gait, speed, bank, phase, t }) { const K = SKATE_POSE; const fast = clamp(speed / 7, 0, 1); const s1 = Math.sin(phase * Math.PI * 2); const s2 = Math.sin(phase * Math.PI * 4); // Idle breathing, so a stopped skater is not a statue. const idle = (1 - gait) * Math.sin(t * 1.6) * 0.02; const crouch = lerp(K.crouchIdle, K.crouchStride, gait) + idle; const pitch = lerp(K.leanIdle, K.leanFast, fast); const twist = K.hipTwist * gait; // Pelvis rocks with the push — the hip on the pushing side drops and rotates // open, which is most of what makes a stride read as a stride and not a run. E(P.q.pelvis, pitch * 0.18, twist * s1, -bank * 0.25 + gait * 0.05 * s1); E(P.q.spine1, pitch * 0.3, -twist * 0.35 * s1, -bank * K.bankTorso * 0.3); E(P.q.spine2, pitch * 0.3, -twist * 0.45 * s1, -bank * K.bankTorso * 0.35); E(P.q.spine3, pitch * 0.22 + idle, -K.shoulderTwist * gait * s1, -bank * K.bankTorso * 0.25); // Neck and head pull back up: the torso is folded forward, the eyes are not. E(P.q.neck, -pitch * 0.5, 0, bank * K.bankHeadCounter * 0.4); E(P.q.head, -pitch * 0.42, K.shoulderTwist * 0.3 * gait * s1, bank * K.bankHeadCounter * 0.6); // Arms swing opposite the legs and slightly across the chest. Amplitude is // pure gait: a gliding skater's hands barely move. // The rest skeleton is an A-pose, so the arms already sit ~30° off the body. // Roll about local Z is what brings them in, and its sign is mirrored: the // left arm tucks on negative Z, the right on positive. Getting that backwards // is what turns a skater into a scarecrow, so it is written as `-m` once here // rather than as a per-side constant. const swing = K.armSwing * gait; for (const side of ['L', 'R']) { const m = side === 'L' ? 1 : -1; const armPhase = side === 'L' ? s1 : -s1; E(P.q[`clavicle${side}`], 0.04, 0, -m * (0.04 + 0.05 * gait)); E( P.q[`upperArm${side}`], // Shoulders sit forward of the ribs at speed, hands ahead of the chest. -0.45 - 0.35 * fast + swing * armPhase, m * (0.1 + 0.12 * gait), -m * K.armTuck, ); E(P.q[`forearm${side}`], K.elbow - 0.25 * gait - Math.abs(armPhase) * 0.12 * gait, 0, -m * 0.1); E(P.q[`hand${side}`], -0.1, 0, -m * 0.06); } // Vertical bob is small and at twice the stride rate: the body rises over // each push, not once per cycle. P.rootOffset.set(-bank * 0.06, -crouch + gait * 0.018 * s2, gait * 0.02); E(P.rootQuat, pitch, 0, -bank); } /** * Hockey stop: both blades thrown across the direction of travel, weight * dropped hard onto them, shoulders squared back up the ice. * * `dir` is +1 or -1 for which shoulder leads, so a stop has a side to it. */ export function poseStop(P, { speed, dir, t }) { const bite = clamp(speed / 6, 0.25, 1); const shake = Math.sin(t * 22) * 0.012 * bite; E(P.q.pelvis, 0.12, dir * 0.55 * bite, dir * 0.18 * bite); E(P.q.spine1, 0.16 + shake, -dir * 0.18 * bite, -dir * 0.12 * bite); E(P.q.spine2, 0.16 + shake, -dir * 0.2 * bite, -dir * 0.14 * bite); E(P.q.spine3, 0.1, -dir * 0.16 * bite, -dir * 0.1 * bite); E(P.q.neck, -0.24, -dir * 0.2 * bite, 0); E(P.q.head, -0.18, -dir * 0.24 * bite, 0); for (const side of ['L', 'R']) { const m = side === 'L' ? 1 : -1; // Hands come out for balance against the deceleration — the one pose where // the arms should leave the body, so the tuck roll relaxes toward zero. E(P.q[`clavicle${side}`], 0, 0, -m * 0.04); E(P.q[`upperArm${side}`], -0.72 * bite, m * 0.16, -m * (0.3 - 0.28 * bite)); E(P.q[`forearm${side}`], -0.5 - 0.3 * bite, 0, -m * 0.12); E(P.q[`hand${side}`], -0.12, 0, 0); } // Deep sit into the stop, hips back over the heels. P.rootOffset.set(dir * 0.05 * bite, -(0.2 + 0.12 * bite), -0.06 * bite); E(P.rootQuat, 0.12, 0, dir * 0.28 * bite); }