Files
healer-man/src/game/mobAnimation.ts
T
2026-08-14 15:56:39 -04:00

410 lines
12 KiB
TypeScript

import type { MobArchetype } from "./mobPopulation";
import { selectWoWAnimationVariation } from "./combatAnimation";
const TAU = Math.PI * 2;
const UINT32_RANGE = 0x1_0000_0000;
export type MobAnimationState = "idle" | "moving" | "attacking" | "dead";
export interface MobAnimationStateInput {
readonly dead: boolean;
readonly engaged: boolean;
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
readonly moving: boolean;
readonly feared?: boolean;
readonly movementLocked?: boolean;
}
export interface NamedAnimationClip {
readonly name: string;
}
export interface MobAnimationClipResolution<TClip extends NamedAnimationClip> {
readonly clip: TClip;
readonly requestedState: MobAnimationState;
readonly resolvedState: MobAnimationState;
readonly exact: boolean;
}
export type MobCombatAnimationKind = "attack" | "cast" | "wound" | "death";
export type MobProceduralCombatState = "attacking" | "wound" | "dead";
const MOB_COMBAT_ANIMATION_IDS: Readonly<Record<MobCombatAnimationKind, readonly number[]>> = {
attack: [16, 17, 18, 19, 85, 87, 88, 95, 57, 58, 118, 53, 54, 32, 33, 2],
cast: [32, 33, 53, 54, 2, 51, 52],
wound: [10, 9, 8],
death: [1, 121, 134, 148, 189],
};
const MOB_COMBAT_SEMANTIC_CLIPS: Readonly<Record<MobCombatAnimationKind, RegExp>> = {
attack: /^attack\b/i,
cast: /^cast\b/i,
wound: /^(wound|hurt)\b/i,
death: /^(death|drown)\b/i,
};
const CLIP_FALLBACK_ORDER: Readonly<Record<MobAnimationState, readonly MobAnimationState[]>> = {
idle: ["idle", "moving", "attacking", "dead"],
moving: ["moving", "idle", "attacking", "dead"],
attacking: ["attacking", "idle", "moving", "dead"],
dead: ["dead", "idle", "moving", "attacking"],
};
function classifyAnimationClip(name: string): MobAnimationState | null {
const normalized = name.trim();
if (/^(death|drown)\b/i.test(normalized)) return "dead";
if (/^attack(?!.*ready)/i.test(normalized)) return "attacking";
if (/^(walk|run)\b/i.test(normalized)) return "moving";
if (/^(stand|idle)\b/i.test(normalized)) return "idle";
return null;
}
/** Resolves render animation without importing or subscribing to combatStore. */
export function resolveMobAnimationState(input: MobAnimationStateInput): MobAnimationState {
if (input.dead) return "dead";
// Fear owns locomotion while active, even when the low-frequency combat
// phase still says attacking. This keeps the visual state aligned with the
// renderer's flee steering and prevents a feared actor from striking in place.
if (input.feared) return input.movementLocked ? "idle" : "moving";
if (input.combatPhase === "chasing" || input.combatPhase === "returning") return "moving";
return input.moving ? "moving" : "idle";
}
/** Selects a native one-shot and rotates through every exported variation. */
export function resolveMobCombatAnimationClip<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
kind: MobCombatAnimationKind,
revision: number,
): TClip | null {
const wowClip = selectWoWAnimationVariation(clips, MOB_COMBAT_ANIMATION_IDS[kind], revision);
if (wowClip) return wowClip;
const semanticClips = clips.filter((clip) => MOB_COMBAT_SEMANTIC_CLIPS[kind].test(clip.name.trim()));
return semanticClips.length ? semanticClips[Math.abs(revision) % semanticClips.length] : null;
}
/**
* Selects the best extracted clip and reports whether it exactly represents the
* requested state. Callers use that flag to add a small procedural fallback for
* older Stand/Walk-only creature exports.
*/
export function resolveMobAnimationClip<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
requestedState: MobAnimationState,
): MobAnimationClipResolution<TClip> | null {
for (const resolvedState of CLIP_FALLBACK_ORDER[requestedState]) {
const clip = clips.find((candidate) => classifyAnimationClip(candidate.name) === resolvedState);
if (clip) {
return {
clip,
requestedState,
resolvedState,
exact: requestedState === resolvedState,
};
}
}
return clips[0]
? { clip: clips[0], requestedState, resolvedState: "idle", exact: false }
: null;
}
interface MobAnimationProfile {
readonly idleHz: number;
readonly movingHz: number;
readonly idleBob: number;
readonly movingBob: number;
readonly idlePitch: number;
readonly movingPitch: number;
readonly idleYaw: number;
readonly movingYaw: number;
readonly idleRoll: number;
readonly movingRoll: number;
readonly idleDeform: number;
readonly movingDeform: number;
}
/**
* Root-motion profiles for static creature meshes. Values are deliberately
* restrained so feet remain close to the sampled dungeon floor while each
* silhouette still has a recognizable gait.
*/
const MOB_ANIMATION_PROFILES: Readonly<Record<MobArchetype, MobAnimationProfile>> = {
humanoid: {
idleHz: 0.55,
movingHz: 1.8,
idleBob: 0.025,
movingBob: 0.09,
idlePitch: 0.008,
movingPitch: 0.035,
idleYaw: 0.012,
movingYaw: 0.01,
idleRoll: 0.012,
movingRoll: 0.055,
idleDeform: 0.012,
movingDeform: 0.014,
},
raptor: {
idleHz: 0.65,
movingHz: 2.15,
idleBob: 0.025,
movingBob: 0.09,
idlePitch: 0.014,
movingPitch: 0.04,
idleYaw: 0.018,
movingYaw: 0.018,
idleRoll: 0.01,
movingRoll: 0.04,
idleDeform: 0.01,
movingDeform: 0.012,
},
crocolisk: {
idleHz: 0.35,
movingHz: 1.15,
idleBob: 0.018,
movingBob: 0.045,
idlePitch: 0.008,
movingPitch: 0.018,
idleYaw: 0.025,
movingYaw: 0.025,
idleRoll: 0.008,
movingRoll: 0.025,
idleDeform: 0.006,
movingDeform: 0.008,
},
ooze: {
idleHz: 0.65,
movingHz: 1.45,
idleBob: 0.05,
movingBob: 0.075,
idlePitch: 0,
movingPitch: 0,
idleYaw: 0.01,
movingYaw: 0.01,
idleRoll: 0.015,
movingRoll: 0.02,
idleDeform: 0.07,
movingDeform: 0.085,
},
plant: {
idleHz: 0.32,
movingHz: 1.05,
idleBob: 0.02,
movingBob: 0.05,
idlePitch: 0.03,
movingPitch: 0.03,
idleYaw: 0.018,
movingYaw: 0.018,
idleRoll: 0.035,
movingRoll: 0.055,
idleDeform: 0.015,
movingDeform: 0.018,
},
serpent: {
idleHz: 0.5,
movingHz: 1.55,
idleBob: 0.025,
movingBob: 0.055,
idlePitch: 0.012,
movingPitch: 0.018,
idleYaw: 0.065,
movingYaw: 0.085,
idleRoll: 0.025,
movingRoll: 0.035,
idleDeform: 0.01,
movingDeform: 0.012,
},
turtle: {
idleHz: 0.28,
movingHz: 0.75,
idleBob: 0.012,
movingBob: 0.035,
idlePitch: 0.006,
movingPitch: 0.012,
idleYaw: 0.008,
movingYaw: 0.008,
idleRoll: 0.006,
movingRoll: 0.022,
idleDeform: 0.004,
movingDeform: 0.006,
},
lizard: {
idleHz: 0.4,
movingHz: 1.3,
idleBob: 0.02,
movingBob: 0.065,
idlePitch: 0.012,
movingPitch: 0.025,
idleYaw: 0.03,
movingYaw: 0.035,
idleRoll: 0.012,
movingRoll: 0.04,
idleDeform: 0.008,
movingDeform: 0.01,
},
murloc: {
idleHz: 0.65,
movingHz: 1.75,
idleBob: 0.03,
movingBob: 0.105,
idlePitch: 0.015,
movingPitch: 0.045,
idleYaw: 0.025,
movingYaw: 0.025,
idleRoll: 0.02,
movingRoll: 0.06,
idleDeform: 0.012,
movingDeform: 0.015,
},
winged: {
idleHz: 0.95,
movingHz: 1.35,
idleBob: 0.16,
movingBob: 0.2,
idlePitch: 0.018,
movingPitch: 0.025,
idleYaw: 0.025,
movingYaw: 0.035,
idleRoll: 0.045,
movingRoll: 0.065,
idleDeform: 0.008,
movingDeform: 0.012,
},
};
/** Caller-owned output mutated by {@link sampleMobAnimation}. */
export interface MutableMobAnimationTransform {
offsetY: number;
offsetZ: number;
rotationX: number;
rotationY: number;
rotationZ: number;
scaleX: number;
scaleY: number;
scaleZ: number;
}
export function createMobAnimationTransform(): MutableMobAnimationTransform {
return {
offsetY: 0,
offsetZ: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
};
}
/** Stable 32-bit FNV-1a hash used to stagger otherwise identical creatures. */
export function hashMobAnimationId(id: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < id.length; index += 1) {
hash ^= id.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/** Returns a deterministic phase in the half-open range [0, 2 PI). */
export function getMobAnimationPhase(id: string): number {
return (hashMobAnimationId(id) / UINT32_RANGE) * TAU;
}
/**
* Samples an archetype's idle or moving root pose into caller-owned storage.
* The function performs no allocations and overwrites every output property,
* so repeated sampling cannot accumulate transform drift.
*/
export function sampleMobAnimation(
archetype: MobArchetype,
moving: boolean,
elapsed: number,
phase: number,
out: MutableMobAnimationTransform,
): MutableMobAnimationTransform {
const profile = MOB_ANIMATION_PROFILES[archetype];
const frequency = moving ? profile.movingHz : profile.idleHz;
const cycle = elapsed * frequency * TAU + phase;
const stride = Math.sin(cycle);
const doubleStride = Math.sin(cycle * 2 + Math.PI * 0.25);
const slowSway = Math.sin(cycle * 0.5 + phase * 0.173);
const bob = moving ? profile.movingBob : profile.idleBob;
const pitch = moving ? profile.movingPitch : profile.idlePitch;
const yaw = moving ? profile.movingYaw : profile.idleYaw;
const roll = moving ? profile.movingRoll : profile.idleRoll;
const deform = moving ? profile.movingDeform : profile.idleDeform;
// Moving bodies lift on both footfalls. Idle bodies breathe above their
// floor sample instead of penetrating it during the negative half-cycle.
out.offsetY = moving
? Math.abs(stride) * bob
: (slowSway * 0.5 + 0.5) * bob;
out.offsetZ = 0;
out.rotationX = doubleStride * pitch;
out.rotationY = slowSway * yaw;
out.rotationZ = stride * roll;
const deformation = doubleStride * deform;
if (archetype === "ooze") {
// A slime's volume reads better when the horizontal and vertical axes
// deform in opposite directions.
out.scaleX = 1 + deformation;
out.scaleY = 1 - deformation * 0.75;
out.scaleZ = 1 + deformation;
} else {
out.scaleX = 1 - deformation * 0.15;
out.scaleY = 1 + deformation;
out.scaleZ = 1 - deformation * 0.15;
}
return out;
}
/**
* Procedural combat fallback for proxy actors and legacy Stand/Walk-only GLBs.
* Death clamps to a terminal pose; attack repeats without accumulating drift.
*/
export function sampleMobCombatFallback(
state: MobProceduralCombatState,
elapsed: number,
out: MutableMobAnimationTransform,
): MutableMobAnimationTransform {
const safeElapsed = Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
out.scaleX = 1;
out.scaleY = 1;
out.scaleZ = 1;
out.rotationY = 0;
if (state === "dead") {
const progress = Math.min(1, safeElapsed / 0.72);
const eased = 1 - (1 - progress) ** 3;
out.offsetY = 0;
out.offsetZ = 0;
out.rotationX = 0;
out.rotationZ = -1.38 * eased;
return out;
}
if (state === "wound") {
const progress = Math.min(1, safeElapsed / 0.36);
const recoil = Math.sin(progress * Math.PI);
out.offsetY = recoil * 0.025;
out.offsetZ = -recoil * 0.14;
out.rotationX = recoil * 0.13;
out.rotationZ = -recoil * 0.055;
out.scaleX = 1 + recoil * 0.025;
out.scaleY = 1 - recoil * 0.04;
out.scaleZ = 1 + recoil * 0.025;
return out;
}
const cycle = (safeElapsed % 1) * TAU;
const strike = Math.max(0, Math.sin(cycle));
out.offsetY = strike * 0.035;
out.offsetZ = strike * 0.18;
out.rotationX = -strike * 0.18;
out.rotationZ = Math.sin(cycle * 2) * 0.025;
return out;
}