import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog"; import type { AbilityDefinition } from "./abilityCatalog"; import { abilityAnimationById } from "./abilityAnimationLookup"; import type { CharacterVerticalMotion } from "./playerJump"; export type CharacterCombatAnimationKind = | "ability-start" | "ability-release" | "ability-cancel" | "attack" | "wound" | "death"; /** A monotonic presentation event consumed by the render layer exactly once. */ export interface CharacterCombatAnimationEvent { readonly revision: number; readonly kind: CharacterCombatAnimationKind; readonly abilityId?: string; } export interface NamedAnimationClip { readonly name: string; } export interface ParsedWoWAnimationName { readonly label: string; readonly id: number | null; readonly variation: number; } type RunewakerCombatMotion = "melee" | "bow" | "spell"; export type CharacterBaseMotion = "idle" | "walk" | "run"; export type CharacterWeaponStance = | "bow" | "one-handed" | "two-handed" | "two-handed-left"; export interface CharacterClipSelection { readonly clip: TClip; readonly loop: boolean; readonly terminal: boolean; readonly native: boolean; } const MELEE_CLASSES = new Set([ "warrior", "paladin", "rogue", "death-knight", ]); const CLASS_ATTACK_IDS: Readonly> = { warrior: [57, 17, 58, 18, 16, 118], paladin: [57, 17, 59, 16, 118], hunter: [46, 47, 49, 107, 16], rogue: [85, 88, 87, 117, 57, 17, 95, 16], priest: [53, 54, 32, 2, 16], "death-knight": [58, 18, 57, 17, 16], shaman: [57, 17, 53, 54, 32, 16], mage: [53, 54, 32, 2, 16], warlock: [53, 54, 32, 2, 16], druid: [53, 54, 32, 118, 16], }; /** Parse the names emitted by wow.export without depending on Three.js. */ export function parseWoWAnimationName(name: string): ParsedWoWAnimationName { const match = name.match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i); if (!match) return { label: name.trim(), id: null, variation: 0 }; return { label: match[1].trim(), id: Number.parseInt(match[2], 10), variation: Number.parseInt(match[3], 10), }; } export function clipsForAnimationIds( clips: readonly TClip[], ids: readonly number[], ): TClip[] { const rank = new Map(ids.map((id, index) => [id, index])); return clips .filter((clip) => { const id = parseWoWAnimationName(clip.name).id; return id !== null && rank.has(id); }) .sort((left, right) => { const leftName = parseWoWAnimationName(left.name); const rightName = parseWoWAnimationName(right.name); return (rank.get(leftName.id!)! - rank.get(rightName.id!)!) || (leftName.variation - rightName.variation); }); } /** Selects all native variations of the highest-priority animation ID. */ export function selectWoWAnimationVariation( clips: readonly TClip[], ids: readonly number[], revision = 0, ): TClip | undefined { for (const id of ids) { const variations = clips .filter((clip) => parseWoWAnimationName(clip.name).id === id) .sort((left, right) => ( parseWoWAnimationName(left.name).variation - parseWoWAnimationName(right.name).variation )); if (variations.length > 0) return variations[Math.abs(revision) % variations.length]; } return undefined; } export function selectCharacterBaseClip( clips: readonly TClip[], motion: CharacterBaseMotion, weaponStance?: CharacterWeaponStance, ): TClip | undefined { if (motion === "idle" && weaponStance) { const readyIds = weaponStance === "bow" ? [109, 29, 26] : weaponStance === "one-handed" ? [26] : weaponStance === "two-handed-left" ? [28, 27] : [27, 28]; const runewakerIdle = weaponStance === "bow" ? "idle - bow_idle" : weaponStance === "one-handed" ? "idle - 1h_idle" : "idle - 2h_idle"; const ready = selectWoWAnimationVariation(clips, readyIds) ?? clips.find((clip) => clip.name.toLowerCase().startsWith(runewakerIdle)); if (ready) return ready; } const ids = motion === "run" ? [5, 4, 0] : motion === "walk" ? [4, 5, 0] : [0, 4, 5]; return selectWoWAnimationVariation(clips, ids); } export function selectCharacterVerticalClip( clips: readonly TClip[], motion: Exclude, moving: boolean, ): CharacterClipSelection | null { const ids = motion === "rising" ? [37, 38, 40] : motion === "falling" ? [40, 38, 37] : moving ? [133, 39, 38] : [39, 133, 38]; const runewakerNames = motion === "rising" ? ["Action - jump_up", "Action - jump_loop"] : motion === "falling" ? ["Action - jump_down", "Action - jump_loop"] : moving ? ["Action - jump_end_run", "Action - jump_end_back"] : ["Action - jump_end_back", "Action - jump_end_run"]; const clip = selectWoWAnimationVariation(clips, ids) ?? selectRunewakerClipVariation(clips, runewakerNames); return clip ? { clip, loop: motion === "falling", terminal: false, native: true, } : null; } function isMeleeAbility(ability: AbilityDefinition | null, classId: ClassId): boolean { if (!ability) return MELEE_CLASSES.has(baseClassFor(classId)); if (ability.range.max <= 4) return true; const searchable = `${ability.id} ${ability.name}`.toLowerCase(); return /strike|slash|stab|kick|gouge|eviscer|melee|weapon|mangle|claw/.test(searchable); } function selectRunewakerClipVariation( clips: readonly TClip[], namePrefixes: readonly string[], revision = 0, ): TClip | undefined { for (const prefix of namePrefixes) { const normalizedPrefix = prefix.toLowerCase(); const variations = clips.filter((clip) => clip.name.toLowerCase().startsWith(normalizedPrefix)); if (variations.length > 0) return variations[Math.abs(revision) % variations.length]; } return undefined; } function runewakerCombatMotion( classId: ClassId, ability: AbilityDefinition | null, ): RunewakerCombatMotion { const baseClassId = baseClassFor(classId); if (ability && ability.target !== "hostile") return "spell"; if (baseClassId === "hunter") return "bow"; if (["rom-warrior", "rom-rogue", "rom-knight", "rom-warden", "rom-champion"].includes(classId)) { return "melee"; } if (ability && /strike|slash|stab|kick|gouge|pierc|weapon|claw/.test(`${ability.id} ${ability.name}`.toLowerCase())) { return "melee"; } return "spell"; } function runewakerMeleeReleaseNames( classId: ClassId, ability: AbilityDefinition | null, ): readonly string[] { const searchable = ability ? `${ability.id} ${ability.name}`.toLowerCase() : ""; if (/shield/.test(searchable)) return ["Action - shield_bash", "Attack - 1h_attack"]; if (/spin|whirl|cleave/.test(searchable)) return ["Action - spinslash", "Attack - 2h_attack"]; if (/kick/.test(searchable)) return ["Action - spinkick", "Attack - unarmed_attack"]; if (/charge|surprise/.test(searchable)) return ["Action - charge", "Attack - 1h_attack"]; if (/pierc|stab|probing/.test(searchable)) return ["Action - 1h_pierce", "Attack - 1h_attack"]; if (classId === "rom-rogue") return ["Attack - oh_attack", "Attack - 1h_attack"]; if (classId === "rom-warden") return ["Attack - polearm_attack", "Attack - 2h_attack"]; if (classId === "rom-champion") return ["Attack - 2h_attack", "Attack - 1h_attack"]; return ["Attack - 1h_attack", "Attack - 2h_attack", "Attack - unarmed_attack"]; } function selectRunewakerCharacterEventClip( clips: readonly TClip[], classId: ClassId, event: CharacterCombatAnimationEvent, ability: AbilityDefinition | null, ): CharacterClipSelection | null { if (event.kind === "ability-cancel") return null; const motion = runewakerCombatMotion(classId, ability); let names: readonly string[]; let loop = false; let terminal = false; switch (event.kind) { case "ability-start": names = motion === "bow" ? ["Action - bow_channel", "Action - bow_ready"] : motion === "melee" ? ["Action - 1h_charge_up", "Action - 2h_charge_up", "Cast - casting_delay"] : ability?.castMode === "channel" ? ["Cast - continuous_cast", "Action - continuous_buff", "Cast - casting_delay"] : ["Cast - casting_delay", "Cast - continuous_cast"]; loop = true; break; case "ability-release": if (motion === "bow") names = ["Action - bow_release", "Attack - 1h_attack"]; else if (motion === "melee") names = runewakerMeleeReleaseNames(classId, ability); else names = ability?.target === "hostile" ? ["Cast - cast01", "Action - debuff01"] : ["Action - buff01", "Cast - cast01"]; break; case "attack": names = motion === "bow" ? ["Action - bow_release", "Attack - 1h_attack"] : runewakerMeleeReleaseNames(classId, ability); break; case "wound": names = ["Wound - hurt"]; break; case "death": names = ["Death - death", "Terminal Pose - dead"]; terminal = true; break; } const clip = selectRunewakerClipVariation(clips, names, event.revision); return clip ? { clip, loop, terminal, native: true } : null; } function attackIds(classId: ClassId, ability: AbilityDefinition | null): readonly number[] { const baseClassId = baseClassFor(classId); const classAttackIds = CLASS_ATTACK_IDS[baseClassId]; const searchable = ability ? `${ability.id} ${ability.name}`.toLowerCase() : ""; if (/kick/.test(searchable)) return [95, ...classAttackIds]; if (/gouge|pierc|stab/.test(searchable)) return [85, 88, ...classAttackIds]; if (/shield/.test(searchable)) return [59, 24, 36, ...classAttackIds]; if (baseClassId === "hunter") return CLASS_ATTACK_IDS.hunter; if (/special|eviscer|execute|mortal|stormstrike|crusader/.test(searchable)) { return [57, 58, 118, ...classAttackIds]; } return classAttackIds; } function spellReadyIds(ability: AbilityDefinition | null): readonly number[] { if (ability?.castMode === "channel") { return ability.target === "hostile" ? [124, 125, 51, 31] : [125, 124, 52, 31]; } return ability?.target === "hostile" ? [51, 31, 56, 52] : [52, 31, 56, 51]; } function spellReleaseIds(ability: AbilityDefinition | null): readonly number[] { return ability?.target === "hostile" ? [53, 32, 54, 33, 2] : [54, 32, 53, 33, 2]; } export function selectCharacterEventClip( clips: readonly TClip[], classId: ClassId, event: CharacterCombatAnimationEvent, ): CharacterClipSelection | null { const ability = event.abilityId ? abilityAnimationById(event.abilityId) : null; if (event.kind === "ability-cancel") return null; if (classId.startsWith("rom-")) { return selectRunewakerCharacterEventClip(clips, classId, event, ability); } let ids: readonly number[]; let loop = false; let terminal = false; switch (event.kind) { case "ability-start": ids = isMeleeAbility(ability, classId) ? [56, 25, 26, 27, 28] : spellReadyIds(ability); loop = true; break; case "ability-release": ids = isMeleeAbility(ability, classId) ? attackIds(classId, ability) : spellReleaseIds(ability); break; case "attack": ids = attackIds(classId, ability); break; case "wound": ids = [10, 9, 8]; break; case "death": ids = [1, 121, 134, 148, 189]; terminal = true; break; } const clip = selectWoWAnimationVariation(clips, ids, event.revision); return clip ? { clip, loop, terminal, native: true } : null; }