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