Files
healer-man/src/game/combatAnimation.ts
T
2026-08-30 14:16:56 -04:00

327 lines
12 KiB
TypeScript

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<TClip extends NamedAnimationClip> {
readonly clip: TClip;
readonly loop: boolean;
readonly terminal: boolean;
readonly native: boolean;
}
const MELEE_CLASSES = new Set<ClassId>([
"warrior",
"paladin",
"rogue",
"death-knight",
]);
const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
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<TClip extends NamedAnimationClip>(
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<TClip extends NamedAnimationClip>(
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<TClip extends NamedAnimationClip>(
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<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
motion: Exclude<CharacterVerticalMotion, "grounded">,
moving: boolean,
): CharacterClipSelection<TClip> | 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<TClip extends NamedAnimationClip>(
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<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
classId: ClassId,
event: CharacterCombatAnimationEvent,
ability: AbilityDefinition | null,
): CharacterClipSelection<TClip> | 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<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
classId: ClassId,
event: CharacterCombatAnimationEvent,
): CharacterClipSelection<TClip> | 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;
}