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, }; }