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 { 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> = { 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> = { attack: /^attack\b/i, cast: /^cast\b/i, wound: /^(wound|hurt)\b/i, death: /^(death|drown)\b/i, }; const CLIP_FALLBACK_ORDER: Readonly> = { 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( 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( clips: readonly TClip[], requestedState: MobAnimationState, ): MobAnimationClipResolution | 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> = { 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; }