import type { CanonicalBone } from "@psx/shared"; /** * Procedural animation clips authored against the canonical skeleton. * * Because every channel targets a canonical bone *name* rather than a joint * index, a clip generated for one character plays on any other character the * harness produces (GDD §10.2). Nothing here depends on the figure's * proportions — only on its topology. * * Rotation-only, and deliberately so: root translation would fight the * runtime's `CharacterMover`, which owns the character's position. The mover * drives where the body goes; these clips only say what the limbs do. * * ## Axis convention * * The harness skeleton uses **identity rest rotations** and local translations * only (Y-up, Z-forward, right-handed — same as the runtime). Under that setup: * * - A limb hanging down the −Y axis swings **backward (−Z)** under **+X** pitch. * - Forward swing (toward +Z, the facing direction) is therefore **−X**. * - Knee flex (shin folds back toward −Z when the thigh is vertical) is **+X**. * - Elbow flex (hand comes forward toward +Z when the upper arm hangs) is **−X**. * - Spine lean toward +Z (forward) is **+X** (children sit along +Y). */ export interface Keyframe { time: number; /** Euler XYZ in radians, converted to quaternions at export. */ rotation: [number, number, number]; } export interface AnimationChannel { bone: CanonicalBone; keyframes: Keyframe[]; } export interface AnimationClip { name: string; duration: number; loop: boolean; channels: AnimationChannel[]; } /** Samples per cycle. Low on purpose — PSX animation was chunky. */ const SAMPLES = 8; /** * Builds a channel by sampling a function over one cycle. * The final key repeats the first so looping playback has no seam. */ function cycle( bone: CanonicalBone, duration: number, fn: (phase: number) => [number, number, number], ): AnimationChannel { const keyframes: Keyframe[] = []; for (let i = 0; i <= SAMPLES; i++) { const phase = i / SAMPLES; keyframes.push({ time: phase * duration, rotation: fn(phase % 1) }); } return { bone, keyframes }; } const wave = (phase: number, offset = 0): number => Math.sin((phase + offset) * Math.PI * 2); /** Neutral pose — every bone at rest. Used as the bind/reset clip. */ export function buildIdlePose(): AnimationClip { return { name: "TPose", duration: 0.001, loop: false, channels: [{ bone: "Hips", keyframes: [{ time: 0, rotation: [0, 0, 0] }] }], }; } /** * Breathing idle. Survival horror characters stand tense, so the motion is * small and slow — big idle sway reads as comedic. */ export function buildIdleClip(duration = 4): AnimationClip { return { name: "Idle", duration, loop: true, channels: [ // Spine children sit along +Y: +X leans forward (+Z). Tiny breath. cycle("Spine", duration, (p) => [wave(p) * 0.022, 0, 0]), cycle("Spine1", duration, (p) => [wave(p, 0.08) * 0.018, 0, 0]), cycle("Neck", duration, (p) => [wave(p, 0.15) * -0.03, wave(p, 0.3) * 0.04, 0]), // Soft arm hang: slight −X is forward; elbows rest slightly flexed (−X). cycle("LeftArm", duration, (p) => [wave(p, 0.1) * -0.03, 0, 0.05]), cycle("RightArm", duration, (p) => [wave(p, 0.1) * -0.03, 0, -0.05]), cycle("LeftForeArm", duration, (p) => [-0.2 + wave(p, 0.2) * -0.02, 0, 0]), cycle("RightForeArm", duration, (p) => [-0.2 + wave(p, 0.2) * -0.02, 0, 0]), ], }; } /** * Walk cycle. Arms swing in opposition to the legs — the single detail that * makes a walk read as a walk rather than a shuffle. */ export function buildWalkClip(duration = 1, stride = 0.55): AnimationClip { return { name: "Walk", duration, loop: true, channels: [ // Counter-rotation through the torso, small enough not to look like a strut. cycle("Hips", duration, (p) => [0, wave(p) * 0.06, 0]), // Slight forward lean (+X with +Y spine). cycle("Spine", duration, (p) => [0.05, wave(p) * -0.05, 0]), cycle("Spine2", duration, (p) => [0.02, wave(p) * -0.07, 0]), // Hips: −X is forward (+Z). Opposite legs are half a cycle apart. cycle("LeftUpLeg", duration, (p) => [-wave(p) * stride, 0, 0]), cycle("RightUpLeg", duration, (p) => [-wave(p, 0.5) * stride, 0, 0]), // Knees only flex one way: +X folds the shin back (foot toward −Z when vertical). // Peak flex just after the hip starts the swing (phase offset 0.25 / 0.75). cycle("LeftLeg", duration, (p) => [Math.max(0, wave(p, 0.25)) * stride * 1.15, 0, 0]), cycle("RightLeg", duration, (p) => [Math.max(0, wave(p, 0.75)) * stride * 1.15, 0, 0]), // Plantar flex follows the stride so the sole doesn't skate. cycle("LeftFoot", duration, (p) => [-wave(p, 0.15) * 0.22, 0, 0]), cycle("RightFoot", duration, (p) => [-wave(p, 0.65) * 0.22, 0, 0]), // Arms opposite the legs (0.5 phase), −X forward. cycle("LeftArm", duration, (p) => [-wave(p, 0.5) * stride * 0.55, 0, 0.08]), cycle("RightArm", duration, (p) => [-wave(p) * stride * 0.55, 0, -0.08]), // Elbows: −X flexes the hand forward. Extra bend on the forward swing. cycle("LeftForeArm", duration, (p) => [-0.22 - Math.max(0, wave(p, 0.5)) * 0.32, 0, 0]), cycle("RightForeArm", duration, (p) => [-0.22 - Math.max(0, wave(p)) * 0.32, 0, 0]), ], }; } /** Run cycle: longer stride, deeper forward lean, faster loop. */ export function buildRunClip(duration = 0.62): AnimationClip { const walk = buildWalkClip(duration, 0.95); return { ...walk, name: "Run", channels: walk.channels.map((channel) => channel.bone === "Spine" ? { ...channel, keyframes: channel.keyframes.map((key) => ({ ...key, // Deeper forward lean for the run. rotation: [0.18, key.rotation[1], key.rotation[2]] as [number, number, number], })), } : channel, ), }; } /** * Quick-turn: the 180° spin from GDD §6.2. Body-only — the runtime rotates the * character's yaw itself, so this adds the lean and arm swing that sell it. */ export function buildQuickTurnClip(duration = 0.22): AnimationClip { const arc = (phase: number): number => Math.sin(phase * Math.PI); const keys = (fn: (phase: number) => [number, number, number]): Keyframe[] => Array.from({ length: 5 }, (_, i) => { const phase = i / 4; return { time: phase * duration, rotation: fn(phase) }; }); return { name: "QuickTurn", duration, loop: false, channels: [ { bone: "Spine", keyframes: keys((p) => [0, arc(p) * -0.4, 0]) }, { bone: "Spine2", keyframes: keys((p) => [0, arc(p) * -0.3, 0]) }, // −X is forward; arms brace into the spin. { bone: "LeftArm", keyframes: keys((p) => [arc(p) * -0.5, 0, 0.25]) }, { bone: "RightArm", keyframes: keys((p) => [arc(p) * 0.35, 0, -0.25]) }, { bone: "LeftUpLeg", keyframes: keys((p) => [arc(p) * -0.35, 0, 0]) }, { bone: "RightUpLeg", keyframes: keys((p) => [arc(p) * 0.35, 0, 0]) }, ], }; } /** * Corpse / ambient body pose — not a locomotion clip. * * Weight settles into the hips, spine folds, head hangs, arms go limp. Used by * room NPCs so they don't stand in the breathing Idle like living party members. */ export function buildSlumpedClip(): AnimationClip { const hold = (bone: CanonicalBone, rotation: [number, number, number]): AnimationChannel => ({ bone, keyframes: [ { time: 0, rotation }, { time: 0.001, rotation }, ], }); return { name: "Slumped", duration: 0.001, loop: false, channels: [ // Collapse into a sit-lean against a wall: pelvis tucks, spine curls forward. hold("Hips", [0.12, 0.08, 0.04]), hold("Spine", [0.45, 0.18, 0.1]), hold("Spine1", [0.35, 0.12, 0.06]), hold("Spine2", [0.2, 0.08, 0.04]), hold("Neck", [0.55, 0.25, 0.12]), hold("Head", [0.4, 0.2, 0.08]), // Arms hang heavy — −X brings hands forward/down rather than locking behind. hold("LeftShoulder", [0.1, 0, 0.15]), hold("RightShoulder", [0.1, 0, -0.15]), hold("LeftArm", [0.35, 0.1, 0.55]), hold("RightArm", [0.55, -0.15, -0.65]), hold("LeftForeArm", [-0.85, 0.1, 0.05]), hold("RightForeArm", [-0.7, -0.1, -0.05]), hold("LeftHand", [0.1, 0, 0.15]), hold("RightHand", [0.15, 0, -0.2]), // Legs splay loosely; +X on the knee folds the shin back. hold("LeftUpLeg", [-0.35, 0.2, 0.12]), hold("RightUpLeg", [-0.25, -0.25, -0.15]), hold("LeftLeg", [0.85, 0, 0]), hold("RightLeg", [1.05, 0, 0]), hold("LeftFoot", [-0.2, 0.05, 0]), hold("RightFoot", [-0.15, -0.05, 0]), ], }; } export function buildDefaultClips(): AnimationClip[] { return [ buildIdleClip(), buildWalkClip(), buildRunClip(), buildQuickTurnClip(), buildSlumpedClip(), ]; } /** Euler XYZ to quaternion, matching three.js's default order and glTF XYZW. */ export function eulerToQuaternion( x: number, y: number, z: number, ): [number, number, number, number] { const cx = Math.cos(x / 2); const sx = Math.sin(x / 2); const cy = Math.cos(y / 2); const sy = Math.sin(y / 2); const cz = Math.cos(z / 2); const sz = Math.sin(z / 2); return [ sx * cy * cz + cx * sy * sz, cx * sy * cz - sx * cy * sz, cx * cy * sz + sx * sy * cz, cx * cy * cz - sx * sy * sz, ]; }