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

248 lines
8.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { MobArchetype } from "./mobPopulation";
import {
createMobAnimationTransform,
getMobAnimationPhase,
hashMobAnimationId,
resolveMobAnimationClip,
resolveMobCombatAnimationClip,
resolveMobAnimationState,
sampleMobCombatFallback,
sampleMobAnimation,
} from "./mobAnimation";
const ARCHETYPES: readonly MobArchetype[] = [
"humanoid",
"raptor",
"crocolisk",
"ooze",
"plant",
"serpent",
"turtle",
"lizard",
"murloc",
"winged",
];
describe("mob procedural animation sampling", () => {
it("creates an identity transform for caller-owned reuse", () => {
expect(createMobAnimationTransform()).toEqual({
offsetY: 0,
offsetZ: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
});
});
it("keeps locomotion continuous while combat one-shots are event driven", () => {
expect(resolveMobAnimationState({
dead: true,
engaged: true,
combatPhase: "attacking",
moving: true,
})).toBe("dead");
expect(resolveMobAnimationState({
dead: false,
engaged: true,
combatPhase: "attacking",
moving: true,
})).toBe("moving");
expect(resolveMobAnimationState({
dead: false,
engaged: true,
combatPhase: "attacking",
moving: false,
feared: true,
})).toBe("moving");
expect(resolveMobAnimationState({
dead: false,
engaged: true,
combatPhase: "attacking",
moving: false,
feared: true,
movementLocked: true,
})).toBe("idle");
expect(resolveMobAnimationState({
dead: false,
engaged: true,
combatPhase: "chasing",
moving: false,
})).toBe("moving");
expect(resolveMobAnimationState({
dead: false,
engaged: false,
combatPhase: "idle",
moving: false,
})).toBe("idle");
});
it("selects attack and wound clips independently and rotates native variations", () => {
const clips = [
{ name: "Stand (ID 0 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 1)" },
{ name: "CombatWound (ID 9 variation 0)" },
];
expect(resolveMobCombatAnimationClip(clips, "attack", 0)).toBe(clips[1]);
expect(resolveMobCombatAnimationClip(clips, "attack", 1)).toBe(clips[2]);
expect(resolveMobCombatAnimationClip(clips, "attack", 2)).toBe(clips[1]);
expect(resolveMobCombatAnimationClip(clips, "wound", 1)).toBe(clips[3]);
});
it("selects semantic RuneWaker combat clips when numeric WoW ids are absent", () => {
const clips = [
{ name: "Attack - 1h_attack01" },
{ name: "Attack - unarmed_attack02" },
{ name: "Cast - casting01" },
{ name: "Wound - hurt" },
{ name: "Death - death" },
];
expect(resolveMobCombatAnimationClip(clips, "attack", 0)).toBe(clips[0]);
expect(resolveMobCombatAnimationClip(clips, "attack", 1)).toBe(clips[1]);
expect(resolveMobCombatAnimationClip(clips, "cast", 0)).toBe(clips[2]);
expect(resolveMobCombatAnimationClip(clips, "wound", 0)).toBe(clips[3]);
expect(resolveMobCombatAnimationClip(clips, "death", 0)).toBe(clips[4]);
});
it("selects extracted Attack and Death clips before fallback clips", () => {
const clips = [
{ name: "Stand (ID 0 variation 0)" },
{ name: "Walk (ID 4 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 0)" },
{ name: "Death (ID 1 variation 0)" },
];
expect(resolveMobAnimationClip(clips, "attacking")).toMatchObject({
clip: clips[2],
resolvedState: "attacking",
exact: true,
});
expect(resolveMobAnimationClip(clips, "dead")).toMatchObject({
clip: clips[3],
resolvedState: "dead",
exact: true,
});
});
it("reports Stand as an inexact fallback when combat clips are absent", () => {
const clips = [
{ name: "Stand (ID 0 variation 0)" },
{ name: "Walk (ID 4 variation 0)" },
];
expect(resolveMobAnimationClip(clips, "attacking")).toMatchObject({
clip: clips[0],
resolvedState: "idle",
exact: false,
});
expect(resolveMobAnimationClip(clips, "dead")).toMatchObject({
clip: clips[0],
resolvedState: "idle",
exact: false,
});
});
it("clamps procedural death once and keeps attack fallback bounded", () => {
const transform = createMobAnimationTransform();
sampleMobCombatFallback("dead", 0.72, transform);
const terminal = { ...transform };
sampleMobCombatFallback("dead", 30, transform);
expect(transform).toEqual(terminal);
expect(transform.rotationZ).toBeLessThan(-1.3);
for (const elapsed of [0, 0.1, 1, 10, Number.NaN]) {
sampleMobCombatFallback("attacking", elapsed, transform);
expect(Object.values(transform).every(Number.isFinite)).toBe(true);
expect(Math.abs(transform.offsetZ)).toBeLessThanOrEqual(0.18);
expect(Math.abs(transform.rotationX)).toBeLessThanOrEqual(0.18);
}
sampleMobCombatFallback("wound", 0.18, transform);
expect(transform.offsetZ).toBeLessThan(-0.1);
expect(transform.rotationX).toBeGreaterThan(0.1);
sampleMobCombatFallback("wound", 0.36, transform);
expect(transform.offsetZ).toBeCloseTo(0);
expect(Object.values(transform).every(Number.isFinite)).toBe(true);
});
it("returns and overwrites the same transform object without drift", () => {
const transform = createMobAnimationTransform();
const returned = sampleMobAnimation("raptor", true, 1.25, 0.7, transform);
const first = { ...transform };
transform.offsetY = 999;
transform.offsetZ = 999;
transform.rotationX = 999;
transform.rotationY = 999;
transform.rotationZ = 999;
transform.scaleX = 999;
transform.scaleY = 999;
transform.scaleZ = 999;
sampleMobAnimation("raptor", true, 1.25, 0.7, transform);
expect(returned).toBe(transform);
expect(transform).toEqual(first);
});
it("derives stable, bounded phases from instance ids", () => {
const first = getMobAnimationPhase("entrance-ravager");
const repeated = getMobAnimationPhase("entrance-ravager");
const other = getMobAnimationPhase("entrance-guardian");
expect(hashMobAnimationId("entrance-ravager")).toBe(hashMobAnimationId("entrance-ravager"));
expect(first).toBe(repeated);
expect(first).toBeGreaterThanOrEqual(0);
expect(first).toBeLessThan(Math.PI * 2);
expect(other).not.toBe(first);
});
it("staggered phases produce different poses at the same elapsed time", () => {
const first = createMobAnimationTransform();
const second = createMobAnimationTransform();
sampleMobAnimation("humanoid", true, 0.5, getMobAnimationPhase("pack-a"), first);
sampleMobAnimation("humanoid", true, 0.5, getMobAnimationPhase("pack-b"), second);
expect(second).not.toEqual(first);
});
it("gives ooze an inverse squash-and-stretch pose", () => {
const transform = createMobAnimationTransform();
sampleMobAnimation("ooze", true, 0.125, 0.3, transform);
expect(transform.scaleX).toBeCloseTo(transform.scaleZ);
expect(Math.sign(transform.scaleX - 1)).toBe(-Math.sign(transform.scaleY - 1));
expect(Math.abs(transform.scaleX - 1)).toBeGreaterThan(0.02);
});
it("keeps all shipped archetype poses finite and conservatively bounded", () => {
const transform = createMobAnimationTransform();
for (const archetype of ARCHETYPES) {
for (const moving of [false, true]) {
for (const elapsed of [0, 0.1, 1, 20, 3_600]) {
sampleMobAnimation(archetype, moving, elapsed, 1.234, transform);
const values = Object.values(transform);
expect(values.every(Number.isFinite)).toBe(true);
expect(transform.offsetY).toBeGreaterThanOrEqual(0);
expect(transform.offsetY).toBeLessThanOrEqual(0.2);
expect(Math.abs(transform.rotationX)).toBeLessThanOrEqual(0.05);
expect(Math.abs(transform.rotationY)).toBeLessThanOrEqual(0.09);
expect(Math.abs(transform.rotationZ)).toBeLessThanOrEqual(0.07);
expect(transform.scaleX).toBeGreaterThan(0.9);
expect(transform.scaleX).toBeLessThan(1.1);
expect(transform.scaleY).toBeGreaterThan(0.9);
expect(transform.scaleY).toBeLessThan(1.1);
expect(transform.scaleZ).toBeGreaterThan(0.9);
expect(transform.scaleZ).toBeLessThan(1.1);
}
}
}
});
});