feat(animation): decompose AnimationController into FSM-based architecture

Replace the 2,200-line monolithic AnimationController (goto-driven,
single class, untestable) with a composed FSM architecture per
refactor.md.

New subsystem (src/rendering/animation/ — 16 headers, 10 sources):
- CharacterAnimator: FSM composer implementing ICharacterAnimator
- LocomotionFSM: idle/walk/run/sprint/jump/swim/strafe
- CombatFSM: melee/ranged/spell cast/stun/hit reaction/charge
- ActivityFSM: emote/loot/sit-down/sitting/sit-up
- MountFSM: idle/run/flight/taxi/fidget/rear-up (per-instance RNG)
- AnimCapabilitySet + AnimCapabilityProbe: probe once at model load,
  eliminate per-frame hasAnimation() linear search
- AnimationManager: registry of CharacterAnimator by GUID
- EmoteRegistry: DBC-backed emote command → animId singleton
- FootstepDriver, SfxStateDriver: extracted from AnimationController

animation_ids.hpp/.cpp moved to animation/ subdirectory (452 named
constants); all include paths updated.

AnimationController retained as thin adapter (~400 LOC): collects
FrameInput, delegates to CharacterAnimator, applies AnimOutput.

Priority order: Mount > Stun > HitReaction > Spell > Charge >
Melee/Ranged > CombatIdle > Emote > Loot > Sit > Locomotion.
STAY_IN_STATE policy when all FSMs return valid=false.

Bugs fixed:
- Remove static mt19937 in mount fidget (shared state across all
  mounted units) — replaced with per-instance seeded RNG
- Remove goto from mounted animation branch (skipped init)
- Remove per-frame hasAnimation() calls (now one probe at load)
- Fix VK_INDEX_TYPE_UINT16 → UINT32 in shadow pass

Tests (4 new suites, all ASAN+UBSan clean):
- test_locomotion_fsm: 167 assertions
- test_combat_fsm: 125 cases
- test_activity_fsm: 112 cases
- test_anim_capability: 56 cases

docs/ANIMATION_SYSTEM.md added (architecture reference).
This commit is contained in:
Paul 2026-04-05 12:27:35 +03:00
parent e58f9b4b40
commit b4989dc11f
53 changed files with 5110 additions and 2099 deletions

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