123 lines
4.6 KiB
JavaScript
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,
|
|
};
|
|
}
|