Files
healer-man/scripts/dungeon-pipeline/runtime-creature-animations.mjs
T
2026-08-14 15:56:39 -04:00

123 lines
4.6 KiB
JavaScript

import { prune } from "@gltf-transform/functions";
export const CREATURE_RUNTIME_ANIMATION_POLICY = "wow-creature-runtime-v1";
const ANIMATION_GROUPS = Object.freeze({
stand: Object.freeze({ ids: [0], pattern: /^(stand|idle)\b/i, variations: false }),
walk: Object.freeze({ ids: [4], pattern: /^walk\b/i, variations: false }),
run: Object.freeze({ ids: [5], pattern: /^run\b/i, variations: false }),
attack: Object.freeze({
ids: [16, 17, 18, 19, 85, 87, 88, 95, 57, 58, 118, 53, 54, 32, 33, 2],
pattern: /^attack(?!.*ready)/i,
variations: true,
}),
cast: Object.freeze({
ids: [32, 33, 53, 54, 2, 51, 52],
pattern: /^(spellcast|cast)\b/i,
variations: true,
}),
wound: Object.freeze({
ids: [10, 9, 8],
pattern: /^(standwound|combatwound|combatcritical|wound|hurt)\b/i,
variations: true,
}),
death: Object.freeze({
ids: [1, 121, 134, 148, 189],
pattern: /^(death|drown)\b/i,
variations: true,
}),
});
export function parseWoWAnimationName(name) {
const match = String(name).match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i);
if (!match) return { label: String(name).trim(), id: null, variation: 0 };
return {
label: match[1].trim(),
id: Number.parseInt(match[2], 10),
variation: Number.parseInt(match[3], 10),
};
}
function selectedGroup(animations, definition) {
for (const id of definition.ids) {
const matches = animations
.filter((animation) => parseWoWAnimationName(animation.getName()).id === id)
.sort((left, right) => (
parseWoWAnimationName(left.getName()).variation
- parseWoWAnimationName(right.getName()).variation
));
if (matches.length) return definition.variations ? matches : matches.slice(0, 1);
}
const semanticMatches = animations.filter((animation) => (
definition.pattern.test(animation.getName().trim())
));
return definition.variations ? semanticMatches : semanticMatches.slice(0, 1);
}
/**
* Selects exactly the animation families consumed by MobPopulation. Combat
* families keep every variation of the highest-priority native WoW animation
* ID, preserving the runtime's deterministic variation cycling.
*/
export function selectCreatureRuntimeAnimations(animations) {
const usableAnimations = animations.filter((animation) => (
animation.listChannels().length > 0 && animation.listSamplers().length > 0
));
const retained = new Set();
const groups = {};
for (const [group, definition] of Object.entries(ANIMATION_GROUPS)) {
const selected = selectedGroup(usableAnimations, definition);
groups[group] = selected.map((animation) => animation.getName());
for (const animation of selected) retained.add(animation);
}
return {
animations: animations.filter((animation) => retained.has(animation)),
groups,
retainedNames: animations
.filter((animation) => retained.has(animation))
.map((animation) => animation.getName()),
removedNames: animations
.filter((animation) => !retained.has(animation))
.map((animation) => animation.getName()),
};
}
export function semanticCreatureAnimations(names) {
return {
stand: names.some((name) => /^Stand\b/i.test(name)),
walk: names.some((name) => /^Walk\b/i.test(name)),
run: names.some((name) => /^Run\b/i.test(name)),
attack: names.some((name) => /^Attack(?!.*Ready)/i.test(name)),
cast: names.some((name) => /^(SpellCast|Cast)/i.test(name)),
hit: names.some((name) => /^(StandWound|CombatWound|CombatCritical|Wound|Hurt)/i.test(name)),
death: names.some((name) => /^(Death|Drown)\b/i.test(name)),
};
}
export async function trimCreatureRuntimeAnimations(document) {
const root = document.getRoot();
const sourceAnimations = root.listAnimations();
const selection = selectCreatureRuntimeAnimations(sourceAnimations);
const retained = new Set(selection.animations);
for (const animation of sourceAnimations) {
if (retained.has(animation)) continue;
// Animation.dispose() detaches the Animation itself, but its orphaned
// channels and samplers continue to retain their accessors in memory. Tear
// down the owned graph explicitly so prune can remove the discarded clip
// payload from the written GLB.
for (const channel of animation.listChannels()) channel.dispose();
for (const sampler of animation.listSamplers()) sampler.dispose();
animation.dispose();
}
if (selection.removedNames.length) {
await document.transform(prune({ keepAttributes: true, keepSolidTextures: true }));
}
return {
policy: CREATURE_RUNTIME_ANIMATION_POLICY,
sourceClipCount: sourceAnimations.length,
runtimeClipCount: selection.retainedNames.length,
...selection,
};
}