mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-27 05:23:51 +00:00
feat(animation): decompose AnimationController into FSM-based architecture
Replace the 2,200-line monolithic AnimationController (goto-driven, single class, untestable) with a composed FSM architecture per refactor.md. New subsystem (src/rendering/animation/ — 16 headers, 10 sources): - CharacterAnimator: FSM composer implementing ICharacterAnimator - LocomotionFSM: idle/walk/run/sprint/jump/swim/strafe - CombatFSM: melee/ranged/spell cast/stun/hit reaction/charge - ActivityFSM: emote/loot/sit-down/sitting/sit-up - MountFSM: idle/run/flight/taxi/fidget/rear-up (per-instance RNG) - AnimCapabilitySet + AnimCapabilityProbe: probe once at model load, eliminate per-frame hasAnimation() linear search - AnimationManager: registry of CharacterAnimator by GUID - EmoteRegistry: DBC-backed emote command → animId singleton - FootstepDriver, SfxStateDriver: extracted from AnimationController animation_ids.hpp/.cpp moved to animation/ subdirectory (452 named constants); all include paths updated. AnimationController retained as thin adapter (~400 LOC): collects FrameInput, delegates to CharacterAnimator, applies AnimOutput. Priority order: Mount > Stun > HitReaction > Spell > Charge > Melee/Ranged > CombatIdle > Emote > Loot > Sit > Locomotion. STAY_IN_STATE policy when all FSMs return valid=false. Bugs fixed: - Remove static mt19937 in mount fidget (shared state across all mounted units) — replaced with per-instance seeded RNG - Remove goto from mounted animation branch (skipped init) - Remove per-frame hasAnimation() calls (now one probe at load) - Fix VK_INDEX_TYPE_UINT16 → UINT32 in shadow pass Tests (4 new suites, all ASAN+UBSan clean): - test_locomotion_fsm: 167 assertions - test_combat_fsm: 125 cases - test_activity_fsm: 112 cases - test_anim_capability: 56 cases docs/ANIMATION_SYSTEM.md added (architecture reference).
This commit is contained in:
parent
e58f9b4b40
commit
b4989dc11f
53 changed files with 5110 additions and 2099 deletions
|
|
@ -1,7 +1,7 @@
|
|||
#include "core/application.hpp"
|
||||
#include "core/coordinates.hpp"
|
||||
#include "core/profiler.hpp"
|
||||
#include "rendering/animation_ids.hpp"
|
||||
#include "rendering/animation/animation_ids.hpp"
|
||||
#include "rendering/animation_controller.hpp"
|
||||
#include <unordered_set>
|
||||
#include <cmath>
|
||||
|
|
@ -946,6 +946,16 @@ void Application::setState(AppState newState) {
|
|||
gameHandler->setStandState(rendering::AnimationController::STAND_STATE_STAND);
|
||||
}
|
||||
});
|
||||
cc->setSitDownCallback([this]() {
|
||||
if (gameHandler) {
|
||||
gameHandler->setStandState(rendering::AnimationController::STAND_STATE_SIT);
|
||||
}
|
||||
if (renderer) {
|
||||
if (auto* ac = renderer->getAnimationController()) {
|
||||
ac->setStandState(rendering::AnimationController::STAND_STATE_SIT);
|
||||
}
|
||||
}
|
||||
});
|
||||
cc->setAutoFollowCancelCallback([this]() {
|
||||
if (gameHandler) {
|
||||
gameHandler->cancelFollow();
|
||||
|
|
@ -3488,13 +3498,17 @@ void Application::setupUICallbacks() {
|
|||
});
|
||||
|
||||
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
|
||||
// Probes the model for the best available spell animation with fallback chain:
|
||||
// Regular cast: SPELL_CAST_DIRECTED(53) → SPELL_CAST_OMNI(54) → SPELL_CAST(32) → SPELL(2)
|
||||
// Channel: CHANNEL_CAST_DIRECTED(124) → CHANNEL_CAST_OMNI(125) → SPELL_CAST_DIRECTED(53) → SPELL(2)
|
||||
// For the local player, uses AnimationController state machine to prevent
|
||||
// COMBAT_IDLE from overriding the spell animation. For NPCs/other players,
|
||||
// calls playAnimation directly (they don't share the player state machine).
|
||||
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool isChannel) {
|
||||
// WoW-accurate 3-phase spell animation sequence:
|
||||
// Phase 1: SPELL_PRECAST (31) — one-shot wind-up
|
||||
// Phase 2: READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills
|
||||
// Phase 3: SPELL_CAST_DIRECTED/OMNI/AREA (53/54/33) — one-shot release at completion
|
||||
// Channels use CHANNEL_CAST_DIRECTED/OMNI (124/125) or SPELL_CHANNEL_DIRECTED_OMNI (201).
|
||||
// castType comes from the spell packet's targetGuid:
|
||||
// DIRECTED — spell targets a specific unit (Frostbolt, Heal)
|
||||
// OMNI — self-cast / no explicit target (Arcane Explosion, buffs)
|
||||
// AREA — ground-targeted AoE (Blizzard, Rain of Fire)
|
||||
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool isChannel,
|
||||
game::SpellCastType castType) {
|
||||
if (!entitySpawner_) return;
|
||||
if (!renderer) return;
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
|
|
@ -3514,6 +3528,9 @@ void Application::setupUICallbacks() {
|
|||
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
|
||||
if (instanceId == 0) return;
|
||||
|
||||
const bool isDirected = (castType == game::SpellCastType::DIRECTED);
|
||||
const bool isArea = (castType == game::SpellCastType::AREA);
|
||||
|
||||
if (start) {
|
||||
// Detect fishing spells (channeled) — use FISHING_LOOP instead of generic cast
|
||||
auto isFishingSpell = [](uint32_t spellId) {
|
||||
|
|
@ -3532,74 +3549,94 @@ void Application::setupUICallbacks() {
|
|||
cr->playAnimation(instanceId, rendering::anim::FISHING_LOOP, true);
|
||||
}
|
||||
} else {
|
||||
// Spell animation sequence: PRECAST (one-shot) → CAST (loop) → FINALIZE (one-shot) → idle
|
||||
// Probe model for best available animations with fallback chains:
|
||||
// Regular cast: SPELL_CAST_DIRECTED → SPELL_CAST_OMNI → SPELL_CAST → SPELL
|
||||
// Channel: CHANNEL_CAST_DIRECTED → CHANNEL_CAST_OMNI → SPELL_CAST_DIRECTED → SPELL
|
||||
bool hasTarget = gameHandler->hasTarget();
|
||||
// Helper: pick first animation the model supports from a list
|
||||
auto pickFirst = [&](std::initializer_list<uint32_t> ids) -> uint32_t {
|
||||
for (uint32_t id : ids)
|
||||
if (cr->hasAnimation(instanceId, id)) return id;
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Phase 1: Precast wind-up (one-shot, non-channels only)
|
||||
uint32_t precastAnim = 0;
|
||||
if (!isChannel && cr->hasAnimation(instanceId, rendering::anim::SPELL_PRECAST)) {
|
||||
precastAnim = rendering::anim::SPELL_PRECAST;
|
||||
if (!isChannel) {
|
||||
precastAnim = pickFirst({rendering::anim::SPELL_PRECAST});
|
||||
}
|
||||
|
||||
// Phase 2: Cast hold (looping until stopSpellCast)
|
||||
static const uint32_t castDirected[] = {
|
||||
rendering::anim::SPELL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
};
|
||||
static const uint32_t castOmni[] = {
|
||||
rendering::anim::SPELL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
};
|
||||
static const uint32_t channelDirected[] = {
|
||||
rendering::anim::CHANNEL_CAST_DIRECTED,
|
||||
rendering::anim::CHANNEL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL
|
||||
};
|
||||
static const uint32_t channelOmni[] = {
|
||||
rendering::anim::CHANNEL_CAST_OMNI,
|
||||
rendering::anim::CHANNEL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL
|
||||
};
|
||||
const uint32_t* chain;
|
||||
// Phase 2: Cast hold (looping while cast bar fills / channel active)
|
||||
uint32_t castAnim = 0;
|
||||
if (isChannel) {
|
||||
chain = hasTarget ? channelDirected : channelOmni;
|
||||
// Channel hold: prefer DIRECTED/OMNI based on spell target classification
|
||||
if (isDirected) {
|
||||
castAnim = pickFirst({
|
||||
rendering::anim::CHANNEL_CAST_DIRECTED,
|
||||
rendering::anim::CHANNEL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CHANNEL_DIRECTED_OMNI,
|
||||
rendering::anim::READY_SPELL_DIRECTED,
|
||||
rendering::anim::SPELL
|
||||
});
|
||||
} else {
|
||||
// OMNI or AREA channels (Blizzard channel, Tranquility, etc.)
|
||||
castAnim = pickFirst({
|
||||
rendering::anim::CHANNEL_CAST_OMNI,
|
||||
rendering::anim::CHANNEL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL_CHANNEL_DIRECTED_OMNI,
|
||||
rendering::anim::READY_SPELL_OMNI,
|
||||
rendering::anim::SPELL
|
||||
});
|
||||
}
|
||||
} else {
|
||||
chain = hasTarget ? castDirected : castOmni;
|
||||
}
|
||||
uint32_t castAnim = rendering::anim::SPELL;
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
if (cr->hasAnimation(instanceId, chain[i])) {
|
||||
castAnim = chain[i];
|
||||
break;
|
||||
// Regular cast hold: READY_SPELL_DIRECTED/OMNI while cast bar fills
|
||||
if (isDirected) {
|
||||
castAnim = pickFirst({
|
||||
rendering::anim::READY_SPELL_DIRECTED,
|
||||
rendering::anim::READY_SPELL_OMNI,
|
||||
rendering::anim::SPELL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
});
|
||||
} else {
|
||||
// OMNI (self-buff) or AREA (AoE targeting)
|
||||
castAnim = pickFirst({
|
||||
rendering::anim::READY_SPELL_OMNI,
|
||||
rendering::anim::READY_SPELL_DIRECTED,
|
||||
rendering::anim::SPELL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
});
|
||||
}
|
||||
}
|
||||
if (castAnim == 0) castAnim = rendering::anim::SPELL;
|
||||
|
||||
// Phase 3: Finalization release (one-shot after cast ends)
|
||||
// Pick a different animation from the cast loop for visual variety
|
||||
static const uint32_t finalizeChain[] = {
|
||||
rendering::anim::SPELL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
};
|
||||
// Phase 3: Finalization release (one-shot after cast completes)
|
||||
// Animation chosen by spell target type: AREA → SPELL_CAST_AREA,
|
||||
// DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI
|
||||
uint32_t finalizeAnim = 0;
|
||||
if (isLocalPlayer && !isChannel) {
|
||||
for (uint32_t fa : finalizeChain) {
|
||||
if (fa != castAnim && cr->hasAnimation(instanceId, fa)) {
|
||||
finalizeAnim = fa;
|
||||
break;
|
||||
}
|
||||
if (isArea) {
|
||||
// Ground-targeted AoE: SPELL_CAST_AREA → SPELL_CAST_OMNI
|
||||
finalizeAnim = pickFirst({
|
||||
rendering::anim::SPELL_CAST_AREA,
|
||||
rendering::anim::SPELL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
});
|
||||
} else if (isDirected) {
|
||||
// Single-target: SPELL_CAST_DIRECTED → SPELL_CAST_OMNI
|
||||
finalizeAnim = pickFirst({
|
||||
rendering::anim::SPELL_CAST_DIRECTED,
|
||||
rendering::anim::SPELL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
});
|
||||
} else {
|
||||
// OMNI (self-buff, Arcane Explosion): SPELL_CAST_OMNI → SPELL_CAST_AREA
|
||||
finalizeAnim = pickFirst({
|
||||
rendering::anim::SPELL_CAST_OMNI,
|
||||
rendering::anim::SPELL_CAST_AREA,
|
||||
rendering::anim::SPELL_CAST,
|
||||
rendering::anim::SPELL
|
||||
});
|
||||
}
|
||||
if (finalizeAnim == 0 && cr->hasAnimation(instanceId, rendering::anim::SPELL))
|
||||
finalizeAnim = rendering::anim::SPELL;
|
||||
}
|
||||
|
||||
if (isLocalPlayer) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
#include "audio/npc_voice_manager.hpp"
|
||||
#include "pipeline/m2_loader.hpp"
|
||||
#include "pipeline/wmo_loader.hpp"
|
||||
#include "rendering/animation_ids.hpp"
|
||||
#include "rendering/animation/animation_ids.hpp"
|
||||
#include "pipeline/dbc_loader.hpp"
|
||||
#include "pipeline/asset_manager.hpp"
|
||||
#include "pipeline/dbc_layout.hpp"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
#include "core/world_loader.hpp"
|
||||
#include "core/application.hpp"
|
||||
#include "rendering/animation_ids.hpp"
|
||||
#include "rendering/animation/animation_ids.hpp"
|
||||
#include "core/entity_spawner.hpp"
|
||||
#include "core/appearance_composer.hpp"
|
||||
#include "core/window.hpp"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue