248 lines
8.3 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|