diff --git a/CMakeLists.txt b/CMakeLists.txt index 866ac38a..84e84b62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -616,7 +616,17 @@ set(WOWEE_SOURCES src/rendering/spell_visual_system.cpp src/rendering/post_process_pipeline.cpp src/rendering/animation_controller.cpp - src/rendering/animation_ids.cpp + src/rendering/animation/animation_ids.cpp + src/rendering/animation/emote_registry.cpp + src/rendering/animation/footstep_driver.cpp + src/rendering/animation/sfx_state_driver.cpp + src/rendering/animation/anim_capability_probe.cpp + src/rendering/animation/locomotion_fsm.cpp + src/rendering/animation/combat_fsm.cpp + src/rendering/animation/activity_fsm.cpp + src/rendering/animation/mount_fsm.cpp + src/rendering/animation/character_animator.cpp # Renamed from player_animator.cpp; npc_animator.cpp removed + src/rendering/animation/animation_manager.cpp src/rendering/loading_screen.cpp # UI diff --git a/docs/ANIMATION_SYSTEM.md b/docs/ANIMATION_SYSTEM.md new file mode 100644 index 00000000..6dca06d7 --- /dev/null +++ b/docs/ANIMATION_SYSTEM.md @@ -0,0 +1,110 @@ +# Animation System + +Unified, FSM-based animation system for all characters (players, NPCs, companions). +Every character uses the same `CharacterAnimator` — there is no separate NPC/Mob animator. + +## Architecture + +``` +AnimationController (thin adapter — bridges Renderer ↔ CharacterAnimator) + └─ CharacterAnimator (FSM composer — implements ICharacterAnimator) + ├─ CombatFSM (stun, hit reaction, spell cast, melee, ranged, charge) + ├─ ActivityFSM (emote, loot, sit/stand/kneel/sleep) + ├─ LocomotionFSM (idle, walk, run, sprint, jump, swim, strafe) + └─ MountFSM (mount idle, mount run, flight) + +AnimationManager (registry of CharacterAnimator instances by ID) +AnimCapabilitySet (probed once per model — cached resolved anim IDs) +AnimCapabilityProbe (queries which animations a model supports) +``` + +### Priority Resolution + +`CharacterAnimator::resolveAnimation()` runs every frame. The first FSM to +return a valid `AnimOutput` wins: + +1. **Mount** — if mounted, return `MOUNT` (overrides everything) +2. **Combat** — stun > hit reaction > spell > charge > melee/ranged > combat idle +3. **Activity** — emote > loot > sit/stand transitions +4. **Locomotion** — run/walk/sprint/jump/swim/strafe/idle + +If no FSM produces a valid output, the last animation continues (STAY policy). + +### Overlay Layer + +After resolution, `applyOverlays()` substitutes stealth animation variants +(stealth idle, stealth walk, stealth run) without changing sub-FSM state. + +## File Map + +### Headers (`include/rendering/animation/`) + +| File | Purpose | +|---|---| +| `i_animator.hpp` | Base interface: `onEvent()`, `update()` | +| `i_character_animator.hpp` | 20 virtual methods (combat, spells, emotes, mounts, etc.) | +| `character_animator.hpp` | FSM composer — the single animator class | +| `locomotion_fsm.hpp` | Movement states: idle, walk, run, sprint, jump, swim | +| `combat_fsm.hpp` | Combat states: melee, ranged, spell cast, stun, hit reaction | +| `activity_fsm.hpp` | Activity states: emote, loot, sit/stand/kneel | +| `mount_fsm.hpp` | Mount states: idle, run, flight, taxi | +| `anim_capability_set.hpp` | Probed capability flags + resolved animation IDs | +| `anim_capability_probe.hpp` | Probes a model for available animations | +| `anim_event.hpp` | `AnimEvent` enum (MOVE_START, MOVE_STOP, JUMP, etc.) | +| `animation_manager.hpp` | Central registry of CharacterAnimator instances | +| `weapon_type.hpp` | WeaponLoadout, RangedWeaponType enums | +| `emote_registry.hpp` | Emote name → animation ID lookup | +| `footstep_driver.hpp` | Footstep sound event driver | +| `sfx_state_driver.hpp` | State-transition SFX (jump, land, swim enter/exit) | +| `i_anim_renderer.hpp` | Interface for renderer animation queries | + +### Sources (`src/rendering/animation/`) + +| File | Purpose | +|---|---| +| `character_animator.cpp` | ICharacterAnimator implementation + priority resolver | +| `locomotion_fsm.cpp` | Locomotion state transitions + resolve logic | +| `combat_fsm.cpp` | Combat state transitions + resolve logic | +| `activity_fsm.cpp` | Activity state transitions + resolve logic | +| `mount_fsm.cpp` | Mount state transitions + resolve logic | +| `anim_capability_probe.cpp` | Model animation probing | +| `animation_manager.cpp` | Registry CRUD + bulk update | +| `emote_registry.cpp` | Emote database | +| `footstep_driver.cpp` | Footstep timing logic | +| `sfx_state_driver.cpp` | SFX transition detection | + +### Controller (`include/rendering/animation_controller.hpp` + `src/rendering/animation_controller.cpp`) + +Thin adapter that: +- Collects per-frame input from camera/renderer → `CharacterAnimator::FrameInput` +- Forwards state changes (combat, emote, spell, mount, etc.) → `CharacterAnimator` +- Reads `AnimOutput` → applies via `CharacterRenderer` +- Owns footstep and SFX drivers + +## Key Types + +- **`AnimEvent`** — discrete events: `MOVE_START`, `MOVE_STOP`, `JUMP`, `LAND`, `MOUNT`, `DISMOUNT`, etc. +- **`AnimOutput`** — result of FSM resolution: `{animId, loop, valid}`. `valid=false` means STAY. +- **`AnimCapabilitySet`** — probed once per model load. Caches resolved IDs and capability flags. +- **`CharacterAnimator::FrameInput`** — per-frame input struct (movement flags, timers, animation state queries). + +## Adding a New Animation State + +1. Decide which FSM owns the state (combat, activity, locomotion, or mount). +2. Add the state enum to the FSM's `State` enum. +3. Add transitions in the FSM's `resolve()` method. +4. Add resolved ID fields to `AnimCapabilitySet` if the animation needs model probing. +5. If the state needs external triggering, add a method to `ICharacterAnimator` and implement in `CharacterAnimator`. + +## Tests + +Each FSM has its own test file in `tests/`: +- `test_locomotion_fsm.cpp` +- `test_combat_fsm.cpp` +- `test_activity_fsm.cpp` +- `test_anim_capability.cpp` + +Run all tests: +```bash +cd build && ctest --output-on-failure +``` diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 36f40aae..1d40d16c 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -940,7 +940,9 @@ public: // Spell cast animation callbacks — true=start cast/channel, false=finish/cancel // guid: caster (may be player or another unit), isChannel: channel vs regular cast - using SpellCastAnimCallback = std::function; + // castType: DIRECTED (unit target), OMNI (self/no target), AREA (ground AoE) + using SpellCastAnimCallback = std::function; void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } // Fired when the player's own spell cast fails (spellId of the failed spell). diff --git a/include/game/handler_types.hpp b/include/game/handler_types.hpp index 1826a0e9..e76e20fe 100644 --- a/include/game/handler_types.hpp +++ b/include/game/handler_types.hpp @@ -40,13 +40,25 @@ struct TalentTabEntry { // ---- Spell / cast state ---- +// Spell targeting classification for animation selection. +// Derived from the spell packet's targetGuid field — NOT the player's UI target. +// DIRECTED — spell targets a specific unit (Frostbolt, Heal, Shadow Bolt) +// OMNI — self-cast / no explicit target (Arcane Explosion, buffs) +// AREA — ground-targeted AoE (Blizzard, Rain of Fire, Flamestrike) +enum class SpellCastType : uint8_t { + DIRECTED = 0, // Has a specific unit target + OMNI = 1, // Self / no target + AREA = 2, // Ground-targeted AoE +}; + struct UnitCastState { - bool casting = false; - bool isChannel = false; - uint32_t spellId = 0; - float timeRemaining = 0.0f; - float timeTotal = 0.0f; - bool interruptible = true; + bool casting = false; + bool isChannel = false; + uint32_t spellId = 0; + float timeRemaining = 0.0f; + float timeTotal = 0.0f; + bool interruptible = true; + SpellCastType castType = SpellCastType::OMNI; }; // ---- Equipment sets (WotLK) ---- diff --git a/include/rendering/animation/activity_fsm.hpp b/include/rendering/animation/activity_fsm.hpp new file mode 100644 index 00000000..f5a3a33a --- /dev/null +++ b/include/rendering/animation/activity_fsm.hpp @@ -0,0 +1,110 @@ +#pragma once + +#include "rendering/animation/anim_capability_set.hpp" +#include "rendering/animation/anim_event.hpp" +#include +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// ActivityFSM +// +// Pure logic state machine for non-combat activities: emote, loot, sit/sleep/kneel. +// Sit chain (down → loop → up) auto-advances on one-shot completion. +// All activities cancel on movement. +// ============================================================================ +class ActivityFSM { +public: + enum class State : uint8_t { + NONE, + EMOTE, + LOOTING, // One-shot LOOT anim + LOOT_KNEELING, // KNEEL_LOOP until loot window closes + LOOT_END, // One-shot KNEEL_END exit anim + SIT_DOWN, + SITTING, + SIT_UP, + }; + + struct Input { + bool moving = false; + bool sprinting = false; + bool jumping = false; + bool grounded = true; + bool swimming = false; + bool sitting = false; // Camera controller sitting state + bool stunned = false; + // Animation state query for one-shot completion detection + uint32_t currentAnimId = 0; + float currentAnimTime = 0.0f; + float currentAnimDuration = 0.0f; + bool haveAnimState = false; + }; + + void onEvent(AnimEvent event); + + /// Evaluate current state against input and capabilities. + AnimOutput resolve(const Input& in, const AnimCapabilitySet& caps); + + State getState() const { return state_; } + void setState(State s) { state_ = s; } + bool isActive() const { return state_ != State::NONE; } + void reset(); + + // ── Emote management ──────────────────────────────────────────────── + void startEmote(uint32_t animId, bool loop); + void cancelEmote(); + bool isEmoteActive() const { return emoteActive_; } + uint32_t getEmoteAnimId() const { return emoteAnimId_; } + + // ── Sit/sleep/kneel management ────────────────────────────────────── + // WoW UnitStandStateType constants + static constexpr uint8_t STAND_STATE_STAND = 0; + static constexpr uint8_t STAND_STATE_SIT = 1; + static constexpr uint8_t STAND_STATE_SIT_CHAIR = 2; + static constexpr uint8_t STAND_STATE_SLEEP = 3; + static constexpr uint8_t STAND_STATE_SIT_LOW = 4; + static constexpr uint8_t STAND_STATE_SIT_MED = 5; + static constexpr uint8_t STAND_STATE_SIT_HIGH = 6; + static constexpr uint8_t STAND_STATE_DEAD = 7; + static constexpr uint8_t STAND_STATE_KNEEL = 8; + + void setStandState(uint8_t standState); + uint8_t getStandState() const { return standState_; } + + // ── Loot management ───────────────────────────────────────────────── + void startLooting(); + void stopLooting(); + + static constexpr uint8_t PRIORITY = 30; + +private: + State state_ = State::NONE; + + // Emote state + bool emoteActive_ = false; + uint32_t emoteAnimId_ = 0; + bool emoteLoop_ = false; + + // Sit/sleep/kneel transition animations + uint8_t standState_ = 0; + uint32_t sitDownAnim_ = 0; + uint32_t sitLoopAnim_ = 0; + uint32_t sitUpAnim_ = 0; + bool sitDownAnimSeen_ = false; // Track whether one-shot has started playing + bool sitUpAnimSeen_ = false; + uint8_t sitDownFrames_ = 0; // Frames spent in SIT_DOWN (for safety timeout) + uint8_t sitUpFrames_ = 0; // Frames spent in SIT_UP + bool lootAnimSeen_ = false; + uint8_t lootFrames_ = 0; + bool lootEndAnimSeen_ = false; + uint8_t lootEndFrames_ = 0; + + void updateTransitions(const Input& in); + bool oneShotComplete(const Input& in, uint32_t expectedAnimId) const; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/anim_capability_probe.hpp b/include/rendering/animation/anim_capability_probe.hpp new file mode 100644 index 00000000..b0e41574 --- /dev/null +++ b/include/rendering/animation/anim_capability_probe.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include "rendering/animation/anim_capability_set.hpp" + +namespace wowee { +namespace rendering { + +class Renderer; + +// ============================================================================ +// AnimCapabilityProbe +// +// Scans a model's animation sequences once and caches the results in an +// AnimCapabilitySet. All animation selection then uses the probed set +// instead of per-frame hasAnimation() calls. +// ============================================================================ +class AnimCapabilityProbe { +public: + AnimCapabilityProbe() = default; + + /// Probe all animation capabilities for the given character instance. + /// Returns a fully-populated AnimCapabilitySet. + static AnimCapabilitySet probe(Renderer* renderer, uint32_t instanceId); + + /// Probe mount animation capabilities (separate model). + static AnimCapabilitySet probeMountModel(Renderer* renderer, uint32_t mountInstanceId); + +private: + /// Pick the first available animation from candidates for the given instance. + /// Returns 0 if none available. + static uint32_t pickFirst(Renderer* renderer, uint32_t instanceId, + const uint32_t* candidates, size_t count); +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/anim_capability_set.hpp b/include/rendering/animation/anim_capability_set.hpp new file mode 100644 index 00000000..dd7d770f --- /dev/null +++ b/include/rendering/animation/anim_capability_set.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// AnimFallbackPolicy +// +// Controls what happens when a requested animation is unavailable. +// ============================================================================ +enum class AnimFallbackPolicy : uint8_t { + STAY_IN_STATE, // Keep current animation (default for player) + FIRST_AVAILABLE, // Try candidates list, stay if all fail + NONE, // Do nothing (default for expired queue) +}; + +// ============================================================================ +// AnimOutput +// +// Unified animation selection result. When valid=false, callers should +// keep the currently playing animation (STAY_IN_STATE policy). +// ============================================================================ +struct AnimOutput { + uint32_t animId = 0; + bool loop = false; + bool valid = false; + + /// Construct a valid output. + static AnimOutput ok(uint32_t id, bool looping) { + return {id, looping, true}; + } + /// Construct an invalid output (STAY policy). + static AnimOutput stay() { + return {0, false, false}; + } +}; + +// ============================================================================ +// AnimCapabilitySet +// +// Probed once per model load. Caches which animations a model supports +// and the resolved IDs (after fallback chains). This eliminates per-frame +// hasAnimation() calls. +// ============================================================================ +struct AnimCapabilitySet { + // ── Locomotion resolved IDs ───────────────────────────────────────── + uint32_t resolvedStand = 0; + uint32_t resolvedWalk = 0; + uint32_t resolvedRun = 0; + uint32_t resolvedSprint = 0; + uint32_t resolvedWalkBackwards = 0; + uint32_t resolvedStrafeLeft = 0; + uint32_t resolvedStrafeRight = 0; + uint32_t resolvedRunLeft = 0; + uint32_t resolvedRunRight = 0; + uint32_t resolvedJumpStart = 0; + uint32_t resolvedJump = 0; // Mid-air loop + uint32_t resolvedJumpEnd = 0; + uint32_t resolvedSwimIdle = 0; + uint32_t resolvedSwim = 0; + uint32_t resolvedSwimBackwards = 0; + uint32_t resolvedSwimLeft = 0; + uint32_t resolvedSwimRight = 0; + + // ── Combat resolved IDs ───────────────────────────────────────────── + uint32_t resolvedCombatIdle = 0; + uint32_t resolvedMelee1H = 0; + uint32_t resolvedMelee2H = 0; + uint32_t resolvedMelee2HLoose = 0; + uint32_t resolvedMeleeUnarmed = 0; + uint32_t resolvedMeleeFist = 0; + uint32_t resolvedMeleePierce = 0; // Dagger + uint32_t resolvedMeleeOffHand = 0; + uint32_t resolvedMeleeOffHandFist = 0; + uint32_t resolvedMeleeOffHandPierce = 0; + uint32_t resolvedMeleeOffHandUnarmed = 0; + + // ── Ready stances ─────────────────────────────────────────────────── + uint32_t resolvedReady1H = 0; + uint32_t resolvedReady2H = 0; + uint32_t resolvedReady2HLoose = 0; + uint32_t resolvedReadyUnarmed = 0; + uint32_t resolvedReadyFist = 0; + uint32_t resolvedReadyBow = 0; + uint32_t resolvedReadyRifle = 0; + uint32_t resolvedReadyCrossbow = 0; + uint32_t resolvedReadyThrown = 0; + + // ── Ranged attack resolved IDs ────────────────────────────────────── + uint32_t resolvedFireBow = 0; + uint32_t resolvedAttackRifle = 0; + uint32_t resolvedAttackCrossbow = 0; + uint32_t resolvedAttackThrown = 0; + uint32_t resolvedLoadBow = 0; + uint32_t resolvedLoadRifle = 0; + + // ── Special attacks ───────────────────────────────────────────────── + uint32_t resolvedSpecial1H = 0; + uint32_t resolvedSpecial2H = 0; + uint32_t resolvedSpecialUnarmed = 0; + uint32_t resolvedShieldBash = 0; + + // ── Activity resolved IDs ─────────────────────────────────────────── + uint32_t resolvedStandWound = 0; + uint32_t resolvedSitDown = 0; + uint32_t resolvedSitLoop = 0; + uint32_t resolvedSitUp = 0; + uint32_t resolvedKneel = 0; + uint32_t resolvedDeath = 0; + + // ── Stealth ───────────────────────────────────────────────────────── + uint32_t resolvedStealthIdle = 0; + uint32_t resolvedStealthWalk = 0; + uint32_t resolvedStealthRun = 0; + + // ── Misc ──────────────────────────────────────────────────────────── + uint32_t resolvedMount = 0; + uint32_t resolvedUnsheathe = 0; + uint32_t resolvedSheathe = 0; + uint32_t resolvedStun = 0; + uint32_t resolvedCombatWound = 0; + uint32_t resolvedLoot = 0; + + // ── Capability flags (bitfield) ───────────────────────────────────── + bool hasStand : 1; + bool hasWalk : 1; + bool hasRun : 1; + bool hasSprint : 1; + bool hasWalkBackwards : 1; + bool hasJump : 1; + bool hasSwim : 1; + bool hasMelee : 1; + bool hasStealth : 1; + bool hasDeath : 1; + bool hasMount : 1; + + // Default-initialize all flags to false + AnimCapabilitySet() + : hasStand(false), hasWalk(false), hasRun(false), hasSprint(false), + hasWalkBackwards(false), hasJump(false), hasSwim(false), + hasMelee(false), hasStealth(false), hasDeath(false), hasMount(false) {} +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/anim_event.hpp b/include/rendering/animation/anim_event.hpp new file mode 100644 index 00000000..dfa8b332 --- /dev/null +++ b/include/rendering/animation/anim_event.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// AnimEvent +// +// Event-driven animation state transitions. Sub-FSMs react to these events +// instead of polling conditions every frame. +// ============================================================================ +enum class AnimEvent : uint8_t { + MOVE_START, MOVE_STOP, + SPRINT_START, SPRINT_STOP, + JUMP, LANDED, + SWIM_ENTER, SWIM_EXIT, + COMBAT_ENTER, COMBAT_EXIT, + STUN_ENTER, STUN_EXIT, + SPELL_START, SPELL_STOP, + HIT_REACT, CHARGE_START, CHARGE_END, + EMOTE_START, EMOTE_STOP, + LOOT_START, LOOT_STOP, + SIT, STAND_UP, + MOUNT, DISMOUNT, + STEALTH_ENTER, STEALTH_EXIT, +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation_ids.hpp b/include/rendering/animation/animation_ids.hpp similarity index 100% rename from include/rendering/animation_ids.hpp rename to include/rendering/animation/animation_ids.hpp diff --git a/include/rendering/animation/animation_manager.hpp b/include/rendering/animation/animation_manager.hpp new file mode 100644 index 00000000..7a759e09 --- /dev/null +++ b/include/rendering/animation/animation_manager.hpp @@ -0,0 +1,56 @@ +#pragma once + +// Renamed from PlayerAnimator/NpcAnimator dual-map → unified CharacterAnimator registry. +// NpcAnimator removed — all characters use the same generic CharacterAnimator. +#include "rendering/animation/character_animator.hpp" +#include "rendering/animation/anim_capability_set.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// AnimationManager +// +// Central registry for all character animators. Owned by Renderer, replaces +// scattered AnimationController* passing. +// +// Single animator type: +// CharacterAnimator — generic animator for any character (player, NPC, +// companion). Full FSM composition with priority +// resolver. +// +// AnimationController becomes a thin shim delegating to this manager +// until all callsites are migrated. +// ============================================================================ +class AnimationManager { +public: + AnimationManager() = default; + + // ── Character animators ───────────────────────────────────────────── + + /// Get or create a CharacterAnimator for the given instance ID. + CharacterAnimator& getOrCreate(uint32_t instanceId); + + /// Get existing CharacterAnimator (nullptr if not found). + CharacterAnimator* get(uint32_t instanceId); + + /// Remove a character animator. + void remove(uint32_t instanceId); + + // ── Per-frame ─────────────────────────────────────────────────────── + + /// Update all registered animators. + void updateAll(float dt); + + // ── Counts ────────────────────────────────────────────────────────── + size_t count() const { return animators_.size(); } + +private: + std::unordered_map> animators_; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/character_animator.hpp b/include/rendering/animation/character_animator.hpp new file mode 100644 index 00000000..56ca09ad --- /dev/null +++ b/include/rendering/animation/character_animator.hpp @@ -0,0 +1,145 @@ +#pragma once + +#include "rendering/animation/i_character_animator.hpp" +#include "rendering/animation/anim_capability_set.hpp" +#include "rendering/animation/locomotion_fsm.hpp" +#include "rendering/animation/combat_fsm.hpp" +#include "rendering/animation/activity_fsm.hpp" +#include "rendering/animation/mount_fsm.hpp" +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// CharacterAnimator +// +// Generic animator for any character (player, NPC, companion). +// Composes LocomotionFSM, CombatFSM, ActivityFSM, and MountFSM with +// a priority resolver. Implements ICharacterAnimator. +// +// Priority order: Stun > HitReaction > Spell > Charge > Combat > Activity > Locomotion +// +// No idle fallback: if all FSMs return {valid=false}, last animation continues. +// +// Overlay layer (stealth, sprint) substitutes the resolved anim without +// changing sub-FSM state. +// ============================================================================ +class CharacterAnimator final : public ICharacterAnimator { +public: + CharacterAnimator(); + + // ── IAnimator ─────────────────────────────────────────────────────── + void onEvent(AnimEvent event) override; + void update(float dt) override; + + // ── ICharacterAnimator ────────────────────────────────────────────── + void startSpellCast(uint32_t precast, uint32_t cast, bool loop, uint32_t finalize) override; + void stopSpellCast() override; + void triggerMeleeSwing() override; + void triggerRangedShot() override; + void triggerHitReaction(uint32_t animId) override; + void triggerSpecialAttack(uint32_t spellId) override; + void setEquippedWeaponType(const WeaponLoadout& loadout) override; + void setEquippedRangedType(RangedWeaponType type) override; + void playEmote(uint32_t animId, bool loop) override; + void cancelEmote() override; + void startLooting() override; + void stopLooting() override; + void setStunned(bool stunned) override; + void setCharging(bool charging) override; + void setStandState(uint8_t state) override; + void setStealthed(bool stealth) override; + void setInCombat(bool combat) override; + void setLowHealth(bool low) override; + void setSprintAuraActive(bool active) override; + + // ── Configuration ─────────────────────────────────────────────────── + void setCapabilities(const AnimCapabilitySet& caps) { caps_ = caps; } + const AnimCapabilitySet& getCapabilities() const { return caps_; } + void setWeaponLoadout(const WeaponLoadout& loadout) { loadout_ = loadout; } + const WeaponLoadout& getWeaponLoadout() const { return loadout_; } + + // ── Mount ─────────────────────────────────────────────────────────── + void configureMountFSM(const MountFSM::MountAnimSet& anims, bool taxiFlight); + void clearMountFSM(); + bool isMountActive() const { return mount_.isActive(); } + MountFSM& getMountFSM() { return mount_; } + + // ── Sub-FSM access (for transition queries) ───────────────────────── + LocomotionFSM& getLocomotion() { return locomotion_; } + const LocomotionFSM& getLocomotion() const { return locomotion_; } + CombatFSM& getCombat() { return combat_; } + const CombatFSM& getCombat() const { return combat_; } + ActivityFSM& getActivity() { return activity_; } + const ActivityFSM& getActivity() const { return activity_; } + + // ── Last resolved output ──────────────────────────────────────────── + AnimOutput getLastOutput() const { return lastOutput_; } + + // ── Input injection (set per-frame from AnimationController) ──────── + struct FrameInput { + // From camera controller + bool moving = false; + bool sprinting = false; + bool movingForward = false; + bool movingBackward = false; + bool autoRunning = false; + bool strafeLeft = false; + bool strafeRight = false; + bool grounded = true; + bool jumping = false; + bool swimming = false; + bool sitting = false; + bool flyingActive = false; + bool ascending = false; + bool descending = false; + bool jumpKeyPressed = false; + float characterYaw = 0.0f; + // Melee/ranged timers + float meleeSwingTimer = 0.0f; + float rangedShootTimer = 0.0f; + uint32_t specialAttackAnimId = 0; + uint32_t rangedAnimId = 0; + // Animation state query + uint32_t currentAnimId = 0; + float currentAnimTime = 0.0f; + float currentAnimDuration = 0.0f; + bool haveAnimState = false; + // Mount state query + uint32_t curMountAnim = 0; + float curMountTime = 0.0f; + float curMountDuration = 0.0f; + bool haveMountState = false; + }; + + void setFrameInput(const FrameInput& input) { frameInput_ = input; } + +private: + AnimCapabilitySet caps_; + WeaponLoadout loadout_; + + LocomotionFSM locomotion_; + CombatFSM combat_; + ActivityFSM activity_; + MountFSM mount_; + + // Overlay flags + bool stealthed_ = false; + bool sprintAura_ = false; + bool lowHealth_ = false; + bool inCombat_ = false; + + float lastDt_ = 0.0f; + FrameInput frameInput_; + AnimOutput lastOutput_; + + /// Priority resolver: highest-priority active FSM wins. + AnimOutput resolveAnimation(); + + /// Apply stealth/sprint overlays to the resolved animation. + AnimOutput applyOverlays(AnimOutput base) const; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/combat_fsm.hpp b/include/rendering/animation/combat_fsm.hpp new file mode 100644 index 00000000..8c189b67 --- /dev/null +++ b/include/rendering/animation/combat_fsm.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include "rendering/animation/anim_capability_set.hpp" +#include "rendering/animation/anim_event.hpp" +#include "rendering/animation/weapon_type.hpp" +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// CombatFSM +// +// Pure logic state machine for combat animation. No renderer dependency. +// States: INACTIVE · COMBAT_IDLE · MELEE_SWING · RANGED_SHOOT · RANGED_LOAD · +// SPELL_PRECAST · SPELL_CASTING · SPELL_FINALIZE · HIT_REACTION · +// STUNNED · CHARGE · UNSHEATHE · SHEATHE +// +// Stun overrides all combat states. Spell state cleared on interrupts. +// offHandTurn_ alternation managed internally. +// ============================================================================ +class CombatFSM { +public: + enum class State : uint8_t { + INACTIVE, + COMBAT_IDLE, + MELEE_SWING, + RANGED_SHOOT, + RANGED_LOAD, + SPELL_PRECAST, + SPELL_CASTING, + SPELL_FINALIZE, + HIT_REACTION, + STUNNED, + CHARGE, + UNSHEATHE, + SHEATHE, + }; + + struct Input { + bool inCombat = false; + bool grounded = true; + bool jumping = false; + bool swimming = false; + bool moving = false; + bool sprinting = false; + bool lowHealth = false; + float meleeSwingTimer = 0.0f; // >0 = melee active + float rangedShootTimer = 0.0f; // >0 = ranged active + uint32_t specialAttackAnimId = 0; + uint32_t rangedAnimId = 0; + // Animation state query for one-shot completion detection + uint32_t currentAnimId = 0; + float currentAnimTime = 0.0f; + float currentAnimDuration = 0.0f; + bool haveAnimState = false; + // Whether model has specific one-shot animations + bool hasUnsheathe = false; + bool hasSheathe = false; + }; + + void onEvent(AnimEvent event); + + /// Evaluate current state against input and capabilities. + AnimOutput resolve(const Input& in, const AnimCapabilitySet& caps, + const WeaponLoadout& loadout); + + State getState() const { return state_; } + void setState(State s) { state_ = s; } + + bool isStunned() const { return state_ == State::STUNNED; } + bool isActive() const { return state_ != State::INACTIVE; } + void reset(); + + // ── Spell cast management ─────────────────────────────────────────── + void startSpellCast(uint32_t precast, uint32_t cast, bool castLoop, uint32_t finalize); + void stopSpellCast(); + void clearSpellState(); + + // ── Hit/stun management ───────────────────────────────────────────── + void triggerHitReaction(uint32_t animId); + void setStunned(bool stunned); + void setCharging(bool charging); + + static constexpr uint8_t PRIORITY = 50; + +private: + State state_ = State::INACTIVE; + + // Spell cast sequence + uint32_t spellPrecastAnimId_ = 0; + uint32_t spellCastAnimId_ = 0; + uint32_t spellFinalizeAnimId_ = 0; + bool spellCastLoop_ = false; + bool spellPrecastAnimSeen_ = false; + uint8_t spellPrecastFrames_ = 0; + bool spellFinalizeAnimSeen_ = false; + uint8_t spellFinalizeFrames_ = 0; + + // Hit reaction + uint32_t hitReactionAnimId_ = 0; + + // Stun + bool stunned_ = false; + + // Charge + bool charging_ = false; + + // Off-hand alternation for dual wielding + bool offHandTurn_ = false; + + /// Internal: update state transitions based on input. + void updateTransitions(const Input& in); + + /// Detect if a one-shot animation has completed. + bool oneShotComplete(const Input& in, uint32_t expectedAnimId) const; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/emote_registry.hpp b/include/rendering/animation/emote_registry.hpp new file mode 100644 index 00000000..2794fb25 --- /dev/null +++ b/include/rendering/animation/emote_registry.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// EmoteRegistry — extracted from AnimationController +// +// Owns all static emote data, DBC loading, emote text lookup, and +// animation ID resolution. Singleton — loaded once on first use. +// ============================================================================ + +struct EmoteInfo { + uint32_t animId = 0; + uint32_t dbcId = 0; + bool loop = false; + std::string textNoTarget; + std::string textTarget; + std::string othersNoTarget; + std::string othersTarget; + std::string command; +}; + +class EmoteRegistry { +public: + static EmoteRegistry& instance(); + + /// Load emotes from DBC files (called once on first use). + void loadFromDbc(); + + struct EmoteResult { uint32_t animId; bool loop; }; + + /// Look up an emote by chat command (e.g. "dance", "wave"). + std::optional findEmote(const std::string& command) const; + + /// Get the animation ID for a DBC emote ID. + uint32_t animByDbcId(uint32_t dbcId) const; + + /// Get the emote state variant (looping) for a one-shot emote animation. + uint32_t getStateVariant(uint32_t oneShotAnimId) const; + + /// Get first-person emote text for a command. + std::string textFor(const std::string& emoteName, + const std::string* targetName = nullptr) const; + + /// Get DBC ID for an emote command. + uint32_t dbcIdFor(const std::string& emoteName) const; + + /// Get third-person emote text by DBC ID. + std::string textByDbcId(uint32_t dbcId, + const std::string& senderName, + const std::string* targetName = nullptr) const; + + /// Get the full EmoteInfo for a command (nullptr if not found). + const EmoteInfo* findInfo(const std::string& command) const; + +private: + EmoteRegistry() = default; + EmoteRegistry(const EmoteRegistry&) = delete; + EmoteRegistry& operator=(const EmoteRegistry&) = delete; + + void loadFallbackEmotes(); + void buildDbcIdIndex(); + + bool loaded_ = false; + std::unordered_map emoteTable_; + std::unordered_map emoteByDbcId_; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/footstep_driver.hpp b/include/rendering/animation/footstep_driver.hpp new file mode 100644 index 00000000..6dd3005f --- /dev/null +++ b/include/rendering/animation/footstep_driver.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +namespace wowee { + +namespace audio { enum class FootstepSurface : uint8_t; } + +namespace rendering { + +class Renderer; + +// ============================================================================ +// FootstepDriver — extracted from AnimationController +// +// Owns animation-driven footstep event detection, surface resolution, +// and player/mount footstep tracking state. +// ============================================================================ +class FootstepDriver { +public: + FootstepDriver() = default; + + /// Process footstep events for this frame (called from Renderer::update). + void update(float deltaTime, Renderer* renderer, + bool mounted, uint32_t mountInstanceId, bool taxiFlight, + bool isFootstepState); + + /// Detect if a footstep event should trigger based on animation phase crossing. + bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, + float animationDurationMs); + + /// Resolve the surface type under the character for footstep sound selection. + audio::FootstepSurface resolveFootstepSurface(Renderer* renderer) const; + +private: + // Player footstep event tracking (animation-driven) + uint32_t footstepLastAnimationId_ = 0; + float footstepLastNormTime_ = 0.0f; + bool footstepNormInitialized_ = false; + + // Footstep surface cache (avoid expensive queries every step) + mutable audio::FootstepSurface cachedFootstepSurface_{}; + mutable glm::vec3 cachedFootstepPosition_{0.0f, 0.0f, 0.0f}; + mutable float cachedFootstepUpdateTimer_{999.0f}; + + // Mount footstep tracking (separate from player's) + uint32_t mountFootstepLastAnimId_ = 0; + float mountFootstepLastNormTime_ = 0.0f; + bool mountFootstepNormInitialized_ = false; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/i_anim_renderer.hpp b/include/rendering/animation/i_anim_renderer.hpp new file mode 100644 index 00000000..7eec655e --- /dev/null +++ b/include/rendering/animation/i_anim_renderer.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "rendering/animation/anim_capability_set.hpp" +#include "rendering/animation/anim_event.hpp" +#include +#include + +namespace wowee { +namespace pipeline { struct M2Sequence; } + +namespace rendering { + +// ============================================================================ +// IAnimRenderer +// +// Abstraction for renderer animation operations. Sub-FSMs and animators +// talk to this interface, not to CharacterRenderer directly. +// ============================================================================ +class IAnimRenderer { +public: + virtual void playAnimation(uint32_t instanceId, uint32_t animId, bool loop) = 0; + virtual bool hasAnimation(uint32_t instanceId, uint32_t animId) const = 0; + virtual bool getAnimationState(uint32_t instanceId, uint32_t& outAnimId, + float& outTimeMs, float& outDurMs) const = 0; + virtual bool getAnimationSequences(uint32_t instanceId, + std::vector& out) const = 0; + virtual ~IAnimRenderer() = default; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/i_animator.hpp b/include/rendering/animation/i_animator.hpp new file mode 100644 index 00000000..b52055cb --- /dev/null +++ b/include/rendering/animation/i_animator.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "rendering/animation/anim_event.hpp" + +namespace wowee { +namespace rendering { + +// ============================================================================ +// IAnimator +// +// Base interface for all entity animators. Common to player + NPC. +// ============================================================================ +class IAnimator { +public: + virtual void onEvent(AnimEvent event) = 0; + virtual void update(float dt) = 0; + virtual ~IAnimator() = default; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/i_character_animator.hpp b/include/rendering/animation/i_character_animator.hpp new file mode 100644 index 00000000..75c67a3d --- /dev/null +++ b/include/rendering/animation/i_character_animator.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "rendering/animation/i_animator.hpp" +#include "rendering/animation/weapon_type.hpp" +#include +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// ICharacterAnimator +// +// Player-specific animation interface. Extends IAnimator with combat, +// spell, emote, and mount operations. +// ============================================================================ +class ICharacterAnimator : public IAnimator { +public: + virtual void startSpellCast(uint32_t precast, uint32_t cast, bool loop, uint32_t finalize) = 0; + virtual void stopSpellCast() = 0; + virtual void triggerMeleeSwing() = 0; + virtual void triggerRangedShot() = 0; + virtual void triggerHitReaction(uint32_t animId) = 0; + virtual void triggerSpecialAttack(uint32_t spellId) = 0; + virtual void setEquippedWeaponType(const WeaponLoadout& loadout) = 0; + virtual void setEquippedRangedType(RangedWeaponType type) = 0; + virtual void playEmote(uint32_t animId, bool loop) = 0; + virtual void cancelEmote() = 0; + virtual void startLooting() = 0; + virtual void stopLooting() = 0; + virtual void setStunned(bool stunned) = 0; + virtual void setCharging(bool charging) = 0; + virtual void setStandState(uint8_t state) = 0; + virtual void setStealthed(bool stealth) = 0; + virtual void setInCombat(bool combat) = 0; + virtual void setLowHealth(bool low) = 0; + virtual void setSprintAuraActive(bool active) = 0; + virtual ~ICharacterAnimator() = default; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/locomotion_fsm.hpp b/include/rendering/animation/locomotion_fsm.hpp new file mode 100644 index 00000000..2c99ecd4 --- /dev/null +++ b/include/rendering/animation/locomotion_fsm.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "rendering/animation/anim_capability_set.hpp" +#include "rendering/animation/anim_event.hpp" +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// LocomotionFSM +// +// Pure logic state machine for movement animation. No renderer dependency. +// States: IDLE · WALK · RUN · JUMP_START · JUMP_MID · JUMP_END · SWIM_IDLE · SWIM +// +// Grace timer is internal — no external locomotionStopGraceTimer_ needed. +// ============================================================================ +class LocomotionFSM { +public: + enum class State : uint8_t { + IDLE, WALK, RUN, + JUMP_START, JUMP_MID, JUMP_END, + SWIM_IDLE, SWIM, + }; + + struct Input { + bool moving = false; + bool movingForward = false; + bool sprinting = false; + bool movingBackward = false; + bool strafeLeft = false; + bool strafeRight = false; + bool grounded = true; + bool jumping = false; + bool swimming = false; + bool sitting = false; + bool sprintAura = false; // Sprint/Dash aura — use SPRINT anim + float deltaTime = 0.0f; + // Animation state for one-shot completion detection (jump start/end) + uint32_t currentAnimId = 0; + float currentAnimTime = 0.0f; + float currentAnimDuration = 0.0f; + bool haveAnimState = false; + }; + + /// Process event and update internal state. + void onEvent(AnimEvent event); + + /// Evaluate current state against input and capabilities. + /// Returns AnimOutput with valid=false if no change needed (STAY policy). + AnimOutput resolve(const Input& in, const AnimCapabilitySet& caps); + + State getState() const { return state_; } + void setState(State s) { state_ = s; } + void reset(); + + static constexpr uint8_t PRIORITY = 10; + +private: + State state_ = State::IDLE; + + // Grace timer: short delay before switching from WALK/RUN to IDLE + // to avoid flickering on network jitter + float graceTimer_ = 0.0f; + bool wasSprinting_ = false; + + // One-shot tracking for jump start/end animations + bool jumpStartSeen_ = false; + bool jumpEndSeen_ = false; + + static constexpr float kGraceSec = 0.12f; + + /// Internal: update state transitions based on input. + void updateTransitions(const Input& in, const AnimCapabilitySet& caps); + bool oneShotComplete(const Input& in, uint32_t expectedAnimId) const; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/mount_fsm.hpp b/include/rendering/animation/mount_fsm.hpp new file mode 100644 index 00000000..de9ca158 --- /dev/null +++ b/include/rendering/animation/mount_fsm.hpp @@ -0,0 +1,142 @@ +#pragma once + +#include "rendering/animation/anim_capability_set.hpp" +#include "rendering/animation/anim_event.hpp" +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +// ============================================================================ +// MountFSM +// +// Self-contained mount animation state machine. Replaces the ~400-line +// mounted branch of updateCharacterAnimation() and eliminates the `goto`. +// +// Owns: fidget timer, RNG, idle sound timer, mount action state. +// All static RNG replaced with per-instance members. +// ============================================================================ +class MountFSM { +public: + // Animation set discovered at mount time (property-based, not hardcoded) + struct MountAnimSet { + uint32_t jumpStart = 0; + uint32_t jumpLoop = 0; + uint32_t jumpEnd = 0; + uint32_t rearUp = 0; + uint32_t run = 0; + uint32_t stand = 0; + // Flight animations + uint32_t flyIdle = 0; + uint32_t flyForward = 0; + uint32_t flyBackwards = 0; + uint32_t flyLeft = 0; + uint32_t flyRight = 0; + uint32_t flyUp = 0; + uint32_t flyDown = 0; + std::vector fidgets; + }; + + enum class MountState : uint8_t { + IDLE, RUN, JUMP_START, JUMP_LOOP, JUMP_LAND, REAR_UP, FLY, + }; + + enum class MountAction : uint8_t { None, Jump, RearUp }; + + struct Input { + bool moving = false; + bool movingBackward = false; + bool strafeLeft = false; + bool strafeRight = false; + bool grounded = true; + bool jumpKeyPressed = false; + bool flying = false; + bool swimming = false; + bool ascending = false; + bool descending = false; + bool taxiFlight = false; + float deltaTime = 0.0f; + float characterYaw = 0.0f; + // Mount anim state query + uint32_t curMountAnim = 0; + float curMountTime = 0.0f; + float curMountDuration = 0.0f; + bool haveMountState = false; + }; + + /// Output from evaluate(): what to play on rider + mount, and positioning data. + struct Output { + // Mount animation + uint32_t mountAnimId = 0; + bool mountAnimLoop = true; + bool mountAnimChanged = false; // true = should call playAnimation + + // Rider animation + uint32_t riderAnimId = 0; + bool riderAnimLoop = true; + bool riderAnimChanged = false; + + // Mount procedural motion + float mountBob = 0.0f; // Vertical bob offset + float mountPitch = 0.0f; // Pitch (forward lean) + float mountRoll = 0.0f; // Roll (banking) + + // Signals + bool playJumpSound = false; + bool playLandSound = false; + bool playRearUpSound = false; + bool playIdleSound = false; + bool triggerMountJump = false; // Tell camera controller to jump + bool fidgetStarted = false; + }; + + void configure(const MountAnimSet& anims, bool taxiFlight); + void clear(); + void onEvent(AnimEvent event); + + /// Main evaluation: produces Output describing what to play. + Output evaluate(const Input& in); + + bool isActive() const { return active_; } + MountState getState() const { return state_; } + MountAction getAction() const { return action_; } + const MountAnimSet& getAnims() const { return anims_; } + +private: + bool active_ = false; + MountState state_ = MountState::IDLE; + MountAction action_ = MountAction::None; + uint32_t actionPhase_ = 0; + + MountAnimSet anims_; + bool taxiFlight_ = false; + + // Fidget system — per-instance, not static + float fidgetTimer_ = 0.0f; + float nextFidgetTime_ = 8.0f; + uint32_t activeFidget_ = 0; + std::mt19937 rng_; + + // Idle ambient sound timer + float idleSoundTimer_ = 0.0f; + float nextIdleSoundTime_ = 60.0f; + + // Procedural lean + float prevYaw_ = 0.0f; + float roll_ = 0.0f; + + // Last mount animation for change detection + uint32_t lastMountAnim_ = 0; + + /// Resolve the mount animation for the given input (non-taxi). + uint32_t resolveGroundOrFlyAnim(const Input& in) const; + + /// Check if an action animation has completed. + bool actionAnimComplete(const Input& in) const; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/sfx_state_driver.hpp b/include/rendering/animation/sfx_state_driver.hpp new file mode 100644 index 00000000..28b4c266 --- /dev/null +++ b/include/rendering/animation/sfx_state_driver.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace wowee { +namespace rendering { + +class Renderer; +class FootstepDriver; + +// ============================================================================ +// SfxStateDriver — extracted from AnimationController +// +// Tracks state transitions for activity SFX (jump, landing, swim) and +// mount ambient sounds. +// ============================================================================ +class SfxStateDriver { +public: + SfxStateDriver() = default; + + /// Track state transitions and trigger appropriate SFX. + void update(float deltaTime, Renderer* renderer, + bool mounted, bool taxiFlight, + FootstepDriver& footstepDriver); + +private: + bool initialized_ = false; + bool prevGrounded_ = true; + bool prevJumping_ = false; + bool prevFalling_ = false; + bool prevSwimming_ = false; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation/weapon_type.hpp b/include/rendering/animation/weapon_type.hpp new file mode 100644 index 00000000..23c662e7 --- /dev/null +++ b/include/rendering/animation/weapon_type.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace wowee { +namespace rendering { + +/// Ranged weapon type for animation selection (bow/gun/crossbow/thrown) +enum class RangedWeaponType : uint8_t { NONE = 0, BOW, GUN, CROSSBOW, THROWN }; + +// ============================================================================ +// WeaponLoadout — extracted from AnimationController +// +// Consolidates the 6 weapon boolean fields + inventory type + ranged type +// into a single value type. +// ============================================================================ +struct WeaponLoadout { + uint32_t inventoryType = 0; + bool is2HLoose = false; // Polearm or staff + bool isFist = false; // Fist weapon + bool isDagger = false; // Dagger (uses pierce variants) + bool hasOffHand = false; // Has off-hand weapon (dual wield) + bool hasShield = false; // Has shield equipped (for SHIELD_BASH) + RangedWeaponType rangedType = RangedWeaponType::NONE; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/animation_controller.hpp b/include/rendering/animation_controller.hpp index 5602e1e6..0efcc715 100644 --- a/include/rendering/animation_controller.hpp +++ b/include/rendering/animation_controller.hpp @@ -4,22 +4,31 @@ #include #include #include +#include "rendering/animation/footstep_driver.hpp" +#include "rendering/animation/sfx_state_driver.hpp" +#include "rendering/animation/weapon_type.hpp" +#include "rendering/animation/anim_capability_set.hpp" +#include "rendering/animation/character_animator.hpp" namespace wowee { -namespace audio { enum class FootstepSurface : uint8_t; } namespace rendering { class Renderer; -/// Ranged weapon type for animation selection (bow/gun/crossbow/thrown) -enum class RangedWeaponType : uint8_t { NONE = 0, BOW, GUN, CROSSBOW, THROWN }; - // ============================================================================ -// AnimationController — extracted from Renderer (§4.2) +// AnimationController — thin adapter wrapping CharacterAnimator // -// Owns the character locomotion state machine, mount animation state, -// emote system, footstep triggering, surface detection, melee combat -// animation, and activity SFX transition tracking. +// Bridges the Renderer world (camera state, CharacterRenderer, audio) +// and the pure-logic CharacterAnimator + sub-FSMs. Public API unchanged. +// +// Responsibilities: +// · Collect inputs from renderer/camera → CharacterAnimator::FrameInput +// · Forward state-changing calls → CharacterAnimator +// · Read AnimOutput from CharacterAnimator → apply via CharacterRenderer +// · Mount discovery (needs renderer for sequence queries) +// · Mount positioning (needs renderer for attachment queries) +// · Melee/ranged resolution (needs renderer for sequence queries) +// · Footstep and SFX drivers (already extracted) // ============================================================================ class AnimationController { public: @@ -28,6 +37,11 @@ public: void initialize(Renderer* renderer); + /// Probe (or re-probe) animation capabilities for the current character model. + /// Called once during initialize() / onCharacterFollow() and after model changes. + void probeCapabilities(); + const AnimCapabilitySet& getCapabilities() const { return characterAnimator_.getCapabilities(); } + // ── Per-frame update hooks (called from Renderer::update) ────────────── // Runs the character animation state machine (mounted + unmounted). void updateCharacterAnimation(); @@ -47,7 +61,7 @@ public: // ── Emote support ────────────────────────────────────────────────────── void playEmote(const std::string& emoteName); void cancelEmote(); - bool isEmoteActive() const { return emoteActive_; } + bool isEmoteActive() const { return characterAnimator_.getActivity().isEmoteActive(); } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); static uint32_t getEmoteDbcId(const std::string& emoteName); @@ -58,7 +72,7 @@ public: // ── Targeting / combat ───────────────────────────────────────────────── void setTargetPosition(const glm::vec3* pos); - void setInCombat(bool combat) { inCombat_ = combat; } + void setInCombat(bool combat); bool isInCombat() const { return inCombat_; } const glm::vec3* getTargetPosition() const { return targetPosition_; } void resetCombatVisualState(); @@ -70,30 +84,19 @@ public: /// is2HLoose: true for polearms/staves (use ATTACK_2H_LOOSE instead of ATTACK_2H) void setEquippedWeaponType(uint32_t inventoryType, bool is2HLoose = false, bool isFist = false, bool isDagger = false, - bool hasOffHand = false, bool hasShield = false) { - equippedWeaponInvType_ = inventoryType; - equippedIs2HLoose_ = is2HLoose; - equippedIsFist_ = isFist; - equippedIsDagger_ = isDagger; - equippedHasOffHand_ = hasOffHand; - equippedHasShield_ = hasShield; - meleeAnimId_ = 0; // Force re-resolve on next swing - } + bool hasOffHand = false, bool hasShield = false); /// Play a special attack animation for a melee ability (spellId → SPECIAL_1H/2H/SHIELD_BASH/WHIRLWIND) void triggerSpecialAttack(uint32_t spellId); // ── Sprint aura animation ──────────────────────────────────────────── - void setSprintAuraActive(bool active) { sprintAuraActive_ = active; } + void setSprintAuraActive(bool active); // ── Ranged combat ────────────────────────────────────────────────────── - void setEquippedRangedType(RangedWeaponType type) { - equippedRangedType_ = type; - rangedAnimId_ = 0; // Force re-resolve - } + void setEquippedRangedType(RangedWeaponType type); /// Trigger a ranged shot animation (Auto Shot, Shoot, Throw) void triggerRangedShot(); - RangedWeaponType getEquippedRangedType() const { return equippedRangedType_; } - void setCharging(bool charging) { charging_ = charging; } + RangedWeaponType getEquippedRangedType() const { return weaponLoadout_.rangedType; } + void setCharging(bool charging); bool isCharging() const { return charging_; } // ── Spell casting ────────────────────────────────────────────────────── @@ -122,7 +125,7 @@ public: // ── Health-based idle ────────────────────────────────────────────────── /// When true, idle/combat-idle will prefer STAND_WOUND if the model has it. - void setLowHealth(bool low) { lowHealth_ = low; } + void setLowHealth(bool low); // ── Stand state (sit/sleep/kneel transitions) ────────────────────────── // WoW UnitStandStateType constants @@ -166,139 +169,55 @@ public: private: Renderer* renderer_ = nullptr; - // Character animation state machine - enum class CharAnimState { - IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, - SIT_UP, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING, MOUNT, CHARGE, COMBAT_IDLE, - SPELL_PRECAST, SPELL_CASTING, SPELL_FINALIZE, HIT_REACTION, STUNNED, LOOTING, - UNSHEATHE, SHEATHE, // Weapon draw/put-away one-shot transitions - RANGED_SHOOT, RANGED_LOAD // Ranged attack sequence: shoot → reload - }; - CharAnimState charAnimState_ = CharAnimState::IDLE; - float locomotionStopGraceTimer_ = 0.0f; - bool locomotionWasSprinting_ = false; + // ── CharacterAnimator: owns the complete character animation FSM ───── + CharacterAnimator characterAnimator_; + bool capabilitiesProbed_ = false; + + // ── Animation change detection ─────────────────────────────────────── uint32_t lastPlayerAnimRequest_ = UINT32_MAX; bool lastPlayerAnimLoopRequest_ = true; + float lastDeltaTime_ = 0.0f; - // Emote state - bool emoteActive_ = false; - uint32_t emoteAnimId_ = 0; - bool emoteLoop_ = false; - - // Spell cast sequence state (PRECAST → CASTING → FINALIZE) - uint32_t spellPrecastAnimId_ = 0; // One-shot wind-up (phase 1) - uint32_t spellCastAnimId_ = 0; // Looping cast hold (phase 2) - uint32_t spellFinalizeAnimId_ = 0; // One-shot release (phase 3) - bool spellCastLoop_ = false; - - // Hit reaction state - uint32_t hitReactionAnimId_ = 0; - - // Crowd control - bool stunned_ = false; - - // Health-based idle - bool lowHealth_ = false; - - // Stand state (sit/sleep/kneel) - uint8_t standState_ = 0; - uint32_t sitDownAnim_ = 0; // Transition-in animation (one-shot) - uint32_t sitLoopAnim_ = 0; // Looping pose animation - uint32_t sitUpAnim_ = 0; // Transition-out animation (one-shot) - - // Stealth - bool stealthed_ = false; - - // Target facing + // ── Externally-queried state (mirrored to CharacterAnimator) ──────────── const glm::vec3* targetPosition_ = nullptr; bool inCombat_ = false; - - // Footstep event tracking (animation-driven) - uint32_t footstepLastAnimationId_ = 0; - float footstepLastNormTime_ = 0.0f; - bool footstepNormInitialized_ = false; - - // Footstep surface cache (avoid expensive queries every step) - mutable audio::FootstepSurface cachedFootstepSurface_{}; - mutable glm::vec3 cachedFootstepPosition_{0.0f, 0.0f, 0.0f}; - mutable float cachedFootstepUpdateTimer_{999.0f}; - - // Mount footstep tracking (separate from player's) - uint32_t mountFootstepLastAnimId_ = 0; - float mountFootstepLastNormTime_ = 0.0f; - bool mountFootstepNormInitialized_ = false; - - // SFX transition state - bool sfxStateInitialized_ = false; - bool sfxPrevGrounded_ = true; - bool sfxPrevJumping_ = false; - bool sfxPrevFalling_ = false; - bool sfxPrevSwimming_ = false; - - // Melee combat + bool stunned_ = false; bool charging_ = false; + + // ── Footstep event tracking (delegated to FootstepDriver) ──────────── + FootstepDriver footstepDriver_; + + // ── SFX transition state (delegated to SfxStateDriver) ─────────────── + SfxStateDriver sfxStateDriver_; + + // ── Melee combat (needs renderer for sequence queries) ─────────────── float meleeSwingTimer_ = 0.0f; float meleeSwingCooldown_ = 0.0f; float meleeAnimDurationMs_ = 0.0f; uint32_t meleeAnimId_ = 0; - uint32_t specialAttackAnimId_ = 0; // Non-zero during special attack (overrides resolveMeleeAnimId) - uint32_t equippedWeaponInvType_ = 0; - bool equippedIs2HLoose_ = false; // Polearm or staff - bool equippedIsFist_ = false; // Fist weapon - bool equippedIsDagger_ = false; // Dagger (uses pierce variants) - bool equippedHasOffHand_ = false; // Has off-hand weapon (dual wield) - bool equippedHasShield_ = false; // Has shield equipped (for SHIELD_BASH) - bool meleeOffHandTurn_ = false; // Alternates main/off-hand swings + uint32_t specialAttackAnimId_ = 0; + WeaponLoadout weaponLoadout_; + bool meleeOffHandTurn_ = false; - // Ranged weapon state - RangedWeaponType equippedRangedType_ = RangedWeaponType::NONE; - float rangedShootTimer_ = 0.0f; // Countdown for ranged attack animation - uint32_t rangedAnimId_ = 0; // Cached ranged attack animation - - // Mount animation capabilities (discovered at mount time, varies per model) - struct MountAnimSet { - uint32_t jumpStart = 0; // Jump start animation - uint32_t jumpLoop = 0; // Jump airborne loop - uint32_t jumpEnd = 0; // Jump landing - uint32_t rearUp = 0; // Rear-up / special flourish - uint32_t run = 0; // Run animation (discovered, don't assume) - uint32_t stand = 0; // Stand animation (discovered) - // Flight animations (discovered from mount model) - uint32_t flyIdle = 0; - uint32_t flyForward = 0; - uint32_t flyBackwards = 0; - uint32_t flyLeft = 0; - uint32_t flyRight = 0; - uint32_t flyUp = 0; - uint32_t flyDown = 0; - std::vector fidgets; // Idle fidget animations (head turn, tail swish, etc.) - }; - - enum class MountAction { None, Jump, RearUp }; + // ── Ranged weapon state ────────────────────────────────────────────── + float rangedShootTimer_ = 0.0f; + uint32_t rangedAnimId_ = 0; + // ── Mount state (discovery + positioning need renderer) ────────────── uint32_t mountInstanceId_ = 0; float mountHeightOffset_ = 0.0f; - float mountPitch_ = 0.0f; // Up/down tilt (radians) - float mountRoll_ = 0.0f; // Left/right banking (radians) - int mountSeatAttachmentId_ = -1; // -1 unknown, -2 unavailable + float mountPitch_ = 0.0f; + float mountRoll_ = 0.0f; // External roll for taxi flights (lean roll computed by MountFSM) + int mountSeatAttachmentId_ = -1; glm::vec3 smoothedMountSeatPos_ = glm::vec3(0.0f); bool mountSeatSmoothingInit_ = false; - float prevMountYaw_ = 0.0f; // Previous yaw for turn rate calculation (procedural lean) - float lastDeltaTime_ = 0.0f; // Cached for use in updateCharacterAnimation() - MountAction mountAction_ = MountAction::None; // Current mount action (jump/rear-up) - uint32_t mountActionPhase_ = 0; // 0=start, 1=loop, 2=end (for jump chaining) - MountAnimSet mountAnims_; // Cached animation IDs for current mount - float mountIdleFidgetTimer_ = 0.0f; // Timer for random idle fidgets - float mountIdleSoundTimer_ = 0.0f; // Timer for ambient idle sounds - uint32_t mountActiveFidget_ = 0; // Currently playing fidget animation ID (0 = none) bool taxiFlight_ = false; - bool taxiAnimsLogged_ = false; - bool sprintAuraActive_ = false; // Sprint/Dash aura active → use SPRINT anim + bool sprintAuraActive_ = false; - // Private animation helpers - bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs); - audio::FootstepSurface resolveFootstepSurface() const; + // ── Private helpers ────────────────────────────────────────────────── uint32_t resolveMeleeAnimId(); + void updateMountedAnimation(float deltaTime); + void applyMountPositioning(float mountBob, float mountRoll, float characterYaw); }; } // namespace rendering diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 019ce77e..a5593b06 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -98,6 +98,10 @@ public: using StandUpCallback = std::function; void setStandUpCallback(StandUpCallback cb) { standUpCallback_ = std::move(cb); } + // Callback invoked when the player sits down via local input (X key). + using SitDownCallback = std::function; + void setSitDownCallback(SitDownCallback cb) { sitDownCallback_ = std::move(cb); } + // Callback invoked when auto-follow is cancelled by user movement input. using AutoFollowCancelCallback = std::function; void setAutoFollowCancelCallback(AutoFollowCancelCallback cb) { autoFollowCancelCallback_ = std::move(cb); } @@ -310,6 +314,7 @@ private: // Movement callback MovementCallback movementCallback; StandUpCallback standUpCallback_; + SitDownCallback sitDownCallback_; AutoFollowCancelCallback autoFollowCancelCallback_; // Movement speeds diff --git a/src/core/application.cpp b/src/core/application.cpp index 41b639d0..e9f26e41 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -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 #include @@ -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 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) { diff --git a/src/core/entity_spawner.cpp b/src/core/entity_spawner.cpp index c27574a2..838e1f58 100644 --- a/src/core/entity_spawner.cpp +++ b/src/core/entity_spawner.cpp @@ -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" diff --git a/src/core/world_loader.cpp b/src/core/world_loader.cpp index cf7979e1..6811c26a 100644 --- a/src/core/world_loader.cpp +++ b/src/core/world_loader.cpp @@ -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" diff --git a/src/game/combat_handler.cpp b/src/game/combat_handler.cpp index 3e6f8497..48f0b2fd 100644 --- a/src/game/combat_handler.cpp +++ b/src/game/combat_handler.cpp @@ -1070,7 +1070,7 @@ void CombatHandler::setTarget(uint64_t guid) { // Clear previous target's cast bar on target change // (the new target's cast state is naturally fetched from spellHandler_->unitCastStates_ by GUID) - // Inform server of target selection (Phase 1) + // Inform server of target selection if (owner_.isInWorld()) { auto packet = SetSelectionPacket::build(guid); owner_.socket->send(packet); diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp index 05310082..09afc14c 100644 --- a/src/game/entity_controller.cpp +++ b/src/game/entity_controller.cpp @@ -258,7 +258,7 @@ void EntityController::processOutOfRangeObjects(const std::vector& gui } // ============================================================ -// Phase 1: Extracted helper methods +// Extracted helper methods // ============================================================ bool EntityController::extractPlayerAppearance(const std::map& fields, @@ -383,7 +383,7 @@ void EntityController::maybeDetectCoinageIndex(const std::map& entity, ObjectType entityType) { @@ -430,7 +430,7 @@ void EntityController::updateNonPlayerTransportAttachment(const UpdateBlock& blo } } -// 3f: Rebuild playerAuras from UNIT_FIELD_AURAS (Classic/vanilla only). +// Rebuild playerAuras from UNIT_FIELD_AURAS (Classic/vanilla only). // blockFields is used to check if any aura field was updated in this packet. // entity->getFields() is used for reading the full accumulated state. // Normalises Classic harmful bit (0x02) to WotLK debuff bit (0x80) so @@ -481,7 +481,7 @@ void EntityController::syncClassicAurasFromFields(const std::shared_ptr& pendingEvents_.emit("UNIT_AURA", {"player"}); } -// 3h: Detect player mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes +// Detect player mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes void EntityController::detectPlayerMountChange(uint32_t newMountDisplayId, const std::map& blockFields) { uint32_t old = owner_.currentMountDisplayId_; @@ -528,7 +528,7 @@ void EntityController::detectPlayerMountChange(uint32_t newMountDisplayId, } } -// Phase 4: Resolve cached field indices once per handler call. +// Resolve cached field indices once per handler call. EntityController::UnitFieldIndices EntityController::UnitFieldIndices::resolve() { return UnitFieldIndices{ fieldIndex(UF::UNIT_FIELD_HEALTH), @@ -579,7 +579,7 @@ EntityController::PlayerFieldIndices EntityController::PlayerFieldIndices::resol }; } -// 3a: Create the appropriate Entity subclass from the block's object type. +// Create the appropriate Entity subclass from the block's object type. std::shared_ptr EntityController::createEntityFromBlock(const UpdateBlock& block) { switch (block.objectType) { case ObjectType::PLAYER: @@ -596,7 +596,7 @@ std::shared_ptr EntityController::createEntityFromBlock(const UpdateBloc } } -// 3b: Track player-on-transport state from movement blocks. +// Track player-on-transport state from movement blocks. // Consolidates near-identical logic from CREATE and MOVEMENT handlers. // When updateMovementInfoPos is true (MOVEMENT), movementInfo.x/y/z are set // to the raw canonical position when not on a resolved transport. @@ -644,7 +644,7 @@ void EntityController::applyPlayerTransportState(const UpdateBlock& block, } } -// 3c: Apply unit fields during CREATE — sets health/power/level/flags/displayId/etc. +// Apply unit fields during CREATE — sets health/power/level/flags/displayId/etc. // Returns true if the entity is initially dead (health=0 or DYNFLAG_DEAD). bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, std::shared_ptr& unit, @@ -928,7 +928,7 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat return result; } -// 3d: Apply player stat fields (XP, coinage, combat stats, etc.). +// Apply player stat fields (XP, coinage, combat stats, etc.). // Shared between CREATE and VALUES — isCreate controls event firing differences. bool EntityController::applyPlayerStatFields(const std::map& fields, const PlayerFieldIndices& pfi, @@ -1081,7 +1081,7 @@ bool EntityController::applyPlayerStatFields(const std::map& return slotsChanged; } -// 3e: Dispatch entity spawn callbacks for units/players. +// Dispatch entity spawn callbacks for units/players. // Consolidates player/creature spawn callback invocation from CREATE and VALUES handlers. // isDead = unitInitiallyDead (CREATE) or computed isDeadNow && !npcDeathNotified (VALUES). void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType, @@ -1133,7 +1133,7 @@ void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType, } } -// 3g: Track online item/container objects during CREATE. +// Track online item/container objects during CREATE. void EntityController::trackItemOnCreate(const UpdateBlock& block, bool& newItemCreated) { auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); @@ -1169,7 +1169,7 @@ void EntityController::trackItemOnCreate(const UpdateBlock& block, bool& newItem } } -// 3g: Update item stack count / durability / enchants for existing items during VALUES. +// Update item stack count / durability / enchants for existing items during VALUES. void EntityController::updateItemOnValuesUpdate(const UpdateBlock& block, const std::shared_ptr& entity) { bool inventoryChanged = false; @@ -1275,7 +1275,7 @@ void EntityController::updateItemOnValuesUpdate(const UpdateBlock& block, } // ============================================================ -// Phase 5: Object-type handler struct definitions +// Object-type handler struct definitions // ============================================================ struct EntityController::UnitTypeHandler : EntityController::IObjectTypeHandler { @@ -1313,7 +1313,7 @@ struct EntityController::CorpseTypeHandler : EntityController::IObjectTypeHandle }; // ============================================================ -// Phase 5: Handler registry infrastructure +// Handler registry infrastructure // ============================================================ void EntityController::initTypeHandlers() { @@ -1331,7 +1331,7 @@ EntityController::IObjectTypeHandler* EntityController::getTypeHandler(ObjectTyp } // ============================================================ -// Phase 6: Deferred event bus flush +// Deferred event bus flush // ============================================================ void EntityController::flushPendingEvents() { @@ -1342,7 +1342,7 @@ void EntityController::flushPendingEvents() { } // ============================================================ -// Phase 5: Type-specific CREATE handlers +// Type-specific CREATE handlers // ============================================================ void EntityController::onCreateUnit(const UpdateBlock& block, std::shared_ptr& entity) { @@ -1432,7 +1432,7 @@ void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr< } } } - // 3f: Classic aura sync on initial object create + // Classic aura sync on initial object create if (block.guid == owner_.playerGuid) { syncClassicAurasFromFields(entity); } @@ -1449,7 +1449,7 @@ void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr< } } - // 3d: Player stat fields (self only) + // Player stat fields (self only) if (block.guid == owner_.playerGuid) { // Auto-detect coinage index using the previous snapshot vs this full snapshot. maybeDetectCoinageIndex(owner_.lastPlayerFields_, block.fields); @@ -1557,7 +1557,7 @@ void EntityController::onCreateCorpse(const UpdateBlock& block) { } // ============================================================ -// Phase 5: Type-specific VALUES UPDATE handlers +// Type-specific VALUES UPDATE handlers // ============================================================ void EntityController::handleDisplayIdChange(const UpdateBlock& block, @@ -1602,15 +1602,15 @@ void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::share UnitFieldIndices ufi = UnitFieldIndices::resolve(); UnitFieldUpdateResult result = applyUnitFieldsOnUpdate(block, entity, unit, ufi); - // 3f: Classic aura sync from UNIT_FIELD_AURAS when those fields are updated + // Classic aura sync from UNIT_FIELD_AURAS when those fields are updated if (block.guid == owner_.playerGuid) { syncClassicAurasFromFields(entity); } - // 3e: Display ID changed — re-spawn/model-change + // Display ID changed — re-spawn/model-change handleDisplayIdChange(block, entity, unit, result); - // 3d: Self-player stat/inventory/quest field updates + // Self-player stat/inventory/quest field updates if (block.guid == owner_.playerGuid) { const bool needCoinageDetectSnapshot = (owner_.pendingMoneyDelta_ != 0 && owner_.pendingMoneyDeltaTimer_ > 0.0f); @@ -1682,7 +1682,7 @@ void EntityController::onValuesUpdateGameObject(const UpdateBlock& block, std::s } // ============================================================ -// Phase 2: Update type handlers (refactored with Phase 5 dispatch) +// Update type handlers // ============================================================ void EntityController::handleCreateObject(const UpdateBlock& block, bool& newItemCreated) { @@ -1716,7 +1716,7 @@ void EntityController::handleCreateObject(const UpdateBlock& block, bool& newIte // Add to manager entityManager.addEntity(block.guid, entity); - // Phase 5: Dispatch to type-specific handler + // Dispatch to type-specific handler auto* handler = getTypeHandler(block.objectType); if (handler) handler->onCreate(block, entity, newItemCreated); @@ -1741,7 +1741,7 @@ void EntityController::handleValuesUpdate(const UpdateBlock& block) { entity->setField(field.first, field.second); } - // Phase 5: Dispatch to type-specific handler + // Dispatch to type-specific handler auto* handler = getTypeHandler(entity->getType()); if (handler) handler->onValuesUpdate(block, entity); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ff1e34d1..63a747ae 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -31,7 +31,7 @@ #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "core/logger.hpp" -#include "rendering/animation_ids.hpp" +#include "rendering/animation/animation_ids.hpp" #include #include #include @@ -5145,7 +5145,7 @@ const std::vector& GameHandler::getJoinedChannels() const { } // ============================================================ -// Phase 1: Name Queries (delegated to EntityController) +// Name Queries (delegated to EntityController) // ============================================================ void GameHandler::queryPlayerName(uint64_t guid) { @@ -5217,7 +5217,7 @@ void GameHandler::emitAllOtherPlayerEquipment() { } // ============================================================ -// Phase 2: Combat (delegated to CombatHandler) +// Combat (delegated to CombatHandler) // ============================================================ void GameHandler::startAutoAttack(uint64_t targetGuid) { @@ -5372,7 +5372,7 @@ void GameHandler::requestPvpLog() { } // ============================================================ -// Phase 3: Spells +// Spells // ============================================================ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { @@ -5489,7 +5489,7 @@ void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, ui } // ============================================================ -// Phase 4: Group/Party +// Group/Party // ============================================================ void GameHandler::inviteToGroup(const std::string& playerName) { @@ -5606,7 +5606,7 @@ void GameHandler::turnInPetition(uint64_t petitionGuid) { } // ============================================================ -// Phase 5: Loot, Gossip, Vendor +// Loot, Gossip, Vendor // ============================================================ void GameHandler::lootTarget(uint64_t guid) { diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index 119464ae..f458a610 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -845,7 +845,19 @@ void SpellHandler::handleSpellStart(network::Packet& packet) { return; } LOG_DEBUG("SMSG_SPELL_START: caster=0x", std::hex, data.casterUnit, std::dec, - " spell=", data.spellId, " castTime=", data.castTime); + " spell=", data.spellId, " castTime=", data.castTime, + " target=0x", std::hex, data.targetGuid, std::dec); + + // Classify spell targeting for animation selection: + // DIRECTED — targets a specific other unit (Frostbolt, Heal) + // OMNI — self-cast or no explicit target (Arcane Explosion, buffs) + // AREA — ground-targeted AoE with no unit target (Blizzard, Rain of Fire) + auto classifyCast = [](uint64_t targetGuid, uint64_t casterGuid) -> SpellCastType { + if (targetGuid == 0) return SpellCastType::AREA; + if (targetGuid == casterGuid) return SpellCastType::OMNI; + return SpellCastType::DIRECTED; + }; + const SpellCastType castType = classifyCast(data.targetGuid, data.casterUnit); // Track cast bar for any non-player caster if (data.casterUnit != owner_.playerGuid && data.castTime > 0) { @@ -856,8 +868,9 @@ void SpellHandler::handleSpellStart(network::Packet& packet) { s.timeTotal = data.castTime / 1000.0f; s.timeRemaining = s.timeTotal; s.interruptible = owner_.isSpellInterruptible(data.spellId); + s.castType = castType; if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(data.casterUnit, true, false); + owner_.spellCastAnimCallback_(data.casterUnit, true, false, castType); } } @@ -891,7 +904,7 @@ void SpellHandler::handleSpellStart(network::Packet& packet) { } if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, true, false); + owner_.spellCastAnimCallback_(owner_.playerGuid, true, false, castType); } // Hearthstone: pre-load terrain at bind point @@ -954,8 +967,14 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { // Instant spell cast animation — if this wasn't a timed cast and isn't a // melee ability, play a brief spell cast animation (one-shot) if (!wasInTimedCast && !isMeleeAbility && !owner_.isProfessionSpell(data.spellId)) { + // Classify instant spell from SPELL_GO packet target info + SpellCastType goType = SpellCastType::OMNI; + if (data.targetGuid != 0 && data.targetGuid != data.casterUnit) + goType = SpellCastType::DIRECTED; + else if (data.targetGuid == 0 && data.hitCount > 1) + goType = SpellCastType::AREA; if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, true, false); + owner_.spellCastAnimCallback_(owner_.playerGuid, true, false, goType); } } @@ -983,7 +1002,7 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { owner_.pendingGameObjectInteractGuid_ = 0; if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, false, false); + owner_.spellCastAnimCallback_(owner_.playerGuid, false, false, SpellCastType::OMNI); } if (owner_.addonEventCallback_) @@ -1003,11 +1022,17 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { // instant cast — play a brief one-shot spell animation before stopping auto castIt = unitCastStates_.find(data.casterUnit); bool wasTrackedCast = (castIt != unitCastStates_.end()); + // Classify NPC instant spell from SPELL_GO target info + SpellCastType npcGoType = SpellCastType::OMNI; + if (data.targetGuid != 0 && data.targetGuid != data.casterUnit) + npcGoType = SpellCastType::DIRECTED; + else if (data.targetGuid == 0 && data.hitCount > 1) + npcGoType = SpellCastType::AREA; if (!wasTrackedCast && owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(data.casterUnit, true, false); + owner_.spellCastAnimCallback_(data.casterUnit, true, false, npcGoType); } if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(data.casterUnit, false, false); + owner_.spellCastAnimCallback_(data.casterUnit, false, false, SpellCastType::OMNI); } bool targetsPlayer = false; for (const auto& tgt : data.hitTargets) { @@ -2400,13 +2425,13 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) { } } if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, false, false); + owner_.spellCastAnimCallback_(owner_.playerGuid, false, false, SpellCastType::OMNI); } } else { // Another unit's cast failed — clear their tracked cast bar unitCastStates_.erase(failGuid); if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(failGuid, false, false); + owner_.spellCastAnimCallback_(failGuid, false, false, SpellCastType::OMNI); } } } @@ -3219,8 +3244,12 @@ void SpellHandler::handleChannelStart(network::Packet& packet) { " spell=", chanSpellId, " total=", chanTotalMs, "ms"); // Play channeling animation (looping) + // Channel packets don't carry targetGuid — use player's current target as hint + SpellCastType chanType = SpellCastType::OMNI; + if (chanCaster == owner_.playerGuid && owner_.targetGuid != 0) + chanType = SpellCastType::DIRECTED; if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(chanCaster, true, true); + owner_.spellCastAnimCallback_(chanCaster, true, true, chanType); } // Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons @@ -3260,7 +3289,7 @@ void SpellHandler::handleChannelUpdate(network::Packet& packet) { if (chanRemainMs == 0) { // Stop channeling animation — return to idle if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(chanCaster2, false, true); + owner_.spellCastAnimCallback_(chanCaster2, false, true, SpellCastType::OMNI); } auto unitId = owner_.guidToUnitId(chanCaster2); if (!unitId.empty()) diff --git a/src/rendering/animation/activity_fsm.cpp b/src/rendering/animation/activity_fsm.cpp new file mode 100644 index 00000000..71138998 --- /dev/null +++ b/src/rendering/animation/activity_fsm.cpp @@ -0,0 +1,339 @@ +#include "rendering/animation/activity_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" + +namespace wowee { +namespace rendering { + +// ── One-shot completion helper ─────────────────────────────────────────────── + +bool ActivityFSM::oneShotComplete(const Input& in, uint32_t expectedAnimId) const { + if (!in.haveAnimState) return false; + return in.currentAnimId != expectedAnimId || + (in.currentAnimDuration > 0.1f && in.currentAnimTime >= in.currentAnimDuration - 0.05f); +} + +// ── Event handling ─────────────────────────────────────────────────────────── + +void ActivityFSM::onEvent(AnimEvent event) { + switch (event) { + case AnimEvent::EMOTE_START: + // Handled by startEmote() with animId + break; + case AnimEvent::EMOTE_STOP: + cancelEmote(); + break; + case AnimEvent::LOOT_START: + startLooting(); + break; + case AnimEvent::LOOT_STOP: + stopLooting(); + break; + case AnimEvent::SIT: + // Handled by setStandState() + break; + case AnimEvent::STAND_UP: + if (state_ == State::SITTING || state_ == State::SIT_DOWN) { + if (sitUpAnim_ != 0) + state_ = State::SIT_UP; + else + state_ = State::NONE; + } + break; + case AnimEvent::MOVE_START: + case AnimEvent::JUMP: + case AnimEvent::SWIM_ENTER: + // Movement cancels all activities + if (state_ == State::EMOTE) cancelEmote(); + if (state_ == State::LOOTING || state_ == State::LOOT_KNEELING || state_ == State::LOOT_END) + state_ = State::NONE; + if (state_ == State::SIT_UP || state_ == State::SIT_DOWN) { + state_ = State::NONE; + standState_ = 0; + } + break; + default: + break; + } +} + +// ── Emote management ───────────────────────────────────────────────────────── + +void ActivityFSM::startEmote(uint32_t animId, bool loop) { + emoteActive_ = true; + emoteAnimId_ = animId; + emoteLoop_ = loop; + state_ = State::EMOTE; +} + +void ActivityFSM::cancelEmote() { + emoteActive_ = false; + emoteAnimId_ = 0; + emoteLoop_ = false; + if (state_ == State::EMOTE) state_ = State::NONE; +} + +// ── Sit/sleep/kneel ────────────────────────────────────────────────────────── + +void ActivityFSM::setStandState(uint8_t standState) { + if (standState == standState_) return; + standState_ = standState; + + if (standState == STAND_STATE_STAND) { + // Standing up — exit via SIT_UP if we have an exit animation + return; + } + + if (standState == STAND_STATE_SIT) { + sitDownAnim_ = anim::SIT_GROUND_DOWN; + sitLoopAnim_ = anim::SITTING; + sitUpAnim_ = anim::SIT_GROUND_UP; + sitDownAnimSeen_ = false; + sitDownFrames_ = 0; + state_ = State::SIT_DOWN; + } else if (standState == STAND_STATE_SLEEP) { + sitDownAnim_ = anim::SLEEP_DOWN; + sitLoopAnim_ = anim::SLEEP; + sitUpAnim_ = anim::SLEEP_UP; + sitDownAnimSeen_ = false; + sitDownFrames_ = 0; + state_ = State::SIT_DOWN; + } else if (standState == STAND_STATE_KNEEL) { + sitDownAnim_ = anim::KNEEL_START; + sitLoopAnim_ = anim::KNEEL_LOOP; + sitUpAnim_ = anim::KNEEL_END; + sitDownAnimSeen_ = false; + sitDownFrames_ = 0; + state_ = State::SIT_DOWN; + } else if (standState >= STAND_STATE_SIT_CHAIR && standState <= STAND_STATE_SIT_HIGH) { + // Chair variants — no transition animation, go directly to loop + sitDownAnim_ = 0; + sitUpAnim_ = 0; + if (standState == STAND_STATE_SIT_LOW) { + sitLoopAnim_ = anim::SIT_CHAIR_LOW; + } else if (standState == STAND_STATE_SIT_HIGH) { + sitLoopAnim_ = anim::SIT_CHAIR_HIGH; + } else { + sitLoopAnim_ = anim::SIT_CHAIR_MED; + } + state_ = State::SITTING; + } else if (standState == STAND_STATE_DEAD) { + sitDownAnim_ = 0; + sitLoopAnim_ = 0; + sitUpAnim_ = 0; + return; + } +} + +// ── Loot management ────────────────────────────────────────────────────────── + +void ActivityFSM::startLooting() { + state_ = State::LOOTING; + lootAnimSeen_ = false; + lootFrames_ = 0; +} + +void ActivityFSM::stopLooting() { + if (state_ == State::LOOTING || state_ == State::LOOT_KNEELING) { + state_ = State::LOOT_END; + lootEndAnimSeen_ = false; + lootEndFrames_ = 0; + } +} + +// ── State transitions ──────────────────────────────────────────────────────── + +void ActivityFSM::updateTransitions(const Input& in) { + switch (state_) { + case State::NONE: + break; + + case State::EMOTE: + if (in.swimming || (in.jumping && !in.grounded) || in.moving || in.sitting) { + cancelEmote(); + } else if (!emoteLoop_ && in.haveAnimState) { + if (oneShotComplete(in, emoteAnimId_)) { + cancelEmote(); + } + } + break; + + case State::LOOTING: + if (in.swimming || (in.jumping && !in.grounded) || in.moving) { + state_ = State::NONE; + } else if (in.haveAnimState) { + if (in.currentAnimId == anim::LOOT) lootAnimSeen_ = true; + if (lootAnimSeen_ && oneShotComplete(in, anim::LOOT)) { + state_ = State::LOOT_KNEELING; + } + // Safety: if anim never seen (model lacks it), advance after a timeout + if (!lootAnimSeen_ && ++lootFrames_ > 10) { + state_ = State::LOOT_KNEELING; + } + } + break; + + case State::LOOT_KNEELING: + if (in.swimming || (in.jumping && !in.grounded) || in.moving) { + state_ = State::NONE; + } + // Stays in LOOT_KNEELING until stopLooting() transitions to LOOT_END + break; + + case State::LOOT_END: + if (in.swimming || (in.jumping && !in.grounded) || in.moving) { + state_ = State::NONE; + } else if (in.haveAnimState) { + if (in.currentAnimId == anim::KNEEL_END) lootEndAnimSeen_ = true; + if (lootEndAnimSeen_ && oneShotComplete(in, anim::KNEEL_END)) { + state_ = State::NONE; + } + // Safety timeout + if (!lootEndAnimSeen_ && ++lootEndFrames_ > 10) { + state_ = State::NONE; + } + } + break; + + case State::SIT_DOWN: + if (in.swimming) { + state_ = State::NONE; + standState_ = 0; + } else if (!in.sitting) { + // Stand up requested + if (sitUpAnim_ != 0 && !in.moving) { + sitUpAnimSeen_ = false; + sitUpFrames_ = 0; + state_ = State::SIT_UP; + } else { + state_ = State::NONE; + standState_ = 0; + } + } else if (sitDownAnim_ != 0 && in.haveAnimState) { + // Track whether the sit-down anim has started playing + if (in.currentAnimId == sitDownAnim_) sitDownAnimSeen_ = true; + // Only detect completion after the anim has been seen at least once + if (sitDownAnimSeen_ && oneShotComplete(in, sitDownAnim_)) { + state_ = State::SITTING; + } + // Safety: if animation was never seen after enough frames (model + // may lack it), fall through to the sitting loop. + if (!sitDownAnimSeen_ && ++sitDownFrames_ > 10) { + state_ = State::SITTING; + } + } + break; + + case State::SITTING: + if (in.swimming) { + state_ = State::NONE; + standState_ = 0; + } else if (!in.sitting) { + if (sitUpAnim_ != 0 && !in.moving) { + sitUpAnimSeen_ = false; + sitUpFrames_ = 0; + state_ = State::SIT_UP; + } else { + state_ = State::NONE; + standState_ = 0; + } + } + break; + + case State::SIT_UP: + if (in.swimming || in.moving) { + state_ = State::NONE; + standState_ = 0; + } else if (in.haveAnimState) { + uint32_t expected = sitUpAnim_ ? sitUpAnim_ : anim::SIT_GROUND_UP; + // Track whether the sit-up anim has started playing + if (in.currentAnimId == expected) sitUpAnimSeen_ = true; + // Only detect completion after the anim has been seen at least once + if (sitUpAnimSeen_ && oneShotComplete(in, expected)) { + state_ = State::NONE; + standState_ = 0; + } + // Safety: if animation was never seen after enough frames, finish + if (!sitUpAnimSeen_ && ++sitUpFrames_ > 10) { + state_ = State::NONE; + standState_ = 0; + } + } + break; + } +} + +// ── Animation resolution ───────────────────────────────────────────────────── + +AnimOutput ActivityFSM::resolve(const Input& in, const AnimCapabilitySet& /*caps*/) { + updateTransitions(in); + + if (state_ == State::NONE) return AnimOutput::stay(); + + uint32_t animId = 0; + bool loop = true; + + switch (state_) { + case State::NONE: + return AnimOutput::stay(); + + case State::EMOTE: + animId = emoteAnimId_; + loop = emoteLoop_; + break; + + case State::LOOTING: + animId = anim::LOOT; + loop = false; + break; + + case State::LOOT_KNEELING: + animId = anim::KNEEL_LOOP; + loop = true; + break; + + case State::LOOT_END: + animId = anim::KNEEL_END; + loop = false; + break; + + case State::SIT_DOWN: + animId = sitDownAnim_ ? sitDownAnim_ : anim::SIT_GROUND_DOWN; + loop = false; + break; + + case State::SITTING: + animId = sitLoopAnim_ ? sitLoopAnim_ : anim::SITTING; + loop = true; + break; + + case State::SIT_UP: + animId = sitUpAnim_ ? sitUpAnim_ : anim::SIT_GROUND_UP; + loop = false; + break; + } + + if (animId == 0) return AnimOutput::stay(); + return AnimOutput::ok(animId, loop); +} + +// ── Reset ──────────────────────────────────────────────────────────────────── + +void ActivityFSM::reset() { + state_ = State::NONE; + cancelEmote(); + standState_ = 0; + sitDownAnim_ = 0; + sitLoopAnim_ = 0; + sitUpAnim_ = 0; + sitDownAnimSeen_ = false; + sitUpAnimSeen_ = false; + sitDownFrames_ = 0; + sitUpFrames_ = 0; + lootAnimSeen_ = false; + lootFrames_ = 0; + lootEndAnimSeen_ = false; + lootEndFrames_ = 0; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/anim_capability_probe.cpp b/src/rendering/animation/anim_capability_probe.cpp new file mode 100644 index 00000000..b48fa66b --- /dev/null +++ b/src/rendering/animation/anim_capability_probe.cpp @@ -0,0 +1,287 @@ +// ============================================================================ +// AnimCapabilityProbe +// +// Scans a model's animation capabilities once and returns an +// AnimCapabilitySet with resolved IDs (after fallback chains). +// Extracted from the scattered hasAnimation/pickFirstAvailable calls +// in AnimationController. +// ============================================================================ + +#include "rendering/animation/anim_capability_probe.hpp" +#include "rendering/animation/animation_ids.hpp" +#include "rendering/renderer.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/m2_renderer.hpp" + +namespace wowee { +namespace rendering { + +uint32_t AnimCapabilityProbe::pickFirst(Renderer* renderer, uint32_t instanceId, + const uint32_t* candidates, size_t count) { + auto* charRenderer = renderer->getCharacterRenderer(); + if (!charRenderer) return 0; + for (size_t i = 0; i < count; ++i) { + if (charRenderer->hasAnimation(instanceId, candidates[i])) { + return candidates[i]; + } + } + return 0; +} + +AnimCapabilitySet AnimCapabilityProbe::probe(Renderer* renderer, uint32_t instanceId) { + AnimCapabilitySet caps; + if (!renderer || instanceId == 0) return caps; + + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return caps; + + auto has = [&](uint32_t id) -> bool { + return cr->hasAnimation(instanceId, id); + }; + + // Helper: pick first available from static array + auto pick = [&](const uint32_t* candidates, size_t count) -> uint32_t { + return pickFirst(renderer, instanceId, candidates, count); + }; + + // ── Locomotion ────────────────────────────────────────────────────── + // STAND is animation ID 0. Every M2 model has sequence 0 as its base idle. + // Cannot use ternary `has(0) ? 0 : 0` — both branches are 0. + caps.resolvedStand = anim::STAND; + caps.hasStand = has(anim::STAND); + + { + static const uint32_t walkCands[] = {anim::WALK, anim::RUN}; + caps.resolvedWalk = pick(walkCands, 2); + caps.hasWalk = (caps.resolvedWalk != 0); + } + { + static const uint32_t runCands[] = {anim::RUN, anim::WALK}; + caps.resolvedRun = pick(runCands, 2); + caps.hasRun = (caps.resolvedRun != 0); + } + { + static const uint32_t sprintCands[] = {anim::SPRINT, anim::RUN, anim::WALK}; + caps.resolvedSprint = pick(sprintCands, 3); + caps.hasSprint = (caps.resolvedSprint != 0); + } + { + static const uint32_t walkBackCands[] = {anim::WALK_BACKWARDS, anim::WALK}; + caps.resolvedWalkBackwards = pick(walkBackCands, 2); + caps.hasWalkBackwards = (caps.resolvedWalkBackwards != 0); + } + { + static const uint32_t strafeLeftCands[] = {anim::SHUFFLE_LEFT, anim::RUN_LEFT, anim::WALK}; + caps.resolvedStrafeLeft = pick(strafeLeftCands, 3); + } + { + static const uint32_t strafeRightCands[] = {anim::SHUFFLE_RIGHT, anim::RUN_RIGHT, anim::WALK}; + caps.resolvedStrafeRight = pick(strafeRightCands, 3); + } + { + static const uint32_t runLeftCands[] = {anim::RUN_LEFT, anim::RUN}; + caps.resolvedRunLeft = pick(runLeftCands, 2); + } + { + static const uint32_t runRightCands[] = {anim::RUN_RIGHT, anim::RUN}; + caps.resolvedRunRight = pick(runRightCands, 2); + } + + // ── Jump ──────────────────────────────────────────────────────────── + caps.resolvedJumpStart = has(anim::JUMP_START) ? anim::JUMP_START : 0; + caps.resolvedJump = has(anim::JUMP) ? anim::JUMP : 0; + caps.resolvedJumpEnd = has(anim::JUMP_END) ? anim::JUMP_END : 0; + caps.hasJump = (caps.resolvedJumpStart != 0); + + // ── Swim ──────────────────────────────────────────────────────────── + caps.resolvedSwimIdle = has(anim::SWIM_IDLE) ? anim::SWIM_IDLE : 0; + caps.resolvedSwim = has(anim::SWIM) ? anim::SWIM : 0; + caps.hasSwim = (caps.resolvedSwimIdle != 0 || caps.resolvedSwim != 0); + { + static const uint32_t swimBackCands[] = {anim::SWIM_BACKWARDS, anim::SWIM}; + caps.resolvedSwimBackwards = pick(swimBackCands, 2); + } + { + static const uint32_t swimLeftCands[] = {anim::SWIM_LEFT, anim::SWIM}; + caps.resolvedSwimLeft = pick(swimLeftCands, 2); + } + { + static const uint32_t swimRightCands[] = {anim::SWIM_RIGHT, anim::SWIM}; + caps.resolvedSwimRight = pick(swimRightCands, 2); + } + + // ── Melee combat (fallback chains match resolveMeleeAnimId) ───────── + { + static const uint32_t melee1HCands[] = { + anim::ATTACK_1H, anim::ATTACK_2H, anim::ATTACK_UNARMED, + anim::ATTACK_2H_LOOSE, anim::PARRY_UNARMED, anim::PARRY_1H}; + caps.resolvedMelee1H = pick(melee1HCands, 6); + } + { + static const uint32_t melee2HCands[] = { + anim::ATTACK_2H, anim::ATTACK_1H, anim::ATTACK_UNARMED, + anim::ATTACK_2H_LOOSE, anim::PARRY_UNARMED, anim::PARRY_1H}; + caps.resolvedMelee2H = pick(melee2HCands, 6); + } + { + static const uint32_t melee2HLooseCands[] = { + anim::ATTACK_2H_LOOSE_PIERCE, anim::ATTACK_2H_LOOSE, + anim::ATTACK_2H, anim::ATTACK_1H, anim::ATTACK_UNARMED}; + caps.resolvedMelee2HLoose = pick(melee2HLooseCands, 5); + } + { + static const uint32_t meleeUnarmedCands[] = { + anim::ATTACK_UNARMED, anim::ATTACK_1H, anim::ATTACK_2H, + anim::ATTACK_2H_LOOSE, anim::PARRY_UNARMED, anim::PARRY_1H}; + caps.resolvedMeleeUnarmed = pick(meleeUnarmedCands, 6); + } + { + static const uint32_t meleeFistCands[] = { + anim::ATTACK_FIST_1H, anim::ATTACK_FIST_1H_OFF, + anim::ATTACK_1H, anim::ATTACK_UNARMED, + anim::PARRY_FIST_1H, anim::PARRY_1H}; + caps.resolvedMeleeFist = pick(meleeFistCands, 6); + } + { + static const uint32_t meleePierceCands[] = { + anim::ATTACK_1H_PIERCE, anim::ATTACK_1H, anim::ATTACK_UNARMED}; + caps.resolvedMeleePierce = pick(meleePierceCands, 3); + } + { + static const uint32_t meleeOffCands[] = { + anim::ATTACK_OFF, anim::ATTACK_1H, anim::ATTACK_UNARMED}; + caps.resolvedMeleeOffHand = pick(meleeOffCands, 3); + } + { + static const uint32_t meleeOffFistCands[] = { + anim::ATTACK_FIST_1H_OFF, anim::ATTACK_OFF, + anim::ATTACK_FIST_1H, anim::ATTACK_1H}; + caps.resolvedMeleeOffHandFist = pick(meleeOffFistCands, 4); + } + { + static const uint32_t meleeOffPierceCands[] = { + anim::ATTACK_OFF_PIERCE, anim::ATTACK_OFF, + anim::ATTACK_1H_PIERCE, anim::ATTACK_1H}; + caps.resolvedMeleeOffHandPierce = pick(meleeOffPierceCands, 4); + } + { + static const uint32_t meleeOffUnarmedCands[] = { + anim::ATTACK_UNARMED_OFF, anim::ATTACK_UNARMED, + anim::ATTACK_OFF, anim::ATTACK_1H}; + caps.resolvedMeleeOffHandUnarmed = pick(meleeOffUnarmedCands, 4); + } + caps.hasMelee = (caps.resolvedMelee1H != 0 || caps.resolvedMeleeUnarmed != 0); + + // ── Ready stances ─────────────────────────────────────────────────── + { + static const uint32_t ready1HCands[] = { + anim::READY_1H, anim::READY_2H, anim::READY_UNARMED}; + caps.resolvedReady1H = pick(ready1HCands, 3); + } + { + static const uint32_t ready2HCands[] = { + anim::READY_2H, anim::READY_2H_LOOSE, anim::READY_1H, anim::READY_UNARMED}; + caps.resolvedReady2H = pick(ready2HCands, 4); + } + { + static const uint32_t ready2HLooseCands[] = { + anim::READY_2H_LOOSE, anim::READY_2H, anim::READY_1H, anim::READY_UNARMED}; + caps.resolvedReady2HLoose = pick(ready2HLooseCands, 4); + } + { + static const uint32_t readyUnarmedCands[] = { + anim::READY_UNARMED, anim::READY_1H, anim::READY_FIST}; + caps.resolvedReadyUnarmed = pick(readyUnarmedCands, 3); + } + { + static const uint32_t readyFistCands[] = { + anim::READY_FIST_1H, anim::READY_FIST, anim::READY_1H, anim::READY_UNARMED}; + caps.resolvedReadyFist = pick(readyFistCands, 4); + } + { + static const uint32_t readyBowCands[] = { + anim::READY_BOW, anim::READY_1H, anim::READY_UNARMED}; + caps.resolvedReadyBow = pick(readyBowCands, 3); + } + { + static const uint32_t readyRifleCands[] = { + anim::READY_RIFLE, anim::READY_BOW, anim::READY_1H, anim::READY_UNARMED}; + caps.resolvedReadyRifle = pick(readyRifleCands, 4); + } + { + static const uint32_t readyCrossbowCands[] = { + anim::READY_CROSSBOW, anim::READY_BOW, anim::READY_1H, anim::READY_UNARMED}; + caps.resolvedReadyCrossbow = pick(readyCrossbowCands, 4); + } + { + static const uint32_t readyThrownCands[] = { + anim::READY_THROWN, anim::READY_1H, anim::READY_UNARMED}; + caps.resolvedReadyThrown = pick(readyThrownCands, 3); + } + + // ── Ranged attacks ────────────────────────────────────────────────── + { + static const uint32_t fireBowCands[] = {anim::FIRE_BOW, anim::ATTACK_BOW}; + caps.resolvedFireBow = pick(fireBowCands, 2); + } + caps.resolvedAttackRifle = has(anim::ATTACK_RIFLE) ? anim::ATTACK_RIFLE : 0; + { + static const uint32_t attackCrossbowCands[] = {anim::ATTACK_CROSSBOW, anim::ATTACK_BOW}; + caps.resolvedAttackCrossbow = pick(attackCrossbowCands, 2); + } + caps.resolvedAttackThrown = has(anim::ATTACK_THROWN) ? anim::ATTACK_THROWN : 0; + caps.resolvedLoadBow = has(anim::LOAD_BOW) ? anim::LOAD_BOW : 0; + caps.resolvedLoadRifle = has(anim::LOAD_RIFLE) ? anim::LOAD_RIFLE : 0; + + // ── Special attacks ───────────────────────────────────────────────── + caps.resolvedSpecial1H = has(anim::SPECIAL_1H) ? anim::SPECIAL_1H : 0; + caps.resolvedSpecial2H = has(anim::SPECIAL_2H) ? anim::SPECIAL_2H : 0; + caps.resolvedSpecialUnarmed = has(anim::SPECIAL_UNARMED) ? anim::SPECIAL_UNARMED : 0; + caps.resolvedShieldBash = has(anim::SHIELD_BASH) ? anim::SHIELD_BASH : 0; + + // ── Combat idle ───────────────────────────────────────────────────── + // Base combat idle — weapon-specific stances are resolved per-frame + // using ready stance fields above + caps.resolvedCombatIdle = has(anim::READY_1H) ? anim::READY_1H + : (has(anim::READY_UNARMED) ? anim::READY_UNARMED : 0); + + // ── Activity animations ───────────────────────────────────────────── + caps.resolvedStandWound = has(anim::STAND_WOUND) ? anim::STAND_WOUND : 0; + caps.resolvedDeath = has(anim::DEATH) ? anim::DEATH : 0; + caps.hasDeath = (caps.resolvedDeath != 0); + caps.resolvedLoot = has(anim::LOOT) ? anim::LOOT : 0; + caps.resolvedSitDown = has(anim::SIT_GROUND_DOWN) ? anim::SIT_GROUND_DOWN : 0; + caps.resolvedSitLoop = has(anim::SITTING) ? anim::SITTING : 0; + caps.resolvedSitUp = has(anim::SIT_GROUND_UP) ? anim::SIT_GROUND_UP : 0; + caps.resolvedKneel = has(anim::KNEEL_LOOP) ? anim::KNEEL_LOOP : 0; + + // ── Stealth ───────────────────────────────────────────────────────── + caps.resolvedStealthIdle = has(anim::STEALTH_STAND) ? anim::STEALTH_STAND : 0; + caps.resolvedStealthWalk = has(anim::STEALTH_WALK) ? anim::STEALTH_WALK : 0; + caps.resolvedStealthRun = has(anim::STEALTH_RUN) ? anim::STEALTH_RUN : 0; + caps.hasStealth = (caps.resolvedStealthIdle != 0); + + // ── Misc ──────────────────────────────────────────────────────────── + caps.resolvedMount = has(anim::MOUNT) ? anim::MOUNT : 0; + caps.hasMount = (caps.resolvedMount != 0); + caps.resolvedUnsheathe = has(anim::UNSHEATHE) ? anim::UNSHEATHE : 0; + { + static const uint32_t sheatheCands[] = {anim::SHEATHE, anim::HIP_SHEATHE}; + caps.resolvedSheathe = pick(sheatheCands, 2); + } + caps.resolvedStun = has(anim::STUN) ? anim::STUN : 0; + caps.resolvedCombatWound = has(anim::COMBAT_WOUND) ? anim::COMBAT_WOUND : 0; + + return caps; +} + +AnimCapabilitySet AnimCapabilityProbe::probeMountModel(Renderer* /*renderer*/, uint32_t /*mountInstanceId*/) { + // Mount models use M2Renderer, not CharacterRenderer + // For now, mount capabilities are handled separately via MountAnimSet discovery + // This stub returns an empty set — mount animations are discovered in setMounted() + AnimCapabilitySet caps; + return caps; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation_ids.cpp b/src/rendering/animation/animation_ids.cpp similarity index 99% rename from src/rendering/animation_ids.cpp rename to src/rendering/animation/animation_ids.cpp index 82b244d8..22f5b607 100644 --- a/src/rendering/animation_ids.cpp +++ b/src/rendering/animation/animation_ids.cpp @@ -2,7 +2,7 @@ // animation_ids.cpp — Inverse lookup & DBC validation // Generated from animation_ids.hpp (452 constants, IDs 0–451) // ============================================================================ -#include "rendering/animation_ids.hpp" +#include "rendering/animation/animation_ids.hpp" #include "pipeline/dbc_loader.hpp" #include "core/logger.hpp" diff --git a/src/rendering/animation/animation_manager.cpp b/src/rendering/animation/animation_manager.cpp new file mode 100644 index 00000000..5951a015 --- /dev/null +++ b/src/rendering/animation/animation_manager.cpp @@ -0,0 +1,36 @@ +// Renamed from PlayerAnimator/NpcAnimator dual-map → unified CharacterAnimator registry. +// NpcAnimator methods removed — all characters use CharacterAnimator. +#include "rendering/animation/animation_manager.hpp" + +namespace wowee { +namespace rendering { + +// ── Character animators ────────────────────────────────────────────────────── + +CharacterAnimator& AnimationManager::getOrCreate(uint32_t instanceId) { + auto it = animators_.find(instanceId); + if (it != animators_.end()) return *it->second; + + auto [inserted, _] = animators_.emplace(instanceId, std::make_unique()); + return *inserted->second; +} + +CharacterAnimator* AnimationManager::get(uint32_t instanceId) { + auto it = animators_.find(instanceId); + return it != animators_.end() ? it->second.get() : nullptr; +} + +void AnimationManager::remove(uint32_t instanceId) { + animators_.erase(instanceId); +} + +// ── Update all ─────────────────────────────────────────────────────────────── + +void AnimationManager::updateAll(float dt) { + for (auto& [id, animator] : animators_) { + animator->update(dt); + } +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/character_animator.cpp b/src/rendering/animation/character_animator.cpp new file mode 100644 index 00000000..c7a74abb --- /dev/null +++ b/src/rendering/animation/character_animator.cpp @@ -0,0 +1,216 @@ +// Renamed from player_animator.cpp → character_animator.cpp +// Class renamed: PlayerAnimator → CharacterAnimator +// All animations are now generic (character-based, not player-specific). +#include "rendering/animation/character_animator.hpp" +#include "rendering/animation/animation_ids.hpp" +#include "game/inventory.hpp" + +namespace wowee { +namespace rendering { + +CharacterAnimator::CharacterAnimator() = default; + +// ── IAnimator ──────────────────────────────────────────────────────────────── + +void CharacterAnimator::onEvent(AnimEvent event) { + locomotion_.onEvent(event); + combat_.onEvent(event); + activity_.onEvent(event); + mount_.onEvent(event); +} + +void CharacterAnimator::update(float dt) { + lastDt_ = dt; + lastOutput_ = resolveAnimation(); +} + +// ── ICharacterAnimator ────────────────────────────────────────────────────── + +void CharacterAnimator::startSpellCast(uint32_t precast, uint32_t cast, bool loop, uint32_t finalize) { + combat_.startSpellCast(precast, cast, loop, finalize); +} + +void CharacterAnimator::stopSpellCast() { + combat_.stopSpellCast(); +} + +void CharacterAnimator::triggerMeleeSwing() { + // Melee is handled via timer in FrameInput — CombatFSM transitions automatically +} + +void CharacterAnimator::triggerRangedShot() { + // Ranged is handled via timer in FrameInput — CombatFSM transitions automatically +} + +void CharacterAnimator::triggerHitReaction(uint32_t animId) { + combat_.triggerHitReaction(animId); +} + +void CharacterAnimator::triggerSpecialAttack(uint32_t /*spellId*/) { + // Special attack animation is injected via FrameInput::specialAttackAnimId +} + +void CharacterAnimator::setEquippedWeaponType(const WeaponLoadout& loadout) { + loadout_ = loadout; +} + +void CharacterAnimator::setEquippedRangedType(RangedWeaponType type) { + loadout_.rangedType = type; +} + +void CharacterAnimator::playEmote(uint32_t animId, bool loop) { + activity_.startEmote(animId, loop); +} + +void CharacterAnimator::cancelEmote() { + activity_.cancelEmote(); +} + +void CharacterAnimator::startLooting() { + activity_.startLooting(); +} + +void CharacterAnimator::stopLooting() { + activity_.stopLooting(); +} + +void CharacterAnimator::setStunned(bool stunned) { + combat_.setStunned(stunned); +} + +void CharacterAnimator::setCharging(bool charging) { + combat_.setCharging(charging); +} + +void CharacterAnimator::setStandState(uint8_t state) { + activity_.setStandState(state); +} + +void CharacterAnimator::setStealthed(bool stealth) { + stealthed_ = stealth; +} + +void CharacterAnimator::setInCombat(bool combat) { + inCombat_ = combat; +} + +void CharacterAnimator::setLowHealth(bool low) { + lowHealth_ = low; +} + +void CharacterAnimator::setSprintAuraActive(bool active) { + sprintAura_ = active; +} + +// ── Mount ──────────────────────────────────────────────────────────────────── + +void CharacterAnimator::configureMountFSM(const MountFSM::MountAnimSet& anims, bool taxiFlight) { + mount_.configure(anims, taxiFlight); +} + +void CharacterAnimator::clearMountFSM() { + mount_.clear(); +} + +// ── Priority resolver ──────────────────────────────────────────────────────── + +AnimOutput CharacterAnimator::resolveAnimation() { + const auto& fi = frameInput_; + + // ── Mount takes over everything ───────────────────────────────────── + if (mount_.isActive()) { + // MountFSM returns mount-specific output; rider anim is separate + // For the main character animation, we return MOUNT (or flight variant) + uint32_t riderAnim = caps_.resolvedMount ? caps_.resolvedMount : anim::MOUNT; + return AnimOutput::ok(riderAnim, true); + } + + // ── Build combat input ────────────────────────────────────────────── + CombatFSM::Input combatIn; + combatIn.inCombat = inCombat_; + combatIn.grounded = fi.grounded; + combatIn.jumping = fi.jumping; + combatIn.swimming = fi.swimming; + combatIn.moving = fi.moving; + combatIn.sprinting = fi.sprinting; + combatIn.lowHealth = lowHealth_; + combatIn.meleeSwingTimer = fi.meleeSwingTimer; + combatIn.rangedShootTimer = fi.rangedShootTimer; + combatIn.specialAttackAnimId = fi.specialAttackAnimId; + combatIn.rangedAnimId = fi.rangedAnimId; + combatIn.currentAnimId = fi.currentAnimId; + combatIn.currentAnimTime = fi.currentAnimTime; + combatIn.currentAnimDuration = fi.currentAnimDuration; + combatIn.haveAnimState = fi.haveAnimState; + combatIn.hasUnsheathe = caps_.resolvedUnsheathe != 0; + combatIn.hasSheathe = caps_.resolvedSheathe != 0; + + // ── Combat FSM (highest priority for non-mount) ───────────────────── + auto combatOut = combat_.resolve(combatIn, caps_, loadout_); + if (combatOut.valid) return applyOverlays(combatOut); + + // ── Activity FSM (emote, loot, sit) ───────────────────────────────── + ActivityFSM::Input actIn; + actIn.moving = fi.moving; + actIn.sprinting = fi.sprinting; + actIn.jumping = fi.jumping; + actIn.grounded = fi.grounded; + actIn.swimming = fi.swimming; + actIn.sitting = fi.sitting; + actIn.stunned = combat_.isStunned(); + actIn.currentAnimId = fi.currentAnimId; + actIn.currentAnimTime = fi.currentAnimTime; + actIn.currentAnimDuration = fi.currentAnimDuration; + actIn.haveAnimState = fi.haveAnimState; + + auto actOut = activity_.resolve(actIn, caps_); + if (actOut.valid) return actOut; + + // ── Locomotion FSM (lowest priority) ──────────────────────────────── + LocomotionFSM::Input locoIn; + locoIn.moving = fi.moving; + locoIn.movingForward = fi.movingForward; + locoIn.sprinting = fi.sprinting; + locoIn.movingBackward = fi.movingBackward; + locoIn.strafeLeft = fi.strafeLeft; + locoIn.strafeRight = fi.strafeRight; + locoIn.grounded = fi.grounded; + locoIn.jumping = fi.jumping; + locoIn.swimming = fi.swimming; + locoIn.sitting = fi.sitting; + locoIn.sprintAura = sprintAura_; + locoIn.deltaTime = lastDt_; + // Animation state for one-shot completion detection (jump start/end) + locoIn.currentAnimId = fi.currentAnimId; + locoIn.currentAnimTime = fi.currentAnimTime; + locoIn.currentAnimDuration = fi.currentAnimDuration; + locoIn.haveAnimState = fi.haveAnimState; + + auto locoOut = locomotion_.resolve(locoIn, caps_); + if (locoOut.valid) return applyOverlays(locoOut); + + // All FSMs returned invalid → STAY (keep last animation) + return AnimOutput::stay(); +} + +// ── Overlay application ────────────────────────────────────────────────────── + +AnimOutput CharacterAnimator::applyOverlays(AnimOutput base) const { + if (!stealthed_) return base; + + // Stealth substitution based on locomotion state + auto locoState = locomotion_.getState(); + if (locoState == LocomotionFSM::State::IDLE) { + if (caps_.resolvedStealthIdle) base.animId = caps_.resolvedStealthIdle; + } else if (locoState == LocomotionFSM::State::WALK) { + if (caps_.resolvedStealthWalk) base.animId = caps_.resolvedStealthWalk; + } else if (locoState == LocomotionFSM::State::RUN) { + if (caps_.resolvedStealthRun) base.animId = caps_.resolvedStealthRun; + else if (caps_.resolvedStealthWalk) base.animId = caps_.resolvedStealthWalk; + } + + return base; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/combat_fsm.cpp b/src/rendering/animation/combat_fsm.cpp new file mode 100644 index 00000000..997fb938 --- /dev/null +++ b/src/rendering/animation/combat_fsm.cpp @@ -0,0 +1,459 @@ +#include "rendering/animation/combat_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" +#include "game/inventory.hpp" + +namespace wowee { +namespace rendering { + +// ── One-shot completion helper ─────────────────────────────────────────────── + +bool CombatFSM::oneShotComplete(const Input& in, uint32_t expectedAnimId) const { + if (!in.haveAnimState) return false; + // Renderer auto-returns one-shots to STAND; detect that OR normal completion + return in.currentAnimId != expectedAnimId || + (in.currentAnimDuration > 0.1f && in.currentAnimTime >= in.currentAnimDuration - 0.05f); +} + +// ── Event handling ─────────────────────────────────────────────────────────── + +void CombatFSM::onEvent(AnimEvent event) { + switch (event) { + case AnimEvent::COMBAT_ENTER: + if (state_ == State::INACTIVE) + state_ = State::UNSHEATHE; + break; + case AnimEvent::COMBAT_EXIT: + if (state_ == State::COMBAT_IDLE) + state_ = State::SHEATHE; + break; + case AnimEvent::STUN_ENTER: + clearSpellState(); + hitReactionAnimId_ = 0; + stunned_ = true; + state_ = State::STUNNED; + break; + case AnimEvent::STUN_EXIT: + stunned_ = false; + if (state_ == State::STUNNED) + state_ = State::INACTIVE; + break; + case AnimEvent::HIT_REACT: + // Handled by triggerHitReaction() with animId + break; + case AnimEvent::CHARGE_START: + charging_ = true; + clearSpellState(); + state_ = State::CHARGE; + break; + case AnimEvent::CHARGE_END: + charging_ = false; + if (state_ == State::CHARGE) + state_ = State::INACTIVE; + break; + case AnimEvent::SWIM_ENTER: + clearSpellState(); + hitReactionAnimId_ = 0; + stunned_ = false; + state_ = State::INACTIVE; + break; + default: + break; + } +} + +// ── Spell cast management ──────────────────────────────────────────────────── + +void CombatFSM::startSpellCast(uint32_t precast, uint32_t cast, bool castLoop, uint32_t finalize) { + spellPrecastAnimId_ = precast; + spellCastAnimId_ = cast; + spellCastLoop_ = castLoop; + spellFinalizeAnimId_ = finalize; + spellPrecastAnimSeen_ = false; + spellPrecastFrames_ = 0; + spellFinalizeAnimSeen_ = false; + spellFinalizeFrames_ = 0; + state_ = (precast != 0) ? State::SPELL_PRECAST : State::SPELL_CASTING; +} + +void CombatFSM::stopSpellCast() { + if (state_ != State::SPELL_PRECAST && state_ != State::SPELL_CASTING) return; + spellFinalizeAnimSeen_ = false; + spellFinalizeFrames_ = 0; + state_ = State::SPELL_FINALIZE; +} + +void CombatFSM::clearSpellState() { + spellPrecastAnimId_ = 0; + spellCastAnimId_ = 0; + spellCastLoop_ = false; + spellFinalizeAnimId_ = 0; + spellPrecastAnimSeen_ = false; + spellPrecastFrames_ = 0; + spellFinalizeAnimSeen_ = false; + spellFinalizeFrames_ = 0; +} + +// ── Hit/stun ───────────────────────────────────────────────────────────────── + +void CombatFSM::triggerHitReaction(uint32_t animId) { + // Don't interrupt swim/jump/stun states + if (state_ == State::STUNNED) return; + // Interrupt spell casting + if (state_ == State::SPELL_PRECAST || state_ == State::SPELL_CASTING || state_ == State::SPELL_FINALIZE) { + clearSpellState(); + } + hitReactionAnimId_ = animId; + state_ = State::HIT_REACTION; +} + +void CombatFSM::setStunned(bool stunned) { + stunned_ = stunned; + if (stunned) { + if (state_ == State::SPELL_PRECAST || state_ == State::SPELL_CASTING || state_ == State::SPELL_FINALIZE) { + clearSpellState(); + } + hitReactionAnimId_ = 0; + state_ = State::STUNNED; + } else { + if (state_ == State::STUNNED) + state_ = State::INACTIVE; + } +} + +void CombatFSM::setCharging(bool charging) { + charging_ = charging; + if (charging) { + clearSpellState(); + hitReactionAnimId_ = 0; + state_ = State::CHARGE; + } else if (state_ == State::CHARGE) { + state_ = State::INACTIVE; + } +} + +// ── State transitions ──────────────────────────────────────────────────────── + +void CombatFSM::updateTransitions(const Input& in) { + // Stun override: can't act while stunned + if (stunned_ && state_ != State::STUNNED) { + state_ = State::STUNNED; + return; + } + + // Force melee/ranged overrides + if (in.meleeSwingTimer > 0.0f && !stunned_ && in.grounded && !in.swimming) { + if (state_ != State::MELEE_SWING) { + clearSpellState(); + hitReactionAnimId_ = 0; + } + state_ = State::MELEE_SWING; + return; + } + if (in.rangedShootTimer > 0.0f && !stunned_ && in.meleeSwingTimer <= 0.0f && in.grounded && !in.swimming) { + if (state_ != State::RANGED_SHOOT) { + clearSpellState(); + hitReactionAnimId_ = 0; + } + state_ = State::RANGED_SHOOT; + return; + } + if (charging_ && !stunned_) { + if (state_ != State::CHARGE) { + clearSpellState(); + hitReactionAnimId_ = 0; + } + state_ = State::CHARGE; + return; + } + + switch (state_) { + case State::INACTIVE: + if (in.inCombat && in.grounded && !in.swimming && !in.moving) { + state_ = in.hasUnsheathe ? State::UNSHEATHE : State::COMBAT_IDLE; + } + break; + + case State::COMBAT_IDLE: + if (in.swimming || in.jumping || !in.grounded || in.moving) { + state_ = State::INACTIVE; + } else if (!in.inCombat) { + state_ = in.hasSheathe ? State::SHEATHE : State::INACTIVE; + } + break; + + case State::MELEE_SWING: + if (in.meleeSwingTimer <= 0.0f) { + if (in.swimming) { + state_ = State::INACTIVE; + } else if (in.inCombat && in.grounded) { + state_ = State::COMBAT_IDLE; + } else { + state_ = State::INACTIVE; + } + } + break; + + case State::RANGED_SHOOT: + if (in.rangedShootTimer <= 0.0f) { + if (in.swimming) { + state_ = State::INACTIVE; + } else if (in.inCombat && in.grounded) { + state_ = State::RANGED_LOAD; + } else { + state_ = State::INACTIVE; + } + } + break; + + case State::RANGED_LOAD: + if (in.swimming || in.jumping || !in.grounded || in.moving) { + state_ = State::INACTIVE; + } else if (in.inCombat) { + state_ = State::COMBAT_IDLE; + } else { + state_ = State::INACTIVE; + } + break; + + case State::SPELL_PRECAST: + if (in.swimming || (in.jumping && !in.grounded) || (!in.grounded && !in.jumping)) { + clearSpellState(); + state_ = State::INACTIVE; + } else if (in.haveAnimState) { + uint32_t expectedAnim = spellPrecastAnimId_ ? spellPrecastAnimId_ : anim::SPELL_PRECAST; + if (in.currentAnimId == expectedAnim) spellPrecastAnimSeen_ = true; + if (spellPrecastAnimSeen_ && oneShotComplete(in, expectedAnim)) { + state_ = State::SPELL_CASTING; + } + if (!spellPrecastAnimSeen_ && ++spellPrecastFrames_ > 10) { + state_ = State::SPELL_CASTING; + } + } + break; + + case State::SPELL_CASTING: + if (in.swimming || (in.jumping && !in.grounded) || (!in.grounded && !in.jumping)) { + clearSpellState(); + state_ = State::INACTIVE; + } else if (in.moving) { + clearSpellState(); + state_ = State::INACTIVE; + } + // Stays in SPELL_CASTING until stopSpellCast() is called externally + break; + + case State::SPELL_FINALIZE: { + if (in.swimming || (in.jumping && !in.grounded)) { + clearSpellState(); + state_ = State::INACTIVE; + } else if (in.haveAnimState) { + uint32_t expectedAnim = spellFinalizeAnimId_ ? spellFinalizeAnimId_ + : (spellCastAnimId_ ? spellCastAnimId_ : anim::SPELL); + if (in.currentAnimId == expectedAnim) spellFinalizeAnimSeen_ = true; + if (spellFinalizeAnimSeen_ && oneShotComplete(in, expectedAnim)) { + clearSpellState(); + state_ = in.inCombat ? State::COMBAT_IDLE : State::INACTIVE; + } + // Safety: if finalize anim never seen (model lacks it), finish after timeout + if (!spellFinalizeAnimSeen_ && ++spellFinalizeFrames_ > 10) { + clearSpellState(); + state_ = in.inCombat ? State::COMBAT_IDLE : State::INACTIVE; + } + } + break; + } + + case State::HIT_REACTION: + if (in.swimming || in.moving) { + hitReactionAnimId_ = 0; + state_ = State::INACTIVE; + } else if (in.haveAnimState) { + uint32_t expectedAnim = hitReactionAnimId_ ? hitReactionAnimId_ : anim::COMBAT_WOUND; + if (oneShotComplete(in, expectedAnim)) { + hitReactionAnimId_ = 0; + state_ = in.inCombat ? State::COMBAT_IDLE : State::INACTIVE; + } + } + break; + + case State::STUNNED: + if (!stunned_) { + state_ = in.inCombat ? State::COMBAT_IDLE : State::INACTIVE; + } else if (in.swimming) { + stunned_ = false; + state_ = State::INACTIVE; + } + break; + + case State::CHARGE: + if (!charging_) { + state_ = State::INACTIVE; + } + break; + + case State::UNSHEATHE: + if (in.swimming || in.moving) { + state_ = State::INACTIVE; + } else if (in.haveAnimState && oneShotComplete(in, anim::UNSHEATHE)) { + state_ = State::COMBAT_IDLE; + } + break; + + case State::SHEATHE: + if (in.swimming || in.moving) { + state_ = State::INACTIVE; + } else if (in.inCombat) { + state_ = State::COMBAT_IDLE; + } else if (in.haveAnimState && oneShotComplete(in, anim::SHEATHE)) { + state_ = State::INACTIVE; + } + break; + } +} + +// ── Animation resolution ───────────────────────────────────────────────────── + +AnimOutput CombatFSM::resolve(const Input& in, const AnimCapabilitySet& caps, + const WeaponLoadout& loadout) { + updateTransitions(in); + + if (state_ == State::INACTIVE) return AnimOutput::stay(); + + uint32_t animId = 0; + bool loop = true; + + switch (state_) { + case State::INACTIVE: + return AnimOutput::stay(); + + case State::COMBAT_IDLE: + if (in.lowHealth && caps.resolvedStandWound) { + animId = caps.resolvedStandWound; + } else if (loadout.rangedType == RangedWeaponType::BOW) { + animId = caps.resolvedReadyBow; + } else if (loadout.rangedType == RangedWeaponType::GUN) { + animId = caps.resolvedReadyRifle; + } else if (loadout.rangedType == RangedWeaponType::CROSSBOW) { + animId = caps.resolvedReadyCrossbow; + } else if (loadout.rangedType == RangedWeaponType::THROWN) { + animId = caps.resolvedReadyThrown; + } else if (loadout.is2HLoose) { + animId = caps.resolvedReady2HLoose; + } else if (loadout.inventoryType == game::InvType::TWO_HAND) { + animId = caps.resolvedReady2H; + } else if (loadout.isFist) { + animId = caps.resolvedReadyFist; + } else if (loadout.inventoryType == game::InvType::NON_EQUIP) { + animId = caps.resolvedReadyUnarmed; + } else { + animId = caps.resolvedReady1H; + } + loop = true; + break; + + case State::MELEE_SWING: + if (in.specialAttackAnimId != 0) { + animId = in.specialAttackAnimId; + } else { + // Resolve melee animation using probed capabilities + weapon loadout + bool useOffHand = loadout.hasOffHand && offHandTurn_; + offHandTurn_ = loadout.hasOffHand ? !offHandTurn_ : false; + + if (useOffHand) { + if (loadout.isFist) animId = caps.resolvedMeleeOffHandFist; + else if (loadout.isDagger) animId = caps.resolvedMeleeOffHandPierce; + else if (loadout.inventoryType == game::InvType::NON_EQUIP) animId = caps.resolvedMeleeOffHandUnarmed; + else animId = caps.resolvedMeleeOffHand; + } else if (loadout.isFist) { + animId = caps.resolvedMeleeFist; + } else if (loadout.isDagger) { + animId = caps.resolvedMeleePierce; + } else if (loadout.is2HLoose) { + animId = caps.resolvedMelee2HLoose; + } else if (loadout.inventoryType == game::InvType::TWO_HAND) { + animId = caps.resolvedMelee2H; + } else if (loadout.inventoryType == game::InvType::NON_EQUIP) { + animId = caps.resolvedMeleeUnarmed; + } else { + animId = caps.resolvedMelee1H; + } + } + if (animId == 0) animId = anim::STAND; // Melee must play something + loop = false; + break; + + case State::RANGED_SHOOT: + animId = in.rangedAnimId ? in.rangedAnimId : anim::ATTACK_BOW; + loop = false; + break; + + case State::RANGED_LOAD: + switch (loadout.rangedType) { + case RangedWeaponType::BOW: animId = caps.resolvedLoadBow; break; + case RangedWeaponType::GUN: animId = caps.resolvedLoadRifle; break; + case RangedWeaponType::CROSSBOW: animId = caps.resolvedLoadBow; break; + default: break; + } + loop = false; + break; + + case State::SPELL_PRECAST: + animId = spellPrecastAnimId_ ? spellPrecastAnimId_ : anim::SPELL_PRECAST; + loop = false; + break; + + case State::SPELL_CASTING: + animId = spellCastAnimId_ ? spellCastAnimId_ : anim::SPELL; + loop = spellCastLoop_; + break; + + case State::SPELL_FINALIZE: + animId = spellFinalizeAnimId_ ? spellFinalizeAnimId_ + : (spellCastAnimId_ ? spellCastAnimId_ : anim::SPELL); + loop = false; + break; + + case State::HIT_REACTION: + animId = hitReactionAnimId_ ? hitReactionAnimId_ + : (caps.resolvedCombatWound ? caps.resolvedCombatWound : anim::COMBAT_WOUND); + loop = false; + break; + + case State::STUNNED: + animId = caps.resolvedStun ? caps.resolvedStun : anim::STUN; + loop = true; + break; + + case State::CHARGE: + animId = caps.resolvedRun ? caps.resolvedRun : anim::RUN; + loop = true; + break; + + case State::UNSHEATHE: + animId = caps.resolvedUnsheathe ? caps.resolvedUnsheathe : anim::UNSHEATHE; + loop = false; + break; + + case State::SHEATHE: + animId = caps.resolvedSheathe ? caps.resolvedSheathe : anim::SHEATHE; + loop = false; + break; + } + + if (animId == 0) return AnimOutput::stay(); + return AnimOutput::ok(animId, loop); +} + +// ── Reset ──────────────────────────────────────────────────────────────────── + +void CombatFSM::reset() { + state_ = State::INACTIVE; + clearSpellState(); + hitReactionAnimId_ = 0; + stunned_ = false; + charging_ = false; + offHandTurn_ = false; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/emote_registry.cpp b/src/rendering/animation/emote_registry.cpp new file mode 100644 index 00000000..88236349 --- /dev/null +++ b/src/rendering/animation/emote_registry.cpp @@ -0,0 +1,299 @@ +#include "rendering/animation/emote_registry.hpp" +#include "rendering/animation/animation_ids.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" +#include "core/application.hpp" +#include "core/logger.hpp" + +#include +#include +#include + +namespace wowee { +namespace rendering { + +// ── Helper functions (moved from animation_controller.cpp) ─────────────────── + +static std::vector parseEmoteCommands(const std::string& raw) { + std::vector out; + std::string cur; + for (char c : raw) { + if (std::isalnum(static_cast(c)) || c == '_') { + cur.push_back(static_cast(std::tolower(static_cast(c)))); + } else if (!cur.empty()) { + out.push_back(cur); + cur.clear(); + } + } + if (!cur.empty()) out.push_back(cur); + return out; +} + +static bool isLoopingEmote(const std::string& command) { + static const std::unordered_set kLooping = { + "dance", "train", "dead", "eat", "work", + }; + return kLooping.find(command) != kLooping.end(); +} + +// Map one-shot emote animation IDs to their persistent EMOTE_STATE_* looping variants. +// When a looping emote is played, we prefer the STATE variant if the model has it. +static uint32_t getEmoteStateVariantStatic(uint32_t oneShotAnimId) { + static const std::unordered_map kStateMap = { + {anim::EMOTE_DANCE, anim::EMOTE_STATE_DANCE}, + {anim::EMOTE_LAUGH, anim::EMOTE_STATE_LAUGH}, + {anim::EMOTE_POINT, anim::EMOTE_STATE_POINT}, + {anim::EMOTE_EAT, anim::EMOTE_STATE_EAT}, + {anim::EMOTE_ROAR, anim::EMOTE_STATE_ROAR}, + {anim::EMOTE_APPLAUD, anim::EMOTE_STATE_APPLAUD}, + {anim::EMOTE_WORK, anim::EMOTE_STATE_WORK}, + {anim::EMOTE_USE_STANDING, anim::EMOTE_STATE_USE_STANDING}, + {anim::EATING_LOOP, anim::EMOTE_STATE_EAT}, + }; + auto it = kStateMap.find(oneShotAnimId); + return it != kStateMap.end() ? it->second : 0; +} + +static std::string replacePlaceholders(const std::string& text, const std::string* targetName) { + if (text.empty()) return text; + std::string out; + out.reserve(text.size() + 16); + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == '%' && i + 1 < text.size() && text[i + 1] == 's') { + if (targetName && !targetName->empty()) out += *targetName; + i++; + } else { + out.push_back(text[i]); + } + } + return out; +} + +// ── EmoteRegistry implementation ───────────────────────────────────────────── + +EmoteRegistry& EmoteRegistry::instance() { + static EmoteRegistry inst; + return inst; +} + +void EmoteRegistry::loadFromDbc() { + if (loaded_) return; + loaded_ = true; + + auto* assetManager = core::Application::getInstance().getAssetManager(); + if (!assetManager) { + LOG_WARNING("Emotes: no AssetManager"); + loadFallbackEmotes(); + return; + } + + auto emotesTextDbc = assetManager->loadDBC("EmotesText.dbc"); + auto emotesTextDataDbc = assetManager->loadDBC("EmotesTextData.dbc"); + if (!emotesTextDbc || !emotesTextDataDbc || !emotesTextDbc->isLoaded() || !emotesTextDataDbc->isLoaded()) { + LOG_WARNING("Emotes: DBCs not available (EmotesText/EmotesTextData)"); + loadFallbackEmotes(); + return; + } + + const auto* activeLayout = pipeline::getActiveDBCLayout(); + const auto* etdL = activeLayout ? activeLayout->getLayout("EmotesTextData") : nullptr; + const auto* emL = activeLayout ? activeLayout->getLayout("Emotes") : nullptr; + const auto* etL = activeLayout ? activeLayout->getLayout("EmotesText") : nullptr; + + std::unordered_map textData; + textData.reserve(emotesTextDataDbc->getRecordCount()); + for (uint32_t r = 0; r < emotesTextDataDbc->getRecordCount(); ++r) { + uint32_t id = emotesTextDataDbc->getUInt32(r, etdL ? (*etdL)["ID"] : 0); + std::string text = emotesTextDataDbc->getString(r, etdL ? (*etdL)["Text"] : 1); + if (!text.empty()) textData.emplace(id, std::move(text)); + } + + std::unordered_map emoteIdToAnim; + if (auto emotesDbc = assetManager->loadDBC("Emotes.dbc"); emotesDbc && emotesDbc->isLoaded()) { + emoteIdToAnim.reserve(emotesDbc->getRecordCount()); + for (uint32_t r = 0; r < emotesDbc->getRecordCount(); ++r) { + uint32_t emoteId = emotesDbc->getUInt32(r, emL ? (*emL)["ID"] : 0); + uint32_t animId = emotesDbc->getUInt32(r, emL ? (*emL)["AnimID"] : 2); + if (animId != 0) emoteIdToAnim[emoteId] = animId; + } + } + + emoteTable_.clear(); + emoteTable_.reserve(emotesTextDbc->getRecordCount()); + for (uint32_t r = 0; r < emotesTextDbc->getRecordCount(); ++r) { + uint32_t recordId = emotesTextDbc->getUInt32(r, etL ? (*etL)["ID"] : 0); + std::string cmdRaw = emotesTextDbc->getString(r, etL ? (*etL)["Command"] : 1); + if (cmdRaw.empty()) continue; + + uint32_t emoteRef = emotesTextDbc->getUInt32(r, etL ? (*etL)["EmoteRef"] : 2); + uint32_t animId = 0; + auto animIt = emoteIdToAnim.find(emoteRef); + if (animIt != emoteIdToAnim.end()) { + animId = animIt->second; + } else { + animId = emoteRef; + } + + uint32_t senderTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["SenderTargetTextID"] : 5); + uint32_t senderNoTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["SenderNoTargetTextID"] : 9); + uint32_t othersTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["OthersTargetTextID"] : 3); + uint32_t othersNoTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["OthersNoTargetTextID"] : 7); + + std::string textTarget, textNoTarget, oTarget, oNoTarget; + if (auto it = textData.find(senderTargetTextId); it != textData.end()) textTarget = it->second; + if (auto it = textData.find(senderNoTargetTextId); it != textData.end()) textNoTarget = it->second; + if (auto it = textData.find(othersTargetTextId); it != textData.end()) oTarget = it->second; + if (auto it = textData.find(othersNoTargetTextId); it != textData.end()) oNoTarget = it->second; + + for (const std::string& cmd : parseEmoteCommands(cmdRaw)) { + if (cmd.empty()) continue; + EmoteInfo info; + info.animId = animId; + info.dbcId = recordId; + info.loop = isLoopingEmote(cmd); + info.textNoTarget = textNoTarget; + info.textTarget = textTarget; + info.othersNoTarget = oNoTarget; + info.othersTarget = oTarget; + info.command = cmd; + emoteTable_.emplace(cmd, std::move(info)); + } + } + + if (emoteTable_.empty()) { + LOG_WARNING("Emotes: DBC loaded but no commands parsed, using fallback list"); + loadFallbackEmotes(); + } else { + LOG_INFO("Emotes: loaded ", emoteTable_.size(), " commands from DBC"); + } + + buildDbcIdIndex(); +} + +void EmoteRegistry::loadFallbackEmotes() { + if (!emoteTable_.empty()) return; + emoteTable_ = { + {"wave", {anim::EMOTE_WAVE, 0, false, "You wave.", "You wave at %s.", "%s waves.", "%s waves at %s.", "wave"}}, + {"bow", {anim::EMOTE_BOW, 0, false, "You bow down graciously.", "You bow down before %s.", "%s bows down graciously.", "%s bows down before %s.", "bow"}}, + {"laugh", {anim::EMOTE_LAUGH, 0, false, "You laugh.", "You laugh at %s.", "%s laughs.", "%s laughs at %s.", "laugh"}}, + {"point", {anim::EMOTE_POINT, 0, false, "You point over yonder.", "You point at %s.", "%s points over yonder.", "%s points at %s.", "point"}}, + {"cheer", {anim::EMOTE_CHEER, 0, false, "You cheer!", "You cheer at %s.", "%s cheers!", "%s cheers at %s.", "cheer"}}, + {"dance", {anim::EMOTE_DANCE, 0, true, "You burst into dance.", "You dance with %s.", "%s bursts into dance.", "%s dances with %s.", "dance"}}, + {"kneel", {anim::EMOTE_KNEEL, 0, false, "You kneel down.", "You kneel before %s.", "%s kneels down.", "%s kneels before %s.", "kneel"}}, + {"applaud", {anim::EMOTE_APPLAUD, 0, false, "You applaud. Bravo!", "You applaud at %s. Bravo!", "%s applauds. Bravo!", "%s applauds at %s. Bravo!", "applaud"}}, + {"shout", {anim::EMOTE_SHOUT, 0, false, "You shout.", "You shout at %s.", "%s shouts.", "%s shouts at %s.", "shout"}}, + {"chicken", {anim::EMOTE_CHICKEN, 0, false, "With arms flapping, you strut around. Cluck, Cluck, Chicken!", + "With arms flapping, you strut around %s. Cluck, Cluck, Chicken!", + "%s struts around. Cluck, Cluck, Chicken!", "%s struts around %s. Cluck, Cluck, Chicken!", "chicken"}}, + {"cry", {anim::EMOTE_CRY, 0, false, "You cry.", "You cry on %s's shoulder.", "%s cries.", "%s cries on %s's shoulder.", "cry"}}, + {"kiss", {anim::EMOTE_KISS, 0, false, "You blow a kiss into the wind.", "You blow a kiss to %s.", "%s blows a kiss into the wind.", "%s blows a kiss to %s.", "kiss"}}, + {"roar", {anim::EMOTE_ROAR, 0, false, "You roar with bestial vigor. So fierce!", "You roar with bestial vigor at %s. So fierce!", "%s roars with bestial vigor. So fierce!", "%s roars with bestial vigor at %s. So fierce!", "roar"}}, + {"salute", {anim::EMOTE_SALUTE, 0, false, "You salute.", "You salute %s with respect.", "%s salutes.", "%s salutes %s with respect.", "salute"}}, + {"rude", {anim::EMOTE_RUDE, 0, false, "You make a rude gesture.", "You make a rude gesture at %s.", "%s makes a rude gesture.", "%s makes a rude gesture at %s.", "rude"}}, + {"flex", {anim::EMOTE_FLEX, 0, false, "You flex your muscles. Oooooh so strong!", "You flex at %s. Oooooh so strong!", "%s flexes. Oooooh so strong!", "%s flexes at %s. Oooooh so strong!", "flex"}}, + {"shy", {anim::EMOTE_SHY, 0, false, "You smile shyly.", "You smile shyly at %s.", "%s smiles shyly.", "%s smiles shyly at %s.", "shy"}}, + {"beg", {anim::EMOTE_BEG, 0, false, "You beg everyone around you. How pathetic.", "You beg %s. How pathetic.", "%s begs everyone around. How pathetic.", "%s begs %s. How pathetic.", "beg"}}, + {"eat", {anim::EMOTE_EAT, 0, true, "You begin to eat.", "You begin to eat in front of %s.", "%s begins to eat.", "%s begins to eat in front of %s.", "eat"}}, + {"talk", {anim::EMOTE_TALK, 0, false, "You talk.", "You talk to %s.", "%s talks.", "%s talks to %s.", "talk"}}, + {"work", {anim::EMOTE_WORK, 0, true, "You begin to work.", "You begin to work near %s.", "%s begins to work.", "%s begins to work near %s.", "work"}}, + {"train", {anim::EMOTE_TRAIN, 0, true, "You let off a train whistle. Choo Choo!", "You let off a train whistle at %s. Choo Choo!", "%s lets off a train whistle. Choo Choo!", "%s lets off a train whistle at %s. Choo Choo!", "train"}}, + {"dead", {anim::EMOTE_DEAD, 0, true, "You play dead.", "You play dead in front of %s.", "%s plays dead.", "%s plays dead in front of %s.", "dead"}}, + }; + buildDbcIdIndex(); +} + +void EmoteRegistry::buildDbcIdIndex() { + emoteByDbcId_.clear(); + for (auto& [cmd, info] : emoteTable_) { + if (info.dbcId != 0) { + emoteByDbcId_.emplace(info.dbcId, &info); + } + } +} + +std::optional EmoteRegistry::findEmote(const std::string& command) const { + auto it = emoteTable_.find(command); + if (it == emoteTable_.end()) return std::nullopt; + const auto& info = it->second; + if (info.animId == 0) return std::nullopt; + return EmoteResult{info.animId, info.loop}; +} + +uint32_t EmoteRegistry::animByDbcId(uint32_t dbcId) const { + auto it = emoteByDbcId_.find(dbcId); + if (it != emoteByDbcId_.end()) { + return it->second->animId; + } + return 0; +} + +uint32_t EmoteRegistry::getStateVariant(uint32_t oneShotAnimId) const { + return getEmoteStateVariantStatic(oneShotAnimId); +} + +const EmoteInfo* EmoteRegistry::findInfo(const std::string& command) const { + auto it = emoteTable_.find(command); + return it != emoteTable_.end() ? &it->second : nullptr; +} + +std::string EmoteRegistry::textFor(const std::string& emoteName, + const std::string* targetName) const { + auto it = emoteTable_.find(emoteName); + if (it != emoteTable_.end()) { + const auto& info = it->second; + const std::string& base = (targetName ? info.textTarget : info.textNoTarget); + if (!base.empty()) { + return replacePlaceholders(base, targetName); + } + if (targetName && !targetName->empty()) { + return "You " + info.command + " at " + *targetName + "."; + } + return "You " + info.command + "."; + } + return ""; +} + +uint32_t EmoteRegistry::dbcIdFor(const std::string& emoteName) const { + auto it = emoteTable_.find(emoteName); + if (it != emoteTable_.end()) { + return it->second.dbcId; + } + return 0; +} + +std::string EmoteRegistry::textByDbcId(uint32_t dbcId, + const std::string& senderName, + const std::string* targetName) const { + auto it = emoteByDbcId_.find(dbcId); + if (it == emoteByDbcId_.end()) return ""; + + const EmoteInfo& info = *it->second; + + if (targetName && !targetName->empty()) { + if (!info.othersTarget.empty()) { + std::string out; + out.reserve(info.othersTarget.size() + senderName.size() + targetName->size()); + bool firstReplaced = false; + for (size_t i = 0; i < info.othersTarget.size(); ++i) { + if (info.othersTarget[i] == '%' && i + 1 < info.othersTarget.size() && info.othersTarget[i + 1] == 's') { + out += firstReplaced ? *targetName : senderName; + firstReplaced = true; + ++i; + } else { + out.push_back(info.othersTarget[i]); + } + } + return out; + } + return senderName + " " + info.command + "s at " + *targetName + "."; + } else { + if (!info.othersNoTarget.empty()) { + return replacePlaceholders(info.othersNoTarget, &senderName); + } + return senderName + " " + info.command + "s."; + } +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/footstep_driver.cpp b/src/rendering/animation/footstep_driver.cpp new file mode 100644 index 00000000..60d4a074 --- /dev/null +++ b/src/rendering/animation/footstep_driver.cpp @@ -0,0 +1,207 @@ +#include "rendering/animation/footstep_driver.hpp" +#include "rendering/renderer.hpp" +#include "rendering/camera_controller.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/terrain_manager.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/water_renderer.hpp" +#include "rendering/swim_effects.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/footstep_manager.hpp" +#include "audio/movement_sound_manager.hpp" + +#include +#include +#include + +namespace wowee { +namespace rendering { + +// ── Footstep event detection (moved from AnimationController) ──────────────── + +bool FootstepDriver::shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs) { + if (animationDurationMs <= 1.0f) { + footstepNormInitialized_ = false; + return false; + } + + float wrappedTime = animationTimeMs; + while (wrappedTime >= animationDurationMs) { + wrappedTime -= animationDurationMs; + } + if (wrappedTime < 0.0f) wrappedTime += animationDurationMs; + float norm = wrappedTime / animationDurationMs; + + if (animationId != footstepLastAnimationId_) { + footstepLastAnimationId_ = animationId; + footstepLastNormTime_ = norm; + footstepNormInitialized_ = true; + return false; + } + + if (!footstepNormInitialized_) { + footstepNormInitialized_ = true; + footstepLastNormTime_ = norm; + return false; + } + + auto crossed = [&](float eventNorm) { + if (footstepLastNormTime_ <= norm) { + return footstepLastNormTime_ < eventNorm && eventNorm <= norm; + } + return footstepLastNormTime_ < eventNorm || eventNorm <= norm; + }; + + bool trigger = crossed(0.22f) || crossed(0.72f); + footstepLastNormTime_ = norm; + return trigger; +} + +audio::FootstepSurface FootstepDriver::resolveFootstepSurface(Renderer* renderer) const { + auto* cameraController = renderer->getCameraController(); + if (!cameraController || !cameraController->isThirdPerson()) { + return audio::FootstepSurface::STONE; + } + + const glm::vec3& p = renderer->getCharacterPosition(); + + float distSq = glm::dot(p - cachedFootstepPosition_, p - cachedFootstepPosition_); + if (distSq < 2.25f && cachedFootstepUpdateTimer_ < 0.5f) { + return cachedFootstepSurface_; + } + + cachedFootstepPosition_ = p; + cachedFootstepUpdateTimer_ = 0.0f; + + if (cameraController->isSwimming()) { + cachedFootstepSurface_ = audio::FootstepSurface::WATER; + return audio::FootstepSurface::WATER; + } + + auto* waterRenderer = renderer->getWaterRenderer(); + if (waterRenderer) { + auto waterH = waterRenderer->getWaterHeightAt(p.x, p.y); + if (waterH && p.z < (*waterH + 0.25f)) { + cachedFootstepSurface_ = audio::FootstepSurface::WATER; + return audio::FootstepSurface::WATER; + } + } + + auto* wmoRenderer = renderer->getWMORenderer(); + auto* terrainManager = renderer->getTerrainManager(); + if (wmoRenderer) { + auto wmoFloor = wmoRenderer->getFloorHeight(p.x, p.y, p.z + 1.5f); + auto terrainFloor = terrainManager ? terrainManager->getHeightAt(p.x, p.y) : std::nullopt; + if (wmoFloor && (!terrainFloor || *wmoFloor >= *terrainFloor - 0.1f)) { + cachedFootstepSurface_ = audio::FootstepSurface::STONE; + return audio::FootstepSurface::STONE; + } + } + + audio::FootstepSurface surface = audio::FootstepSurface::STONE; + + if (terrainManager) { + auto texture = terrainManager->getDominantTextureAt(p.x, p.y); + if (texture) { + std::string t = *texture; + for (char& c : t) c = static_cast(std::tolower(static_cast(c))); + if (t.find("snow") != std::string::npos || t.find("ice") != std::string::npos) surface = audio::FootstepSurface::SNOW; + else if (t.find("grass") != std::string::npos || t.find("moss") != std::string::npos || t.find("leaf") != std::string::npos) surface = audio::FootstepSurface::GRASS; + else if (t.find("sand") != std::string::npos || t.find("dirt") != std::string::npos || t.find("mud") != std::string::npos) surface = audio::FootstepSurface::DIRT; + else if (t.find("wood") != std::string::npos || t.find("timber") != std::string::npos) surface = audio::FootstepSurface::WOOD; + else if (t.find("metal") != std::string::npos || t.find("iron") != std::string::npos) surface = audio::FootstepSurface::METAL; + else if (t.find("stone") != std::string::npos || t.find("rock") != std::string::npos || t.find("cobble") != std::string::npos || t.find("brick") != std::string::npos) surface = audio::FootstepSurface::STONE; + } + } + + cachedFootstepSurface_ = surface; + return surface; +} + +// ── Footstep update (moved from AnimationController::updateFootsteps) ──────── + +void FootstepDriver::update(float deltaTime, Renderer* renderer, + bool mounted, uint32_t mountInstanceId, bool taxiFlight, + bool isFootstepState) { + auto* footstepManager = renderer->getAudioCoordinator()->getFootstepManager(); + if (!footstepManager) return; + + auto* characterRenderer = renderer->getCharacterRenderer(); + auto* cameraController = renderer->getCameraController(); + uint32_t characterInstanceId = renderer->getCharacterInstanceId(); + + footstepManager->update(deltaTime); + cachedFootstepUpdateTimer_ += deltaTime; + + bool canPlayFootsteps = characterRenderer && characterInstanceId > 0 && + cameraController && cameraController->isThirdPerson() && + cameraController->isGrounded() && !cameraController->isSwimming(); + + if (canPlayFootsteps && mounted && mountInstanceId > 0 && !taxiFlight) { + // Mount footsteps: use mount's animation for timing + uint32_t animId = 0; + float animTimeMs = 0.0f, animDurationMs = 0.0f; + if (characterRenderer->getAnimationState(mountInstanceId, animId, animTimeMs, animDurationMs) && + animDurationMs > 1.0f && cameraController->isMoving()) { + float wrappedTime = animTimeMs; + while (wrappedTime >= animDurationMs) { + wrappedTime -= animDurationMs; + } + if (wrappedTime < 0.0f) wrappedTime += animDurationMs; + float norm = wrappedTime / animDurationMs; + + if (animId != mountFootstepLastAnimId_) { + mountFootstepLastAnimId_ = animId; + mountFootstepLastNormTime_ = norm; + mountFootstepNormInitialized_ = true; + } else if (!mountFootstepNormInitialized_) { + mountFootstepNormInitialized_ = true; + mountFootstepLastNormTime_ = norm; + } else { + auto crossed = [&](float eventNorm) { + if (mountFootstepLastNormTime_ <= norm) { + return mountFootstepLastNormTime_ < eventNorm && eventNorm <= norm; + } + return mountFootstepLastNormTime_ < eventNorm || eventNorm <= norm; + }; + if (crossed(0.25f) || crossed(0.75f)) { + footstepManager->playFootstep(resolveFootstepSurface(renderer), true); + } + mountFootstepLastNormTime_ = norm; + } + } else { + mountFootstepNormInitialized_ = false; + } + footstepNormInitialized_ = false; + } else if (canPlayFootsteps && isFootstepState) { + uint32_t animId = 0; + float animTimeMs = 0.0f; + float animDurationMs = 0.0f; + if (characterRenderer->getAnimationState(characterInstanceId, animId, animTimeMs, animDurationMs) && + shouldTriggerFootstepEvent(animId, animTimeMs, animDurationMs)) { + auto surface = resolveFootstepSurface(renderer); + footstepManager->playFootstep(surface, cameraController->isSprinting()); + if (surface == audio::FootstepSurface::WATER) { + if (renderer->getAudioCoordinator()->getMovementSoundManager()) { + renderer->getAudioCoordinator()->getMovementSoundManager()->playWaterFootstep(audio::MovementSoundManager::CharacterSize::MEDIUM); + } + auto* swimEffects = renderer->getSwimEffects(); + auto* waterRenderer = renderer->getWaterRenderer(); + if (swimEffects && waterRenderer) { + const glm::vec3& characterPosition = renderer->getCharacterPosition(); + auto wh = waterRenderer->getWaterHeightAt(characterPosition.x, characterPosition.y); + if (wh) { + swimEffects->spawnFootSplash(characterPosition, *wh); + } + } + } + } + mountFootstepNormInitialized_ = false; + } else { + footstepNormInitialized_ = false; + mountFootstepNormInitialized_ = false; + } +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/locomotion_fsm.cpp b/src/rendering/animation/locomotion_fsm.cpp new file mode 100644 index 00000000..75c226e2 --- /dev/null +++ b/src/rendering/animation/locomotion_fsm.cpp @@ -0,0 +1,283 @@ +#include "rendering/animation/locomotion_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" +#include + +namespace wowee { +namespace rendering { + +// ── One-shot completion helper ───────────────────────────────────────────────────────── + +bool LocomotionFSM::oneShotComplete(const Input& in, uint32_t expectedAnimId) const { + if (!in.haveAnimState) return false; + return in.currentAnimId != expectedAnimId || + (in.currentAnimDuration > 0.1f && in.currentAnimTime >= in.currentAnimDuration - 0.05f); +} + +// ── Event handling ─────────────────────────────────────────────────────────── + +void LocomotionFSM::onEvent(AnimEvent event) { + switch (event) { + case AnimEvent::SWIM_ENTER: + state_ = State::SWIM_IDLE; + break; + case AnimEvent::SWIM_EXIT: + state_ = State::IDLE; + break; + case AnimEvent::JUMP: + if (state_ != State::SWIM_IDLE && state_ != State::SWIM) { + jumpStartSeen_ = false; + state_ = State::JUMP_START; + } + break; + case AnimEvent::LANDED: + if (state_ == State::JUMP_MID || state_ == State::JUMP_START) { + jumpEndSeen_ = false; + state_ = State::JUMP_END; + } + break; + case AnimEvent::MOVE_START: + graceTimer_ = kGraceSec; + break; + case AnimEvent::MOVE_STOP: + // Grace timer handles the delay in updateTransitions + break; + default: + break; + } +} + +// ── State transitions ──────────────────────────────────────────────────────── + +void LocomotionFSM::updateTransitions(const Input& in, const AnimCapabilitySet& caps) { + // Update grace timer + if (in.moving) { + graceTimer_ = kGraceSec; + wasSprinting_ = in.sprinting; + } else { + graceTimer_ = std::max(0.0f, graceTimer_ - in.deltaTime); + } + + const bool effectiveMoving = in.moving || graceTimer_ > 0.0f; + const bool effectiveSprinting = in.sprinting || (!in.moving && effectiveMoving && wasSprinting_); + + switch (state_) { + case State::IDLE: + if (in.swimming) { + state_ = effectiveMoving ? State::SWIM : State::SWIM_IDLE; + } else if (!in.grounded && in.jumping) { + jumpStartSeen_ = false; + state_ = State::JUMP_START; + } else if (!in.grounded) { + state_ = State::JUMP_MID; + } else if (effectiveMoving && effectiveSprinting) { + state_ = State::RUN; + } else if (effectiveMoving) { + state_ = State::WALK; + } + break; + + case State::WALK: + if (in.swimming) { + state_ = effectiveMoving ? State::SWIM : State::SWIM_IDLE; + } else if (!in.grounded && in.jumping) { + jumpStartSeen_ = false; + state_ = State::JUMP_START; + } else if (!in.grounded) { + state_ = State::JUMP_MID; + } else if (!effectiveMoving) { + state_ = State::IDLE; + } else if (effectiveSprinting) { + state_ = State::RUN; + } + break; + + case State::RUN: + if (in.swimming) { + state_ = effectiveMoving ? State::SWIM : State::SWIM_IDLE; + } else if (!in.grounded && in.jumping) { + jumpStartSeen_ = false; + state_ = State::JUMP_START; + } else if (!in.grounded) { + state_ = State::JUMP_MID; + } else if (!effectiveMoving) { + state_ = State::IDLE; + } else if (!effectiveSprinting) { + state_ = State::WALK; + } + break; + + case State::JUMP_START: + if (in.swimming) { + state_ = State::SWIM_IDLE; + } else if (in.grounded) { + state_ = State::JUMP_END; + jumpEndSeen_ = false; + } else if (caps.resolvedJumpStart == 0) { + // Model doesn't have JUMP_START animation — skip to mid-air + state_ = State::JUMP_MID; + } else if (in.haveAnimState) { + // Use the same resolved ID that resolve() outputs + uint32_t expected = caps.resolvedJumpStart; + if (in.currentAnimId == expected) jumpStartSeen_ = true; + // Also detect completion via renderer's auto-STAND reset: + // once the animation was seen and currentAnimId changed, it completed. + if (jumpStartSeen_ && oneShotComplete(in, expected)) { + state_ = State::JUMP_MID; + } + } else { + // No animation state available — fall through after 1 frame + state_ = State::JUMP_MID; + } + break; + + case State::JUMP_MID: + if (in.swimming) { + state_ = State::SWIM_IDLE; + } else if (in.grounded) { + state_ = State::JUMP_END; + } + break; + + case State::JUMP_END: + if (in.swimming) { + state_ = effectiveMoving ? State::SWIM : State::SWIM_IDLE; + } else if (effectiveMoving) { + // Movement overrides landing animation + state_ = effectiveSprinting ? State::RUN : State::WALK; + } else if (caps.resolvedJumpEnd == 0) { + // Model doesn't have JUMP_END animation — go straight to IDLE + state_ = State::IDLE; + } else if (in.haveAnimState) { + uint32_t expected = caps.resolvedJumpEnd; + if (in.currentAnimId == expected) jumpEndSeen_ = true; + // Only transition to IDLE after landing animation completes + if (jumpEndSeen_ && oneShotComplete(in, expected)) { + state_ = State::IDLE; + } + } else { + state_ = State::IDLE; + } + break; + + case State::SWIM_IDLE: + if (!in.swimming) { + state_ = effectiveMoving ? State::WALK : State::IDLE; + } else if (effectiveMoving) { + state_ = State::SWIM; + } + break; + + case State::SWIM: + if (!in.swimming) { + state_ = effectiveMoving ? State::WALK : State::IDLE; + } else if (!effectiveMoving) { + state_ = State::SWIM_IDLE; + } + break; + } +} + +// ── Animation resolution ───────────────────────────────────────────────────── + +AnimOutput LocomotionFSM::resolve(const Input& in, const AnimCapabilitySet& caps) { + updateTransitions(in, caps); + + const bool pureStrafe = !in.movingForward && !in.movingBackward; // strafe without forward/back + const bool anyStrafeLeft = in.strafeLeft && !in.strafeRight && pureStrafe; + const bool anyStrafeRight = in.strafeRight && !in.strafeLeft && pureStrafe; + + uint32_t animId = anim::STAND; + bool animSelected = true; + bool loop = true; + + switch (state_) { + case State::IDLE: + animId = anim::STAND; + break; + + case State::WALK: + if (in.movingBackward) { + animId = caps.resolvedWalkBackwards ? caps.resolvedWalkBackwards + : caps.resolvedWalk ? caps.resolvedWalk + : anim::WALK_BACKWARDS; + } else if (anyStrafeLeft) { + animId = caps.resolvedStrafeLeft ? caps.resolvedStrafeLeft : anim::SHUFFLE_LEFT; + } else if (anyStrafeRight) { + animId = caps.resolvedStrafeRight ? caps.resolvedStrafeRight : anim::SHUFFLE_RIGHT; + } else { + animId = caps.resolvedWalk ? caps.resolvedWalk : anim::WALK; + } + break; + + case State::RUN: + if (in.movingBackward) { + animId = caps.resolvedWalkBackwards ? caps.resolvedWalkBackwards + : caps.resolvedWalk ? caps.resolvedWalk + : anim::WALK_BACKWARDS; + } else if (anyStrafeLeft) { + animId = caps.resolvedRunLeft ? caps.resolvedRunLeft + : caps.resolvedRun ? caps.resolvedRun + : anim::RUN; + } else if (anyStrafeRight) { + animId = caps.resolvedRunRight ? caps.resolvedRunRight + : caps.resolvedRun ? caps.resolvedRun + : anim::RUN; + } else if (in.sprintAura) { + animId = caps.resolvedSprint ? caps.resolvedSprint : anim::RUN; + } else { + animId = caps.resolvedRun ? caps.resolvedRun : anim::RUN; + } + break; + + case State::JUMP_START: + animId = caps.resolvedJumpStart ? caps.resolvedJumpStart : anim::JUMP_START; + loop = false; + break; + case State::JUMP_MID: + animId = caps.resolvedJump ? caps.resolvedJump : anim::JUMP; + loop = true; // Must loop — long falls outlast a single play cycle + break; + case State::JUMP_END: + animId = caps.resolvedJumpEnd ? caps.resolvedJumpEnd : anim::JUMP_END; + loop = false; + break; + + case State::SWIM_IDLE: + animId = caps.resolvedSwimIdle ? caps.resolvedSwimIdle : anim::SWIM_IDLE; + break; + + case State::SWIM: + if (in.movingBackward) { + animId = caps.resolvedSwimBackwards ? caps.resolvedSwimBackwards + : caps.resolvedSwim ? caps.resolvedSwim + : anim::SWIM; + } else if (anyStrafeLeft) { + animId = caps.resolvedSwimLeft ? caps.resolvedSwimLeft + : caps.resolvedSwim ? caps.resolvedSwim + : anim::SWIM; + } else if (anyStrafeRight) { + animId = caps.resolvedSwimRight ? caps.resolvedSwimRight + : caps.resolvedSwim ? caps.resolvedSwim + : anim::SWIM; + } else { + animId = caps.resolvedSwim ? caps.resolvedSwim : anim::SWIM; + } + break; + } + + if (!animSelected) return AnimOutput::stay(); + return AnimOutput::ok(animId, loop); +} + +// ── Reset ──────────────────────────────────────────────────────────────────── + +void LocomotionFSM::reset() { + state_ = State::IDLE; + graceTimer_ = 0.0f; + wasSprinting_ = false; + jumpStartSeen_ = false; + jumpEndSeen_ = false; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/mount_fsm.cpp b/src/rendering/animation/mount_fsm.cpp new file mode 100644 index 00000000..b34d73d0 --- /dev/null +++ b/src/rendering/animation/mount_fsm.cpp @@ -0,0 +1,342 @@ +#include "rendering/animation/mount_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" +#include +#include + +namespace wowee { +namespace rendering { + +// ── Configure / Clear ──────────────────────────────────────────────────────── + +void MountFSM::configure(const MountAnimSet& anims, bool taxiFlight) { + anims_ = anims; + taxiFlight_ = taxiFlight; + active_ = true; + state_ = MountState::IDLE; + action_ = MountAction::None; + actionPhase_ = 0; + fidgetTimer_ = 0.0f; + activeFidget_ = 0; + idleSoundTimer_ = 0.0f; + prevYaw_ = 0.0f; + roll_ = 0.0f; + lastMountAnim_ = 0; + + // Seed per-instance RNG + std::random_device rd; + rng_.seed(rd()); + nextFidgetTime_ = std::uniform_real_distribution(6.0f, 12.0f)(rng_); + nextIdleSoundTime_ = std::uniform_real_distribution(45.0f, 90.0f)(rng_); +} + +void MountFSM::clear() { + active_ = false; + state_ = MountState::IDLE; + action_ = MountAction::None; + actionPhase_ = 0; + taxiFlight_ = false; + anims_ = {}; + fidgetTimer_ = 0.0f; + activeFidget_ = 0; + idleSoundTimer_ = 0.0f; + lastMountAnim_ = 0; +} + +// ── Event handling ─────────────────────────────────────────────────────────── + +void MountFSM::onEvent(AnimEvent event) { + if (!active_) return; + switch (event) { + case AnimEvent::JUMP: + // Jump only triggered via evaluate() input check + break; + case AnimEvent::DISMOUNT: + clear(); + break; + default: + break; + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +bool MountFSM::actionAnimComplete(const Input& in) const { + return in.haveMountState && in.curMountDuration > 0.1f && + (in.curMountTime >= in.curMountDuration - 0.05f); +} + +uint32_t MountFSM::resolveGroundOrFlyAnim(const Input& in) const { + const bool pureStrafe = !in.movingBackward; + const bool anyStrafeLeft = in.strafeLeft && !in.strafeRight && pureStrafe; + const bool anyStrafeRight = in.strafeRight && !in.strafeLeft && pureStrafe; + + if (in.moving) { + if (in.flying) { + if (in.ascending) { + return anims_.flyUp ? anims_.flyUp : (anims_.flyForward ? anims_.flyForward : anim::RUN); + } else if (in.descending) { + return anims_.flyDown ? anims_.flyDown : (anims_.flyForward ? anims_.flyForward : anim::RUN); + } else if (anyStrafeLeft) { + return anims_.flyLeft ? anims_.flyLeft : (anims_.flyForward ? anims_.flyForward : anim::RUN); + } else if (anyStrafeRight) { + return anims_.flyRight ? anims_.flyRight : (anims_.flyForward ? anims_.flyForward : anim::RUN); + } else if (in.movingBackward) { + return anims_.flyBackwards ? anims_.flyBackwards : (anims_.flyForward ? anims_.flyForward : anim::RUN); + } else { + return anims_.flyForward ? anims_.flyForward : (anims_.flyIdle ? anims_.flyIdle : anim::RUN); + } + } else if (in.swimming) { + // Mounted swimming — simplified, no per-direction mount swim anims needed here + // (the original code used pickMountAnim with mount-specific swim IDs) + return anims_.run ? anims_.run : anim::RUN; + } else if (anyStrafeLeft) { + return anims_.run ? anims_.run : anim::RUN; + } else if (anyStrafeRight) { + return anims_.run ? anims_.run : anim::RUN; + } else if (in.movingBackward) { + return anims_.run ? anims_.run : anim::RUN; + } else { + return anim::RUN; + } + } else { + // Idle + if (in.swimming) { + return anims_.stand ? anims_.stand : anim::STAND; + } else if (in.flying) { + if (in.ascending) { + return anims_.flyUp ? anims_.flyUp : (anims_.flyIdle ? anims_.flyIdle : anim::STAND); + } else if (in.descending) { + return anims_.flyDown ? anims_.flyDown : (anims_.flyIdle ? anims_.flyIdle : anim::STAND); + } else { + return anims_.flyIdle ? anims_.flyIdle : (anims_.flyForward ? anims_.flyForward : anim::STAND); + } + } else { + return anims_.stand ? anims_.stand : anim::STAND; + } + } +} + +// ── Main evaluation ────────────────────────────────────────────────────────── + +MountFSM::Output MountFSM::evaluate(const Input& in) { + Output out; + if (!active_) return out; + + const float dt = in.deltaTime; + + // ── Procedural lean ───────────────────────────────────────────────── + if (!taxiFlight_ && in.moving && dt > 0.0f) { + float turnRate = (in.characterYaw - prevYaw_) / dt; + while (turnRate > 180.0f) turnRate -= 360.0f; + while (turnRate < -180.0f) turnRate += 360.0f; + float targetLean = std::clamp(turnRate * 0.15f, -0.25f, 0.25f); + roll_ = roll_ + (targetLean - roll_) * (1.0f - std::exp(-6.0f * dt)); + } else { + roll_ = roll_ + (0.0f - roll_) * (1.0f - std::exp(-8.0f * dt)); + } + prevYaw_ = in.characterYaw; + out.mountRoll = roll_; + + // ── Rider animation ───────────────────────────────────────────────── + out.riderAnimId = anim::MOUNT; + out.riderAnimLoop = true; + // (Flight rider variants handled by the caller via capability set, not here) + + // ── Taxi flight branch ────────────────────────────────────────────── + if (taxiFlight_) { + // Try flight animations in preference order using discovered anims + uint32_t taxiAnim = anim::STAND; + if (anims_.flyForward) taxiAnim = anims_.flyForward; + else if (anims_.flyIdle) taxiAnim = anims_.flyIdle; + else if (anims_.run) taxiAnim = anims_.run; + + out.mountAnimId = taxiAnim; + out.mountAnimLoop = true; + out.mountAnimChanged = (!in.haveMountState || in.curMountAnim != taxiAnim); + + // Bob calculation for taxi + if (in.moving && in.haveMountState && in.curMountDuration > 1.0f) { + float wrappedTime = in.curMountTime; + while (wrappedTime >= in.curMountDuration) wrappedTime -= in.curMountDuration; + float norm = wrappedTime / in.curMountDuration; + out.mountBob = std::sin(norm * 2.0f * 3.14159f * 2.0f) * 0.12f; + } + + lastMountAnim_ = out.mountAnimId; + return out; + } + + // ── Jump/rear-up trigger ──────────────────────────────────────────── + if (in.jumpKeyPressed && in.grounded && action_ == MountAction::None) { + if (in.moving && anims_.jumpLoop > 0) { + action_ = MountAction::Jump; + actionPhase_ = 1; // Start with loop directly (matching original) + out.mountAnimId = anims_.jumpLoop; + out.mountAnimLoop = true; + out.mountAnimChanged = true; + out.playJumpSound = true; + out.triggerMountJump = true; + lastMountAnim_ = out.mountAnimId; + + // Bob calc + if (in.haveMountState && in.curMountDuration > 1.0f) { + float wrappedTime = in.curMountTime; + while (wrappedTime >= in.curMountDuration) wrappedTime -= in.curMountDuration; + float norm = wrappedTime / in.curMountDuration; + out.mountBob = std::sin(norm * 2.0f * 3.14159f) * 0.12f; + } + return out; + } else if (!in.moving && anims_.rearUp > 0) { + action_ = MountAction::RearUp; + actionPhase_ = 0; + out.mountAnimId = anims_.rearUp; + out.mountAnimLoop = false; + out.mountAnimChanged = true; + out.playRearUpSound = true; + lastMountAnim_ = out.mountAnimId; + return out; + } + } + + // ── Handle active mount actions (jump chaining or rear-up) ────────── + if (action_ != MountAction::None) { + bool animFinished = actionAnimComplete(in); + + if (action_ == MountAction::Jump) { + if (actionPhase_ == 0 && animFinished && anims_.jumpLoop > 0) { + actionPhase_ = 1; + out.mountAnimId = anims_.jumpLoop; + out.mountAnimLoop = true; + out.mountAnimChanged = true; + } else if (actionPhase_ == 0 && animFinished) { + actionPhase_ = 1; + out.mountAnimId = in.curMountAnim; + } else if (actionPhase_ == 1 && in.grounded && anims_.jumpEnd > 0) { + actionPhase_ = 2; + out.mountAnimId = anims_.jumpEnd; + out.mountAnimLoop = false; + out.mountAnimChanged = true; + out.playLandSound = true; + } else if (actionPhase_ == 1 && in.grounded) { + action_ = MountAction::None; + out.mountAnimId = in.moving ? anims_.run : anims_.stand; + out.mountAnimLoop = true; + out.mountAnimChanged = true; + } else if (actionPhase_ == 2 && animFinished) { + action_ = MountAction::None; + out.mountAnimId = in.moving ? anims_.run : anims_.stand; + out.mountAnimLoop = true; + out.mountAnimChanged = true; + } else { + out.mountAnimId = in.curMountAnim; + } + } else if (action_ == MountAction::RearUp) { + if (animFinished) { + action_ = MountAction::None; + out.mountAnimId = in.moving ? anims_.run : anims_.stand; + out.mountAnimLoop = true; + out.mountAnimChanged = true; + } else { + out.mountAnimId = in.curMountAnim; + } + } + + // Bob calc + if (in.moving && in.haveMountState && in.curMountDuration > 1.0f) { + float wrappedTime = in.curMountTime; + while (wrappedTime >= in.curMountDuration) wrappedTime -= in.curMountDuration; + float norm = wrappedTime / in.curMountDuration; + out.mountBob = std::sin(norm * 2.0f * 3.14159f) * 0.12f; + } + lastMountAnim_ = out.mountAnimId; + return out; + } + + // ── Normal movement animation resolution ──────────────────────────── + uint32_t mountAnimId = resolveGroundOrFlyAnim(in); + + // ── Cancel active fidget on movement ──────────────────────────────── + if (in.moving && activeFidget_ != 0) { + activeFidget_ = 0; + out.mountAnimId = mountAnimId; + out.mountAnimLoop = true; + out.mountAnimChanged = true; + lastMountAnim_ = out.mountAnimId; + + // Bob calc + if (in.haveMountState && in.curMountDuration > 1.0f) { + float wrappedTime = in.curMountTime; + while (wrappedTime >= in.curMountDuration) wrappedTime -= in.curMountDuration; + float norm = wrappedTime / in.curMountDuration; + out.mountBob = std::sin(norm * 2.0f * 3.14159f) * 0.12f; + } + return out; + } + + // ── Check if active fidget completed ──────────────────────────────── + if (!in.moving && activeFidget_ != 0) { + if (in.haveMountState) { + if (in.curMountAnim != activeFidget_ || + in.curMountTime >= in.curMountDuration * 0.95f) { + activeFidget_ = 0; + } + } + } + + // ── Idle fidgets ──────────────────────────────────────────────────── + if (!in.moving && action_ == MountAction::None && activeFidget_ == 0 && !anims_.fidgets.empty()) { + fidgetTimer_ += dt; + if (fidgetTimer_ >= nextFidgetTime_) { + std::uniform_int_distribution dist(0, anims_.fidgets.size() - 1); + uint32_t fidgetAnim = anims_.fidgets[dist(rng_)]; + activeFidget_ = fidgetAnim; + fidgetTimer_ = 0.0f; + nextFidgetTime_ = std::uniform_real_distribution(6.0f, 12.0f)(rng_); + + out.mountAnimId = fidgetAnim; + out.mountAnimLoop = false; + out.mountAnimChanged = true; + out.fidgetStarted = true; + lastMountAnim_ = out.mountAnimId; + return out; + } + } + if (in.moving) fidgetTimer_ = 0.0f; + + // ── Idle ambient sounds ───────────────────────────────────────────── + if (!in.moving) { + idleSoundTimer_ += dt; + if (idleSoundTimer_ >= nextIdleSoundTime_) { + out.playIdleSound = true; + idleSoundTimer_ = 0.0f; + nextIdleSoundTime_ = std::uniform_real_distribution(45.0f, 90.0f)(rng_); + } + } else { + idleSoundTimer_ = 0.0f; + } + + // ── Set output ────────────────────────────────────────────────────── + out.mountAnimId = activeFidget_ != 0 ? activeFidget_ : mountAnimId; + out.mountAnimLoop = (activeFidget_ == 0); + // Only trigger playAnimation if animation actually changed and no action/fidget active + if (action_ == MountAction::None && activeFidget_ == 0 && + (!in.haveMountState || in.curMountAnim != mountAnimId)) { + out.mountAnimChanged = true; + out.mountAnimId = mountAnimId; + } + + // Bob calculation + if (in.moving && in.haveMountState && in.curMountDuration > 1.0f) { + float wrappedTime = in.curMountTime; + while (wrappedTime >= in.curMountDuration) wrappedTime -= in.curMountDuration; + float norm = wrappedTime / in.curMountDuration; + float bobSpeed = taxiFlight_ ? 2.0f : 1.0f; + out.mountBob = std::sin(norm * 2.0f * 3.14159f * bobSpeed) * 0.12f; + } + + lastMountAnim_ = out.mountAnimId; + return out; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation/sfx_state_driver.cpp b/src/rendering/animation/sfx_state_driver.cpp new file mode 100644 index 00000000..5eb241d0 --- /dev/null +++ b/src/rendering/animation/sfx_state_driver.cpp @@ -0,0 +1,94 @@ +// ============================================================================ +// SfxStateDriver — extracted from AnimationController +// +// Tracks state transitions for activity SFX (jump, landing, swim) and +// mount ambient sounds. Moved from AnimationController::updateSfxState(). +// ============================================================================ + +#include "rendering/animation/sfx_state_driver.hpp" +#include "rendering/animation/footstep_driver.hpp" +#include "rendering/renderer.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/activity_sound_manager.hpp" +#include "audio/mount_sound_manager.hpp" +#include "audio/music_manager.hpp" +#include "rendering/camera_controller.hpp" + +namespace wowee { +namespace rendering { + +void SfxStateDriver::update(float deltaTime, Renderer* renderer, + bool mounted, bool taxiFlight, + FootstepDriver& footstepDriver) { + auto* activitySoundManager = renderer->getAudioCoordinator()->getActivitySoundManager(); + if (!activitySoundManager) return; + + auto* cameraController = renderer->getCameraController(); + + activitySoundManager->update(deltaTime); + if (cameraController && cameraController->isThirdPerson()) { + bool grounded = cameraController->isGrounded(); + bool jumping = cameraController->isJumping(); + bool falling = cameraController->isFalling(); + bool swimming = cameraController->isSwimming(); + bool moving = cameraController->isMoving(); + + if (!initialized_) { + prevGrounded_ = grounded; + prevJumping_ = jumping; + prevFalling_ = falling; + prevSwimming_ = swimming; + initialized_ = true; + } + + // Jump detection + if (jumping && !prevJumping_ && !swimming) { + activitySoundManager->playJump(); + } + + // Landing detection + if (grounded && !prevGrounded_) { + bool hardLanding = prevFalling_; + activitySoundManager->playLanding( + footstepDriver.resolveFootstepSurface(renderer), hardLanding); + } + + // Water transitions + if (swimming && !prevSwimming_) { + activitySoundManager->playWaterEnter(); + } else if (!swimming && prevSwimming_) { + activitySoundManager->playWaterExit(); + } + + activitySoundManager->setSwimmingState(swimming, moving); + + if (renderer->getAudioCoordinator()->getMusicManager()) { + renderer->getAudioCoordinator()->getMusicManager()->setUnderwaterMode(swimming); + } + + prevGrounded_ = grounded; + prevJumping_ = jumping; + prevFalling_ = falling; + prevSwimming_ = swimming; + } else { + activitySoundManager->setSwimmingState(false, false); + if (renderer->getAudioCoordinator()->getMusicManager()) { + renderer->getAudioCoordinator()->getMusicManager()->setUnderwaterMode(false); + } + initialized_ = false; + } + + // Mount ambient sounds + if (renderer->getAudioCoordinator()->getMountSoundManager()) { + renderer->getAudioCoordinator()->getMountSoundManager()->update(deltaTime); + if (cameraController && mounted) { + bool isMoving = cameraController->isMoving(); + bool flying = taxiFlight || !cameraController->isGrounded(); + renderer->getAudioCoordinator()->getMountSoundManager()->setMoving(isMoving); + renderer->getAudioCoordinator()->getMountSoundManager()->setFlying(flying); + } + } +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/animation_controller.cpp b/src/rendering/animation_controller.cpp index eb459c04..c1cdb610 100644 --- a/src/rendering/animation_controller.cpp +++ b/src/rendering/animation_controller.cpp @@ -1,5 +1,8 @@ #include "rendering/animation_controller.hpp" -#include "rendering/animation_ids.hpp" +#include "rendering/animation/emote_registry.hpp" +#include "rendering/animation/anim_capability_probe.hpp" +#include "rendering/animation/mount_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" #include "rendering/renderer.hpp" #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" @@ -13,8 +16,6 @@ #include "rendering/spell_visual_system.hpp" #include "pipeline/m2_loader.hpp" #include "pipeline/asset_manager.hpp" -#include "pipeline/dbc_loader.hpp" -#include "pipeline/dbc_layout.hpp" #include "game/inventory.hpp" #include "core/application.hpp" #include "core/logger.hpp" @@ -29,7 +30,6 @@ #include #include #include -#include #include #include #include @@ -39,208 +39,6 @@ namespace wowee { namespace rendering { -// ── Static emote data (shared across all AnimationController instances) ────── - -struct EmoteInfo { - uint32_t animId = 0; - uint32_t dbcId = 0; - bool loop = false; - std::string textNoTarget; - std::string textTarget; - std::string othersNoTarget; - std::string othersTarget; - std::string command; -}; - -static std::unordered_map EMOTE_TABLE; -static std::unordered_map EMOTE_BY_DBCID; -static bool emoteTableLoaded = false; - -static std::vector parseEmoteCommands(const std::string& raw) { - std::vector out; - std::string cur; - for (char c : raw) { - if (std::isalnum(static_cast(c)) || c == '_') { - cur.push_back(static_cast(std::tolower(static_cast(c)))); - } else if (!cur.empty()) { - out.push_back(cur); - cur.clear(); - } - } - if (!cur.empty()) out.push_back(cur); - return out; -} - -static bool isLoopingEmote(const std::string& command) { - static const std::unordered_set kLooping = { - "dance", "train", "dead", "eat", "work", - }; - return kLooping.find(command) != kLooping.end(); -} - -// Map one-shot emote animation IDs to their persistent EMOTE_STATE_* looping variants. -// When a looping emote is played, we prefer the STATE variant if the model has it. -static uint32_t getEmoteStateVariant(uint32_t oneShotAnimId) { - static const std::unordered_map kStateMap = { - {anim::EMOTE_DANCE, anim::EMOTE_STATE_DANCE}, - {anim::EMOTE_LAUGH, anim::EMOTE_STATE_LAUGH}, - {anim::EMOTE_POINT, anim::EMOTE_STATE_POINT}, - {anim::EMOTE_EAT, anim::EMOTE_STATE_EAT}, - {anim::EMOTE_ROAR, anim::EMOTE_STATE_ROAR}, - {anim::EMOTE_APPLAUD, anim::EMOTE_STATE_APPLAUD}, - {anim::EMOTE_WORK, anim::EMOTE_STATE_WORK}, - {anim::EMOTE_USE_STANDING, anim::EMOTE_STATE_USE_STANDING}, - {anim::EATING_LOOP, anim::EMOTE_STATE_EAT}, - }; - auto it = kStateMap.find(oneShotAnimId); - return it != kStateMap.end() ? it->second : 0; -} - -static void loadFallbackEmotes() { - if (!EMOTE_TABLE.empty()) return; - EMOTE_TABLE = { - {"wave", {anim::EMOTE_WAVE, 0, false, "You wave.", "You wave at %s.", "%s waves.", "%s waves at %s.", "wave"}}, - {"bow", {anim::EMOTE_BOW, 0, false, "You bow down graciously.", "You bow down before %s.", "%s bows down graciously.", "%s bows down before %s.", "bow"}}, - {"laugh", {anim::EMOTE_LAUGH, 0, false, "You laugh.", "You laugh at %s.", "%s laughs.", "%s laughs at %s.", "laugh"}}, - {"point", {anim::EMOTE_POINT, 0, false, "You point over yonder.", "You point at %s.", "%s points over yonder.", "%s points at %s.", "point"}}, - {"cheer", {anim::EMOTE_CHEER, 0, false, "You cheer!", "You cheer at %s.", "%s cheers!", "%s cheers at %s.", "cheer"}}, - {"dance", {anim::EMOTE_DANCE, 0, true, "You burst into dance.", "You dance with %s.", "%s bursts into dance.", "%s dances with %s.", "dance"}}, - {"kneel", {anim::EMOTE_KNEEL, 0, false, "You kneel down.", "You kneel before %s.", "%s kneels down.", "%s kneels before %s.", "kneel"}}, - {"applaud", {anim::EMOTE_APPLAUD, 0, false, "You applaud. Bravo!", "You applaud at %s. Bravo!", "%s applauds. Bravo!", "%s applauds at %s. Bravo!", "applaud"}}, - {"shout", {anim::EMOTE_SHOUT, 0, false, "You shout.", "You shout at %s.", "%s shouts.", "%s shouts at %s.", "shout"}}, - {"chicken", {anim::EMOTE_CHICKEN, 0, false, "With arms flapping, you strut around. Cluck, Cluck, Chicken!", - "With arms flapping, you strut around %s. Cluck, Cluck, Chicken!", - "%s struts around. Cluck, Cluck, Chicken!", "%s struts around %s. Cluck, Cluck, Chicken!", "chicken"}}, - {"cry", {anim::EMOTE_CRY, 0, false, "You cry.", "You cry on %s's shoulder.", "%s cries.", "%s cries on %s's shoulder.", "cry"}}, - {"kiss", {anim::EMOTE_KISS, 0, false, "You blow a kiss into the wind.", "You blow a kiss to %s.", "%s blows a kiss into the wind.", "%s blows a kiss to %s.", "kiss"}}, - {"roar", {anim::EMOTE_ROAR, 0, false, "You roar with bestial vigor. So fierce!", "You roar with bestial vigor at %s. So fierce!", "%s roars with bestial vigor. So fierce!", "%s roars with bestial vigor at %s. So fierce!", "roar"}}, - {"salute", {anim::EMOTE_SALUTE, 0, false, "You salute.", "You salute %s with respect.", "%s salutes.", "%s salutes %s with respect.", "salute"}}, - {"rude", {anim::EMOTE_RUDE, 0, false, "You make a rude gesture.", "You make a rude gesture at %s.", "%s makes a rude gesture.", "%s makes a rude gesture at %s.", "rude"}}, - {"flex", {anim::EMOTE_FLEX, 0, false, "You flex your muscles. Oooooh so strong!", "You flex at %s. Oooooh so strong!", "%s flexes. Oooooh so strong!", "%s flexes at %s. Oooooh so strong!", "flex"}}, - {"shy", {anim::EMOTE_SHY, 0, false, "You smile shyly.", "You smile shyly at %s.", "%s smiles shyly.", "%s smiles shyly at %s.", "shy"}}, - {"beg", {anim::EMOTE_BEG, 0, false, "You beg everyone around you. How pathetic.", "You beg %s. How pathetic.", "%s begs everyone around. How pathetic.", "%s begs %s. How pathetic.", "beg"}}, - {"eat", {anim::EMOTE_EAT, 0, true, "You begin to eat.", "You begin to eat in front of %s.", "%s begins to eat.", "%s begins to eat in front of %s.", "eat"}}, - {"talk", {anim::EMOTE_TALK, 0, false, "You talk.", "You talk to %s.", "%s talks.", "%s talks to %s.", "talk"}}, - {"work", {anim::EMOTE_WORK, 0, true, "You begin to work.", "You begin to work near %s.", "%s begins to work.", "%s begins to work near %s.", "work"}}, - {"train", {anim::EMOTE_TRAIN, 0, true, "You let off a train whistle. Choo Choo!", "You let off a train whistle at %s. Choo Choo!", "%s lets off a train whistle. Choo Choo!", "%s lets off a train whistle at %s. Choo Choo!", "train"}}, - {"dead", {anim::EMOTE_DEAD, 0, true, "You play dead.", "You play dead in front of %s.", "%s plays dead.", "%s plays dead in front of %s.", "dead"}}, - }; -} - -static std::string replacePlaceholders(const std::string& text, const std::string* targetName) { - if (text.empty()) return text; - std::string out; - out.reserve(text.size() + 16); - for (size_t i = 0; i < text.size(); ++i) { - if (text[i] == '%' && i + 1 < text.size() && text[i + 1] == 's') { - if (targetName && !targetName->empty()) out += *targetName; - i++; - } else { - out.push_back(text[i]); - } - } - return out; -} - -static void loadEmotesFromDbc() { - if (emoteTableLoaded) return; - emoteTableLoaded = true; - - auto* assetManager = core::Application::getInstance().getAssetManager(); - if (!assetManager) { - LOG_WARNING("Emotes: no AssetManager"); - loadFallbackEmotes(); - return; - } - - auto emotesTextDbc = assetManager->loadDBC("EmotesText.dbc"); - auto emotesTextDataDbc = assetManager->loadDBC("EmotesTextData.dbc"); - if (!emotesTextDbc || !emotesTextDataDbc || !emotesTextDbc->isLoaded() || !emotesTextDataDbc->isLoaded()) { - LOG_WARNING("Emotes: DBCs not available (EmotesText/EmotesTextData)"); - loadFallbackEmotes(); - return; - } - - const auto* activeLayout = pipeline::getActiveDBCLayout(); - const auto* etdL = activeLayout ? activeLayout->getLayout("EmotesTextData") : nullptr; - const auto* emL = activeLayout ? activeLayout->getLayout("Emotes") : nullptr; - const auto* etL = activeLayout ? activeLayout->getLayout("EmotesText") : nullptr; - - std::unordered_map textData; - textData.reserve(emotesTextDataDbc->getRecordCount()); - for (uint32_t r = 0; r < emotesTextDataDbc->getRecordCount(); ++r) { - uint32_t id = emotesTextDataDbc->getUInt32(r, etdL ? (*etdL)["ID"] : 0); - std::string text = emotesTextDataDbc->getString(r, etdL ? (*etdL)["Text"] : 1); - if (!text.empty()) textData.emplace(id, std::move(text)); - } - - std::unordered_map emoteIdToAnim; - if (auto emotesDbc = assetManager->loadDBC("Emotes.dbc"); emotesDbc && emotesDbc->isLoaded()) { - emoteIdToAnim.reserve(emotesDbc->getRecordCount()); - for (uint32_t r = 0; r < emotesDbc->getRecordCount(); ++r) { - uint32_t emoteId = emotesDbc->getUInt32(r, emL ? (*emL)["ID"] : 0); - uint32_t animId = emotesDbc->getUInt32(r, emL ? (*emL)["AnimID"] : 2); - if (animId != 0) emoteIdToAnim[emoteId] = animId; - } - } - - EMOTE_TABLE.clear(); - EMOTE_TABLE.reserve(emotesTextDbc->getRecordCount()); - for (uint32_t r = 0; r < emotesTextDbc->getRecordCount(); ++r) { - uint32_t recordId = emotesTextDbc->getUInt32(r, etL ? (*etL)["ID"] : 0); - std::string cmdRaw = emotesTextDbc->getString(r, etL ? (*etL)["Command"] : 1); - if (cmdRaw.empty()) continue; - - uint32_t emoteRef = emotesTextDbc->getUInt32(r, etL ? (*etL)["EmoteRef"] : 2); - uint32_t animId = 0; - auto animIt = emoteIdToAnim.find(emoteRef); - if (animIt != emoteIdToAnim.end()) { - animId = animIt->second; - } else { - animId = emoteRef; - } - - uint32_t senderTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["SenderTargetTextID"] : 5); - uint32_t senderNoTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["SenderNoTargetTextID"] : 9); - uint32_t othersTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["OthersTargetTextID"] : 3); - uint32_t othersNoTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["OthersNoTargetTextID"] : 7); - - std::string textTarget, textNoTarget, oTarget, oNoTarget; - if (auto it = textData.find(senderTargetTextId); it != textData.end()) textTarget = it->second; - if (auto it = textData.find(senderNoTargetTextId); it != textData.end()) textNoTarget = it->second; - if (auto it = textData.find(othersTargetTextId); it != textData.end()) oTarget = it->second; - if (auto it = textData.find(othersNoTargetTextId); it != textData.end()) oNoTarget = it->second; - - for (const std::string& cmd : parseEmoteCommands(cmdRaw)) { - if (cmd.empty()) continue; - EmoteInfo info; - info.animId = animId; - info.dbcId = recordId; - info.loop = isLoopingEmote(cmd); - info.textNoTarget = textNoTarget; - info.textTarget = textTarget; - info.othersNoTarget = oNoTarget; - info.othersTarget = oTarget; - info.command = cmd; - EMOTE_TABLE.emplace(cmd, std::move(info)); - } - } - - if (EMOTE_TABLE.empty()) { - LOG_WARNING("Emotes: DBC loaded but no commands parsed, using fallback list"); - loadFallbackEmotes(); - } else { - LOG_INFO("Emotes: loaded ", EMOTE_TABLE.size(), " commands from DBC"); - } - - EMOTE_BY_DBCID.clear(); - for (auto& [cmd, info] : EMOTE_TABLE) { - if (info.dbcId != 0) { - EMOTE_BY_DBCID.emplace(info.dbcId, &info); - } - } -} - // ── AnimationController implementation ─────────────────────────────────────── AnimationController::AnimationController() = default; @@ -250,276 +48,143 @@ void AnimationController::initialize(Renderer* renderer) { renderer_ = renderer; } +void AnimationController::probeCapabilities() { + if (!renderer_) return; + uint32_t instanceId = renderer_->getCharacterInstanceId(); + if (instanceId == 0) return; + auto caps = AnimCapabilityProbe::probe(renderer_, instanceId); + characterAnimator_.setCapabilities(caps); + capabilitiesProbed_ = true; +} + void AnimationController::onCharacterFollow(uint32_t /*instanceId*/) { // Reset animation state when follow target changes + capabilitiesProbed_ = false; } // ── Emote support ──────────────────────────────────────────────────────────── void AnimationController::playEmote(const std::string& emoteName) { - loadEmotesFromDbc(); - auto it = EMOTE_TABLE.find(emoteName); - if (it == EMOTE_TABLE.end()) return; + auto& registry = EmoteRegistry::instance(); + registry.loadFromDbc(); + auto result = registry.findEmote(emoteName); + if (!result) return; - const auto& info = it->second; - if (info.animId == 0) return; - emoteActive_ = true; - emoteAnimId_ = info.animId; - emoteLoop_ = info.loop; + uint32_t animId = result->animId; + bool loop = result->loop; // For looping emotes, prefer the EMOTE_STATE_* variant if the model has it - if (emoteLoop_) { - uint32_t stateVariant = getEmoteStateVariant(emoteAnimId_); + if (loop) { + uint32_t stateVariant = registry.getStateVariant(animId); if (stateVariant != 0) { auto* characterRenderer = renderer_->getCharacterRenderer(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); if (characterRenderer && characterInstanceId > 0 && characterRenderer->hasAnimation(characterInstanceId, stateVariant)) { - emoteAnimId_ = stateVariant; + animId = stateVariant; } } } - charAnimState_ = CharAnimState::EMOTE; + // Forward to CharacterAnimator (ActivityFSM handles emote state) + characterAnimator_.playEmote(animId, loop); + // Immediately play the emote animation on the renderer auto* characterRenderer = renderer_->getCharacterRenderer(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); if (characterRenderer && characterInstanceId > 0) { - characterRenderer->playAnimation(characterInstanceId, emoteAnimId_, emoteLoop_); + characterRenderer->playAnimation(characterInstanceId, animId, loop); + lastPlayerAnimRequest_ = animId; + lastPlayerAnimLoopRequest_ = loop; } } void AnimationController::cancelEmote() { - emoteActive_ = false; - emoteAnimId_ = 0; - emoteLoop_ = false; -} - -void AnimationController::startSpellCast(uint32_t precastAnimId, uint32_t castAnimId, bool castLoop, - uint32_t finalizeAnimId) { - spellPrecastAnimId_ = precastAnimId; - spellCastAnimId_ = castAnimId; - spellCastLoop_ = castLoop; - spellFinalizeAnimId_ = finalizeAnimId; - - // Start with precast phase if available, otherwise go straight to cast - if (spellPrecastAnimId_ != 0) { - charAnimState_ = CharAnimState::SPELL_PRECAST; - } else { - charAnimState_ = CharAnimState::SPELL_CASTING; - } - // Force immediate animation update by invalidating the last request - lastPlayerAnimRequest_ = UINT32_MAX; -} - -void AnimationController::stopSpellCast() { - if (charAnimState_ != CharAnimState::SPELL_PRECAST && - charAnimState_ != CharAnimState::SPELL_CASTING) return; - - if (spellFinalizeAnimId_ != 0) { - // Transition to finalization phase — one-shot release animation - charAnimState_ = CharAnimState::SPELL_FINALIZE; - lastPlayerAnimRequest_ = UINT32_MAX; - } else if (spellCastLoop_) { - // No finalize anim — let current cast cycle finish as one-shot - spellCastLoop_ = false; - charAnimState_ = CharAnimState::SPELL_FINALIZE; - lastPlayerAnimRequest_ = UINT32_MAX; - } else { - // Instant cast (no finalize, no loop) — wait for completion in current state - charAnimState_ = CharAnimState::SPELL_FINALIZE; - lastPlayerAnimRequest_ = UINT32_MAX; - } -} - -void AnimationController::startLooting() { - // Don't override jump, swim, stun, or death states - if (charAnimState_ == CharAnimState::JUMP_START || - charAnimState_ == CharAnimState::JUMP_MID || - charAnimState_ == CharAnimState::JUMP_END || - charAnimState_ == CharAnimState::SWIM || - charAnimState_ == CharAnimState::SWIM_IDLE || - charAnimState_ == CharAnimState::STUNNED) return; - charAnimState_ = CharAnimState::LOOTING; - lastPlayerAnimRequest_ = UINT32_MAX; -} - -void AnimationController::stopLooting() { - if (charAnimState_ != CharAnimState::LOOTING) return; - charAnimState_ = CharAnimState::IDLE; - lastPlayerAnimRequest_ = UINT32_MAX; -} - -void AnimationController::triggerHitReaction(uint32_t animId) { - // Hit reactions interrupt spell casting but not jumps/swimming/stun - if (charAnimState_ == CharAnimState::JUMP_START || - charAnimState_ == CharAnimState::JUMP_MID || - charAnimState_ == CharAnimState::JUMP_END || - charAnimState_ == CharAnimState::SWIM || - charAnimState_ == CharAnimState::SWIM_IDLE || - charAnimState_ == CharAnimState::STUNNED) return; - if (charAnimState_ == CharAnimState::SPELL_CASTING || - charAnimState_ == CharAnimState::SPELL_PRECAST || - charAnimState_ == CharAnimState::SPELL_FINALIZE) { - spellPrecastAnimId_ = 0; - spellCastAnimId_ = 0; - spellCastLoop_ = false; - spellFinalizeAnimId_ = 0; - } - hitReactionAnimId_ = animId; - charAnimState_ = CharAnimState::HIT_REACTION; - lastPlayerAnimRequest_ = UINT32_MAX; -} - -void AnimationController::setStunned(bool stunned) { - stunned_ = stunned; - if (stunned) { - // Stun overrides most states (not swimming/jumping — those are physics) - if (charAnimState_ == CharAnimState::SWIM || - charAnimState_ == CharAnimState::SWIM_IDLE) return; - // Interrupt spell casting - if (charAnimState_ == CharAnimState::SPELL_CASTING || - charAnimState_ == CharAnimState::SPELL_PRECAST || - charAnimState_ == CharAnimState::SPELL_FINALIZE) { - spellPrecastAnimId_ = 0; - spellCastAnimId_ = 0; - spellCastLoop_ = false; - spellFinalizeAnimId_ = 0; - } - hitReactionAnimId_ = 0; - charAnimState_ = CharAnimState::STUNNED; - lastPlayerAnimRequest_ = UINT32_MAX; - } else { - if (charAnimState_ == CharAnimState::STUNNED) { - charAnimState_ = inCombat_ ? CharAnimState::COMBAT_IDLE : CharAnimState::IDLE; - lastPlayerAnimRequest_ = UINT32_MAX; - } - } -} - -void AnimationController::setStandState(uint8_t state) { - if (state == standState_) return; - standState_ = state; - - if (state == STAND_STATE_STAND) { - // Standing up — exit animation handled by state machine (!sitting → SIT_UP) - // sitUpAnim_ is retained from the entry so the correct exit animation plays. - return; - } - - // Configure transition/loop/exit animations per stand-state type - if (state == STAND_STATE_SIT) { - // Ground sit - sitDownAnim_ = anim::SIT_GROUND_DOWN; - sitLoopAnim_ = anim::SITTING; - sitUpAnim_ = anim::SIT_GROUND_UP; - charAnimState_ = CharAnimState::SIT_DOWN; - } else if (state == STAND_STATE_SLEEP) { - // Sleep - sitDownAnim_ = anim::SLEEP_DOWN; - sitLoopAnim_ = anim::SLEEP; - sitUpAnim_ = anim::SLEEP_UP; - charAnimState_ = CharAnimState::SIT_DOWN; - } else if (state == STAND_STATE_KNEEL) { - // Kneel - sitDownAnim_ = anim::KNEEL_START; - sitLoopAnim_ = anim::KNEEL_LOOP; - sitUpAnim_ = anim::KNEEL_END; - charAnimState_ = CharAnimState::SIT_DOWN; - } else if (state >= STAND_STATE_SIT_CHAIR && state <= STAND_STATE_SIT_HIGH) { - // Chair variants — no transition animation, go directly to loop - sitDownAnim_ = 0; - sitUpAnim_ = 0; - if (state == STAND_STATE_SIT_LOW) { - sitLoopAnim_ = anim::SIT_CHAIR_LOW; - } else if (state == STAND_STATE_SIT_HIGH) { - sitLoopAnim_ = anim::SIT_CHAIR_HIGH; - } else { - sitLoopAnim_ = anim::SIT_CHAIR_MED; - } - charAnimState_ = CharAnimState::SITTING; - } else if (state == STAND_STATE_DEAD) { - // Dead — leave to death handling elsewhere - sitDownAnim_ = 0; - sitLoopAnim_ = 0; - sitUpAnim_ = 0; - return; - } - lastPlayerAnimRequest_ = UINT32_MAX; -} - -void AnimationController::setStealthed(bool stealth) { - if (stealthed_ == stealth) return; - stealthed_ = stealth; - lastPlayerAnimRequest_ = UINT32_MAX; + characterAnimator_.cancelEmote(); } std::string AnimationController::getEmoteText(const std::string& emoteName, const std::string* targetName) { - loadEmotesFromDbc(); - auto it = EMOTE_TABLE.find(emoteName); - if (it != EMOTE_TABLE.end()) { - const auto& info = it->second; - const std::string& base = (targetName ? info.textTarget : info.textNoTarget); - if (!base.empty()) { - return replacePlaceholders(base, targetName); - } - if (targetName && !targetName->empty()) { - return "You " + info.command + " at " + *targetName + "."; - } - return "You " + info.command + "."; - } - return ""; + auto& registry = EmoteRegistry::instance(); + registry.loadFromDbc(); + return registry.textFor(emoteName, targetName); } uint32_t AnimationController::getEmoteDbcId(const std::string& emoteName) { - loadEmotesFromDbc(); - auto it = EMOTE_TABLE.find(emoteName); - if (it != EMOTE_TABLE.end()) { - return it->second.dbcId; - } - return 0; + auto& registry = EmoteRegistry::instance(); + registry.loadFromDbc(); + return registry.dbcIdFor(emoteName); } std::string AnimationController::getEmoteTextByDbcId(uint32_t dbcId, const std::string& senderName, const std::string* targetName) { - loadEmotesFromDbc(); - auto it = EMOTE_BY_DBCID.find(dbcId); - if (it == EMOTE_BY_DBCID.end()) return ""; - - const EmoteInfo& info = *it->second; - - if (targetName && !targetName->empty()) { - if (!info.othersTarget.empty()) { - std::string out; - out.reserve(info.othersTarget.size() + senderName.size() + targetName->size()); - bool firstReplaced = false; - for (size_t i = 0; i < info.othersTarget.size(); ++i) { - if (info.othersTarget[i] == '%' && i + 1 < info.othersTarget.size() && info.othersTarget[i + 1] == 's') { - out += firstReplaced ? *targetName : senderName; - firstReplaced = true; - ++i; - } else { - out.push_back(info.othersTarget[i]); - } - } - return out; - } - return senderName + " " + info.command + "s at " + *targetName + "."; - } else { - if (!info.othersNoTarget.empty()) { - return replacePlaceholders(info.othersNoTarget, &senderName); - } - return senderName + " " + info.command + "s."; - } + auto& registry = EmoteRegistry::instance(); + registry.loadFromDbc(); + return registry.textByDbcId(dbcId, senderName, targetName); } uint32_t AnimationController::getEmoteAnimByDbcId(uint32_t dbcId) { - loadEmotesFromDbc(); - auto it = EMOTE_BY_DBCID.find(dbcId); - if (it != EMOTE_BY_DBCID.end()) { - return it->second->animId; - } - return 0; + auto& registry = EmoteRegistry::instance(); + registry.loadFromDbc(); + return registry.animByDbcId(dbcId); +} + +// ── Spell casting ──────────────────────────────────────────────────────────── + +void AnimationController::startSpellCast(uint32_t precastAnimId, uint32_t castAnimId, bool castLoop, + uint32_t finalizeAnimId) { + characterAnimator_.startSpellCast(precastAnimId, castAnimId, castLoop, finalizeAnimId); +} + +void AnimationController::stopSpellCast() { + characterAnimator_.stopSpellCast(); +} + +// ── Loot animation ─────────────────────────────────────────────────────────── + +void AnimationController::startLooting() { + characterAnimator_.startLooting(); +} + +void AnimationController::stopLooting() { + characterAnimator_.stopLooting(); +} + +// ── Hit reactions ──────────────────────────────────────────────────────────── + +void AnimationController::triggerHitReaction(uint32_t animId) { + characterAnimator_.triggerHitReaction(animId); +} + +// ── Crowd control ──────────────────────────────────────────────────────────── + +void AnimationController::setStunned(bool stunned) { + stunned_ = stunned; + characterAnimator_.setStunned(stunned); +} + +// ── Health-based idle ──────────────────────────────────────────────────────── + +void AnimationController::setLowHealth(bool low) { + characterAnimator_.setLowHealth(low); +} + +// ── Stand state ────────────────────────────────────────────────────────────── + +void AnimationController::setStandState(uint8_t state) { + characterAnimator_.setStandState(state); +} + +// ── Stealth ────────────────────────────────────────────────────────────────── + +void AnimationController::setStealthed(bool stealth) { + characterAnimator_.setStealthed(stealth); +} + +// ── Sprint aura ────────────────────────────────────────────────────────────── + +void AnimationController::setSprintAuraActive(bool active) { + sprintAuraActive_ = active; + characterAnimator_.setSprintAuraActive(active); } // ── Targeting / combat ─────────────────────────────────────────────────────── @@ -528,6 +193,11 @@ void AnimationController::setTargetPosition(const glm::vec3* pos) { targetPosition_ = pos; } +void AnimationController::setInCombat(bool combat) { + inCombat_ = combat; + characterAnimator_.setInCombat(combat); +} + void AnimationController::resetCombatVisualState() { inCombat_ = false; targetPosition_ = nullptr; @@ -536,20 +206,17 @@ void AnimationController::resetCombatVisualState() { specialAttackAnimId_ = 0; rangedShootTimer_ = 0.0f; rangedAnimId_ = 0; - spellPrecastAnimId_ = 0; - spellCastAnimId_ = 0; - spellCastLoop_ = false; - spellFinalizeAnimId_ = 0; - hitReactionAnimId_ = 0; stunned_ = false; - lowHealth_ = false; - if (charAnimState_ == CharAnimState::SPELL_CASTING || - charAnimState_ == CharAnimState::SPELL_PRECAST || - charAnimState_ == CharAnimState::SPELL_FINALIZE || - charAnimState_ == CharAnimState::HIT_REACTION || - charAnimState_ == CharAnimState::STUNNED || - charAnimState_ == CharAnimState::RANGED_SHOOT) - charAnimState_ = CharAnimState::IDLE; + charging_ = false; + + // Reset all CharacterAnimator combat state + characterAnimator_.setInCombat(false); + characterAnimator_.setStunned(false); + characterAnimator_.setCharging(false); + characterAnimator_.setLowHealth(false); + characterAnimator_.stopSpellCast(); + characterAnimator_.triggerHitReaction(0); // Clear hit reaction + if (auto* svs = renderer_->getSpellVisualSystem()) svs->reset(); } @@ -565,8 +232,8 @@ void AnimationController::triggerMeleeSwing() { uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown_ > 0.0f) return; - if (emoteActive_) { - cancelEmote(); + if (characterAnimator_.getActivity().isEmoteActive()) { + characterAnimator_.cancelEmote(); } specialAttackAnimId_ = 0; // Clear any special attack override resolveMeleeAnimId(); @@ -580,24 +247,37 @@ void AnimationController::triggerMeleeSwing() { } } +void AnimationController::setEquippedWeaponType(uint32_t inventoryType, bool is2HLoose, + bool isFist, bool isDagger, + bool hasOffHand, bool hasShield) { + weaponLoadout_.inventoryType = inventoryType; + weaponLoadout_.is2HLoose = is2HLoose; + weaponLoadout_.isFist = isFist; + weaponLoadout_.isDagger = isDagger; + weaponLoadout_.hasOffHand = hasOffHand; + weaponLoadout_.hasShield = hasShield; + meleeAnimId_ = 0; // Force re-resolve on next swing + characterAnimator_.setEquippedWeaponType(weaponLoadout_); +} + void AnimationController::triggerSpecialAttack(uint32_t /*spellId*/) { auto* characterRenderer = renderer_->getCharacterRenderer(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown_ > 0.0f) return; - if (emoteActive_) { - cancelEmote(); + if (characterAnimator_.getActivity().isEmoteActive()) { + characterAnimator_.cancelEmote(); } auto has = [&](uint32_t id) { return characterRenderer->hasAnimation(characterInstanceId, id); }; // Choose special attack animation based on equipped weapon type uint32_t specAnim = 0; - if (equippedHasShield_ && has(anim::SHIELD_BASH)) { + if (weaponLoadout_.hasShield && has(anim::SHIELD_BASH)) { specAnim = anim::SHIELD_BASH; - } else if ((equippedWeaponInvType_ == game::InvType::TWO_HAND || equippedIs2HLoose_) && has(anim::SPECIAL_2H)) { + } else if ((weaponLoadout_.inventoryType == game::InvType::TWO_HAND || weaponLoadout_.is2HLoose) && has(anim::SPECIAL_2H)) { specAnim = anim::SPECIAL_2H; - } else if (equippedWeaponInvType_ != game::InvType::NON_EQUIP && has(anim::SPECIAL_1H)) { + } else if (weaponLoadout_.inventoryType != game::InvType::NON_EQUIP && has(anim::SPECIAL_1H)) { specAnim = anim::SPECIAL_1H; } else if (has(anim::SPECIAL_UNARMED)) { specAnim = anim::SPECIAL_UNARMED; @@ -634,18 +314,29 @@ void AnimationController::triggerSpecialAttack(uint32_t /*spellId*/) { // ── Ranged combat ──────────────────────────────────────────────────────────── +void AnimationController::setEquippedRangedType(RangedWeaponType type) { + weaponLoadout_.rangedType = type; + rangedAnimId_ = 0; + characterAnimator_.setEquippedRangedType(type); +} + +void AnimationController::setCharging(bool c) { + charging_ = c; + characterAnimator_.setCharging(c); +} + void AnimationController::triggerRangedShot() { auto* characterRenderer = renderer_->getCharacterRenderer(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); if (!characterRenderer || characterInstanceId == 0) return; if (rangedShootTimer_ > 0.0f) return; - if (emoteActive_) cancelEmote(); + if (characterAnimator_.getActivity().isEmoteActive()) characterAnimator_.cancelEmote(); auto has = [&](uint32_t id) { return characterRenderer->hasAnimation(characterInstanceId, id); }; // Resolve ranged attack animation based on weapon type uint32_t shootAnim = 0; - switch (equippedRangedType_) { + switch (weaponLoadout_.rangedType) { case RangedWeaponType::BOW: if (has(anim::FIRE_BOW)) shootAnim = anim::FIRE_BOW; else if (has(anim::ATTACK_BOW)) shootAnim = anim::ATTACK_BOW; @@ -692,7 +383,7 @@ uint32_t AnimationController::resolveMeleeAnimId() { } // When dual-wielding, bypass cache to alternate main/off-hand animations - if (!equippedHasOffHand_ && meleeAnimId_ != 0 && characterRenderer->hasAnimation(characterInstanceId, meleeAnimId_)) { + if (!weaponLoadout_.hasOffHand && meleeAnimId_ != 0 && characterRenderer->hasAnimation(characterInstanceId, meleeAnimId_)) { return meleeAnimId_; } @@ -727,37 +418,37 @@ uint32_t AnimationController::resolveMeleeAnimId() { static const uint32_t candidatesOffHandUnarmed[] = {anim::ATTACK_UNARMED_OFF, anim::ATTACK_UNARMED, anim::ATTACK_OFF, anim::ATTACK_1H}; // Dual-wield: alternate main-hand and off-hand swings - bool useOffHand = equippedHasOffHand_ && meleeOffHandTurn_; - meleeOffHandTurn_ = equippedHasOffHand_ ? !meleeOffHandTurn_ : false; + bool useOffHand = weaponLoadout_.hasOffHand && meleeOffHandTurn_; + meleeOffHandTurn_ = weaponLoadout_.hasOffHand ? !meleeOffHandTurn_ : false; if (useOffHand) { - if (equippedIsFist_) { + if (weaponLoadout_.isFist) { attackCandidates = candidatesOffHandFist; candidateCount = 4; - } else if (equippedIsDagger_) { + } else if (weaponLoadout_.isDagger) { attackCandidates = candidatesOffHandPierce; candidateCount = 4; - } else if (equippedWeaponInvType_ == game::InvType::NON_EQUIP) { + } else if (weaponLoadout_.inventoryType == game::InvType::NON_EQUIP) { attackCandidates = candidatesOffHandUnarmed; candidateCount = 4; } else { attackCandidates = candidatesOffHand; candidateCount = 3; } - } else if (equippedIsFist_) { + } else if (weaponLoadout_.isFist) { attackCandidates = candidatesFist; candidateCount = 6; - } else if (equippedIsDagger_) { + } else if (weaponLoadout_.isDagger) { attackCandidates = candidatesDagger; candidateCount = 3; - } else if (equippedIs2HLoose_) { + } else if (weaponLoadout_.is2HLoose) { // Polearm thrust uses pierce variant attackCandidates = candidates2HLoosePierce; candidateCount = 5; - } else if (equippedWeaponInvType_ == game::InvType::TWO_HAND) { + } else if (weaponLoadout_.inventoryType == game::InvType::TWO_HAND) { attackCandidates = candidates2H; candidateCount = 6; - } else if (equippedWeaponInvType_ == game::InvType::NON_EQUIP) { + } else if (weaponLoadout_.inventoryType == game::InvType::NON_EQUIP) { attackCandidates = candidatesUnarmed; candidateCount = 6; } else { @@ -878,9 +569,8 @@ void AnimationController::setMounted(uint32_t mountInstId, uint32_t mountDisplay mountSeatAttachmentId_ = -1; smoothedMountSeatPos_ = renderer_->getCharacterPosition(); mountSeatSmoothingInit_ = false; - mountAction_ = MountAction::None; - mountActionPhase_ = 0; - charAnimState_ = CharAnimState::MOUNT; + mountPitch_ = 0.0f; + if (cameraController) { cameraController->setMounted(true); cameraController->setMountHeightOffset(heightOffset); @@ -1011,23 +701,24 @@ void AnimationController::setMounted(uint32_t mountInstId, uint32_t mountDisplay auto [discoveredStart, discoveredLoop, discoveredEnd] = discoverJumpSet(); - mountAnims_.jumpStart = discoveredStart > 0 ? discoveredStart : findFirst({anim::FALL, anim::JUMP_START}); - mountAnims_.jumpLoop = discoveredLoop > 0 ? discoveredLoop : findFirst({anim::JUMP}); - mountAnims_.jumpEnd = discoveredEnd > 0 ? discoveredEnd : findFirst({anim::JUMP_END}); - mountAnims_.rearUp = findFirst({anim::MOUNT_SPECIAL, anim::RUN_RIGHT, anim::FALL}); - mountAnims_.run = findFirst({anim::RUN, anim::WALK}); - mountAnims_.stand = findFirst({anim::STAND}); + // Build MountAnimSet for MountFSM + MountFSM::MountAnimSet mountAnims; + mountAnims.jumpStart = discoveredStart > 0 ? discoveredStart : findFirst({anim::FALL, anim::JUMP_START}); + mountAnims.jumpLoop = discoveredLoop > 0 ? discoveredLoop : findFirst({anim::JUMP}); + mountAnims.jumpEnd = discoveredEnd > 0 ? discoveredEnd : findFirst({anim::JUMP_END}); + mountAnims.rearUp = findFirst({anim::MOUNT_SPECIAL, anim::RUN_RIGHT, anim::FALL}); + mountAnims.run = findFirst({anim::RUN, anim::WALK}); + mountAnims.stand = findFirst({anim::STAND}); // Discover flight animations (flying mounts only — may all be 0 for ground mounts) - mountAnims_.flyIdle = findFirst({anim::FLY_IDLE}); - mountAnims_.flyForward = findFirst({anim::FLY_FORWARD, anim::FLY_RUN_2}); - mountAnims_.flyBackwards = findFirst({anim::FLY_BACKWARDS, anim::FLY_WALK_BACKWARDS}); - mountAnims_.flyLeft = findFirst({anim::FLY_LEFT, anim::FLY_SHUFFLE_LEFT}); - mountAnims_.flyRight = findFirst({anim::FLY_RIGHT, anim::FLY_SHUFFLE_RIGHT}); - mountAnims_.flyUp = findFirst({anim::FLY_UP, anim::FLY_RISE}); - mountAnims_.flyDown = findFirst({anim::FLY_DOWN}); + mountAnims.flyIdle = findFirst({anim::FLY_IDLE}); + mountAnims.flyForward = findFirst({anim::FLY_FORWARD, anim::FLY_RUN_2}); + mountAnims.flyBackwards = findFirst({anim::FLY_BACKWARDS, anim::FLY_WALK_BACKWARDS}); + mountAnims.flyLeft = findFirst({anim::FLY_LEFT, anim::FLY_SHUFFLE_LEFT}); + mountAnims.flyRight = findFirst({anim::FLY_RIGHT, anim::FLY_SHUFFLE_RIGHT}); + mountAnims.flyUp = findFirst({anim::FLY_UP, anim::FLY_RISE}); + mountAnims.flyDown = findFirst({anim::FLY_DOWN}); // Discover idle fidget animations using proper WoW M2 metadata - mountAnims_.fidgets.clear(); core::Logger::getInstance().debug("Scanning for fidget animations in ", sequences.size(), " sequences"); core::Logger::getInstance().debug("=== ALL potential fidgets (no metadata filter) ==="); @@ -1068,25 +759,28 @@ void AnimationController::setMounted(uint32_t mountInstId, uint32_t mountDisplay if (!isLoop && (hasFrequency || hasReplay) && isStationary && reasonableDuration && !isDeathOrWound && !isAttackOrCombat && !isSpecial) { - bool chainsToStand = (seq.nextAnimation == static_cast(mountAnims_.stand)) || - (seq.aliasNext == mountAnims_.stand) || + bool chainsToStand = (seq.nextAnimation == static_cast(mountAnims.stand)) || + (seq.aliasNext == mountAnims.stand) || (seq.nextAnimation == -1); - mountAnims_.fidgets.push_back(seq.id); + mountAnims.fidgets.push_back(seq.id); core::Logger::getInstance().debug(" >> Selected fidget: id=", seq.id, (chainsToStand ? " (chains to stand)" : "")); } } - if (mountAnims_.run == 0) mountAnims_.run = mountAnims_.stand; + if (mountAnims.run == 0) mountAnims.run = mountAnims.stand; - core::Logger::getInstance().debug("Mount animation set: jumpStart=", mountAnims_.jumpStart, - " jumpLoop=", mountAnims_.jumpLoop, - " jumpEnd=", mountAnims_.jumpEnd, - " rearUp=", mountAnims_.rearUp, - " run=", mountAnims_.run, - " stand=", mountAnims_.stand, - " fidgets=", mountAnims_.fidgets.size()); + core::Logger::getInstance().debug("Mount animation set: jumpStart=", mountAnims.jumpStart, + " jumpLoop=", mountAnims.jumpLoop, + " jumpEnd=", mountAnims.jumpEnd, + " rearUp=", mountAnims.rearUp, + " run=", mountAnims.run, + " stand=", mountAnims.stand, + " fidgets=", mountAnims.fidgets.size()); + + // Configure MountFSM via CharacterAnimator + characterAnimator_.configureMountFSM(mountAnims, taxiFlight_); if (renderer_->getAudioCoordinator()->getMountSoundManager()) { bool isFlying = taxiFlight_; @@ -1102,9 +796,10 @@ void AnimationController::clearMount() { mountSeatAttachmentId_ = -1; smoothedMountSeatPos_ = glm::vec3(0.0f); mountSeatSmoothingInit_ = false; - mountAction_ = MountAction::None; - mountActionPhase_ = 0; - charAnimState_ = CharAnimState::IDLE; + + // Clear MountFSM via CharacterAnimator + characterAnimator_.clearMountFSM(); + if (auto* cameraController = renderer_->getCameraController()) { cameraController->setMounted(false); cameraController->setMountHeightOffset(0.0f); @@ -1118,7 +813,8 @@ void AnimationController::clearMount() { // ── Query helpers ──────────────────────────────────────────────────────────── bool AnimationController::isFootstepAnimationState() const { - return charAnimState_ == CharAnimState::WALK || charAnimState_ == CharAnimState::RUN; + auto state = characterAnimator_.getLocomotion().getState(); + return state == LocomotionFSM::State::WALK || state == LocomotionFSM::State::RUN; } // ── Melee timers ───────────────────────────────────────────────────────────── @@ -1137,1099 +833,292 @@ void AnimationController::updateMeleeTimers(float deltaTime) { } } -// ── Character animation state machine ──────────────────────────────────────── +// ── Mount positioning helper ───────────────────────────────────────────────── + +void AnimationController::applyMountPositioning(float mountBob, float mountRoll, float characterYaw) { + auto* characterRenderer = renderer_->getCharacterRenderer(); + uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); + if (!characterRenderer || characterInstanceId == 0) return; + + float mountYawRad = glm::radians(characterYaw); + + // Position mount model + if (mountInstanceId_ > 0) { + const glm::vec3& characterPosition = renderer_->getCharacterPosition(); + characterRenderer->setInstancePosition(mountInstanceId_, characterPosition); + characterRenderer->setInstanceRotation(mountInstanceId_, glm::vec3(mountPitch_, mountRoll, mountYawRad)); + } + + // Use mount's attachment point for proper bone-driven rider positioning. + if (taxiFlight_) { + glm::mat4 mountSeatTransform(1.0f); + bool haveSeat = false; + static constexpr uint32_t kTaxiSeatAttachmentId = 0; + if (mountSeatAttachmentId_ == -1) { + mountSeatAttachmentId_ = static_cast(kTaxiSeatAttachmentId); + } + if (mountSeatAttachmentId_ >= 0) { + haveSeat = characterRenderer->getAttachmentTransform( + mountInstanceId_, static_cast(mountSeatAttachmentId_), mountSeatTransform); + } + if (!haveSeat) { + mountSeatAttachmentId_ = -2; + } + + if (haveSeat) { + glm::vec3 targetRiderPos = glm::vec3(mountSeatTransform[3]) + glm::vec3(0.0f, 0.0f, 0.02f); + mountSeatSmoothingInit_ = false; + smoothedMountSeatPos_ = targetRiderPos; + characterRenderer->setInstancePosition(characterInstanceId, targetRiderPos); + } else { + mountSeatSmoothingInit_ = false; + const glm::vec3& characterPosition = renderer_->getCharacterPosition(); + glm::vec3 playerPos = characterPosition + glm::vec3(0.0f, 0.0f, mountHeightOffset_ + 0.10f); + characterRenderer->setInstancePosition(characterInstanceId, playerPos); + } + + float riderPitch = mountPitch_ * 0.35f; + float riderRoll = mountRoll * 0.35f; + characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, mountYawRad)); + return; + } + + // Ground mounts: try a seat attachment first. + const glm::vec3& characterPosition = renderer_->getCharacterPosition(); + bool moving = renderer_->getCameraController() && renderer_->getCameraController()->isMoving(); + + glm::mat4 mountSeatTransform; + bool haveSeat = false; + if (mountSeatAttachmentId_ >= 0) { + haveSeat = characterRenderer->getAttachmentTransform( + mountInstanceId_, static_cast(mountSeatAttachmentId_), mountSeatTransform); + } else if (mountSeatAttachmentId_ == -1) { + static constexpr uint32_t kSeatAttachments[] = {0, 5, 6, 7, 8}; + for (uint32_t attId : kSeatAttachments) { + if (characterRenderer->getAttachmentTransform(mountInstanceId_, attId, mountSeatTransform)) { + mountSeatAttachmentId_ = static_cast(attId); + haveSeat = true; + break; + } + } + if (!haveSeat) { + mountSeatAttachmentId_ = -2; + } + } + + if (haveSeat) { + glm::vec3 mountSeatPos = glm::vec3(mountSeatTransform[3]); + glm::vec3 seatOffset = glm::vec3(0.0f, 0.0f, taxiFlight_ ? 0.04f : 0.08f); + glm::vec3 targetRiderPos = mountSeatPos + seatOffset; + if (moving) { + mountSeatSmoothingInit_ = false; + smoothedMountSeatPos_ = targetRiderPos; + } else if (!mountSeatSmoothingInit_) { + smoothedMountSeatPos_ = targetRiderPos; + mountSeatSmoothingInit_ = true; + } else { + float smoothHz = taxiFlight_ ? 10.0f : 14.0f; + float alpha = 1.0f - std::exp(-smoothHz * std::max(lastDeltaTime_, 0.001f)); + smoothedMountSeatPos_ = glm::mix(smoothedMountSeatPos_, targetRiderPos, alpha); + } + + characterRenderer->setInstancePosition(characterInstanceId, smoothedMountSeatPos_); + + float yawRad = glm::radians(characterYaw); + float riderPitch = mountPitch_ * 0.35f; + float riderRoll = mountRoll * 0.35f; + characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, yawRad)); + } else { + mountSeatSmoothingInit_ = false; + float yawRad = glm::radians(characterYaw); + glm::mat4 mountRotation = glm::mat4(1.0f); + mountRotation = glm::rotate(mountRotation, yawRad, glm::vec3(0.0f, 0.0f, 1.0f)); + mountRotation = glm::rotate(mountRotation, mountRoll, glm::vec3(1.0f, 0.0f, 0.0f)); + mountRotation = glm::rotate(mountRotation, mountPitch_, glm::vec3(0.0f, 1.0f, 0.0f)); + glm::vec3 localOffset(0.0f, 0.0f, mountHeightOffset_ + mountBob); + glm::vec3 worldOffset = glm::vec3(mountRotation * glm::vec4(localOffset, 0.0f)); + glm::vec3 playerPos = characterPosition + worldOffset; + characterRenderer->setInstancePosition(characterInstanceId, playerPos); + characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(mountPitch_, mountRoll, yawRad)); + } +} + +// ── Mounted animation update (uses MountFSM) ──────────────────────────────── + +void AnimationController::updateMountedAnimation(float deltaTime) { + auto* characterRenderer = renderer_->getCharacterRenderer(); + auto* cameraController = renderer_->getCameraController(); + uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); + float characterYaw = renderer_->getCharacterYaw(); + auto& mountFSM = characterAnimator_.getMountFSM(); + + // Build MountFSM input + MountFSM::Input mountIn; + mountIn.moving = cameraController->isMoving(); + mountIn.movingBackward = cameraController->isMovingBackward(); + mountIn.strafeLeft = cameraController->isStrafingLeft(); + mountIn.strafeRight = cameraController->isStrafingRight(); + mountIn.grounded = cameraController->isGrounded(); + mountIn.jumpKeyPressed = cameraController->isJumpKeyPressed(); + mountIn.flying = cameraController->isFlyingActive(); + mountIn.swimming = cameraController->isSwimming(); + mountIn.ascending = cameraController->isAscending(); + mountIn.descending = cameraController->isDescending(); + mountIn.taxiFlight = taxiFlight_; + mountIn.deltaTime = deltaTime; + mountIn.characterYaw = characterYaw; + // Mount animation state query + if (mountInstanceId_ > 0 && characterRenderer) { + mountIn.haveMountState = characterRenderer->getAnimationState( + mountInstanceId_, mountIn.curMountAnim, mountIn.curMountTime, mountIn.curMountDuration); + } + + // Evaluate MountFSM + auto mountOut = mountFSM.evaluate(mountIn); + + // Apply mount animation if changed + if (mountOut.mountAnimChanged && mountInstanceId_ > 0 && characterRenderer) { + characterRenderer->playAnimation(mountInstanceId_, mountOut.mountAnimId, mountOut.mountAnimLoop); + } + + // Rider animation — defaults to MOUNT, but uses MOUNT_FLIGHT_* variants when flying + uint32_t riderAnim = anim::MOUNT; + if (cameraController->isFlyingActive()) { + auto hasRider = [&](uint32_t id) { return characterRenderer->hasAnimation(characterInstanceId, id); }; + if (mountIn.moving) { + if (cameraController->isAscending() && hasRider(anim::MOUNT_FLIGHT_UP)) + riderAnim = anim::MOUNT_FLIGHT_UP; + else if (cameraController->isDescending() && hasRider(anim::MOUNT_FLIGHT_DOWN)) + riderAnim = anim::MOUNT_FLIGHT_DOWN; + else if (hasRider(anim::MOUNT_FLIGHT_FORWARD)) + riderAnim = anim::MOUNT_FLIGHT_FORWARD; + } else { + if (hasRider(anim::MOUNT_FLIGHT_IDLE)) + riderAnim = anim::MOUNT_FLIGHT_IDLE; + } + } + + // Apply rider animation + uint32_t currentAnimId = 0; + float currentAnimTimeMs = 0.0f, currentAnimDurationMs = 0.0f; + bool haveState = characterRenderer->getAnimationState(characterInstanceId, currentAnimId, currentAnimTimeMs, currentAnimDurationMs); + if (!haveState || currentAnimId != riderAnim) { + characterRenderer->playAnimation(characterInstanceId, riderAnim, true); + lastPlayerAnimRequest_ = riderAnim; + lastPlayerAnimLoopRequest_ = true; + } + + // Handle mount sounds + auto* mountSoundMgr = renderer_->getAudioCoordinator()->getMountSoundManager(); + if (mountOut.playJumpSound && mountSoundMgr) { + mountSoundMgr->playJumpSound(); + } + if (mountOut.playLandSound && mountSoundMgr) { + mountSoundMgr->playLandSound(); + } + if (mountOut.playRearUpSound && mountSoundMgr) { + mountSoundMgr->playRearUpSound(); + } + if (mountOut.playIdleSound && mountSoundMgr) { + mountSoundMgr->playIdleSound(); + } + if (mountOut.triggerMountJump && cameraController) { + cameraController->triggerMountJump(); + } + + // Apply positioning (uses mountBob and mountRoll from MountFSM) + // For taxi flights, use external mountRoll_ set by setMountPitchRoll + // For ground mounts, use MountFSM's computed lean roll + float finalRoll = taxiFlight_ ? mountRoll_ : mountOut.mountRoll; + applyMountPositioning(mountOut.mountBob, finalRoll, characterYaw); +} + +// ── Character animation state machine (delegates to CharacterAnimator) ────────── void AnimationController::updateCharacterAnimation() { auto* characterRenderer = renderer_->getCharacterRenderer(); auto* cameraController = renderer_->getCameraController(); uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); - - CharAnimState newState = charAnimState_; - - const bool rawMoving = cameraController->isMoving(); - const bool rawSprinting = cameraController->isSprinting(); - constexpr float kLocomotionStopGraceSec = 0.12f; - if (rawMoving) { - locomotionStopGraceTimer_ = kLocomotionStopGraceSec; - locomotionWasSprinting_ = rawSprinting; - } else { - locomotionStopGraceTimer_ = std::max(0.0f, locomotionStopGraceTimer_ - lastDeltaTime_); + // Lazy probe: populate capability set once per model + if (!capabilitiesProbed_ && characterRenderer && characterInstanceId != 0) { + probeCapabilities(); } - bool moving = rawMoving || locomotionStopGraceTimer_ > 0.0f; - bool movingForward = cameraController->isMovingForward(); - bool movingBackward = cameraController->isMovingBackward(); - bool autoRunning = cameraController->isAutoRunning(); - bool strafeLeft = cameraController->isStrafingLeft(); - bool strafeRight = cameraController->isStrafingRight(); - bool pureStrafe = !movingForward && !movingBackward && !autoRunning; - bool anyStrafeLeft = strafeLeft && !strafeRight && pureStrafe; - bool anyStrafeRight = strafeRight && !strafeLeft && pureStrafe; - bool grounded = cameraController->isGrounded(); - bool jumping = cameraController->isJumping(); - bool sprinting = rawSprinting || (!rawMoving && moving && locomotionWasSprinting_); - bool sitting = cameraController->isSitting(); - bool swim = cameraController->isSwimming(); - bool forceMelee = meleeSwingTimer_ > 0.0f && grounded && !swim; - bool forceRanged = rangedShootTimer_ > 0.0f && grounded && !swim; - const glm::vec3& characterPosition = renderer_->getCharacterPosition(); - float characterYaw = renderer_->getCharacterYaw(); - - // When mounted, force MOUNT state and skip normal transitions + // When mounted, delegate to MountFSM and handle positioning if (isMounted()) { - newState = CharAnimState::MOUNT; - charAnimState_ = newState; - - // Rider animation — defaults to MOUNT, but uses MOUNT_FLIGHT_* variants when flying - uint32_t riderAnim = anim::MOUNT; - if (cameraController->isFlyingActive()) { - auto hasRider = [&](uint32_t id) { return characterRenderer->hasAnimation(characterInstanceId, id); }; - if (moving) { - if (cameraController->isAscending() && hasRider(anim::MOUNT_FLIGHT_UP)) - riderAnim = anim::MOUNT_FLIGHT_UP; - else if (cameraController->isDescending() && hasRider(anim::MOUNT_FLIGHT_DOWN)) - riderAnim = anim::MOUNT_FLIGHT_DOWN; - else if (hasRider(anim::MOUNT_FLIGHT_FORWARD)) - riderAnim = anim::MOUNT_FLIGHT_FORWARD; - } else { - if (hasRider(anim::MOUNT_FLIGHT_IDLE)) - riderAnim = anim::MOUNT_FLIGHT_IDLE; - } - } - - uint32_t currentAnimId = 0; - float currentAnimTimeMs = 0.0f, currentAnimDurationMs = 0.0f; - bool haveState = characterRenderer->getAnimationState(characterInstanceId, currentAnimId, currentAnimTimeMs, currentAnimDurationMs); - if (!haveState || currentAnimId != riderAnim) { - characterRenderer->playAnimation(characterInstanceId, riderAnim, true); - } - - float mountBob = 0.0f; - float mountYawRad = glm::radians(characterYaw); - if (mountInstanceId_ > 0) { - characterRenderer->setInstancePosition(mountInstanceId_, characterPosition); - - if (!taxiFlight_ && moving && lastDeltaTime_ > 0.0f) { - float currentYawDeg = characterYaw; - float turnRate = (currentYawDeg - prevMountYaw_) / lastDeltaTime_; - while (turnRate > 180.0f) turnRate -= 360.0f; - while (turnRate < -180.0f) turnRate += 360.0f; - - float targetLean = glm::clamp(turnRate * 0.15f, -0.25f, 0.25f); - mountRoll_ = glm::mix(mountRoll_, targetLean, lastDeltaTime_ * 6.0f); - prevMountYaw_ = currentYawDeg; - } else { - mountRoll_ = glm::mix(mountRoll_, 0.0f, lastDeltaTime_ * 8.0f); - } - - characterRenderer->setInstanceRotation(mountInstanceId_, glm::vec3(mountPitch_, mountRoll_, mountYawRad)); - - auto pickMountAnim = [&](std::initializer_list candidates, uint32_t fallback) -> uint32_t { - for (uint32_t id : candidates) { - if (characterRenderer->hasAnimation(mountInstanceId_, id)) { - return id; - } - } - return fallback; - }; - - uint32_t mountAnimId = anim::STAND; - - uint32_t curMountAnim = 0; - float curMountTime = 0, curMountDur = 0; - bool haveMountState = characterRenderer->getAnimationState(mountInstanceId_, curMountAnim, curMountTime, curMountDur); - - if (taxiFlight_) { - if (!taxiAnimsLogged_) { - taxiAnimsLogged_ = true; - LOG_INFO("Taxi flight active: mountInstanceId_=", mountInstanceId_, - " curMountAnim=", curMountAnim, " haveMountState=", haveMountState); - std::vector seqs; - if (characterRenderer->getAnimationSequences(mountInstanceId_, seqs)) { - std::string animList; - for (const auto& s : seqs) { - if (!animList.empty()) animList += ", "; - animList += std::to_string(s.id); - } - LOG_INFO("Taxi mount available animations: [", animList, "]"); - } - } - - uint32_t flyAnims[] = {anim::FLY_FORWARD, anim::FLY_IDLE, anim::FLY_RUN_2, anim::FLY_SPELL, anim::FLY_RISE, anim::SPELL_KNEEL_LOOP, anim::FLY_CUSTOM_SPELL_10, anim::DEAD, anim::RUN}; - mountAnimId = anim::STAND; - for (uint32_t fa : flyAnims) { - if (characterRenderer->hasAnimation(mountInstanceId_, fa)) { - mountAnimId = fa; - break; - } - } - - if (!haveMountState || curMountAnim != mountAnimId) { - LOG_INFO("Taxi mount: playing animation ", mountAnimId); - characterRenderer->playAnimation(mountInstanceId_, mountAnimId, true); - } - - goto taxi_mount_done; - } else { - taxiAnimsLogged_ = false; - } - - // Check for jump trigger - if (cameraController->isJumpKeyPressed() && grounded && mountAction_ == MountAction::None) { - if (moving && mountAnims_.jumpLoop > 0) { - LOG_DEBUG("Mount jump triggered while moving: using jumpLoop anim ", mountAnims_.jumpLoop); - characterRenderer->playAnimation(mountInstanceId_, mountAnims_.jumpLoop, true); - mountAction_ = MountAction::Jump; - mountActionPhase_ = 1; - mountAnimId = mountAnims_.jumpLoop; - if (renderer_->getAudioCoordinator()->getMountSoundManager()) { - renderer_->getAudioCoordinator()->getMountSoundManager()->playJumpSound(); - } - if (cameraController) { - cameraController->triggerMountJump(); - } - } else if (!moving && mountAnims_.rearUp > 0) { - LOG_DEBUG("Mount rear-up triggered: playing rearUp anim ", mountAnims_.rearUp); - characterRenderer->playAnimation(mountInstanceId_, mountAnims_.rearUp, false); - mountAction_ = MountAction::RearUp; - mountActionPhase_ = 0; - mountAnimId = mountAnims_.rearUp; - if (renderer_->getAudioCoordinator()->getMountSoundManager()) { - renderer_->getAudioCoordinator()->getMountSoundManager()->playRearUpSound(); - } - } - } - - // Handle active mount actions (jump chaining or rear-up) - if (mountAction_ != MountAction::None) { - bool animFinished = haveMountState && curMountDur > 0.1f && - (curMountTime >= curMountDur - 0.05f); - - if (mountAction_ == MountAction::Jump) { - if (mountActionPhase_ == 0 && animFinished && mountAnims_.jumpLoop > 0) { - LOG_DEBUG("Mount jump: phase 0→1 (JumpStart→JumpLoop anim ", mountAnims_.jumpLoop, ")"); - characterRenderer->playAnimation(mountInstanceId_, mountAnims_.jumpLoop, true); - mountActionPhase_ = 1; - mountAnimId = mountAnims_.jumpLoop; - } else if (mountActionPhase_ == 0 && animFinished && mountAnims_.jumpLoop == 0) { - LOG_DEBUG("Mount jump: phase 0→1 (no JumpLoop, holding JumpStart)"); - mountActionPhase_ = 1; - } else if (mountActionPhase_ == 1 && grounded && mountAnims_.jumpEnd > 0) { - LOG_DEBUG("Mount jump: phase 1→2 (landed, JumpEnd anim ", mountAnims_.jumpEnd, ")"); - characterRenderer->playAnimation(mountInstanceId_, mountAnims_.jumpEnd, false); - mountActionPhase_ = 2; - mountAnimId = mountAnims_.jumpEnd; - if (renderer_->getAudioCoordinator()->getMountSoundManager()) { - renderer_->getAudioCoordinator()->getMountSoundManager()->playLandSound(); - } - } else if (mountActionPhase_ == 1 && grounded && mountAnims_.jumpEnd == 0) { - LOG_DEBUG("Mount jump: phase 1→done (landed, no JumpEnd, returning to ", - moving ? "run" : "stand", " anim ", (moving ? mountAnims_.run : mountAnims_.stand), ")"); - mountAction_ = MountAction::None; - mountAnimId = moving ? mountAnims_.run : mountAnims_.stand; - characterRenderer->playAnimation(mountInstanceId_, mountAnimId, true); - } else if (mountActionPhase_ == 2 && animFinished) { - LOG_DEBUG("Mount jump: phase 2→done (JumpEnd finished, returning to ", - moving ? "run" : "stand", " anim ", (moving ? mountAnims_.run : mountAnims_.stand), ")"); - mountAction_ = MountAction::None; - mountAnimId = moving ? mountAnims_.run : mountAnims_.stand; - characterRenderer->playAnimation(mountInstanceId_, mountAnimId, true); - } else { - mountAnimId = curMountAnim; - } - } else if (mountAction_ == MountAction::RearUp) { - if (animFinished) { - LOG_DEBUG("Mount rear-up: finished, returning to ", - moving ? "run" : "stand", " anim ", (moving ? mountAnims_.run : mountAnims_.stand)); - mountAction_ = MountAction::None; - mountAnimId = moving ? mountAnims_.run : mountAnims_.stand; - characterRenderer->playAnimation(mountInstanceId_, mountAnimId, true); - } else { - mountAnimId = curMountAnim; - } - } - } else if (moving) { - const bool flying = cameraController->isFlyingActive(); - const bool mountSwim = cameraController->isSwimming(); - if (flying) { - // Directional flying animations for mount - if (cameraController->isAscending()) { - mountAnimId = pickMountAnim({anim::FLY_UP, anim::FLY_FORWARD}, anim::RUN); - } else if (cameraController->isDescending()) { - mountAnimId = pickMountAnim({anim::FLY_DOWN, anim::FLY_FORWARD}, anim::RUN); - } else if (anyStrafeLeft) { - mountAnimId = pickMountAnim({anim::FLY_LEFT, anim::FLY_SHUFFLE_LEFT, anim::FLY_FORWARD}, anim::RUN); - } else if (anyStrafeRight) { - mountAnimId = pickMountAnim({anim::FLY_RIGHT, anim::FLY_SHUFFLE_RIGHT, anim::FLY_FORWARD}, anim::RUN); - } else if (movingBackward) { - mountAnimId = pickMountAnim({anim::FLY_BACKWARDS, anim::FLY_WALK_BACKWARDS, anim::FLY_FORWARD}, anim::RUN); - } else { - mountAnimId = pickMountAnim({anim::FLY_FORWARD, anim::FLY_IDLE}, anim::RUN); - } - } else if (mountSwim) { - // Mounted swimming animations - if (anyStrafeLeft) { - mountAnimId = pickMountAnim({anim::MOUNT_SWIM_LEFT, anim::SWIM_LEFT, anim::MOUNT_SWIM}, anim::RUN); - } else if (anyStrafeRight) { - mountAnimId = pickMountAnim({anim::MOUNT_SWIM_RIGHT, anim::SWIM_RIGHT, anim::MOUNT_SWIM}, anim::RUN); - } else if (movingBackward) { - mountAnimId = pickMountAnim({anim::MOUNT_SWIM_BACKWARDS, anim::SWIM_BACKWARDS, anim::MOUNT_SWIM}, anim::RUN); - } else { - mountAnimId = pickMountAnim({anim::MOUNT_SWIM, anim::SWIM}, anim::RUN); - } - } else if (anyStrafeLeft) { - mountAnimId = pickMountAnim({anim::MOUNT_RUN_LEFT, anim::RUN_LEFT, anim::SHUFFLE_LEFT, anim::RUN}, anim::RUN); - } else if (anyStrafeRight) { - mountAnimId = pickMountAnim({anim::MOUNT_RUN_RIGHT, anim::RUN_RIGHT, anim::SHUFFLE_RIGHT, anim::RUN}, anim::RUN); - } else if (movingBackward) { - mountAnimId = pickMountAnim({anim::MOUNT_WALK_BACKWARDS, anim::WALK_BACKWARDS}, anim::RUN); - } else { - mountAnimId = anim::RUN; - } - } else if (!moving && cameraController->isSwimming()) { - // Mounted swim idle - mountAnimId = pickMountAnim({anim::MOUNT_SWIM_IDLE, anim::SWIM_IDLE}, anim::STAND); - } else if (!moving && cameraController->isFlyingActive()) { - // Hovering in flight — use FLY_IDLE instead of STAND - if (cameraController->isAscending()) { - mountAnimId = pickMountAnim({anim::FLY_UP, anim::FLY_IDLE}, anim::STAND); - } else if (cameraController->isDescending()) { - mountAnimId = pickMountAnim({anim::FLY_DOWN, anim::FLY_IDLE}, anim::STAND); - } else { - mountAnimId = pickMountAnim({anim::FLY_IDLE, anim::FLY_FORWARD}, anim::STAND); - } - } - - // Cancel active fidget immediately if movement starts - if (moving && mountActiveFidget_ != 0) { - mountActiveFidget_ = 0; - characterRenderer->playAnimation(mountInstanceId_, mountAnimId, true); - } - - // Check if active fidget has completed - if (!moving && mountActiveFidget_ != 0) { - uint32_t curAnim = 0; - float curTime = 0.0f, curDur = 0.0f; - if (characterRenderer->getAnimationState(mountInstanceId_, curAnim, curTime, curDur)) { - if (curAnim != mountActiveFidget_ || curTime >= curDur * 0.95f) { - mountActiveFidget_ = 0; - LOG_DEBUG("Mount fidget completed"); - } - } - } - - // Idle fidgets - if (!moving && mountAction_ == MountAction::None && mountActiveFidget_ == 0 && !mountAnims_.fidgets.empty()) { - mountIdleFidgetTimer_ += lastDeltaTime_; - static std::mt19937 idleRng(std::random_device{}()); - static float nextFidgetTime = std::uniform_real_distribution(6.0f, 12.0f)(idleRng); - - if (mountIdleFidgetTimer_ >= nextFidgetTime) { - std::uniform_int_distribution dist(0, mountAnims_.fidgets.size() - 1); - uint32_t fidgetAnim = mountAnims_.fidgets[dist(idleRng)]; - - characterRenderer->playAnimation(mountInstanceId_, fidgetAnim, false); - mountActiveFidget_ = fidgetAnim; - mountIdleFidgetTimer_ = 0.0f; - nextFidgetTime = std::uniform_real_distribution(6.0f, 12.0f)(idleRng); - - LOG_DEBUG("Mount idle fidget: playing anim ", fidgetAnim); - } - } - if (moving) { - mountIdleFidgetTimer_ = 0.0f; - } - - // Idle ambient sounds - if (!moving && renderer_->getAudioCoordinator()->getMountSoundManager()) { - mountIdleSoundTimer_ += lastDeltaTime_; - static std::mt19937 soundRng(std::random_device{}()); - static float nextIdleSoundTime = std::uniform_real_distribution(45.0f, 90.0f)(soundRng); - - if (mountIdleSoundTimer_ >= nextIdleSoundTime) { - renderer_->getAudioCoordinator()->getMountSoundManager()->playIdleSound(); - mountIdleSoundTimer_ = 0.0f; - nextIdleSoundTime = std::uniform_real_distribution(45.0f, 90.0f)(soundRng); - } - } else if (moving) { - mountIdleSoundTimer_ = 0.0f; - } - - // Only update animation if changed and not in action or fidget - if (mountAction_ == MountAction::None && mountActiveFidget_ == 0 && (!haveMountState || curMountAnim != mountAnimId)) { - bool loop = true; - characterRenderer->playAnimation(mountInstanceId_, mountAnimId, loop); - } - - taxi_mount_done: - mountBob = 0.0f; - if (moving && haveMountState && curMountDur > 1.0f) { - float wrappedTime = curMountTime; - while (wrappedTime >= curMountDur) { - wrappedTime -= curMountDur; - } - float norm = wrappedTime / curMountDur; - float bobSpeed = taxiFlight_ ? 2.0f : 1.0f; - mountBob = std::sin(norm * 2.0f * 3.14159f * bobSpeed) * 0.12f; - } - } - - // Use mount's attachment point for proper bone-driven rider positioning. - if (taxiFlight_) { - glm::mat4 mountSeatTransform(1.0f); - bool haveSeat = false; - static constexpr uint32_t kTaxiSeatAttachmentId = 0; - if (mountSeatAttachmentId_ == -1) { - mountSeatAttachmentId_ = static_cast(kTaxiSeatAttachmentId); - } - if (mountSeatAttachmentId_ >= 0) { - haveSeat = characterRenderer->getAttachmentTransform( - mountInstanceId_, static_cast(mountSeatAttachmentId_), mountSeatTransform); - } - if (!haveSeat) { - mountSeatAttachmentId_ = -2; - } - - if (haveSeat) { - glm::vec3 targetRiderPos = glm::vec3(mountSeatTransform[3]) + glm::vec3(0.0f, 0.0f, 0.02f); - mountSeatSmoothingInit_ = false; - smoothedMountSeatPos_ = targetRiderPos; - characterRenderer->setInstancePosition(characterInstanceId, targetRiderPos); - } else { - mountSeatSmoothingInit_ = false; - glm::vec3 playerPos = characterPosition + glm::vec3(0.0f, 0.0f, mountHeightOffset_ + 0.10f); - characterRenderer->setInstancePosition(characterInstanceId, playerPos); - } - - float riderPitch = mountPitch_ * 0.35f; - float riderRoll = mountRoll_ * 0.35f; - float mountYawRadVal = glm::radians(characterYaw); - characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, mountYawRadVal)); - return; - } - - // Ground mounts: try a seat attachment first. - glm::mat4 mountSeatTransform; - bool haveSeat = false; - if (mountSeatAttachmentId_ >= 0) { - haveSeat = characterRenderer->getAttachmentTransform( - mountInstanceId_, static_cast(mountSeatAttachmentId_), mountSeatTransform); - } else if (mountSeatAttachmentId_ == -1) { - static constexpr uint32_t kSeatAttachments[] = {0, 5, 6, 7, 8}; - for (uint32_t attId : kSeatAttachments) { - if (characterRenderer->getAttachmentTransform(mountInstanceId_, attId, mountSeatTransform)) { - mountSeatAttachmentId_ = static_cast(attId); - haveSeat = true; - break; - } - } - if (!haveSeat) { - mountSeatAttachmentId_ = -2; - } - } - - if (haveSeat) { - glm::vec3 mountSeatPos = glm::vec3(mountSeatTransform[3]); - glm::vec3 seatOffset = glm::vec3(0.0f, 0.0f, taxiFlight_ ? 0.04f : 0.08f); - glm::vec3 targetRiderPos = mountSeatPos + seatOffset; - if (moving) { - mountSeatSmoothingInit_ = false; - smoothedMountSeatPos_ = targetRiderPos; - } else if (!mountSeatSmoothingInit_) { - smoothedMountSeatPos_ = targetRiderPos; - mountSeatSmoothingInit_ = true; - } else { - float smoothHz = taxiFlight_ ? 10.0f : 14.0f; - float alpha = 1.0f - std::exp(-smoothHz * std::max(lastDeltaTime_, 0.001f)); - smoothedMountSeatPos_ = glm::mix(smoothedMountSeatPos_, targetRiderPos, alpha); - } - - characterRenderer->setInstancePosition(characterInstanceId, smoothedMountSeatPos_); - - float yawRad = glm::radians(characterYaw); - float riderPitch = mountPitch_ * 0.35f; - float riderRoll = mountRoll_ * 0.35f; - characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, yawRad)); - } else { - mountSeatSmoothingInit_ = false; - float yawRad = glm::radians(characterYaw); - glm::mat4 mountRotation = glm::mat4(1.0f); - mountRotation = glm::rotate(mountRotation, yawRad, glm::vec3(0.0f, 0.0f, 1.0f)); - mountRotation = glm::rotate(mountRotation, mountRoll_, glm::vec3(1.0f, 0.0f, 0.0f)); - mountRotation = glm::rotate(mountRotation, mountPitch_, glm::vec3(0.0f, 1.0f, 0.0f)); - glm::vec3 localOffset(0.0f, 0.0f, mountHeightOffset_ + mountBob); - glm::vec3 worldOffset = glm::vec3(mountRotation * glm::vec4(localOffset, 0.0f)); - glm::vec3 playerPos = characterPosition + worldOffset; - characterRenderer->setInstancePosition(characterInstanceId, playerPos); - characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(mountPitch_, mountRoll_, yawRad)); - } + updateMountedAnimation(lastDeltaTime_); return; } - if (!forceMelee && !forceRanged) switch (charAnimState_) { - case CharAnimState::IDLE: - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (sitting && grounded) { - newState = CharAnimState::SIT_DOWN; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (moving && sprinting) { - newState = CharAnimState::RUN; - } else if (moving) { - newState = CharAnimState::WALK; - } else if (inCombat_ && grounded) { - // Play unsheathe one-shot before entering combat idle - if (characterRenderer && characterInstanceId > 0 && - characterRenderer->hasAnimation(characterInstanceId, anim::UNSHEATHE)) { - newState = CharAnimState::UNSHEATHE; - } else { - newState = CharAnimState::COMBAT_IDLE; - } - } - break; - - case CharAnimState::WALK: - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (!moving) { - newState = CharAnimState::IDLE; - } else if (sprinting) { - newState = CharAnimState::RUN; - } - break; - - case CharAnimState::RUN: - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (!moving) { - newState = CharAnimState::IDLE; - } else if (!sprinting) { - newState = CharAnimState::WALK; - } - break; - - case CharAnimState::JUMP_START: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (grounded) { - newState = CharAnimState::JUMP_END; - } else { - newState = CharAnimState::JUMP_MID; - } - break; - - case CharAnimState::JUMP_MID: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (grounded) { - newState = CharAnimState::JUMP_END; - } - break; - - case CharAnimState::JUMP_END: - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (moving && sprinting) { - newState = CharAnimState::RUN; - } else if (moving) { - newState = CharAnimState::WALK; - } else { - newState = CharAnimState::IDLE; - } - break; - - case CharAnimState::SIT_DOWN: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (!sitting) { - // Stand up requested — play exit animation if available and not moving - if (sitUpAnim_ != 0 && !moving) { - newState = CharAnimState::SIT_UP; - } else { - newState = CharAnimState::IDLE; - } - } else if (sitDownAnim_ != 0 && characterRenderer && characterInstanceId > 0) { - // Auto-chain: when sit-down one-shot finishes → enter loop - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - // Renderer auto-returns one-shots to STAND — detect that OR normal completion - if (curId != sitDownAnim_ || (curDur > 0.1f && curT >= curDur - 0.05f)) { - newState = CharAnimState::SITTING; - } - } - } - break; - - case CharAnimState::SITTING: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (!sitting) { - if (sitUpAnim_ != 0 && !moving) { - newState = CharAnimState::SIT_UP; - } else { - newState = CharAnimState::IDLE; - } - } - break; - - case CharAnimState::SIT_UP: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (moving) { - // Movement cancels exit animation - newState = sprinting ? CharAnimState::RUN : CharAnimState::WALK; - } else if (characterRenderer && characterInstanceId > 0) { - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - // Renderer auto-returns one-shots to STAND — detect that OR normal completion - if (curId != (sitUpAnim_ ? sitUpAnim_ : anim::SIT_GROUND_UP) - || (curDur > 0.1f && curT >= curDur - 0.05f)) { - newState = CharAnimState::IDLE; - } - } - } - break; - - case CharAnimState::EMOTE: - if (swim) { - cancelEmote(); - newState = CharAnimState::SWIM_IDLE; - } else if (jumping || !grounded) { - cancelEmote(); - newState = CharAnimState::JUMP_START; - } else if (moving) { - cancelEmote(); - newState = sprinting ? CharAnimState::RUN : CharAnimState::WALK; - } else if (sitting) { - cancelEmote(); - newState = CharAnimState::SIT_DOWN; - } else if (!emoteLoop_ && characterRenderer && characterInstanceId > 0) { - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - // Renderer auto-returns one-shots to STAND — detect that OR normal completion - if (curId != emoteAnimId_ || (curDur > 0.1f && curT >= curDur - 0.05f)) { - cancelEmote(); - newState = CharAnimState::IDLE; - } - } - } - break; - - case CharAnimState::LOOTING: - // Cancel loot animation on movement, jump, swim, combat - if (swim) { - stopLooting(); - newState = CharAnimState::SWIM_IDLE; - } else if (jumping || !grounded) { - stopLooting(); - newState = CharAnimState::JUMP_START; - } else if (moving) { - stopLooting(); - newState = sprinting ? CharAnimState::RUN : CharAnimState::WALK; - } - break; - - case CharAnimState::SWIM_IDLE: - if (!swim) { - newState = moving ? CharAnimState::WALK : CharAnimState::IDLE; - } else if (moving) { - newState = CharAnimState::SWIM; - } - break; - - case CharAnimState::SWIM: - if (!swim) { - newState = moving ? CharAnimState::WALK : CharAnimState::IDLE; - } else if (!moving) { - newState = CharAnimState::SWIM_IDLE; - } - break; - - case CharAnimState::MELEE_SWING: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (moving && sprinting) { - newState = CharAnimState::RUN; - } else if (moving) { - newState = CharAnimState::WALK; - } else if (sitting) { - newState = CharAnimState::SIT_DOWN; - } else if (inCombat_) { - newState = CharAnimState::COMBAT_IDLE; - } else { - newState = CharAnimState::IDLE; - } - break; - - case CharAnimState::RANGED_SHOOT: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (moving && sprinting) { - newState = CharAnimState::RUN; - } else if (moving) { - newState = CharAnimState::WALK; - } else if (inCombat_) { - newState = CharAnimState::RANGED_LOAD; - } else { - newState = CharAnimState::IDLE; - } - break; - - case CharAnimState::RANGED_LOAD: - if (swim) { - newState = CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (moving && sprinting) { - newState = CharAnimState::RUN; - } else if (moving) { - newState = CharAnimState::WALK; - } else if (inCombat_) { - newState = CharAnimState::COMBAT_IDLE; - } else { - newState = CharAnimState::IDLE; - } - break; - - case CharAnimState::MOUNT: - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (sitting && grounded) { - newState = CharAnimState::SIT_DOWN; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (moving && sprinting) { - newState = CharAnimState::RUN; - } else if (moving) { - newState = CharAnimState::WALK; - } else { - newState = CharAnimState::IDLE; - } - break; - - case CharAnimState::COMBAT_IDLE: - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - newState = CharAnimState::JUMP_MID; - } else if (moving && sprinting) { - newState = CharAnimState::RUN; - } else if (moving) { - newState = CharAnimState::WALK; - } else if (!inCombat_) { - // Play sheathe one-shot before returning to idle - if (characterRenderer && characterInstanceId > 0 && - characterRenderer->hasAnimation(characterInstanceId, anim::SHEATHE)) { - newState = CharAnimState::SHEATHE; - } else { - newState = CharAnimState::IDLE; - } - } - break; - - case CharAnimState::CHARGE: - break; - - case CharAnimState::UNSHEATHE: - // One-shot weapon draw: when complete → COMBAT_IDLE - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (moving) { - newState = inCombat_ ? (sprinting ? CharAnimState::RUN : CharAnimState::WALK) - : (sprinting ? CharAnimState::RUN : CharAnimState::WALK); - } else if (characterRenderer && characterInstanceId > 0) { - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - if (curId != anim::UNSHEATHE || (curDur > 0.1f && curT >= curDur - 0.05f)) { - newState = CharAnimState::COMBAT_IDLE; - } - } - } - break; - - case CharAnimState::SHEATHE: - // One-shot weapon put-away: when complete → IDLE - if (swim) { - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (moving) { - newState = sprinting ? CharAnimState::RUN : CharAnimState::WALK; - } else if (inCombat_) { - // Re-entered combat during sheathe — go straight to combat idle - newState = CharAnimState::COMBAT_IDLE; - } else if (characterRenderer && characterInstanceId > 0) { - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - if (curId != anim::SHEATHE || (curDur > 0.1f && curT >= curDur - 0.05f)) { - newState = CharAnimState::IDLE; - } - } - } - break; - - case CharAnimState::SPELL_PRECAST: - // One-shot wind-up: auto-advance to SPELL_CASTING when complete - if (swim) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = CharAnimState::JUMP_MID; - } else if (characterRenderer && characterInstanceId > 0) { - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - uint32_t expectedAnim = spellPrecastAnimId_ ? spellPrecastAnimId_ : anim::SPELL_PRECAST; - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - if (curId != expectedAnim || (curDur > 0.1f && curT >= curDur - 0.05f)) { - // Precast finished → advance to casting phase - newState = CharAnimState::SPELL_CASTING; - } - } - } - break; - - case CharAnimState::SPELL_CASTING: - // Spell cast loop holds until interrupted by movement, jump, swim, or stopSpellCast() - if (swim) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = CharAnimState::JUMP_START; - } else if (!grounded) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = CharAnimState::JUMP_MID; - } else if (moving) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = sprinting ? CharAnimState::RUN : CharAnimState::WALK; - } - // Looping cast stays until stopSpellCast() is called externally - break; - - case CharAnimState::SPELL_FINALIZE: { - // One-shot release: play finalize anim completely, then return to idle - if (swim) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (!grounded && jumping) { - spellPrecastAnimId_ = 0; spellCastAnimId_ = 0; spellFinalizeAnimId_ = 0; - newState = CharAnimState::JUMP_START; - } else if (characterRenderer && characterInstanceId > 0) { - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - // Determine which animation we expect to be playing - uint32_t expectedAnim = spellFinalizeAnimId_ ? spellFinalizeAnimId_ - : (spellCastAnimId_ ? spellCastAnimId_ : anim::SPELL); - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - if (curId != expectedAnim || (curDur > 0.1f && curT >= curDur - 0.05f)) { - // Finalization complete → return to idle - spellPrecastAnimId_ = 0; - spellCastAnimId_ = 0; - spellFinalizeAnimId_ = 0; - newState = inCombat_ ? CharAnimState::COMBAT_IDLE : CharAnimState::IDLE; - } - } - } - break; - } - - case CharAnimState::HIT_REACTION: - // One-shot reaction: exit when animation finishes - if (swim) { - hitReactionAnimId_ = 0; - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } else if (moving) { - hitReactionAnimId_ = 0; - newState = sprinting ? CharAnimState::RUN : CharAnimState::WALK; - } else if (characterRenderer && characterInstanceId > 0) { - uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; - uint32_t expectedHitAnim = hitReactionAnimId_ ? hitReactionAnimId_ : anim::COMBAT_WOUND; - if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)) { - // Renderer auto-returns one-shots to STAND — detect that OR normal completion - if (curId != expectedHitAnim || (curDur > 0.1f && curT >= curDur - 0.05f)) { - hitReactionAnimId_ = 0; - newState = inCombat_ ? CharAnimState::COMBAT_IDLE : CharAnimState::IDLE; - } - } - } - break; - - case CharAnimState::STUNNED: - // Stun holds until setStunned(false) is called. - // Only swim can break it (physics override). - if (swim) { - stunned_ = false; - newState = moving ? CharAnimState::SWIM : CharAnimState::SWIM_IDLE; - } - break; + // Build FrameInput for CharacterAnimator from camera/renderer state + CharacterAnimator::FrameInput fi; + fi.moving = cameraController->isMoving(); + fi.sprinting = cameraController->isSprinting(); + fi.movingForward = cameraController->isMovingForward(); + fi.movingBackward = cameraController->isMovingBackward(); + fi.autoRunning = cameraController->isAutoRunning(); + fi.strafeLeft = cameraController->isStrafingLeft(); + fi.strafeRight = cameraController->isStrafingRight(); + fi.grounded = cameraController->isGrounded(); + fi.jumping = cameraController->isJumping(); + fi.swimming = cameraController->isSwimming(); + fi.sitting = cameraController->isSitting(); + fi.flyingActive = cameraController->isFlyingActive(); + fi.ascending = cameraController->isAscending(); + fi.descending = cameraController->isDescending(); + fi.jumpKeyPressed = cameraController->isJumpKeyPressed(); + fi.characterYaw = renderer_->getCharacterYaw(); + // Melee/ranged timers + fi.meleeSwingTimer = meleeSwingTimer_; + fi.rangedShootTimer = rangedShootTimer_; + fi.specialAttackAnimId = specialAttackAnimId_; + fi.rangedAnimId = rangedAnimId_; + // Animation state query for one-shot completion detection + if (characterRenderer && characterInstanceId > 0) { + fi.haveAnimState = characterRenderer->getAnimationState( + characterInstanceId, fi.currentAnimId, fi.currentAnimTime, fi.currentAnimDuration); } - // Stun overrides melee/charge (can't act while stunned) - if (stunned_ && newState != CharAnimState::SWIM && newState != CharAnimState::SWIM_IDLE - && newState != CharAnimState::STUNNED) { - newState = CharAnimState::STUNNED; - } + // Inject FrameInput and resolve animation via CharacterAnimator + characterAnimator_.setFrameInput(fi); + characterAnimator_.update(lastDeltaTime_); - if (forceMelee && !stunned_) { - newState = CharAnimState::MELEE_SWING; - spellPrecastAnimId_ = 0; - spellCastAnimId_ = 0; - spellFinalizeAnimId_ = 0; - hitReactionAnimId_ = 0; - } + // Read the resolved animation output + AnimOutput output = characterAnimator_.getLastOutput(); - if (forceRanged && !stunned_ && !forceMelee) { - newState = CharAnimState::RANGED_SHOOT; - spellPrecastAnimId_ = 0; - spellCastAnimId_ = 0; - spellFinalizeAnimId_ = 0; - hitReactionAnimId_ = 0; - } + // STAY policy: if CharacterAnimator returns invalid, keep current animation + if (!output.valid) return; - if (charging_ && !stunned_) { - newState = CharAnimState::CHARGE; - spellPrecastAnimId_ = 0; - spellCastAnimId_ = 0; - spellFinalizeAnimId_ = 0; - hitReactionAnimId_ = 0; - } - - if (newState != charAnimState_) { - charAnimState_ = newState; - } - - auto pickFirstAvailable = [&](std::initializer_list candidates, uint32_t fallback) -> uint32_t { - for (uint32_t id : candidates) { - if (characterRenderer->hasAnimation(characterInstanceId, id)) { - return id; - } - } - return fallback; - }; - - uint32_t animId = anim::STAND; - bool loop = true; - - switch (charAnimState_) { - case CharAnimState::IDLE: - if (lowHealth_ && characterRenderer->hasAnimation(characterInstanceId, anim::STAND_WOUND)) { - animId = anim::STAND_WOUND; - } else { - animId = anim::STAND; - } - loop = true; - break; - case CharAnimState::WALK: - if (movingBackward) { - animId = pickFirstAvailable({anim::WALK_BACKWARDS}, anim::WALK); - } else if (anyStrafeLeft) { - animId = pickFirstAvailable({anim::SHUFFLE_LEFT, anim::RUN_LEFT}, anim::WALK); - } else if (anyStrafeRight) { - animId = pickFirstAvailable({anim::SHUFFLE_RIGHT, anim::RUN_RIGHT}, anim::WALK); - } else { - animId = pickFirstAvailable({anim::WALK, anim::RUN}, anim::STAND); - } - loop = true; - break; - case CharAnimState::RUN: - if (movingBackward) { - animId = pickFirstAvailable({anim::WALK_BACKWARDS}, anim::WALK); - } else if (anyStrafeLeft) { - animId = pickFirstAvailable({anim::RUN_LEFT}, anim::RUN); - } else if (anyStrafeRight) { - animId = pickFirstAvailable({anim::RUN_RIGHT}, anim::RUN); - } else if (sprintAuraActive_) { - animId = pickFirstAvailable({anim::SPRINT, anim::RUN, anim::WALK}, anim::STAND); - } else { - animId = pickFirstAvailable({anim::RUN, anim::WALK}, anim::STAND); - } - loop = true; - break; - case CharAnimState::JUMP_START: animId = anim::JUMP_START; loop = false; break; - case CharAnimState::JUMP_MID: animId = anim::JUMP; loop = false; break; - case CharAnimState::JUMP_END: animId = anim::JUMP_END; loop = false; break; - case CharAnimState::SIT_DOWN: - animId = sitDownAnim_ ? sitDownAnim_ : anim::SIT_GROUND_DOWN; - loop = false; - break; - case CharAnimState::SITTING: - animId = sitLoopAnim_ ? sitLoopAnim_ : anim::SITTING; - loop = true; - break; - case CharAnimState::SIT_UP: - animId = sitUpAnim_ ? sitUpAnim_ : anim::SIT_GROUND_UP; - loop = false; - break; - case CharAnimState::EMOTE: animId = emoteAnimId_; loop = emoteLoop_; break; - case CharAnimState::LOOTING: animId = anim::LOOT; loop = true; break; - case CharAnimState::SWIM_IDLE: animId = anim::SWIM_IDLE; loop = true; break; - case CharAnimState::SWIM: - if (movingBackward) { - animId = pickFirstAvailable({anim::SWIM_BACKWARDS}, anim::SWIM); - } else if (anyStrafeLeft) { - animId = pickFirstAvailable({anim::SWIM_LEFT}, anim::SWIM); - } else if (anyStrafeRight) { - animId = pickFirstAvailable({anim::SWIM_RIGHT}, anim::SWIM); - } else { - animId = anim::SWIM; - } - loop = true; - break; - case CharAnimState::MELEE_SWING: - if (specialAttackAnimId_ != 0) { - animId = specialAttackAnimId_; - } else { - animId = resolveMeleeAnimId(); - } - if (animId == 0) { - animId = anim::STAND; - } - loop = false; - break; - case CharAnimState::RANGED_SHOOT: - animId = rangedAnimId_ ? rangedAnimId_ : anim::ATTACK_BOW; - loop = false; - break; - case CharAnimState::RANGED_LOAD: - switch (equippedRangedType_) { - case RangedWeaponType::BOW: - animId = pickFirstAvailable({anim::LOAD_BOW}, anim::STAND); break; - case RangedWeaponType::GUN: - animId = pickFirstAvailable({anim::LOAD_RIFLE}, anim::STAND); break; - case RangedWeaponType::CROSSBOW: - animId = pickFirstAvailable({anim::LOAD_BOW}, anim::STAND); break; - default: - animId = anim::STAND; break; - } - loop = false; - break; - case CharAnimState::MOUNT: animId = anim::MOUNT; loop = true; break; - case CharAnimState::COMBAT_IDLE: - // Wounded idle overrides combat stance when HP < 20% - if (lowHealth_ && characterRenderer->hasAnimation(characterInstanceId, anim::STAND_WOUND)) { - animId = anim::STAND_WOUND; - } else if (equippedRangedType_ == RangedWeaponType::BOW) { - animId = pickFirstAvailable( - {anim::READY_BOW, anim::READY_1H, anim::READY_UNARMED}, - anim::STAND); - } else if (equippedRangedType_ == RangedWeaponType::GUN) { - animId = pickFirstAvailable( - {anim::READY_RIFLE, anim::READY_BOW, anim::READY_1H, anim::READY_UNARMED}, - anim::STAND); - } else if (equippedRangedType_ == RangedWeaponType::CROSSBOW) { - animId = pickFirstAvailable( - {anim::READY_CROSSBOW, anim::READY_BOW, anim::READY_1H, anim::READY_UNARMED}, - anim::STAND); - } else if (equippedRangedType_ == RangedWeaponType::THROWN) { - animId = pickFirstAvailable( - {anim::READY_THROWN, anim::READY_1H, anim::READY_UNARMED}, - anim::STAND); - } else if (equippedIs2HLoose_) { - animId = pickFirstAvailable( - {anim::READY_2H_LOOSE, anim::READY_2H, anim::READY_1H, anim::READY_UNARMED}, - anim::STAND); - } else if (equippedWeaponInvType_ == game::InvType::TWO_HAND) { - animId = pickFirstAvailable( - {anim::READY_2H, anim::READY_2H_LOOSE, anim::READY_1H, anim::READY_UNARMED}, - anim::STAND); - } else if (equippedIsFist_) { - animId = pickFirstAvailable( - {anim::READY_FIST_1H, anim::READY_FIST, anim::READY_1H, anim::READY_UNARMED}, - anim::STAND); - } else if (equippedWeaponInvType_ == game::InvType::NON_EQUIP) { - animId = pickFirstAvailable( - {anim::READY_UNARMED, anim::READY_1H, anim::READY_FIST}, - anim::STAND); - } else { - // 1H (inventoryType 13, 21, etc.) - animId = pickFirstAvailable( - {anim::READY_1H, anim::READY_2H, anim::READY_UNARMED}, - anim::STAND); - } - loop = true; - break; - case CharAnimState::CHARGE: - animId = anim::RUN; - loop = true; - break; - case CharAnimState::UNSHEATHE: - animId = anim::UNSHEATHE; - loop = false; - break; - case CharAnimState::SHEATHE: - animId = pickFirstAvailable({anim::SHEATHE, anim::HIP_SHEATHE}, anim::SHEATHE); - loop = false; - break; - case CharAnimState::SPELL_PRECAST: - animId = spellPrecastAnimId_ ? spellPrecastAnimId_ : anim::SPELL_PRECAST; - loop = false; // One-shot wind-up - break; - case CharAnimState::SPELL_CASTING: - animId = spellCastAnimId_ ? spellCastAnimId_ : anim::SPELL; - loop = spellCastLoop_; - break; - case CharAnimState::SPELL_FINALIZE: - // Play finalization anim if set, otherwise let the cast anim finish as one-shot - animId = spellFinalizeAnimId_ ? spellFinalizeAnimId_ - : (spellCastAnimId_ ? spellCastAnimId_ : anim::SPELL); - loop = false; // One-shot release - break; - case CharAnimState::HIT_REACTION: - animId = hitReactionAnimId_ ? hitReactionAnimId_ : anim::COMBAT_WOUND; - loop = false; - break; - case CharAnimState::STUNNED: - animId = anim::STUN; - loop = true; - break; - } - - // Stealth animation substitution: override idle/walk/run with stealth variants - if (stealthed_) { - if (charAnimState_ == CharAnimState::IDLE || charAnimState_ == CharAnimState::COMBAT_IDLE) { - animId = pickFirstAvailable({anim::STEALTH_STAND}, animId); - } else if (charAnimState_ == CharAnimState::WALK) { - animId = pickFirstAvailable({anim::STEALTH_WALK}, animId); - } else if (charAnimState_ == CharAnimState::RUN) { - animId = pickFirstAvailable({anim::STEALTH_RUN, anim::STEALTH_WALK}, animId); - } - } + uint32_t animId = output.animId; + bool loop = output.loop; + // Apply animation to the character renderer uint32_t currentAnimId = 0; float currentAnimTimeMs = 0.0f; float currentAnimDurationMs = 0.0f; bool haveState = characterRenderer->getAnimationState(characterInstanceId, currentAnimId, currentAnimTimeMs, currentAnimDurationMs); const bool requestChanged = (lastPlayerAnimRequest_ != animId) || (lastPlayerAnimLoopRequest_ != loop); - // requestChanged alone is sufficient: covers both anim ID changes AND loop-mode - // changes on the same anim (e.g. spell cast loop → finalization one-shot). - // The currentAnimId check handles engine drift (fallback anim playing instead). - const bool shouldPlay = requestChanged || (haveState && currentAnimId != animId); + // Only re-assert looping animations if the renderer drifted (e.g., external + // playAnimation call). One-shot animations must NOT be re-asserted after the + // renderer auto-resets them to STAND on completion — the FSM detects the ID + // change via oneShotComplete and transitions to the next state in the same frame. + const bool drifted = haveState && currentAnimId != animId && loop; + const bool shouldPlay = requestChanged || drifted; + + // Debug: log animation decisions (only when animation changes or replays) + static uint32_t dbgLastAnim = UINT32_MAX; + if (shouldPlay || animId != dbgLastAnim) { + LOG_DEBUG("[AnimDbg] FSM→", animId, " loop=", loop, + " cur=", currentAnimId, " t=", currentAnimTimeMs, "/", currentAnimDurationMs, + " haveState=", haveState, + " reqChanged=", requestChanged, " drifted=", drifted, " shouldPlay=", shouldPlay, + " lastReq=", lastPlayerAnimRequest_, + " locoState=", static_cast(characterAnimator_.getLocomotion().getState()), + " actState=", static_cast(characterAnimator_.getActivity().getState())); + dbgLastAnim = animId; + } + if (shouldPlay) { characterRenderer->playAnimation(characterInstanceId, animId, loop); lastPlayerAnimRequest_ = animId; @@ -2237,257 +1126,18 @@ void AnimationController::updateCharacterAnimation() { } } -// ── Footstep event detection ───────────────────────────────────────────────── - -bool AnimationController::shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs) { - if (animationDurationMs <= 1.0f) { - footstepNormInitialized_ = false; - return false; - } - - float wrappedTime = animationTimeMs; - while (wrappedTime >= animationDurationMs) { - wrappedTime -= animationDurationMs; - } - if (wrappedTime < 0.0f) wrappedTime += animationDurationMs; - float norm = wrappedTime / animationDurationMs; - - if (animationId != footstepLastAnimationId_) { - footstepLastAnimationId_ = animationId; - footstepLastNormTime_ = norm; - footstepNormInitialized_ = true; - return false; - } - - if (!footstepNormInitialized_) { - footstepNormInitialized_ = true; - footstepLastNormTime_ = norm; - return false; - } - - auto crossed = [&](float eventNorm) { - if (footstepLastNormTime_ <= norm) { - return footstepLastNormTime_ < eventNorm && eventNorm <= norm; - } - return footstepLastNormTime_ < eventNorm || eventNorm <= norm; - }; - - bool trigger = crossed(0.22f) || crossed(0.72f); - footstepLastNormTime_ = norm; - return trigger; -} - -audio::FootstepSurface AnimationController::resolveFootstepSurface() const { - auto* cameraController = renderer_->getCameraController(); - if (!cameraController || !cameraController->isThirdPerson()) { - return audio::FootstepSurface::STONE; - } - - const glm::vec3& p = renderer_->getCharacterPosition(); - - float distSq = glm::dot(p - cachedFootstepPosition_, p - cachedFootstepPosition_); - if (distSq < 2.25f && cachedFootstepUpdateTimer_ < 0.5f) { - return cachedFootstepSurface_; - } - - cachedFootstepPosition_ = p; - cachedFootstepUpdateTimer_ = 0.0f; - - if (cameraController->isSwimming()) { - cachedFootstepSurface_ = audio::FootstepSurface::WATER; - return audio::FootstepSurface::WATER; - } - - auto* waterRenderer = renderer_->getWaterRenderer(); - if (waterRenderer) { - auto waterH = waterRenderer->getWaterHeightAt(p.x, p.y); - if (waterH && p.z < (*waterH + 0.25f)) { - cachedFootstepSurface_ = audio::FootstepSurface::WATER; - return audio::FootstepSurface::WATER; - } - } - - auto* wmoRenderer = renderer_->getWMORenderer(); - auto* terrainManager = renderer_->getTerrainManager(); - if (wmoRenderer) { - auto wmoFloor = wmoRenderer->getFloorHeight(p.x, p.y, p.z + 1.5f); - auto terrainFloor = terrainManager ? terrainManager->getHeightAt(p.x, p.y) : std::nullopt; - if (wmoFloor && (!terrainFloor || *wmoFloor >= *terrainFloor - 0.1f)) { - cachedFootstepSurface_ = audio::FootstepSurface::STONE; - return audio::FootstepSurface::STONE; - } - } - - audio::FootstepSurface surface = audio::FootstepSurface::STONE; - - if (terrainManager) { - auto texture = terrainManager->getDominantTextureAt(p.x, p.y); - if (texture) { - std::string t = *texture; - for (char& c : t) c = static_cast(std::tolower(static_cast(c))); - if (t.find("snow") != std::string::npos || t.find("ice") != std::string::npos) surface = audio::FootstepSurface::SNOW; - else if (t.find("grass") != std::string::npos || t.find("moss") != std::string::npos || t.find("leaf") != std::string::npos) surface = audio::FootstepSurface::GRASS; - else if (t.find("sand") != std::string::npos || t.find("dirt") != std::string::npos || t.find("mud") != std::string::npos) surface = audio::FootstepSurface::DIRT; - else if (t.find("wood") != std::string::npos || t.find("timber") != std::string::npos) surface = audio::FootstepSurface::WOOD; - else if (t.find("metal") != std::string::npos || t.find("iron") != std::string::npos) surface = audio::FootstepSurface::METAL; - else if (t.find("stone") != std::string::npos || t.find("rock") != std::string::npos || t.find("cobble") != std::string::npos || t.find("brick") != std::string::npos) surface = audio::FootstepSurface::STONE; - } - } - - cachedFootstepSurface_ = surface; - return surface; -} - -// ── Footstep update (called from Renderer::update) ────────────────────────── +// ── Footstep update (delegated to FootstepDriver) ─────────────────────────── void AnimationController::updateFootsteps(float deltaTime) { - auto* footstepManager = renderer_->getAudioCoordinator()->getFootstepManager(); - if (!footstepManager) return; - - auto* characterRenderer = renderer_->getCharacterRenderer(); - auto* cameraController = renderer_->getCameraController(); - uint32_t characterInstanceId = renderer_->getCharacterInstanceId(); - - footstepManager->update(deltaTime); - cachedFootstepUpdateTimer_ += deltaTime; - - bool canPlayFootsteps = characterRenderer && characterInstanceId > 0 && - cameraController && cameraController->isThirdPerson() && - cameraController->isGrounded() && !cameraController->isSwimming(); - - if (canPlayFootsteps && isMounted() && mountInstanceId_ > 0 && !taxiFlight_) { - // Mount footsteps: use mount's animation for timing - uint32_t animId = 0; - float animTimeMs = 0.0f, animDurationMs = 0.0f; - if (characterRenderer->getAnimationState(mountInstanceId_, animId, animTimeMs, animDurationMs) && - animDurationMs > 1.0f && cameraController->isMoving()) { - float wrappedTime = animTimeMs; - while (wrappedTime >= animDurationMs) { - wrappedTime -= animDurationMs; - } - if (wrappedTime < 0.0f) wrappedTime += animDurationMs; - float norm = wrappedTime / animDurationMs; - - if (animId != mountFootstepLastAnimId_) { - mountFootstepLastAnimId_ = animId; - mountFootstepLastNormTime_ = norm; - mountFootstepNormInitialized_ = true; - } else if (!mountFootstepNormInitialized_) { - mountFootstepNormInitialized_ = true; - mountFootstepLastNormTime_ = norm; - } else { - auto crossed = [&](float eventNorm) { - if (mountFootstepLastNormTime_ <= norm) { - return mountFootstepLastNormTime_ < eventNorm && eventNorm <= norm; - } - return mountFootstepLastNormTime_ < eventNorm || eventNorm <= norm; - }; - if (crossed(0.25f) || crossed(0.75f)) { - footstepManager->playFootstep(resolveFootstepSurface(), true); - } - mountFootstepLastNormTime_ = norm; - } - } else { - mountFootstepNormInitialized_ = false; - } - footstepNormInitialized_ = false; - } else if (canPlayFootsteps && isFootstepAnimationState()) { - uint32_t animId = 0; - float animTimeMs = 0.0f; - float animDurationMs = 0.0f; - if (characterRenderer->getAnimationState(characterInstanceId, animId, animTimeMs, animDurationMs) && - shouldTriggerFootstepEvent(animId, animTimeMs, animDurationMs)) { - auto surface = resolveFootstepSurface(); - footstepManager->playFootstep(surface, cameraController->isSprinting()); - if (surface == audio::FootstepSurface::WATER) { - if (renderer_->getAudioCoordinator()->getMovementSoundManager()) { - renderer_->getAudioCoordinator()->getMovementSoundManager()->playWaterFootstep(audio::MovementSoundManager::CharacterSize::MEDIUM); - } - auto* swimEffects = renderer_->getSwimEffects(); - auto* waterRenderer = renderer_->getWaterRenderer(); - if (swimEffects && waterRenderer) { - const glm::vec3& characterPosition = renderer_->getCharacterPosition(); - auto wh = waterRenderer->getWaterHeightAt(characterPosition.x, characterPosition.y); - if (wh) { - swimEffects->spawnFootSplash(characterPosition, *wh); - } - } - } - } - mountFootstepNormInitialized_ = false; - } else { - footstepNormInitialized_ = false; - mountFootstepNormInitialized_ = false; - } + footstepDriver_.update(deltaTime, renderer_, isMounted(), mountInstanceId_, + taxiFlight_, isFootstepAnimationState()); } // ── Activity SFX state tracking ────────────────────────────────────────────── void AnimationController::updateSfxState(float deltaTime) { - auto* activitySoundManager = renderer_->getAudioCoordinator()->getActivitySoundManager(); - if (!activitySoundManager) return; - - auto* cameraController = renderer_->getCameraController(); - - activitySoundManager->update(deltaTime); - if (cameraController && cameraController->isThirdPerson()) { - bool grounded = cameraController->isGrounded(); - bool jumping = cameraController->isJumping(); - bool falling = cameraController->isFalling(); - bool swimming = cameraController->isSwimming(); - bool moving = cameraController->isMoving(); - - if (!sfxStateInitialized_) { - sfxPrevGrounded_ = grounded; - sfxPrevJumping_ = jumping; - sfxPrevFalling_ = falling; - sfxPrevSwimming_ = swimming; - sfxStateInitialized_ = true; - } - - if (jumping && !sfxPrevJumping_ && !swimming) { - activitySoundManager->playJump(); - } - - if (grounded && !sfxPrevGrounded_) { - bool hardLanding = sfxPrevFalling_; - activitySoundManager->playLanding(resolveFootstepSurface(), hardLanding); - } - - if (swimming && !sfxPrevSwimming_) { - activitySoundManager->playWaterEnter(); - } else if (!swimming && sfxPrevSwimming_) { - activitySoundManager->playWaterExit(); - } - - activitySoundManager->setSwimmingState(swimming, moving); - - if (renderer_->getAudioCoordinator()->getMusicManager()) { - renderer_->getAudioCoordinator()->getMusicManager()->setUnderwaterMode(swimming); - } - - sfxPrevGrounded_ = grounded; - sfxPrevJumping_ = jumping; - sfxPrevFalling_ = falling; - sfxPrevSwimming_ = swimming; - } else { - activitySoundManager->setSwimmingState(false, false); - if (renderer_->getAudioCoordinator()->getMusicManager()) { - renderer_->getAudioCoordinator()->getMusicManager()->setUnderwaterMode(false); - } - sfxStateInitialized_ = false; - } - - // Mount ambient sounds - if (renderer_->getAudioCoordinator()->getMountSoundManager()) { - renderer_->getAudioCoordinator()->getMountSoundManager()->update(deltaTime); - if (cameraController && isMounted()) { - bool isMoving = cameraController->isMoving(); - bool flying = taxiFlight_ || !cameraController->isGrounded(); - renderer_->getAudioCoordinator()->getMountSoundManager()->setMoving(isMoving); - renderer_->getAudioCoordinator()->getMountSoundManager()->setFlying(flying); - } - } + sfxStateDriver_.update(deltaTime, renderer_, isMounted(), taxiFlight_, + footstepDriver_); } } // namespace rendering diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 248d3263..18ac8441 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -472,6 +472,11 @@ void CameraController::update(float deltaTime) { standUpCallback_(); } + // Notify server when the player sits down via local input + if (!prevSitting && sitting && sitDownCallback_) { + sitDownCallback_(); + } + // Update eye height based on crouch state (smooth transition) float targetEyeHeight = sitting ? CROUCH_EYE_HEIGHT : STAND_EYE_HEIGHT; float heightLerpSpeed = 10.0f * deltaTime; @@ -1364,9 +1369,14 @@ void CameraController::update(float deltaTime) { // Only snap when: // 1. Near ground (within step-up range above) - handles walking // 2. Actually falling from height (was airborne + falling fast) + // Scale snap range with fall speed so slow falls don't teleport + // while extreme speeds still catch geometry penetration. // 3. Was grounded + ground is close (grace for slopes) bool nearGround = (dz >= 0.0f && dz <= stepUp); - bool airFalling = (!grounded && verticalVelocity < -5.0f); + float airSnapRange = std::min(fallCatch, + std::max(0.5f, std::abs(verticalVelocity) * physicsDeltaTime * 2.0f)); + bool airFalling = (!grounded && verticalVelocity < -5.0f + && dz >= -airSnapRange); bool slopeGrace = (grounded && verticalVelocity > -1.0f && dz >= -0.25f && dz <= stepUp * 1.5f); diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 6c5fc7e7..f8666fcd 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -1,6 +1,6 @@ #include "rendering/character_preview.hpp" #include "rendering/character_renderer.hpp" -#include "rendering/animation_ids.hpp" +#include "rendering/animation/animation_ids.hpp" #include "rendering/vk_render_target.hpp" #include "rendering/vk_texture.hpp" #include "rendering/vk_context.hpp" diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 34a8e266..06bb4e08 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -15,7 +15,7 @@ * the original WoW Model Viewer (charcontrol.h, REGION_FAC=2). */ #include "rendering/character_renderer.hpp" -#include "rendering/animation_ids.hpp" +#include "rendering/animation/animation_ids.hpp" #include "rendering/vk_context.hpp" #include "rendering/vk_texture.hpp" #include "rendering/vk_pipeline.hpp" diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index fe424809..0a9629d7 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -349,7 +349,7 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout vkCreateDescriptorSetLayout(device, &ci, nullptr, &boneSetLayout_); } - // Phase 2.1: Instance data set layout (set 3): binding 0 = STORAGE_BUFFER (per-instance data) + // Instance data set layout (set 3): binding 0 = STORAGE_BUFFER (per-instance data) { VkDescriptorSetLayoutBinding binding{}; binding.binding = 0; @@ -476,7 +476,7 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout } } - // Phase 2.1: Instance data SSBO — per-frame buffer holding per-instance transforms, fade, bones. + // Instance data SSBO — per-frame buffer holding per-instance transforms, fade, bones. // Shader reads instanceData[push.instanceDataOffset + gl_InstanceIndex]. { static_assert(sizeof(M2InstanceGPU) == 96, "M2InstanceGPU must be 96 bytes (std430)"); @@ -522,7 +522,7 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout } } - // Phase 2.3: GPU frustum culling — compute pipeline, buffers, descriptors. + // GPU frustum culling — compute pipeline, buffers, descriptors. // Compute shader tests each instance bounding sphere against 6 frustum planes + distance. // Output: uint visibility[] read back by CPU to skip culled instances in sortedVisible_ build. { @@ -1060,7 +1060,7 @@ void M2Renderer::shutdown() { } if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; } if (boneDescPool_) { vkDestroyDescriptorPool(device, boneDescPool_, nullptr); boneDescPool_ = VK_NULL_HANDLE; } - // Phase 2.1: Instance data SSBO cleanup (sets freed with instanceDescPool_) + // Instance data SSBO cleanup (sets freed with instanceDescPool_) for (int i = 0; i < 2; i++) { if (instanceBuffer_[i]) { vmaDestroyBuffer(alloc, instanceBuffer_[i], instanceAlloc_[i]); instanceBuffer_[i] = VK_NULL_HANDLE; } instanceMapped_[i] = nullptr; @@ -1068,7 +1068,7 @@ void M2Renderer::shutdown() { } if (instanceDescPool_) { vkDestroyDescriptorPool(device, instanceDescPool_, nullptr); instanceDescPool_ = VK_NULL_HANDLE; } - // Phase 2.3: GPU frustum culling compute pipeline + buffers cleanup + // GPU frustum culling compute pipeline + buffers cleanup if (cullPipeline_) { vkDestroyPipeline(device, cullPipeline_, nullptr); cullPipeline_ = VK_NULL_HANDLE; } if (cullPipelineLayout_) { vkDestroyPipelineLayout(device, cullPipelineLayout_, nullptr); cullPipelineLayout_ = VK_NULL_HANDLE; } for (int i = 0; i < 2; i++) { @@ -2404,7 +2404,7 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: boneWorkIndices_.push_back(idx); } - // Phase 2: Compute bone matrices (expensive, parallel if enough work) + // Compute bone matrices (expensive, parallel if enough work) const size_t animCount = boneWorkIndices_.size(); if (animCount > 0) { static const size_t minParallelAnimInstances = std::max( @@ -2464,7 +2464,7 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: } } - // Phase 3: Particle update (sequential — uses RNG, not thread-safe) + // Particle update (sequential — uses RNG, not thread-safe) // Only iterate instances that have particle emitters (pre-built list). for (size_t idx : particleInstanceIndices_) { if (idx >= instances.size()) continue; @@ -2518,7 +2518,7 @@ void M2Renderer::prepareRender(uint32_t frameIndex, const Camera& camera) { } } -// Phase 2.3: Dispatch GPU frustum culling compute shader. +// Dispatch GPU frustum culling compute shader. // Called on the primary command buffer BEFORE the render pass begins so that // compute dispatch and memory barrier complete before secondary command buffers // read the visibility output in render(). @@ -2617,7 +2617,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const lastDrawCallCount = 0; - // Phase 2.3: GPU cull results — dispatchCullCompute() already updated smoothedRenderDist_. + // GPU cull results — dispatchCullCompute() already updated smoothedRenderDist_. // Use the cached value (set by dispatchCullCompute or fallback below). const uint32_t frameIndex = vkCtx_->getCurrentFrame(); const uint32_t numInstances = std::min(static_cast(instances.size()), MAX_CULL_INSTANCES); @@ -2649,7 +2649,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const sortedVisible_.reserve(expectedVisible); } - // Phase 2.3: GPU frustum culling — build frustum only for CPU fallback path + // GPU frustum culling — build frustum only for CPU fallback path Frustum frustum; if (!gpuCullAvailable) { const glm::mat4 vp = camera.getProjectionMatrix() * camera.getViewMatrix(); @@ -2661,10 +2661,10 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const const auto& instance = instances[i]; if (gpuCullAvailable) { - // Phase 2.3: GPU already tested flags + distance + frustum + // GPU already tested flags + distance + frustum if (!visibility[i]) continue; } else { - // CPU fallback: same culling logic as before Phase 2.3 + // CPU fallback: same culling logic as before if (!instance.cachedIsValid || instance.cachedIsSmoke || instance.cachedIsInvisibleTrap) continue; glm::vec3 toCam = instance.position - camPos; @@ -2712,7 +2712,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const VkPipeline currentPipeline = VK_NULL_HANDLE; VkDescriptorSet currentMaterialSet = VK_NULL_HANDLE; - // Phase 2.1: Push constants now carry per-batch data only; per-instance data is in instance SSBO. + // Push constants now carry per-batch data only; per-instance data is in instance SSBO. struct M2PushConstants { int32_t texCoordSet; // UV set index (0 or 1) int32_t isFoliage; // Foliage wind animation flag @@ -2734,7 +2734,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const currentPipeline = opaquePipeline_; // Bind dummy bone set (set 2) so non-animated draws have a valid binding. - // Phase 2.4: Bind mega bone SSBO instead — all instances index into one buffer via boneBase. + // Bind mega bone SSBO instead — all instances index into one buffer via boneBase. if (megaBoneSet_[frameIndex]) { vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, 2, 1, &megaBoneSet_[frameIndex], 0, nullptr); @@ -2743,18 +2743,18 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const pipelineLayout_, 2, 1, &dummyBoneSet_, 0, nullptr); } - // Phase 2.1: Bind instance data SSBO (set 3) — per-instance transforms, fade, bones + // Bind instance data SSBO (set 3) — per-instance transforms, fade, bones if (instanceSet_[frameIndex]) { vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, 3, 1, &instanceSet_[frameIndex], 0, nullptr); } - // Phase 2.1: Reset instance SSBO write cursor for this frame + // Reset instance SSBO write cursor for this frame instanceDataCount_ = 0; auto* instSSBO = static_cast(instanceMapped_[frameIndex]); // ===================================================================== - // Phase 2.1: Opaque pass — instanced draws grouped by (modelId, LOD) + // Opaque pass — instanced draws grouped by (modelId, LOD) // ===================================================================== // sortedVisible_ is already sorted by modelId so consecutive entries share // the same vertex/index buffer. Within each model group we sub-group by diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index d5884d94..4fb0af07 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -867,7 +867,7 @@ void Renderer::beginFrame() { // Update per-frame UBO with current camera/lighting state updatePerFrameUBO(); - // --- Off-screen pre-passes (Phase 2.5: render graph) --- + // --- Off-screen pre-passes --- // Build frame graph: registers pre-passes as graph nodes with dependencies. // compile() topologically sorts; execute() runs them with auto barriers. buildFrameGraph(nullptr); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a607dfe3..b5773534 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,4 @@ -# Phase 0: Unit test infrastructure using Catch2 v3 (amalgamated) +# Unit test infrastructure using Catch2 v3 (amalgamated) # Catch2 amalgamated as a library target add_library(catch2_main STATIC @@ -139,7 +139,7 @@ register_test_target(test_frustum) add_executable(test_animation_ids test_animation_ids.cpp ${TEST_COMMON_SOURCES} - ${CMAKE_SOURCE_DIR}/src/rendering/animation_ids.cpp + ${CMAKE_SOURCE_DIR}/src/rendering/animation/animation_ids.cpp ${CMAKE_SOURCE_DIR}/src/pipeline/dbc_loader.cpp ) target_include_directories(test_animation_ids PRIVATE ${TEST_INCLUDE_DIRS}) @@ -148,6 +148,55 @@ target_link_libraries(test_animation_ids PRIVATE catch2_main) add_test(NAME animation_ids COMMAND test_animation_ids) register_test_target(test_animation_ids) +# ── test_locomotion_fsm ────────────────────────────────────── +add_executable(test_locomotion_fsm + test_locomotion_fsm.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src/rendering/animation/locomotion_fsm.cpp +) +target_include_directories(test_locomotion_fsm PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_locomotion_fsm SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_locomotion_fsm PRIVATE catch2_main) +add_test(NAME locomotion_fsm COMMAND test_locomotion_fsm) +register_test_target(test_locomotion_fsm) + +# ── test_combat_fsm ────────────────────────────────────────── +add_executable(test_combat_fsm + test_combat_fsm.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src/rendering/animation/combat_fsm.cpp +) +target_include_directories(test_combat_fsm PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_combat_fsm SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_combat_fsm PRIVATE catch2_main) +add_test(NAME combat_fsm COMMAND test_combat_fsm) +register_test_target(test_combat_fsm) + +# ── test_activity_fsm ──────────────────────────────────────── +add_executable(test_activity_fsm + test_activity_fsm.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src/rendering/animation/activity_fsm.cpp +) +target_include_directories(test_activity_fsm PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_activity_fsm SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_activity_fsm PRIVATE catch2_main) +add_test(NAME activity_fsm COMMAND test_activity_fsm) +register_test_target(test_activity_fsm) + +# NPC animator tests removed — NpcAnimator replaced by generic CharacterAnimator + +# ── test_anim_capability ───────────────────────────────────── +# Header-only struct tests — no source files needed +add_executable(test_anim_capability + test_anim_capability.cpp +) +target_include_directories(test_anim_capability PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_anim_capability SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_anim_capability PRIVATE catch2_main) +add_test(NAME anim_capability COMMAND test_anim_capability) +register_test_target(test_anim_capability) + # ── ASAN / UBSan for test targets ──────────────────────────── if(WOWEE_ENABLE_ASAN AND NOT MSVC) foreach(_t IN LISTS ALL_TEST_TARGETS) diff --git a/tests/test_activity_fsm.cpp b/tests/test_activity_fsm.cpp new file mode 100644 index 00000000..baad15e7 --- /dev/null +++ b/tests/test_activity_fsm.cpp @@ -0,0 +1,112 @@ +// ActivityFSM unit tests +#include +#include "rendering/animation/activity_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" + +using namespace wowee::rendering; +namespace anim = wowee::rendering::anim; + +static AnimCapabilitySet makeActivityCaps() { + AnimCapabilitySet caps; + caps.resolvedStand = anim::STAND; + caps.resolvedSitDown = anim::SIT_GROUND_DOWN; + caps.resolvedSitLoop = anim::SITTING; + caps.resolvedSitUp = anim::SIT_GROUND_UP; + caps.resolvedKneel = anim::KNEEL_LOOP; + caps.resolvedLoot = anim::LOOT; + return caps; +} + +static ActivityFSM::Input idleInput() { + ActivityFSM::Input in; + in.grounded = true; + return in; +} + +TEST_CASE("ActivityFSM: NONE by default", "[activity]") { + ActivityFSM fsm; + CHECK(fsm.getState() == ActivityFSM::State::NONE); + CHECK_FALSE(fsm.isActive()); +} + +TEST_CASE("ActivityFSM: emote starts and returns valid output", "[activity]") { + ActivityFSM fsm; + auto caps = makeActivityCaps(); + + fsm.startEmote(anim::EMOTE_WAVE, false); + CHECK(fsm.getState() == ActivityFSM::State::EMOTE); + CHECK(fsm.isEmoteActive()); + + auto in = idleInput(); + auto out = fsm.resolve(in, caps); + REQUIRE(out.valid); + CHECK(out.animId == anim::EMOTE_WAVE); + CHECK(out.loop == false); +} + +TEST_CASE("ActivityFSM: emote cancelled by movement", "[activity]") { + ActivityFSM fsm; + auto caps = makeActivityCaps(); + + fsm.startEmote(anim::EMOTE_WAVE, false); + + auto in = idleInput(); + in.moving = true; + auto out = fsm.resolve(in, caps); + + // After movement, emote should be cancelled + CHECK_FALSE(fsm.isEmoteActive()); + CHECK(fsm.getState() == ActivityFSM::State::NONE); +} + +TEST_CASE("ActivityFSM: looting starts and stops", "[activity]") { + ActivityFSM fsm; + auto caps = makeActivityCaps(); + + fsm.startLooting(); + CHECK(fsm.getState() == ActivityFSM::State::LOOTING); + + auto in = idleInput(); + auto out = fsm.resolve(in, caps); + REQUIRE(out.valid); + + // stopLooting transitions through LOOT_END (KNEEL_END one-shot) before NONE + fsm.stopLooting(); + CHECK(fsm.getState() == ActivityFSM::State::LOOT_END); +} + +TEST_CASE("ActivityFSM: sit stand state triggers sit down", "[activity]") { + ActivityFSM fsm; + auto caps = makeActivityCaps(); + + fsm.setStandState(ActivityFSM::STAND_STATE_SIT); + CHECK(fsm.getState() == ActivityFSM::State::SIT_DOWN); + + auto in = idleInput(); + in.sitting = true; + auto out = fsm.resolve(in, caps); + REQUIRE(out.valid); +} + +TEST_CASE("ActivityFSM: cancel emote explicitly", "[activity]") { + ActivityFSM fsm; + auto caps = makeActivityCaps(); + + fsm.startEmote(anim::EMOTE_DANCE, true); + CHECK(fsm.isEmoteActive()); + CHECK(fsm.getEmoteAnimId() == anim::EMOTE_DANCE); + + fsm.cancelEmote(); + CHECK_FALSE(fsm.isEmoteActive()); + CHECK(fsm.getState() == ActivityFSM::State::NONE); +} + +TEST_CASE("ActivityFSM: reset clears all state", "[activity]") { + ActivityFSM fsm; + fsm.startEmote(anim::EMOTE_WAVE, false); + CHECK(fsm.isActive()); + + fsm.reset(); + CHECK(fsm.getState() == ActivityFSM::State::NONE); + CHECK_FALSE(fsm.isActive()); +} diff --git a/tests/test_anim_capability.cpp b/tests/test_anim_capability.cpp new file mode 100644 index 00000000..acf51b41 --- /dev/null +++ b/tests/test_anim_capability.cpp @@ -0,0 +1,56 @@ +// AnimCapabilitySet unit tests +#include +#include "rendering/animation/anim_capability_set.hpp" + +using namespace wowee::rendering; + +TEST_CASE("AnimCapabilitySet: default-constructed has all zero IDs", "[capability]") { + AnimCapabilitySet caps; + + CHECK(caps.resolvedStand == 0); + CHECK(caps.resolvedWalk == 0); + CHECK(caps.resolvedRun == 0); + CHECK(caps.resolvedSprint == 0); + CHECK(caps.resolvedJumpStart == 0); + CHECK(caps.resolvedJump == 0); + CHECK(caps.resolvedJumpEnd == 0); + CHECK(caps.resolvedSwimIdle == 0); + CHECK(caps.resolvedSwim == 0); + CHECK(caps.resolvedCombatIdle == 0); + CHECK(caps.resolvedMelee1H == 0); + CHECK(caps.resolvedMelee2H == 0); + CHECK(caps.resolvedStun == 0); + CHECK(caps.resolvedMount == 0); + CHECK(caps.resolvedStealthIdle == 0); + CHECK(caps.resolvedLoot == 0); +} + +TEST_CASE("AnimCapabilitySet: default-constructed has all flags false", "[capability]") { + AnimCapabilitySet caps; + + CHECK_FALSE(caps.hasStand); + CHECK_FALSE(caps.hasWalk); + CHECK_FALSE(caps.hasRun); + CHECK_FALSE(caps.hasSprint); + CHECK_FALSE(caps.hasWalkBackwards); + CHECK_FALSE(caps.hasJump); + CHECK_FALSE(caps.hasSwim); + CHECK_FALSE(caps.hasMelee); + CHECK_FALSE(caps.hasStealth); + CHECK_FALSE(caps.hasDeath); + CHECK_FALSE(caps.hasMount); +} + +TEST_CASE("AnimOutput::ok creates valid output", "[capability]") { + auto out = AnimOutput::ok(42, true); + CHECK(out.valid); + CHECK(out.animId == 42); + CHECK(out.loop == true); +} + +TEST_CASE("AnimOutput::stay creates invalid output", "[capability]") { + auto out = AnimOutput::stay(); + CHECK_FALSE(out.valid); + CHECK(out.animId == 0); + CHECK(out.loop == false); +} diff --git a/tests/test_animation_ids.cpp b/tests/test_animation_ids.cpp index 1fb4b6a2..4bb53790 100644 --- a/tests/test_animation_ids.cpp +++ b/tests/test_animation_ids.cpp @@ -1,6 +1,6 @@ // Animation ID validation tests — covers nameFromId() and validateAgainstDBC() #include -#include "rendering/animation_ids.hpp" +#include "rendering/animation/animation_ids.hpp" #include "pipeline/dbc_loader.hpp" #include #include diff --git a/tests/test_combat_fsm.cpp b/tests/test_combat_fsm.cpp new file mode 100644 index 00000000..bc206e9a --- /dev/null +++ b/tests/test_combat_fsm.cpp @@ -0,0 +1,125 @@ +// CombatFSM unit tests +#include +#include "rendering/animation/combat_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" + +using namespace wowee::rendering; +namespace anim = wowee::rendering::anim; + +static AnimCapabilitySet makeCombatCaps() { + AnimCapabilitySet caps; + caps.resolvedStand = anim::STAND; + caps.resolvedCombatIdle = anim::READY_UNARMED; + caps.resolvedMelee1H = anim::ATTACK_1H; + caps.resolvedMelee2H = anim::ATTACK_2H; + caps.resolvedMeleeUnarmed = anim::ATTACK_UNARMED; + caps.resolvedStun = anim::STUN; + caps.resolvedUnsheathe = anim::UNSHEATHE; + caps.resolvedSheathe = anim::SHEATHE; + caps.hasMelee = true; + return caps; +} + +static CombatFSM::Input combatInput() { + CombatFSM::Input in; + in.inCombat = true; + in.grounded = true; + return in; +} + +static WeaponLoadout unarmedLoadout() { + return WeaponLoadout{}; // Default is unarmed (inventoryType=0) +} + +TEST_CASE("CombatFSM: INACTIVE by default", "[combat]") { + CombatFSM fsm; + CHECK(fsm.getState() == CombatFSM::State::INACTIVE); + CHECK_FALSE(fsm.isActive()); +} + +TEST_CASE("CombatFSM: INACTIVE → UNSHEATHE on COMBAT_ENTER event", "[combat]") { + CombatFSM fsm; + fsm.onEvent(AnimEvent::COMBAT_ENTER); + CHECK(fsm.getState() == CombatFSM::State::UNSHEATHE); + CHECK(fsm.isActive()); +} + +TEST_CASE("CombatFSM: stun overrides active combat", "[combat]") { + CombatFSM fsm; + auto caps = makeCombatCaps(); + auto wl = unarmedLoadout(); + + // Enter combat + fsm.onEvent(AnimEvent::COMBAT_ENTER); + + // Stun + fsm.setStunned(true); + auto in = combatInput(); + auto out = fsm.resolve(in, caps, wl); + + CHECK(fsm.isStunned()); + REQUIRE(out.valid); + CHECK(out.animId == anim::STUN); +} + +TEST_CASE("CombatFSM: stun does not override swimming", "[combat]") { + CombatFSM fsm; + auto caps = makeCombatCaps(); + auto wl = unarmedLoadout(); + + fsm.setStunned(true); + auto in = combatInput(); + in.swimming = true; + auto out = fsm.resolve(in, caps, wl); + + // Swimming overrides combat entirely — FSM should go inactive + // The exact behavior depends on implementation, but stun should not + // force an animation while swimming + CHECK(fsm.getState() != CombatFSM::State::STUNNED); +} + +TEST_CASE("CombatFSM: spell cast sequence", "[combat]") { + CombatFSM fsm; + auto caps = makeCombatCaps(); + auto wl = unarmedLoadout(); + + fsm.startSpellCast(anim::SPELL_PRECAST, anim::SPELL, true, anim::SPELL_CAST); + CHECK(fsm.getState() == CombatFSM::State::SPELL_PRECAST); + + auto in = combatInput(); + auto out = fsm.resolve(in, caps, wl); + REQUIRE(out.valid); + CHECK(out.animId == anim::SPELL_PRECAST); +} + +TEST_CASE("CombatFSM: stopSpellCast transitions to finalize", "[combat]") { + CombatFSM fsm; + auto caps = makeCombatCaps(); + auto wl = unarmedLoadout(); + + fsm.startSpellCast(0, anim::SPELL, true, anim::SPELL_CAST); + // Skip precast (animId=0), go to casting + fsm.setState(CombatFSM::State::SPELL_CASTING); + + fsm.stopSpellCast(); + CHECK(fsm.getState() == CombatFSM::State::SPELL_FINALIZE); +} + +TEST_CASE("CombatFSM: hit reaction", "[combat]") { + CombatFSM fsm; + auto caps = makeCombatCaps(); + auto wl = unarmedLoadout(); + + fsm.triggerHitReaction(anim::COMBAT_WOUND); + CHECK(fsm.getState() == CombatFSM::State::HIT_REACTION); +} + +TEST_CASE("CombatFSM: reset clears all state", "[combat]") { + CombatFSM fsm; + fsm.onEvent(AnimEvent::COMBAT_ENTER); + fsm.setStunned(true); + + fsm.reset(); + CHECK(fsm.getState() == CombatFSM::State::INACTIVE); + CHECK_FALSE(fsm.isStunned()); +} diff --git a/tests/test_locomotion_fsm.cpp b/tests/test_locomotion_fsm.cpp new file mode 100644 index 00000000..07e6cbd8 --- /dev/null +++ b/tests/test_locomotion_fsm.cpp @@ -0,0 +1,167 @@ +// LocomotionFSM unit tests +#include +#include "rendering/animation/locomotion_fsm.hpp" +#include "rendering/animation/animation_ids.hpp" + +using namespace wowee::rendering; +namespace anim = wowee::rendering::anim; + +// Helper: create a capability set with basic locomotion resolved +static AnimCapabilitySet makeLocoCaps() { + AnimCapabilitySet caps; + caps.resolvedStand = anim::STAND; + caps.resolvedWalk = anim::WALK; + caps.resolvedRun = anim::RUN; + caps.resolvedSprint = anim::SPRINT; + caps.resolvedWalkBackwards = anim::WALK_BACKWARDS; + caps.resolvedStrafeLeft = anim::SHUFFLE_LEFT; + caps.resolvedStrafeRight = anim::SHUFFLE_RIGHT; + caps.resolvedJumpStart = anim::JUMP_START; + caps.resolvedJump = anim::JUMP; + caps.resolvedJumpEnd = anim::JUMP_END; + caps.resolvedSwimIdle = anim::SWIM_IDLE; + caps.resolvedSwim = anim::SWIM; + caps.hasStand = true; + caps.hasWalk = true; + caps.hasRun = true; + caps.hasSprint = true; + caps.hasWalkBackwards = true; + caps.hasJump = true; + caps.hasSwim = true; + return caps; +} + +static LocomotionFSM::Input idle() { + LocomotionFSM::Input in; + in.deltaTime = 0.016f; + return in; +} + +TEST_CASE("LocomotionFSM: IDLE → WALK on move start (non-sprinting)", "[locomotion]") { + LocomotionFSM fsm; + auto caps = makeLocoCaps(); + + auto in = idle(); + in.moving = true; + auto out = fsm.resolve(in, caps); + + REQUIRE(out.valid); + REQUIRE(out.animId == anim::WALK); + REQUIRE(out.loop == true); + CHECK(fsm.getState() == LocomotionFSM::State::WALK); +} + +TEST_CASE("LocomotionFSM: IDLE → RUN on move start (sprinting)", "[locomotion]") { + LocomotionFSM fsm; + auto caps = makeLocoCaps(); + + auto in = idle(); + in.moving = true; + in.sprinting = true; + auto out = fsm.resolve(in, caps); + + REQUIRE(out.valid); + REQUIRE(out.animId == anim::RUN); + CHECK(fsm.getState() == LocomotionFSM::State::RUN); +} + +TEST_CASE("LocomotionFSM: WALK → IDLE on move stop (after grace)", "[locomotion]") { + LocomotionFSM fsm; + auto caps = makeLocoCaps(); + + // Start walking + auto in = idle(); + in.moving = true; + fsm.resolve(in, caps); + CHECK(fsm.getState() == LocomotionFSM::State::WALK); + + // Stop moving — grace timer keeps walk for a bit + in.moving = false; + in.deltaTime = 0.2f; // > grace period (0.12s) + auto out = fsm.resolve(in, caps); + + CHECK(fsm.getState() == LocomotionFSM::State::IDLE); +} + +TEST_CASE("LocomotionFSM: WALK → JUMP_START on jump", "[locomotion]") { + LocomotionFSM fsm; + auto caps = makeLocoCaps(); + + // Start walking + auto in = idle(); + in.moving = true; + fsm.resolve(in, caps); + + // Jump + in.jumping = true; + in.grounded = false; + auto out = fsm.resolve(in, caps); + + REQUIRE(out.valid); + REQUIRE(out.animId == anim::JUMP_START); + CHECK(fsm.getState() == LocomotionFSM::State::JUMP_START); +} + +TEST_CASE("LocomotionFSM: SWIM_IDLE → SWIM on move start while swimming", "[locomotion]") { + LocomotionFSM fsm; + auto caps = makeLocoCaps(); + + // Enter swim idle + auto in = idle(); + in.swimming = true; + fsm.resolve(in, caps); + CHECK(fsm.getState() == LocomotionFSM::State::SWIM_IDLE); + + // Start swimming + in.moving = true; + auto out = fsm.resolve(in, caps); + + REQUIRE(out.valid); + REQUIRE(out.animId == anim::SWIM); + CHECK(fsm.getState() == LocomotionFSM::State::SWIM); +} + +TEST_CASE("LocomotionFSM: backward walking resolves WALK_BACKWARDS", "[locomotion]") { + LocomotionFSM fsm; + auto caps = makeLocoCaps(); + + auto in = idle(); + in.moving = true; + in.movingBackward = true; + auto out = fsm.resolve(in, caps); + + REQUIRE(out.valid); + // Should use WALK_BACKWARDS when available + CHECK(out.animId == anim::WALK_BACKWARDS); +} + +TEST_CASE("LocomotionFSM: STAY when WALK_BACKWARDS missing from caps", "[locomotion]") { + LocomotionFSM fsm; + AnimCapabilitySet caps; + caps.resolvedStand = anim::STAND; + caps.resolvedWalk = anim::WALK; + caps.hasStand = true; + caps.hasWalk = true; + // No WALK_BACKWARDS in caps + + auto in = idle(); + in.moving = true; + in.movingBackward = true; + auto out = fsm.resolve(in, caps); + + // Should still resolve something — falls back to walk + REQUIRE(out.valid); +} + +TEST_CASE("LocomotionFSM: reset restores IDLE", "[locomotion]") { + LocomotionFSM fsm; + auto caps = makeLocoCaps(); + + auto in = idle(); + in.moving = true; + fsm.resolve(in, caps); + CHECK(fsm.getState() == LocomotionFSM::State::WALK); + + fsm.reset(); + CHECK(fsm.getState() == LocomotionFSM::State::IDLE); +}