Files
psxbase/tools/src/anim/procedural.ts
T
ryanfitzpatrickio 8a96ede9f2 Initial public release of PSX Adventure Engine
Browser reference stack for PSX-era third-person adventure: fixed cameras,
inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular
character harness. Ships the Ashgrove Precinct Level 1 investigation demo with
a full cast and nine linked rooms.
2026-07-31 06:32:43 -05:00

263 lines
9.3 KiB
TypeScript
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 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,
];
}