327 lines
12 KiB
TypeScript
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;
|
|
}
|