mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-06 17:13:51 +00:00
Extract CatmullRomSpline (include/math/spline.hpp, src/math/spline.cpp) as a
standalone, immutable, thread-safe spline module with O(log n) binary segment
search and fused position+tangent evaluation — replacing the duplicated O(n)
evalTimedCatmullRom/orientationFromTangent pair in TransportManager.
Consolidate 7 copies of spline packet parsing into shared functions in
game/spline_packet.{hpp,cpp}: parseMonsterMoveSplineBody (WotLK/TBC),
parseMonsterMoveSplineBodyVanilla, parseClassicMoveUpdateSpline,
parseWotlkMoveUpdateSpline, and decodePackedDelta. Named SplineFlag constants
replace magic hex literals throughout.
Extract TransportPathRepository (game/transport_path_repository.{hpp,cpp}) from
TransportManager — owns path data, DBC loading, and path inference. Paths stored
as PathEntry wrapping CatmullRomSpline + metadata (zOnly, fromDBC, worldCoords).
TransportManager reduced from ~1200 to ~500 lines, focused on transport lifecycle
and server sync.
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
548 lines
25 KiB
C++
548 lines
25 KiB
C++
#include "core/animation_callback_handler.hpp"
|
|
#include "core/entity_spawner.hpp"
|
|
#include "core/coordinates.hpp"
|
|
#include "core/logger.hpp"
|
|
#include "rendering/renderer.hpp"
|
|
#include "rendering/character_renderer.hpp"
|
|
#include "rendering/camera_controller.hpp"
|
|
#include "rendering/animation_controller.hpp"
|
|
#include "rendering/animation/animation_ids.hpp"
|
|
#include "game/game_handler.hpp"
|
|
#include "game/world_packets.hpp"
|
|
#include "audio/audio_engine.hpp"
|
|
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
|
|
namespace wowee { namespace core {
|
|
|
|
AnimationCallbackHandler::AnimationCallbackHandler(
|
|
EntitySpawner& entitySpawner,
|
|
rendering::Renderer& renderer,
|
|
game::GameHandler& gameHandler)
|
|
: entitySpawner_(entitySpawner)
|
|
, renderer_(renderer)
|
|
, gameHandler_(gameHandler)
|
|
{
|
|
}
|
|
|
|
void AnimationCallbackHandler::resetChargeState() {
|
|
chargeActive_ = false;
|
|
chargeTimer_ = 0.0f;
|
|
chargeDuration_ = 0.0f;
|
|
chargeTargetGuid_ = 0;
|
|
}
|
|
|
|
bool AnimationCallbackHandler::updateCharge(float deltaTime) {
|
|
if (!chargeActive_) return false;
|
|
|
|
// Warrior Charge: lerp position from start to end using smoothstep
|
|
chargeTimer_ += deltaTime;
|
|
float t = std::min(chargeTimer_ / chargeDuration_, 1.0f);
|
|
// smoothstep for natural acceleration/deceleration
|
|
float s = t * t * (3.0f - 2.0f * t);
|
|
glm::vec3 renderPos = chargeStartPos_ + (chargeEndPos_ - chargeStartPos_) * s;
|
|
renderer_.getCharacterPosition() = renderPos;
|
|
|
|
// Keep facing toward target and emit charge effect
|
|
glm::vec3 dir = chargeEndPos_ - chargeStartPos_;
|
|
float dirLenSq = glm::dot(dir, dir);
|
|
if (dirLenSq > 1e-4f) {
|
|
dir *= glm::inversesqrt(dirLenSq);
|
|
float yawDeg = glm::degrees(std::atan2(dir.x, dir.y));
|
|
renderer_.setCharacterYaw(yawDeg);
|
|
if (auto* ac = renderer_.getAnimationController()) ac->emitChargeEffect(renderPos, dir);
|
|
}
|
|
|
|
// Sync to game handler
|
|
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
|
|
gameHandler_.setPosition(canonical.x, canonical.y, canonical.z);
|
|
|
|
// Update camera follow target
|
|
if (renderer_.getCameraController()) {
|
|
glm::vec3* followTarget = renderer_.getCameraController()->getFollowTargetMutable();
|
|
if (followTarget) {
|
|
*followTarget = renderPos;
|
|
}
|
|
}
|
|
|
|
// Charge complete
|
|
if (t >= 1.0f) {
|
|
chargeActive_ = false;
|
|
if (auto* ac = renderer_.getAnimationController()) ac->setCharging(false);
|
|
if (auto* ac = renderer_.getAnimationController()) ac->stopChargeEffect();
|
|
renderer_.getCameraController()->setExternalFollow(false);
|
|
renderer_.getCameraController()->setExternalMoving(false);
|
|
|
|
// Snap to melee range of target's CURRENT position (it may have moved)
|
|
if (chargeTargetGuid_ != 0) {
|
|
auto targetEntity = gameHandler_.getEntityManager().getEntity(chargeTargetGuid_);
|
|
if (targetEntity) {
|
|
glm::vec3 targetCanonical(targetEntity->getX(), targetEntity->getY(), targetEntity->getZ());
|
|
glm::vec3 targetRender = core::coords::canonicalToRender(targetCanonical);
|
|
glm::vec3 toTarget = targetRender - renderPos;
|
|
float dSq = glm::dot(toTarget, toTarget);
|
|
if (dSq > 2.25f) {
|
|
// Place us 1.5 units from target (well within 8-unit melee range)
|
|
glm::vec3 snapPos = targetRender - toTarget * (1.5f * glm::inversesqrt(dSq));
|
|
renderer_.getCharacterPosition() = snapPos;
|
|
glm::vec3 snapCanonical = core::coords::renderToCanonical(snapPos);
|
|
gameHandler_.setPosition(snapCanonical.x, snapCanonical.y, snapCanonical.z);
|
|
if (renderer_.getCameraController()) {
|
|
glm::vec3* ft = renderer_.getCameraController()->getFollowTargetMutable();
|
|
if (ft) *ft = snapPos;
|
|
}
|
|
}
|
|
}
|
|
gameHandler_.startAutoAttack(chargeTargetGuid_);
|
|
if (auto* ac = renderer_.getAnimationController()) ac->triggerMeleeSwing();
|
|
}
|
|
|
|
// Send movement heartbeat so server knows our new position
|
|
gameHandler_.sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
|
|
}
|
|
|
|
return true; // charge is active
|
|
}
|
|
|
|
void AnimationCallbackHandler::setupCallbacks() {
|
|
// Sprint aura callback — use SPRINT(143) animation when sprint-type buff is active
|
|
gameHandler_.setSprintAuraCallback([this](bool active) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (ac) ac->setSprintAuraActive(active);
|
|
});
|
|
|
|
// Vehicle state callback — hide player character when inside a vehicle
|
|
gameHandler_.setVehicleStateCallback([this](bool entered, uint32_t /*vehicleId*/) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
uint32_t instId = renderer_.getCharacterInstanceId();
|
|
if (!cr || instId == 0) return;
|
|
cr->setInstanceVisible(instId, !entered);
|
|
});
|
|
|
|
// Charge callback — warrior rushes toward target
|
|
gameHandler_.setChargeCallback([this](uint64_t targetGuid, float tx, float ty, float tz) {
|
|
if (!renderer_.getCameraController()) return;
|
|
|
|
// Get current player position in render coords
|
|
glm::vec3 startRender = renderer_.getCharacterPosition();
|
|
// Convert target from canonical to render
|
|
glm::vec3 targetRender = core::coords::canonicalToRender(glm::vec3(tx, ty, tz));
|
|
|
|
// Compute direction and stop 2.0 units short (melee reach)
|
|
glm::vec3 dir = targetRender - startRender;
|
|
float distSq = glm::dot(dir, dir);
|
|
if (distSq < 9.0f) return; // Too close, nothing to do
|
|
float invDist = glm::inversesqrt(distSq);
|
|
glm::vec3 dirNorm = dir * invDist;
|
|
glm::vec3 endRender = targetRender - dirNorm * 2.0f;
|
|
|
|
// Face toward target BEFORE starting charge
|
|
float yawRad = std::atan2(dirNorm.x, dirNorm.y);
|
|
float yawDeg = glm::degrees(yawRad);
|
|
renderer_.setCharacterYaw(yawDeg);
|
|
// Sync canonical orientation to server so it knows we turned
|
|
float canonicalYaw = core::coords::normalizeAngleRad(glm::radians(180.0f - yawDeg));
|
|
gameHandler_.setOrientation(canonicalYaw);
|
|
gameHandler_.sendMovement(game::Opcode::MSG_MOVE_SET_FACING);
|
|
|
|
// Set charge state
|
|
chargeActive_ = true;
|
|
chargeTimer_ = 0.0f;
|
|
chargeDuration_ = std::max(std::sqrt(distSq) / 25.0f, 0.3f); // ~25 units/sec
|
|
chargeStartPos_ = startRender;
|
|
chargeEndPos_ = endRender;
|
|
chargeTargetGuid_ = targetGuid;
|
|
|
|
// Disable player input, play charge animation
|
|
renderer_.getCameraController()->setExternalFollow(true);
|
|
renderer_.getCameraController()->clearMovementInputs();
|
|
if (auto* ac = renderer_.getAnimationController()) ac->setCharging(true);
|
|
|
|
// Start charge visual effect (red haze + dust)
|
|
glm::vec3 chargeDir = glm::normalize(endRender - startRender);
|
|
if (auto* ac = renderer_.getAnimationController()) ac->startChargeEffect(startRender, chargeDir);
|
|
|
|
// Play charge whoosh sound (try multiple paths)
|
|
auto& audio = audio::AudioEngine::instance();
|
|
if (!audio.playSound2D("Sound\\Spells\\Charge.wav", 0.8f)) {
|
|
if (!audio.playSound2D("Sound\\Spells\\charge.wav", 0.8f)) {
|
|
if (!audio.playSound2D("Sound\\Spells\\SpellCharge.wav", 0.8f)) {
|
|
// Fallback: weapon whoosh
|
|
audio.playSound2D("Sound\\Item\\Weapons\\WeaponSwings\\mWooshLarge1.wav", 0.9f);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// NPC/player death callback (online mode) - play death animation
|
|
gameHandler_.setNpcDeathCallback([this](uint64_t guid) {
|
|
entitySpawner_.markCreatureDead(guid);
|
|
if (!renderer_.getCharacterRenderer()) return;
|
|
uint32_t instanceId = entitySpawner_.getCreatureInstanceId(guid);
|
|
if (instanceId == 0) instanceId = entitySpawner_.getPlayerInstanceId(guid);
|
|
if (instanceId != 0) {
|
|
renderer_.getCharacterRenderer()->playAnimation(instanceId, rendering::anim::DEATH, false);
|
|
}
|
|
});
|
|
|
|
// NPC/player respawn callback (online mode) - play rise animation then idle
|
|
gameHandler_.setNpcRespawnCallback([this](uint64_t guid) {
|
|
entitySpawner_.unmarkCreatureDead(guid);
|
|
if (!renderer_.getCharacterRenderer()) return;
|
|
uint32_t instanceId = entitySpawner_.getCreatureInstanceId(guid);
|
|
if (instanceId == 0) instanceId = entitySpawner_.getPlayerInstanceId(guid);
|
|
if (instanceId != 0) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
// Play RISE one-shot (auto-returns to STAND when finished), fall back to STAND
|
|
if (cr->hasAnimation(instanceId, rendering::anim::RISE))
|
|
cr->playAnimation(instanceId, rendering::anim::RISE, false);
|
|
else
|
|
cr->playAnimation(instanceId, rendering::anim::STAND, true);
|
|
}
|
|
});
|
|
|
|
// NPC/player swing callback (online mode) - play attack animation
|
|
// Probes the model for the best available attack animation:
|
|
// ATTACK_1H(17) → ATTACK_2H(18) → ATTACK_2H_LOOSE(19) → ATTACK_UNARMED(16)
|
|
gameHandler_.setNpcSwingCallback([this](uint64_t guid) {
|
|
if (!renderer_.getCharacterRenderer()) return;
|
|
uint32_t instanceId = entitySpawner_.getCreatureInstanceId(guid);
|
|
if (instanceId == 0) instanceId = entitySpawner_.getPlayerInstanceId(guid);
|
|
if (instanceId != 0) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
static const uint32_t attackAnims[] = {
|
|
rendering::anim::ATTACK_1H,
|
|
rendering::anim::ATTACK_2H,
|
|
rendering::anim::ATTACK_2H_LOOSE,
|
|
rendering::anim::ATTACK_UNARMED
|
|
};
|
|
bool played = false;
|
|
for (uint32_t anim : attackAnims) {
|
|
if (cr->hasAnimation(instanceId, anim)) {
|
|
cr->playAnimation(instanceId, anim, false);
|
|
played = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!played) cr->playAnimation(instanceId, rendering::anim::ATTACK_UNARMED, false);
|
|
}
|
|
});
|
|
|
|
// Hit reaction callback — plays one-shot dodge/block/wound animation on the victim
|
|
gameHandler_.setHitReactionCallback([this](uint64_t victimGuid, game::GameHandler::HitReaction reaction) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
if (!cr) return;
|
|
|
|
// Determine animation based on reaction type
|
|
uint32_t animId = rendering::anim::COMBAT_WOUND;
|
|
switch (reaction) {
|
|
case game::GameHandler::HitReaction::DODGE: animId = rendering::anim::DODGE; break;
|
|
case game::GameHandler::HitReaction::PARRY: break; // Parry already handled by existing system
|
|
case game::GameHandler::HitReaction::BLOCK: animId = rendering::anim::BLOCK; break;
|
|
case game::GameHandler::HitReaction::SHIELD_BLOCK: animId = rendering::anim::SHIELD_BLOCK; break;
|
|
case game::GameHandler::HitReaction::CRIT_WOUND: animId = rendering::anim::COMBAT_CRITICAL; break;
|
|
case game::GameHandler::HitReaction::WOUND: animId = rendering::anim::COMBAT_WOUND; break;
|
|
}
|
|
|
|
// For local player: use AnimationController state
|
|
bool isLocalPlayer = (victimGuid == gameHandler_.getPlayerGuid());
|
|
if (isLocalPlayer) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (ac) {
|
|
uint32_t charInstId = renderer_.getCharacterInstanceId();
|
|
if (charInstId && cr->hasAnimation(charInstId, animId))
|
|
ac->triggerHitReaction(animId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// For NPCs/other players: direct playAnimation
|
|
uint32_t instanceId = entitySpawner_.getCreatureInstanceId(victimGuid);
|
|
if (instanceId == 0) instanceId = entitySpawner_.getPlayerInstanceId(victimGuid);
|
|
if (instanceId != 0 && cr->hasAnimation(instanceId, animId))
|
|
cr->playAnimation(instanceId, animId, false);
|
|
});
|
|
|
|
// Stun state callback — enters/exits STUNNED animation on local player
|
|
gameHandler_.setStunStateCallback([this](bool stunned) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (ac) ac->setStunned(stunned);
|
|
});
|
|
|
|
// Stealth state callback — switches to stealth animation variants
|
|
gameHandler_.setStealthStateCallback([this](bool stealthed) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (ac) ac->setStealthed(stealthed);
|
|
});
|
|
|
|
// Player health callback — switches to wounded idle when HP < 20%
|
|
gameHandler_.setPlayerHealthCallback([this](uint32_t health, uint32_t maxHealth) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (!ac) return;
|
|
bool lowHp = (maxHealth > 0) && (health > 0) && (health * 5 <= maxHealth);
|
|
ac->setLowHealth(lowHp);
|
|
});
|
|
|
|
// Unit animation hint callback — plays jump (38=JumpMid) animation on other players/NPCs.
|
|
// Swim/walking state is now authoritative from the move-flags callback below.
|
|
// animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync.
|
|
gameHandler_.setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
if (!cr) return;
|
|
uint32_t instanceId = entitySpawner_.getPlayerInstanceId(guid);
|
|
if (instanceId == 0) instanceId = entitySpawner_.getCreatureInstanceId(guid);
|
|
if (instanceId == 0) return;
|
|
// Don't override Death animation
|
|
uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f;
|
|
if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == rendering::anim::DEATH) return;
|
|
cr->playAnimation(instanceId, animId, /*loop=*/true);
|
|
});
|
|
|
|
// Unit move-flags callback — updates swimming and walking state from every MSG_MOVE_* packet.
|
|
// This is more reliable than opcode-based hints for cold joins and heartbeats:
|
|
// a player already swimming when we join will have SWIMMING set on the first heartbeat.
|
|
// Walking(4) vs Running(5) is also driven here from the WALKING flag.
|
|
gameHandler_.setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) {
|
|
const bool isSwimming = (moveFlags & static_cast<uint32_t>(game::MovementFlags::SWIMMING)) != 0;
|
|
const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0;
|
|
const bool isFlying = (moveFlags & static_cast<uint32_t>(game::MovementFlags::FLYING)) != 0;
|
|
auto& swimState = entitySpawner_.getCreatureSwimmingState();
|
|
auto& walkState = entitySpawner_.getCreatureWalkingState();
|
|
auto& flyState = entitySpawner_.getCreatureFlyingState();
|
|
if (isSwimming) swimState[guid] = true;
|
|
else swimState.erase(guid);
|
|
if (isWalking) walkState[guid] = true;
|
|
else walkState.erase(guid);
|
|
if (isFlying) flyState[guid] = true;
|
|
else flyState.erase(guid);
|
|
});
|
|
|
|
// Emote animation callback — play server-driven emote animations on NPCs and other players.
|
|
// When emoteAnim is 0, the NPC's emote state was cleared → revert to STAND.
|
|
// Non-zero values from UNIT_NPC_EMOTESTATE updates are persistent (played looping).
|
|
gameHandler_.setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
if (!cr) return;
|
|
// Look up creature instance first, then online players
|
|
uint32_t emoteInstanceId = entitySpawner_.getCreatureInstanceId(guid);
|
|
if (emoteInstanceId != 0) {
|
|
if (emoteAnim == 0) {
|
|
// Emote state cleared → return to idle
|
|
cr->playAnimation(emoteInstanceId, rendering::anim::STAND, true);
|
|
} else {
|
|
cr->playAnimation(emoteInstanceId, emoteAnim, false);
|
|
}
|
|
return;
|
|
}
|
|
emoteInstanceId = entitySpawner_.getPlayerInstanceId(guid);
|
|
if (emoteInstanceId != 0) {
|
|
cr->playAnimation(emoteInstanceId, emoteAnim, false);
|
|
}
|
|
});
|
|
|
|
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
|
|
// WoW-accurate 3-phase spell animation sequence:
|
|
// SPELL_PRECAST (31) — one-shot wind-up
|
|
// READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills
|
|
// SPELL_CAST_DIRECTED/OMNI/AREA (53/54/33) — one-shot release at completion
|
|
// Channels use CHANNEL_CAST_DIRECTED/OMNI (124/125) or SPELL_CHANNEL_DIRECTED_OMNI (201).
|
|
// castType comes from the spell packet's targetGuid:
|
|
// DIRECTED — spell targets a specific unit (Frostbolt, Heal)
|
|
// OMNI — self-cast / no explicit target (Arcane Explosion, buffs)
|
|
// AREA — ground-targeted AoE (Blizzard, Rain of Fire)
|
|
gameHandler_.setSpellCastAnimCallback([this](uint64_t guid, bool start, bool isChannel,
|
|
game::SpellCastType castType) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
if (!cr) return;
|
|
|
|
// Determine if this is the local player
|
|
bool isLocalPlayer = false;
|
|
uint32_t instanceId = 0;
|
|
{
|
|
uint32_t charInstId = renderer_.getCharacterInstanceId();
|
|
if (charInstId != 0 && guid == gameHandler_.getPlayerGuid()) {
|
|
instanceId = charInstId;
|
|
isLocalPlayer = true;
|
|
}
|
|
}
|
|
if (instanceId == 0) instanceId = entitySpawner_.getCreatureInstanceId(guid);
|
|
if (instanceId == 0) instanceId = entitySpawner_.getPlayerInstanceId(guid);
|
|
if (instanceId == 0) return;
|
|
|
|
const bool isDirected = (castType == game::SpellCastType::DIRECTED);
|
|
const bool isArea = (castType == game::SpellCastType::AREA);
|
|
|
|
if (start) {
|
|
// Detect fishing spells (channeled) — use FISHING_LOOP instead of generic cast
|
|
auto isFishingSpell = [](uint32_t spellId) {
|
|
return spellId == 7620 || spellId == 7731 || spellId == 7732 ||
|
|
spellId == 18248 || spellId == 33095 || spellId == 51294;
|
|
};
|
|
uint32_t currentSpell = isLocalPlayer ? gameHandler_.getCurrentCastSpellId() : 0;
|
|
bool isFishing = isChannel && isFishingSpell(currentSpell);
|
|
|
|
if (isFishing && cr->hasAnimation(instanceId, rendering::anim::FISHING_LOOP)) {
|
|
// Fishing: use FISHING_LOOP (looping idle) for the channel duration
|
|
if (isLocalPlayer) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (ac) ac->startSpellCast(0, rendering::anim::FISHING_LOOP, true, 0);
|
|
} else {
|
|
cr->playAnimation(instanceId, rendering::anim::FISHING_LOOP, true);
|
|
}
|
|
} else {
|
|
// Helper: pick first animation the model supports from a list
|
|
auto pickFirst = [&](std::initializer_list<uint32_t> ids) -> uint32_t {
|
|
for (uint32_t id : ids)
|
|
if (cr->hasAnimation(instanceId, id)) return id;
|
|
return 0;
|
|
};
|
|
|
|
// Precast wind-up (one-shot, non-channels only)
|
|
uint32_t precastAnim = 0;
|
|
if (!isChannel) {
|
|
precastAnim = pickFirst({rendering::anim::SPELL_PRECAST});
|
|
}
|
|
|
|
// Cast hold (looping while cast bar fills / channel active)
|
|
uint32_t castAnim = 0;
|
|
if (isChannel) {
|
|
// Channel hold: prefer DIRECTED/OMNI based on spell target classification
|
|
if (isDirected) {
|
|
castAnim = pickFirst({
|
|
rendering::anim::CHANNEL_CAST_DIRECTED,
|
|
rendering::anim::CHANNEL_CAST_OMNI,
|
|
rendering::anim::SPELL_CHANNEL_DIRECTED_OMNI,
|
|
rendering::anim::READY_SPELL_DIRECTED,
|
|
rendering::anim::SPELL
|
|
});
|
|
} else {
|
|
// OMNI or AREA channels (Blizzard channel, Tranquility, etc.)
|
|
castAnim = pickFirst({
|
|
rendering::anim::CHANNEL_CAST_OMNI,
|
|
rendering::anim::CHANNEL_CAST_DIRECTED,
|
|
rendering::anim::SPELL_CHANNEL_DIRECTED_OMNI,
|
|
rendering::anim::READY_SPELL_OMNI,
|
|
rendering::anim::SPELL
|
|
});
|
|
}
|
|
} else {
|
|
// Regular cast hold: READY_SPELL_DIRECTED/OMNI while cast bar fills
|
|
if (isDirected) {
|
|
castAnim = pickFirst({
|
|
rendering::anim::READY_SPELL_DIRECTED,
|
|
rendering::anim::READY_SPELL_OMNI,
|
|
rendering::anim::SPELL_CAST_DIRECTED,
|
|
rendering::anim::SPELL_CAST,
|
|
rendering::anim::SPELL
|
|
});
|
|
} else {
|
|
// OMNI (self-buff) or AREA (AoE targeting)
|
|
castAnim = pickFirst({
|
|
rendering::anim::READY_SPELL_OMNI,
|
|
rendering::anim::READY_SPELL_DIRECTED,
|
|
rendering::anim::SPELL_CAST_OMNI,
|
|
rendering::anim::SPELL_CAST,
|
|
rendering::anim::SPELL
|
|
});
|
|
}
|
|
}
|
|
if (castAnim == 0) castAnim = rendering::anim::SPELL;
|
|
|
|
// Finalization release (one-shot after cast completes)
|
|
// Animation chosen by spell target type: AREA → SPELL_CAST_AREA,
|
|
// DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI
|
|
uint32_t finalizeAnim = 0;
|
|
if (isLocalPlayer && !isChannel) {
|
|
if (isArea) {
|
|
// Ground-targeted AoE: SPELL_CAST_AREA → SPELL_CAST_OMNI
|
|
finalizeAnim = pickFirst({
|
|
rendering::anim::SPELL_CAST_AREA,
|
|
rendering::anim::SPELL_CAST_OMNI,
|
|
rendering::anim::SPELL_CAST,
|
|
rendering::anim::SPELL
|
|
});
|
|
} else if (isDirected) {
|
|
// Single-target: SPELL_CAST_DIRECTED → SPELL_CAST_OMNI
|
|
finalizeAnim = pickFirst({
|
|
rendering::anim::SPELL_CAST_DIRECTED,
|
|
rendering::anim::SPELL_CAST_OMNI,
|
|
rendering::anim::SPELL_CAST,
|
|
rendering::anim::SPELL
|
|
});
|
|
} else {
|
|
// OMNI (self-buff, Arcane Explosion): SPELL_CAST_OMNI → SPELL_CAST_AREA
|
|
finalizeAnim = pickFirst({
|
|
rendering::anim::SPELL_CAST_OMNI,
|
|
rendering::anim::SPELL_CAST_AREA,
|
|
rendering::anim::SPELL_CAST,
|
|
rendering::anim::SPELL
|
|
});
|
|
}
|
|
}
|
|
|
|
if (isLocalPlayer) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (ac) ac->startSpellCast(precastAnim, castAnim, true, finalizeAnim);
|
|
} else {
|
|
cr->playAnimation(instanceId, castAnim, true);
|
|
}
|
|
} // end !isFishing
|
|
} else {
|
|
// Cast/channel ended — plays finalization anim completely then returns to idle
|
|
if (isLocalPlayer) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (ac) ac->stopSpellCast();
|
|
} else if (isChannel) {
|
|
cr->playAnimation(instanceId, rendering::anim::STAND, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Ghost state callback — make player semi-transparent when in spirit form
|
|
gameHandler_.setGhostStateCallback([this](bool isGhost) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
if (!cr) return;
|
|
uint32_t charInstId = renderer_.getCharacterInstanceId();
|
|
if (charInstId == 0) return;
|
|
cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f);
|
|
});
|
|
|
|
// Stand state animation callback — route through AnimationController state machine
|
|
// for proper sit/sleep/kneel transition animations (down → loop → up)
|
|
gameHandler_.setStandStateCallback([this](uint8_t standState) {
|
|
using AC = rendering::AnimationController;
|
|
|
|
// Sync camera controller sitting flag: block movement while sitting/kneeling
|
|
if (auto* cc = renderer_.getCameraController()) {
|
|
cc->setSitting(standState >= AC::STAND_STATE_SIT &&
|
|
standState <= AC::STAND_STATE_KNEEL &&
|
|
standState != AC::STAND_STATE_DEAD);
|
|
}
|
|
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (!ac) return;
|
|
|
|
// Death is special — play directly, not through sit state machine
|
|
if (standState == AC::STAND_STATE_DEAD) {
|
|
auto* cr = renderer_.getCharacterRenderer();
|
|
if (!cr) return;
|
|
uint32_t charInstId = renderer_.getCharacterInstanceId();
|
|
if (charInstId == 0) return;
|
|
cr->playAnimation(charInstId, rendering::anim::DEATH, false);
|
|
return;
|
|
}
|
|
|
|
ac->setStandState(standState);
|
|
});
|
|
|
|
// Loot window callback — play kneel/loot animation while looting
|
|
gameHandler_.setLootWindowCallback([this](bool open) {
|
|
auto* ac = renderer_.getAnimationController();
|
|
if (!ac) return;
|
|
if (open) ac->startLooting();
|
|
else ac->stopLooting();
|
|
});
|
|
}
|
|
|
|
}} // namespace wowee::core
|