410 lines
12 KiB
TypeScript
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;
|
|
}
|