Merge pull request #48 from ldmonster/feat/animation-handling

[feat] animation: FSM-Based Animation System — Full Refactor
This commit is contained in:
Kelsi Rae Davis 2026-04-05 02:38:02 -07:00 committed by GitHub
commit 8c587ab13d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 5112 additions and 2099 deletions

2
.gitmodules vendored
View file

@ -9,6 +9,8 @@
[submodule "extern/FidelityFX-SDK"]
path = extern/FidelityFX-SDK
url = https://github.com/Kelsidavis/FidelityFX-SDK.git
shallow = true
update = none
[submodule "extern/FidelityFX-FSR2"]
path = extern/FidelityFX-FSR2
url = https://github.com/Kelsidavis/FidelityFX-FSR2.git

View file

@ -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

110
docs/ANIMATION_SYSTEM.md Normal file
View file

@ -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
```

View file

@ -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<void(uint64_t guid, bool start, bool isChannel)>;
// castType: DIRECTED (unit target), OMNI (self/no target), AREA (ground AoE)
using SpellCastAnimCallback = std::function<void(uint64_t guid, bool start, bool isChannel,
SpellCastType castType)>;
void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); }
// Fired when the player's own spell cast fails (spellId of the failed spell).

View file

@ -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) ----

View file

@ -0,0 +1,110 @@
#pragma once
#include "rendering/animation/anim_capability_set.hpp"
#include "rendering/animation/anim_event.hpp"
#include <cstdint>
#include <string>
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

View file

@ -0,0 +1,38 @@
#pragma once
#include <cstdint>
#include <vector>
#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

View file

@ -0,0 +1,147 @@
#pragma once
#include <cstdint>
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

View file

@ -0,0 +1,31 @@
#pragma once
#include <cstdint>
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

View file

@ -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 <cstdint>
#include <unordered_map>
#include <memory>
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<uint32_t, std::unique_ptr<CharacterAnimator>> animators_;
};
} // namespace rendering
} // namespace wowee

View file

@ -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 <cstdint>
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

View file

@ -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 <cstdint>
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

View file

@ -0,0 +1,77 @@
#pragma once
#include <cstdint>
#include <string>
#include <optional>
#include <unordered_map>
#include <vector>
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<EmoteResult> 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<std::string, EmoteInfo> emoteTable_;
std::unordered_map<uint32_t, const EmoteInfo*> emoteByDbcId_;
};
} // namespace rendering
} // namespace wowee

View file

@ -0,0 +1,54 @@
#pragma once
#include <cstdint>
#include <glm/glm.hpp>
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

View file

@ -0,0 +1,31 @@
#pragma once
#include "rendering/animation/anim_capability_set.hpp"
#include "rendering/animation/anim_event.hpp"
#include <cstdint>
#include <vector>
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<pipeline::M2Sequence>& out) const = 0;
virtual ~IAnimRenderer() = default;
};
} // namespace rendering
} // namespace wowee

View file

@ -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

View file

@ -0,0 +1,42 @@
#pragma once
#include "rendering/animation/i_animator.hpp"
#include "rendering/animation/weapon_type.hpp"
#include <cstdint>
#include <string>
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

View file

@ -0,0 +1,79 @@
#pragma once
#include "rendering/animation/anim_capability_set.hpp"
#include "rendering/animation/anim_event.hpp"
#include <cstdint>
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

View file

@ -0,0 +1,142 @@
#pragma once
#include "rendering/animation/anim_capability_set.hpp"
#include "rendering/animation/anim_event.hpp"
#include <cstdint>
#include <vector>
#include <random>
#include <glm/glm.hpp>
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<uint32_t> 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

View file

@ -0,0 +1,35 @@
#pragma once
#include <cstdint>
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

View file

@ -0,0 +1,28 @@
#pragma once
#include <cstdint>
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

View file

@ -4,22 +4,31 @@
#include <string>
#include <vector>
#include <glm/glm.hpp>
#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<uint32_t> 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

View file

@ -98,6 +98,10 @@ public:
using StandUpCallback = std::function<void()>;
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()>;
void setSitDownCallback(SitDownCallback cb) { sitDownCallback_ = std::move(cb); }
// Callback invoked when auto-follow is cancelled by user movement input.
using AutoFollowCancelCallback = std::function<void()>;
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

View file

@ -1,7 +1,7 @@
#include "core/application.hpp"
#include "core/coordinates.hpp"
#include "core/profiler.hpp"
#include "rendering/animation_ids.hpp"
#include "rendering/animation/animation_ids.hpp"
#include "rendering/animation_controller.hpp"
#include <unordered_set>
#include <cmath>
@ -946,6 +946,16 @@ void Application::setState(AppState newState) {
gameHandler->setStandState(rendering::AnimationController::STAND_STATE_STAND);
}
});
cc->setSitDownCallback([this]() {
if (gameHandler) {
gameHandler->setStandState(rendering::AnimationController::STAND_STATE_SIT);
}
if (renderer) {
if (auto* ac = renderer->getAnimationController()) {
ac->setStandState(rendering::AnimationController::STAND_STATE_SIT);
}
}
});
cc->setAutoFollowCancelCallback([this]() {
if (gameHandler) {
gameHandler->cancelFollow();
@ -3488,13 +3498,17 @@ void Application::setupUICallbacks() {
});
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
// Probes the model for the best available spell animation with fallback chain:
// Regular cast: SPELL_CAST_DIRECTED(53) → SPELL_CAST_OMNI(54) → SPELL_CAST(32) → SPELL(2)
// Channel: CHANNEL_CAST_DIRECTED(124) → CHANNEL_CAST_OMNI(125) → SPELL_CAST_DIRECTED(53) → SPELL(2)
// For the local player, uses AnimationController state machine to prevent
// COMBAT_IDLE from overriding the spell animation. For NPCs/other players,
// calls playAnimation directly (they don't share the player state machine).
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool isChannel) {
// WoW-accurate 3-phase spell animation sequence:
// Phase 1: SPELL_PRECAST (31) — one-shot wind-up
// Phase 2: READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills
// Phase 3: SPELL_CAST_DIRECTED/OMNI/AREA (53/54/33) — one-shot release at completion
// Channels use CHANNEL_CAST_DIRECTED/OMNI (124/125) or SPELL_CHANNEL_DIRECTED_OMNI (201).
// castType comes from the spell packet's targetGuid:
// DIRECTED — spell targets a specific unit (Frostbolt, Heal)
// OMNI — self-cast / no explicit target (Arcane Explosion, buffs)
// AREA — ground-targeted AoE (Blizzard, Rain of Fire)
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool isChannel,
game::SpellCastType castType) {
if (!entitySpawner_) return;
if (!renderer) return;
auto* cr = renderer->getCharacterRenderer();
@ -3514,6 +3528,9 @@ void Application::setupUICallbacks() {
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
if (instanceId == 0) return;
const bool isDirected = (castType == game::SpellCastType::DIRECTED);
const bool isArea = (castType == game::SpellCastType::AREA);
if (start) {
// Detect fishing spells (channeled) — use FISHING_LOOP instead of generic cast
auto isFishingSpell = [](uint32_t spellId) {
@ -3532,74 +3549,94 @@ void Application::setupUICallbacks() {
cr->playAnimation(instanceId, rendering::anim::FISHING_LOOP, true);
}
} else {
// Spell animation sequence: PRECAST (one-shot) → CAST (loop) → FINALIZE (one-shot) → idle
// Probe model for best available animations with fallback chains:
// Regular cast: SPELL_CAST_DIRECTED → SPELL_CAST_OMNI → SPELL_CAST → SPELL
// Channel: CHANNEL_CAST_DIRECTED → CHANNEL_CAST_OMNI → SPELL_CAST_DIRECTED → SPELL
bool hasTarget = gameHandler->hasTarget();
// Helper: pick first animation the model supports from a list
auto pickFirst = [&](std::initializer_list<uint32_t> ids) -> uint32_t {
for (uint32_t id : ids)
if (cr->hasAnimation(instanceId, id)) return id;
return 0;
};
// Phase 1: Precast wind-up (one-shot, non-channels only)
uint32_t precastAnim = 0;
if (!isChannel && cr->hasAnimation(instanceId, rendering::anim::SPELL_PRECAST)) {
precastAnim = rendering::anim::SPELL_PRECAST;
if (!isChannel) {
precastAnim = pickFirst({rendering::anim::SPELL_PRECAST});
}
// Phase 2: Cast hold (looping until stopSpellCast)
static const uint32_t castDirected[] = {
rendering::anim::SPELL_CAST_DIRECTED,
rendering::anim::SPELL_CAST_OMNI,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
};
static const uint32_t castOmni[] = {
rendering::anim::SPELL_CAST_OMNI,
rendering::anim::SPELL_CAST_DIRECTED,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
};
static const uint32_t channelDirected[] = {
rendering::anim::CHANNEL_CAST_DIRECTED,
rendering::anim::CHANNEL_CAST_OMNI,
rendering::anim::SPELL_CAST_DIRECTED,
rendering::anim::SPELL
};
static const uint32_t channelOmni[] = {
rendering::anim::CHANNEL_CAST_OMNI,
rendering::anim::CHANNEL_CAST_DIRECTED,
rendering::anim::SPELL_CAST_DIRECTED,
rendering::anim::SPELL
};
const uint32_t* chain;
// Phase 2: Cast hold (looping while cast bar fills / channel active)
uint32_t castAnim = 0;
if (isChannel) {
chain = hasTarget ? channelDirected : channelOmni;
// Channel hold: prefer DIRECTED/OMNI based on spell target classification
if (isDirected) {
castAnim = pickFirst({
rendering::anim::CHANNEL_CAST_DIRECTED,
rendering::anim::CHANNEL_CAST_OMNI,
rendering::anim::SPELL_CHANNEL_DIRECTED_OMNI,
rendering::anim::READY_SPELL_DIRECTED,
rendering::anim::SPELL
});
} else {
// OMNI or AREA channels (Blizzard channel, Tranquility, etc.)
castAnim = pickFirst({
rendering::anim::CHANNEL_CAST_OMNI,
rendering::anim::CHANNEL_CAST_DIRECTED,
rendering::anim::SPELL_CHANNEL_DIRECTED_OMNI,
rendering::anim::READY_SPELL_OMNI,
rendering::anim::SPELL
});
}
} else {
chain = hasTarget ? castDirected : castOmni;
}
uint32_t castAnim = rendering::anim::SPELL;
for (size_t i = 0; i < 4; ++i) {
if (cr->hasAnimation(instanceId, chain[i])) {
castAnim = chain[i];
break;
// Regular cast hold: READY_SPELL_DIRECTED/OMNI while cast bar fills
if (isDirected) {
castAnim = pickFirst({
rendering::anim::READY_SPELL_DIRECTED,
rendering::anim::READY_SPELL_OMNI,
rendering::anim::SPELL_CAST_DIRECTED,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
});
} else {
// OMNI (self-buff) or AREA (AoE targeting)
castAnim = pickFirst({
rendering::anim::READY_SPELL_OMNI,
rendering::anim::READY_SPELL_DIRECTED,
rendering::anim::SPELL_CAST_OMNI,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
});
}
}
if (castAnim == 0) castAnim = rendering::anim::SPELL;
// Phase 3: Finalization release (one-shot after cast ends)
// Pick a different animation from the cast loop for visual variety
static const uint32_t finalizeChain[] = {
rendering::anim::SPELL_CAST_OMNI,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
};
// Phase 3: Finalization release (one-shot after cast completes)
// Animation chosen by spell target type: AREA → SPELL_CAST_AREA,
// DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI
uint32_t finalizeAnim = 0;
if (isLocalPlayer && !isChannel) {
for (uint32_t fa : finalizeChain) {
if (fa != castAnim && cr->hasAnimation(instanceId, fa)) {
finalizeAnim = fa;
break;
}
if (isArea) {
// Ground-targeted AoE: SPELL_CAST_AREA → SPELL_CAST_OMNI
finalizeAnim = pickFirst({
rendering::anim::SPELL_CAST_AREA,
rendering::anim::SPELL_CAST_OMNI,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
});
} else if (isDirected) {
// Single-target: SPELL_CAST_DIRECTED → SPELL_CAST_OMNI
finalizeAnim = pickFirst({
rendering::anim::SPELL_CAST_DIRECTED,
rendering::anim::SPELL_CAST_OMNI,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
});
} else {
// OMNI (self-buff, Arcane Explosion): SPELL_CAST_OMNI → SPELL_CAST_AREA
finalizeAnim = pickFirst({
rendering::anim::SPELL_CAST_OMNI,
rendering::anim::SPELL_CAST_AREA,
rendering::anim::SPELL_CAST,
rendering::anim::SPELL
});
}
if (finalizeAnim == 0 && cr->hasAnimation(instanceId, rendering::anim::SPELL))
finalizeAnim = rendering::anim::SPELL;
}
if (isLocalPlayer) {

View file

@ -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"

View file

@ -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"

View file

@ -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);

View file

@ -258,7 +258,7 @@ void EntityController::processOutOfRangeObjects(const std::vector<uint64_t>& gui
}
// ============================================================
// Phase 1: Extracted helper methods
// Extracted helper methods
// ============================================================
bool EntityController::extractPlayerAppearance(const std::map<uint16_t, uint32_t>& fields,
@ -383,7 +383,7 @@ void EntityController::maybeDetectCoinageIndex(const std::map<uint16_t, uint32_t
}
// ============================================================
// Phase 2: Update type dispatch
// Update type dispatch
// ============================================================
void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) {
@ -404,10 +404,10 @@ void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& ne
}
// ============================================================
// Phase 3: Concern-specific helpers
// Concern-specific helpers
// ============================================================
// 3i: Non-player transport child attachment — identical in CREATE/VALUES/MOVEMENT
// Non-player transport child attachment — identical in CREATE/VALUES/MOVEMENT
void EntityController::updateNonPlayerTransportAttachment(const UpdateBlock& block,
const std::shared_ptr<Entity>& 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<Entity>&
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<uint16_t, uint32_t>& 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<Entity> EntityController::createEntityFromBlock(const UpdateBlock& block) {
switch (block.objectType) {
case ObjectType::PLAYER:
@ -596,7 +596,7 @@ std::shared_ptr<Entity> 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>& 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<uint16_t, uint32_t>& fields,
const PlayerFieldIndices& pfi,
@ -1081,7 +1081,7 @@ bool EntityController::applyPlayerStatFields(const std::map<uint16_t, uint32_t>&
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>& 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>& 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);

View file

@ -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 <glm/gtx/quaternion.hpp>
#include <algorithm>
#include <cmath>
@ -5145,7 +5145,7 @@ const std::vector<std::string>& 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) {

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -2,7 +2,7 @@
// animation_ids.cpp — Inverse lookup & DBC validation
// Generated from animation_ids.hpp (452 constants, IDs 0451)
// ============================================================================
#include "rendering/animation_ids.hpp"
#include "rendering/animation/animation_ids.hpp"
#include "pipeline/dbc_loader.hpp"
#include "core/logger.hpp"

View file

@ -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<CharacterAnimator>());
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

View file

@ -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

View file

@ -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

View file

@ -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 <algorithm>
#include <cctype>
#include <unordered_set>
namespace wowee {
namespace rendering {
// ── Helper functions (moved from animation_controller.cpp) ───────────────────
static std::vector<std::string> parseEmoteCommands(const std::string& raw) {
std::vector<std::string> out;
std::string cur;
for (char c : raw) {
if (std::isalnum(static_cast<unsigned char>(c)) || c == '_') {
cur.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(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<std::string> 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<uint32_t, uint32_t> 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<uint32_t, std::string> 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<uint32_t, uint32_t> 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::EmoteResult> 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

View file

@ -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 <algorithm>
#include <cctype>
#include <cmath>
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<char>(std::tolower(static_cast<unsigned char>(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

View file

@ -0,0 +1,283 @@
#include "rendering/animation/locomotion_fsm.hpp"
#include "rendering/animation/animation_ids.hpp"
#include <algorithm>
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

View file

@ -0,0 +1,342 @@
#include "rendering/animation/mount_fsm.hpp"
#include "rendering/animation/animation_ids.hpp"
#include <algorithm>
#include <cmath>
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<float>(6.0f, 12.0f)(rng_);
nextIdleSoundTime_ = std::uniform_real_distribution<float>(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<size_t> dist(0, anims_.fidgets.size() - 1);
uint32_t fidgetAnim = anims_.fidgets[dist(rng_)];
activeFidget_ = fidgetAnim;
fidgetTimer_ = 0.0f;
nextFidgetTime_ = std::uniform_real_distribution<float>(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<float>(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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -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"

View file

@ -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"

View file

@ -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<size_t>(
@ -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<uint32_t>(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<M2InstanceGPU*>(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

View file

@ -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);

View file

@ -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)

112
tests/test_activity_fsm.cpp Normal file
View file

@ -0,0 +1,112 @@
// ActivityFSM unit tests
#include <catch_amalgamated.hpp>
#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());
}

View file

@ -0,0 +1,56 @@
// AnimCapabilitySet unit tests
#include <catch_amalgamated.hpp>
#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);
}

View file

@ -1,6 +1,6 @@
// Animation ID validation tests — covers nameFromId() and validateAgainstDBC()
#include <catch_amalgamated.hpp>
#include "rendering/animation_ids.hpp"
#include "rendering/animation/animation_ids.hpp"
#include "pipeline/dbc_loader.hpp"
#include <cstring>
#include <vector>

125
tests/test_combat_fsm.cpp Normal file
View file

@ -0,0 +1,125 @@
// CombatFSM unit tests
#include <catch_amalgamated.hpp>
#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());
}

View file

@ -0,0 +1,167 @@
// LocomotionFSM unit tests
#include <catch_amalgamated.hpp>
#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);
}