diff --git a/CMakeLists.txt b/CMakeLists.txt index 84e84b62..b9cacced 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -490,6 +490,13 @@ set(WOWEE_SOURCES src/core/entity_spawner.cpp src/core/appearance_composer.cpp src/core/world_loader.cpp + src/core/npc_interaction_callback_handler.cpp + src/core/audio_callback_handler.cpp + src/core/entity_spawn_callback_handler.cpp + src/core/animation_callback_handler.cpp + src/core/transport_callback_handler.cpp + src/core/world_entry_callback_handler.cpp + src/core/ui_screen_callback_handler.cpp src/core/window.cpp src/core/input.cpp src/core/logger.cpp diff --git a/include/core/animation_callback_handler.hpp b/include/core/animation_callback_handler.hpp new file mode 100644 index 00000000..8353f7c7 --- /dev/null +++ b/include/core/animation_callback_handler.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +namespace wowee { + +namespace rendering { class Renderer; } +namespace game { class GameHandler; } +namespace core { class EntitySpawner; } + +namespace core { + +/// Handles animation callbacks: death, respawn, swing, hit reaction, spell cast, emote, +/// stun, stealth, health, ghost, stand state, loot, sprint, vehicle, charge. +/// Owns charge rush state (interpolated in update). +class AnimationCallbackHandler { +public: + AnimationCallbackHandler(EntitySpawner& entitySpawner, + rendering::Renderer& renderer, + game::GameHandler& gameHandler); + + void setupCallbacks(); + + /// Called each frame from Application::update() to drive charge interpolation. + /// Returns true if charge is active (player is externally driven). + bool updateCharge(float deltaTime); + + // Charge state queries (used by Application::update for externallyDrivenMotion) + bool isCharging() const { return chargeActive_; } + + // Reset charge state (logout/disconnect) + void resetChargeState(); + +private: + EntitySpawner& entitySpawner_; + rendering::Renderer& renderer_; + game::GameHandler& gameHandler_; + + // Charge rush state (moved from Application) + bool chargeActive_ = false; + float chargeTimer_ = 0.0f; + float chargeDuration_ = 0.0f; + glm::vec3 chargeStartPos_{0.0f}; // Render coordinates + glm::vec3 chargeEndPos_{0.0f}; // Render coordinates + uint64_t chargeTargetGuid_ = 0; +}; + +} // namespace core +} // namespace wowee diff --git a/include/core/application.hpp b/include/core/application.hpp index 148131ac..4e7fd2d0 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -34,6 +34,15 @@ namespace addons { class AddonManager; } namespace core { +// Handler forward declarations +class NPCInteractionCallbackHandler; +class AudioCallbackHandler; +class EntitySpawnCallbackHandler; +class AnimationCallbackHandler; +class TransportCallbackHandler; +class WorldEntryCallbackHandler; +class UIScreenCallbackHandler; + enum class AppState { AUTHENTICATION, REALM_SELECTION, @@ -134,9 +143,17 @@ private: std::unique_ptr worldLoader_; std::unique_ptr audioCoordinator_; + // Callback handlers (extracted from setupUICallbacks) + std::unique_ptr npcInteractionCallbacks_; + std::unique_ptr audioCallbacks_; + std::unique_ptr entitySpawnCallbacks_; + std::unique_ptr animationCallbacks_; + std::unique_ptr transportCallbacks_; + std::unique_ptr worldEntryCallbacks_; + std::unique_ptr uiScreenCallbacks_; + AppState state = AppState::AUTHENTICATION; bool running = false; - std::string pendingCreatedCharacterName_; // Auto-select after character creation bool playerCharacterSpawned = false; bool npcsSpawned = false; bool spawnSnapToGround = true; @@ -154,27 +171,11 @@ private: static inline const std::string emptyString_; static inline const std::vector emptyStringVec_; - bool lastTaxiFlight_ = false; - float taxiLandingClampTimer_ = 0.0f; - float worldEntryMovementGraceTimer_ = 0.0f; - - // Hearth teleport: freeze player until terrain loads at destination - bool hearthTeleportPending_ = false; - glm::vec3 hearthTeleportPos_{0.0f}; // render coords - float hearthTeleportTimer_ = 0.0f; // timeout safety float facingSendCooldown_ = 0.0f; // Rate-limits MSG_MOVE_SET_FACING float lastSentCanonicalYaw_ = 1000.0f; // Sentinel — triggers first send float taxiStreamCooldown_ = 0.0f; bool idleYawned_ = false; - // Charge rush state - bool chargeActive_ = false; - float chargeTimer_ = 0.0f; - float chargeDuration_ = 0.0f; - glm::vec3 chargeStartPos_{0.0f}; // Render coordinates - glm::vec3 chargeEndPos_{0.0f}; // Render coordinates - uint64_t chargeTargetGuid_ = 0; - bool wasAutoAttacking_ = false; // Quest marker billboard sprites (above NPCs) diff --git a/include/core/audio_callback_handler.hpp b/include/core/audio_callback_handler.hpp new file mode 100644 index 00000000..853c6eb4 --- /dev/null +++ b/include/core/audio_callback_handler.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +namespace wowee { + +namespace rendering { class Renderer; } +namespace game { class GameHandler; } +namespace audio { class AudioCoordinator; } +namespace pipeline { class AssetManager; } +namespace ui { class UIManager; } + +namespace core { + +/// Handles audio-related callbacks: music, sound effects, level-up, achievement, LFG. +class AudioCallbackHandler { +public: + AudioCallbackHandler(pipeline::AssetManager& assetManager, + audio::AudioCoordinator* audioCoordinator, + rendering::Renderer* renderer, + ui::UIManager* uiManager, + game::GameHandler& gameHandler); + + void setupCallbacks(); + +private: + /// Resolve SoundEntries.dbc → file path for a given soundId (eliminates 3x copy-paste) + std::optional resolveSoundEntryPath(uint32_t soundId) const; + + pipeline::AssetManager& assetManager_; + audio::AudioCoordinator* audioCoordinator_; + rendering::Renderer* renderer_; + ui::UIManager* uiManager_; + game::GameHandler& gameHandler_; +}; + +} // namespace core +} // namespace wowee diff --git a/include/core/entity_spawn_callback_handler.hpp b/include/core/entity_spawn_callback_handler.hpp new file mode 100644 index 00000000..f6abe038 --- /dev/null +++ b/include/core/entity_spawn_callback_handler.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +namespace wowee { + +namespace rendering { class Renderer; } +namespace game { class GameHandler; } +namespace core { class EntitySpawner; } + +namespace core { + +/// Handles entity spawn/despawn callbacks: creatures, players, game objects. +class EntitySpawnCallbackHandler { +public: + /// @param isLocalPlayerGuid Returns true if the given GUID is the local player (to skip self-spawn) + EntitySpawnCallbackHandler(EntitySpawner& entitySpawner, + rendering::Renderer& renderer, + game::GameHandler& gameHandler, + std::function isLocalPlayerGuid); + + void setupCallbacks(); + +private: + EntitySpawner& entitySpawner_; + rendering::Renderer& renderer_; + game::GameHandler& gameHandler_; + std::function isLocalPlayerGuid_; +}; + +} // namespace core +} // namespace wowee diff --git a/include/core/npc_interaction_callback_handler.hpp b/include/core/npc_interaction_callback_handler.hpp new file mode 100644 index 00000000..80930a4d --- /dev/null +++ b/include/core/npc_interaction_callback_handler.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include "audio/npc_voice_manager.hpp" + +namespace wowee { + +namespace rendering { class Renderer; } +namespace game { class GameHandler; } +namespace audio { class AudioCoordinator; } +namespace core { class EntitySpawner; } + +namespace core { + +/// Handles NPC interaction callbacks: greeting, farewell, vendor, aggro voice lines. +class NPCInteractionCallbackHandler { +public: + NPCInteractionCallbackHandler(EntitySpawner& entitySpawner, + rendering::Renderer* renderer, + game::GameHandler& gameHandler, + audio::AudioCoordinator* audioCoordinator); + + void setupCallbacks(); + +private: + /// Resolve NPC voice type from GUID (eliminates 4x copy-paste of display-ID lookup) + audio::VoiceType resolveNpcVoiceType(uint64_t guid) const; + + EntitySpawner& entitySpawner_; + rendering::Renderer* renderer_; + game::GameHandler& gameHandler_; + audio::AudioCoordinator* audioCoordinator_; +}; + +} // namespace core +} // namespace wowee diff --git a/include/core/transport_callback_handler.hpp b/include/core/transport_callback_handler.hpp new file mode 100644 index 00000000..0216dc2c --- /dev/null +++ b/include/core/transport_callback_handler.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace wowee { + +namespace rendering { class Renderer; } +namespace game { class GameHandler; } +namespace core { class EntitySpawner; class WorldLoader; } + +namespace core { + +/// Handles transport-related callbacks: transport spawn/move, taxi, mount. +class TransportCallbackHandler { +public: + TransportCallbackHandler(EntitySpawner& entitySpawner, + rendering::Renderer& renderer, + game::GameHandler& gameHandler, + WorldLoader* worldLoader); + + void setupCallbacks(); + +private: + EntitySpawner& entitySpawner_; + rendering::Renderer& renderer_; + game::GameHandler& gameHandler_; + WorldLoader* worldLoader_; +}; + +} // namespace core +} // namespace wowee diff --git a/include/core/ui_screen_callback_handler.hpp b/include/core/ui_screen_callback_handler.hpp new file mode 100644 index 00000000..ee71f9bb --- /dev/null +++ b/include/core/ui_screen_callback_handler.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +namespace wowee { + +namespace ui { class UIManager; } +namespace game { class GameHandler; class ExpansionRegistry; } +namespace auth { class AuthHandler; } +namespace pipeline { class AssetManager; } + +namespace core { + +// Forward-declared in application.hpp +enum class AppState; + +/// Handles authentication, realm selection, character selection/creation UI callbacks. +/// Owns pendingCreatedCharacterName_. +class UIScreenCallbackHandler { +public: + using SetStateFn = std::function; + + UIScreenCallbackHandler(ui::UIManager& uiManager, + game::GameHandler& gameHandler, + auth::AuthHandler& authHandler, + game::ExpansionRegistry* expansionRegistry, + pipeline::AssetManager* assetManager, + SetStateFn setState); + + void setupCallbacks(); + +private: + ui::UIManager& uiManager_; + game::GameHandler& gameHandler_; + auth::AuthHandler& authHandler_; + game::ExpansionRegistry* expansionRegistry_; + pipeline::AssetManager* assetManager_; + SetStateFn setState_; + + std::string pendingCreatedCharacterName_; // Auto-select after character creation +}; + +} // namespace core +} // namespace wowee diff --git a/include/core/world_entry_callback_handler.hpp b/include/core/world_entry_callback_handler.hpp new file mode 100644 index 00000000..12eed5ec --- /dev/null +++ b/include/core/world_entry_callback_handler.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +namespace wowee { + +namespace rendering { class Renderer; } +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace audio { class AudioCoordinator; } +namespace core { class EntitySpawner; class WorldLoader; } + +namespace core { + +/// Handles world entry, unstuck, hearthstone, and bind point callbacks. +/// Owns hearth-teleport state and worldEntryMovementGraceTimer. +class WorldEntryCallbackHandler { +public: + WorldEntryCallbackHandler(rendering::Renderer& renderer, + game::GameHandler& gameHandler, + WorldLoader* worldLoader, + EntitySpawner* entitySpawner, + audio::AudioCoordinator* audioCoordinator, + pipeline::AssetManager* assetManager); + + void setupCallbacks(); + + /// Called each frame from Application::update() to manage hearth-teleport freeze/thaw. + void update(float deltaTime); + + // State queries (used by Application::update) + float getWorldEntryMovementGraceTimer() const { return worldEntryMovementGraceTimer_; } + void setWorldEntryMovementGraceTimer(float t) { worldEntryMovementGraceTimer_ = t; } + bool isHearthTeleportPending() const { return hearthTeleportPending_; } + + // Reset state (logout/disconnect) + void resetState(); + + // Taxi state (managed by Application::update, but tracked here for clarity) + bool getLastTaxiFlight() const { return lastTaxiFlight_; } + void setLastTaxiFlight(bool v) { lastTaxiFlight_ = v; } + float getTaxiLandingClampTimer() const { return taxiLandingClampTimer_; } + void setTaxiLandingClampTimer(float t) { taxiLandingClampTimer_ = t; } + +private: + /// Sample best floor height at (x, y) from terrain, WMO, and M2 (eliminates 3x duplication) + std::optional sampleBestFloorAt(float x, float y, float probeZ) const; + + /// Clear stuck movement state on player + void clearStuckMovement(); + + /// Sync teleported render position to server + void syncTeleportedPositionToServer(const glm::vec3& renderPos); + + /// Force server-side teleport via GM command + void forceServerTeleportCommand(const glm::vec3& renderPos); + + rendering::Renderer& renderer_; + game::GameHandler& gameHandler_; + WorldLoader* worldLoader_; + EntitySpawner* entitySpawner_; + audio::AudioCoordinator* audioCoordinator_; + pipeline::AssetManager* assetManager_; + + // Hearth teleport: freeze player until terrain loads at destination (moved from Application) + bool hearthTeleportPending_ = false; + glm::vec3 hearthTeleportPos_{0.0f}; // render coords + float hearthTeleportTimer_ = 0.0f; // timeout safety + + float worldEntryMovementGraceTimer_ = 0.0f; + bool lastTaxiFlight_ = false; + float taxiLandingClampTimer_ = 0.0f; +}; + +} // namespace core +} // namespace wowee diff --git a/src/core/animation_callback_handler.cpp b/src/core/animation_callback_handler.cpp new file mode 100644 index 00000000..b5967819 --- /dev/null +++ b/src/core/animation_callback_handler.cpp @@ -0,0 +1,548 @@ +#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 +#include + +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); + renderer_.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; + renderer_.setCharging(false); + renderer_.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_); + renderer_.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(); + renderer_.setCharging(true); + + // Start charge visual effect (red haze + dust) + glm::vec3 chargeDir = glm::normalize(endRender - startRender); + renderer_.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(game::MovementFlags::SWIMMING)) != 0; + const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; + const bool isFlying = (moveFlags & static_cast(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: + // Phase 1: SPELL_PRECAST (31) — one-shot wind-up + // Phase 2: READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills + // Phase 3: SPELL_CAST_DIRECTED/OMNI/AREA (53/54/33) — one-shot release at completion + // Channels use CHANNEL_CAST_DIRECTED/OMNI (124/125) or SPELL_CHANNEL_DIRECTED_OMNI (201). + // castType comes from the spell packet's targetGuid: + // DIRECTED — spell targets a specific unit (Frostbolt, Heal) + // OMNI — self-cast / no explicit target (Arcane Explosion, buffs) + // AREA — ground-targeted AoE (Blizzard, Rain of Fire) + gameHandler_.setSpellCastAnimCallback([this](uint64_t guid, bool start, bool isChannel, + game::SpellCastType castType) { + 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 ids) -> uint32_t { + for (uint32_t id : ids) + if (cr->hasAnimation(instanceId, id)) return id; + return 0; + }; + + // Phase 1: Precast wind-up (one-shot, non-channels only) + uint32_t precastAnim = 0; + if (!isChannel) { + precastAnim = pickFirst({rendering::anim::SPELL_PRECAST}); + } + + // Phase 2: 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; + + // Phase 3: Finalization release (one-shot after cast completes) + // Animation chosen by spell target type: AREA → SPELL_CAST_AREA, + // DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI + uint32_t finalizeAnim = 0; + if (isLocalPlayer && !isChannel) { + 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 diff --git a/src/core/application.cpp b/src/core/application.cpp index e9f26e41..ee8b0e64 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1,6 +1,13 @@ #include "core/application.hpp" #include "core/coordinates.hpp" #include "core/profiler.hpp" +#include "core/npc_interaction_callback_handler.hpp" +#include "core/audio_callback_handler.hpp" +#include "core/entity_spawn_callback_handler.hpp" +#include "core/animation_callback_handler.hpp" +#include "core/transport_callback_handler.hpp" +#include "core/world_entry_callback_handler.hpp" +#include "core/ui_screen_callback_handler.hpp" #include "rendering/animation/animation_ids.hpp" #include "rendering/animation_controller.hpp" #include @@ -1071,19 +1078,14 @@ void Application::logoutToLogin() { if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false); wasAutoAttacking_ = false; if (worldLoader_) worldLoader_->resetLoadedMap(); - lastTaxiFlight_ = false; - taxiLandingClampTimer_ = 0.0f; - worldEntryMovementGraceTimer_ = 0.0f; + if (worldEntryCallbacks_) worldEntryCallbacks_->resetState(); facingSendCooldown_ = 0.0f; lastSentCanonicalYaw_ = 1000.0f; taxiStreamCooldown_ = 0.0f; idleYawned_ = false; // --- Charge state --- - chargeActive_ = false; - chargeTimer_ = 0.0f; - chargeDuration_ = 0.0f; - chargeTargetGuid_ = 0; + if (animationCallbacks_) animationCallbacks_->resetChargeState(); // --- Player identity --- spawnedPlayerGuid_ = 0; @@ -1377,51 +1379,38 @@ void Application::update(float deltaTime) { isM2Transport = (tr && tr->isM2); } bool onWMOTransport = onTransportNow && !isM2Transport; - if (worldEntryMovementGraceTimer_ > 0.0f) { - worldEntryMovementGraceTimer_ -= deltaTime; + if (worldEntryCallbacks_ && worldEntryCallbacks_->getWorldEntryMovementGraceTimer() > 0.0f) { + worldEntryCallbacks_->setWorldEntryMovementGraceTimer( + worldEntryCallbacks_->getWorldEntryMovementGraceTimer() - deltaTime); // Clear stale movement from before teleport each frame // until grace period expires (keys may still be held) if (renderer && renderer->getCameraController()) renderer->getCameraController()->clearMovementInputs(); } - // Hearth teleport: keep player frozen until terrain loads at destination - if (hearthTeleportPending_ && renderer && renderer->getTerrainManager()) { - hearthTeleportTimer_ -= deltaTime; - auto terrainH = renderer->getTerrainManager()->getHeightAt( - hearthTeleportPos_.x, hearthTeleportPos_.y); - if (terrainH || hearthTeleportTimer_ <= 0.0f) { - // Terrain loaded (or timeout) — snap to floor and release - if (terrainH) { - hearthTeleportPos_.z = *terrainH + 0.5f; - renderer->getCameraController()->teleportTo(hearthTeleportPos_); - } - renderer->getCameraController()->setExternalFollow(false); - worldEntryMovementGraceTimer_ = 1.0f; - hearthTeleportPending_ = false; - LOG_INFO("Unstuck hearth: terrain loaded, player released", - terrainH ? "" : " (timeout)"); - } + // Hearth teleport: delegated to WorldEntryCallbackHandler + if (worldEntryCallbacks_) { + worldEntryCallbacks_->update(deltaTime); } if (renderer && renderer->getCameraController()) { - const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_; + const bool externallyDrivenMotion = onTaxi || onWMOTransport || (animationCallbacks_ && animationCallbacks_->isCharging()); // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. - bool hearthFreeze = hearthTeleportPending_; - bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f && - worldEntryMovementGraceTimer_ <= 0.0f && + bool hearthFreeze = worldEntryCallbacks_ && worldEntryCallbacks_->isHearthTeleportPending(); + bool landingClampActive = !onTaxi && worldEntryCallbacks_ && worldEntryCallbacks_->getTaxiLandingClampTimer() > 0.0f && + worldEntryCallbacks_->getWorldEntryMovementGraceTimer() <= 0.0f && !gameHandler->isMounted(); renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive || hearthFreeze); renderer->getCameraController()->setExternalMoving(externallyDrivenMotion); if (externallyDrivenMotion) { // Drop any stale local movement toggles while server drives taxi motion. renderer->getCameraController()->clearMovementInputs(); - taxiLandingClampTimer_ = 0.0f; + if (worldEntryCallbacks_) worldEntryCallbacks_->setTaxiLandingClampTimer(0.0f); } - if (lastTaxiFlight_ && !onTaxi) { + if (worldEntryCallbacks_ && worldEntryCallbacks_->getLastTaxiFlight() && !onTaxi) { renderer->getCameraController()->clearMovementInputs(); // Keep clamping until terrain loads at landing position. // Timer only counts down once a valid floor is found. - taxiLandingClampTimer_ = 2.0f; + if (worldEntryCallbacks_) worldEntryCallbacks_->setTaxiLandingClampTimer(2.0f); } if (landingClampActive) { if (renderer && gameHandler) { @@ -1456,7 +1445,9 @@ void Application::update(float deltaTime) { gameHandler->setPosition(canonical.x, canonical.y, canonical.z); gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); } - taxiLandingClampTimer_ -= deltaTime; + float clampTimer = worldEntryCallbacks_ ? worldEntryCallbacks_->getTaxiLandingClampTimer() : 0.0f; + clampTimer -= deltaTime; + if (worldEntryCallbacks_) worldEntryCallbacks_->setTaxiLandingClampTimer(clampTimer); } // No floor found: don't decrement timer, keep player frozen until terrain loads } @@ -1481,7 +1472,7 @@ void Application::update(float deltaTime) { renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7); renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi); } - lastTaxiFlight_ = onTaxi; + if (worldEntryCallbacks_) worldEntryCallbacks_->setLastTaxiFlight(onTaxi); // Sync character render position ↔ canonical WoW coords each frame if (renderer && gameHandler) { @@ -1535,72 +1526,9 @@ void Application::update(float deltaTime) { *followTarget = renderPos; } } - } else if (chargeActive_) { - // 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); - renderer->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; - renderer->setCharging(false); - renderer->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_); - renderer->triggerMeleeSwing(); - } - - // Send movement heartbeat so server knows our new position - gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); - } + } else if (animationCallbacks_ && animationCallbacks_->isCharging()) { + // Warrior Charge: interpolation delegated to AnimationCallbackHandler + animationCallbacks_->updateCharge(deltaTime); } else { glm::vec3 renderPos = renderer->getCharacterPosition(); @@ -2182,1650 +2110,51 @@ void Application::render() { } void Application::setupUICallbacks() { - // Authentication screen callback - uiManager->getAuthScreen().setOnSuccess([this]() { - LOG_INFO("Authentication successful, transitioning to realm selection"); - setState(AppState::REALM_SELECTION); - }); + // ── UI screen callbacks (auth, realm, character selection/creation) ── + uiScreenCallbacks_ = std::make_unique( + *uiManager, *gameHandler, *authHandler, expansionRegistry_.get(), + assetManager.get(), + [this](AppState s) { setState(s); }); + uiScreenCallbacks_->setupCallbacks(); - // Realm selection callback - uiManager->getRealmScreen().setOnRealmSelected([this](const std::string& realmName, const std::string& realmAddress) { - LOG_INFO("Realm selected: ", realmName, " (", realmAddress, ")"); + // ── World entry, unstuck, hearthstone, bind point ── + worldEntryCallbacks_ = std::make_unique( + *renderer, *gameHandler, worldLoader_.get(), entitySpawner_.get(), + audioCoordinator_.get(), assetManager.get()); + worldEntryCallbacks_->setupCallbacks(); - // Parse realm address (format: "hostname:port") - std::string host = realmAddress; - uint16_t port = 8085; // Default world server port - - size_t colonPos = realmAddress.find(':'); - if (colonPos != std::string::npos) { - host = realmAddress.substr(0, colonPos); - try { port = static_cast(std::stoi(realmAddress.substr(colonPos + 1))); } - catch (...) { LOG_WARNING("Invalid port in realm address: ", realmAddress); } - } - - // Connect to world server - const auto& sessionKey = authHandler->getSessionKey(); - std::string accountName = authHandler->getUsername(); - if (accountName.empty()) { - LOG_WARNING("Auth username missing; falling back to TESTACCOUNT"); - accountName = "TESTACCOUNT"; - } - - uint32_t realmId = 0; - uint16_t realmBuild = 0; - { - // WotLK AUTH_SESSION includes a RealmID field; some servers reject if it's wrong/zero. - const auto& realms = authHandler->getRealms(); - for (const auto& r : realms) { - if (r.name == realmName && r.address == realmAddress) { - realmId = r.id; - realmBuild = r.build; - break; - } - } - LOG_INFO("Selected realmId=", realmId, " realmBuild=", realmBuild); - } - - uint32_t clientBuild = 12340; // default WotLK - if (expansionRegistry_) { - auto* profile = expansionRegistry_->getActive(); - if (profile) clientBuild = profile->worldBuild; - } - // Prefer realm-reported build when available (e.g. vanilla servers - // that report build 5875 in the realm list) - if (realmBuild != 0) { - clientBuild = realmBuild; - LOG_INFO("Using realm-reported build: ", clientBuild); - } - if (gameHandler->connect(host, port, sessionKey, accountName, clientBuild, realmId)) { - LOG_INFO("Connected to world server, transitioning to character selection"); - setState(AppState::CHARACTER_SELECTION); - } else { - LOG_ERROR("Failed to connect to world server"); - } - }); - - // Realm screen back button - return to login - uiManager->getRealmScreen().setOnBack([this]() { - if (authHandler) { - authHandler->disconnect(); - } - uiManager->getRealmScreen().reset(); - setState(AppState::AUTHENTICATION); - }); - - // Character selection callback - uiManager->getCharacterScreen().setOnCharacterSelected([this](uint64_t characterGuid) { - LOG_INFO("Character selected: GUID=0x", std::hex, characterGuid, std::dec); - // Always set the active character GUID - if (gameHandler) { - gameHandler->setActiveCharacterGuid(characterGuid); - } - // Keep CHARACTER_SELECTION active until world entry is fully loaded. - // This avoids exposing pre-load hitching before the loading screen/intro. - }); - - // Character create screen callbacks - uiManager->getCharacterCreateScreen().setOnCreate([this](const game::CharCreateData& data) { - pendingCreatedCharacterName_ = data.name; // Store name for auto-selection - gameHandler->createCharacter(data); - }); - - uiManager->getCharacterCreateScreen().setOnCancel([this]() { - setState(AppState::CHARACTER_SELECTION); - }); - - // Character create result callback - gameHandler->setCharCreateCallback([this](bool success, const std::string& msg) { - if (success) { - // Auto-select the newly created character - if (!pendingCreatedCharacterName_.empty()) { - uiManager->getCharacterScreen().selectCharacterByName(pendingCreatedCharacterName_); - pendingCreatedCharacterName_.clear(); - } - setState(AppState::CHARACTER_SELECTION); - } else { - uiManager->getCharacterCreateScreen().setStatus(msg, true); - pendingCreatedCharacterName_.clear(); - } - }); - - // Character login failure callback - gameHandler->setCharLoginFailCallback([this](const std::string& reason) { - LOG_WARNING("Character login failed: ", reason); - setState(AppState::CHARACTER_SELECTION); - uiManager->getCharacterScreen().setStatus("Login failed: " + reason, true); - }); - - // World entry callback (online mode) - load terrain when entering world - gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) { - LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")" - " initial=", isInitialEntry); - if (renderer) { - renderer->resetCombatVisualState(); - } - - // Reconnect to the same map: terrain stays loaded but all online entities are stale. - // Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world. - uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF; - if (entitySpawner_ && mapId == currentLoadedMap && renderer && renderer->getTerrainManager() && isInitialEntry) { - LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)"); - - // Pending spawn queues and failure caches — clear so previously-failed GUIDs can retry. - // Dead creature guids will be re-populated from fresh server state. - entitySpawner_->clearAllQueues(); - - // Properly despawn all tracked instances from the renderer - entitySpawner_->despawnAllCreatures(); - entitySpawner_->despawnAllPlayers(); - entitySpawner_->despawnAllGameObjects(); - - // Update player position and re-queue nearby tiles (same logic as teleport) - glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); - glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - renderer->getCharacterPosition() = renderPos; - if (renderer->getCameraController()) { - auto* ft = renderer->getCameraController()->getFollowTargetMutable(); - if (ft) *ft = renderPos; - renderer->getCameraController()->clearMovementInputs(); - renderer->getCameraController()->suppressMovementFor(1.0f); - renderer->getCameraController()->suspendGravityFor(10.0f); - } - worldEntryMovementGraceTimer_ = 2.0f; - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - renderer->getTerrainManager()->processReadyTiles(); - { - auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); - std::vector> nearbyTiles; - nearbyTiles.reserve(289); - for (int dy = -8; dy <= 8; dy++) - for (int dx = -8; dx <= 8; dx++) - nearbyTiles.push_back({tileX + dx, tileY + dy}); - renderer->getTerrainManager()->precacheTiles(nearbyTiles); - } - return; - } - - // Same-map teleport (taxi landing, GM teleport, hearthstone on same continent): - if (mapId == currentLoadedMap && renderer && renderer->getTerrainManager()) { - // Check if teleport is far enough to need terrain loading (>500 render units) - glm::vec3 oldPos = renderer->getCharacterPosition(); - glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); - glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - float teleportDistSq = glm::dot(renderPos - oldPos, renderPos - oldPos); - bool farTeleport = (teleportDistSq > 500.0f * 500.0f); - - if (farTeleport) { - // Far same-map teleport (hearthstone, etc.): defer full world reload - // to next frame to avoid blocking the packet handler for 20+ seconds. - LOG_WARNING("Far same-map teleport (dist=", std::sqrt(teleportDistSq), - "), deferring world reload to next frame"); - // Update position immediately so the player doesn't keep moving at old location - renderer->getCharacterPosition() = renderPos; - if (renderer->getCameraController()) { - auto* ft = renderer->getCameraController()->getFollowTargetMutable(); - if (ft) *ft = renderPos; - renderer->getCameraController()->clearMovementInputs(); - renderer->getCameraController()->suppressMovementFor(1.0f); - renderer->getCameraController()->suspendGravityFor(10.0f); - } - if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); - return; - } - LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload"); - // canonical and renderPos already computed above for distance check - renderer->getCharacterPosition() = renderPos; - if (renderer->getCameraController()) { - auto* ft = renderer->getCameraController()->getFollowTargetMutable(); - if (ft) *ft = renderPos; - } - worldEntryMovementGraceTimer_ = 2.0f; - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - // Stop any movement that was active before the teleport - if (renderer->getCameraController()) { - renderer->getCameraController()->clearMovementInputs(); - renderer->getCameraController()->suppressMovementFor(0.5f); - } - // Kick off async upload for any tiles that finished background - // parsing. Use the bounded processReadyTiles() instead of - // processAllReadyTiles() to avoid multi-second main-thread stalls - // when many tiles are ready (the rest will finalize over subsequent - // frames via the normal terrain update loop). - renderer->getTerrainManager()->processReadyTiles(); - - // Queue all remaining tiles within the load radius (8 tiles = 17x17) - // at the new position. precacheTiles skips already-loaded/pending tiles, - // so this only enqueues tiles that aren't yet in the pipeline. - // This ensures background workers immediately start loading everything - // visible from the new position (hearthstone may land far from old location). - { - auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); - std::vector> nearbyTiles; - nearbyTiles.reserve(289); - for (int dy = -8; dy <= 8; dy++) - for (int dx = -8; dx <= 8; dx++) - nearbyTiles.push_back({tileX + dx, tileY + dy}); - renderer->getTerrainManager()->precacheTiles(nearbyTiles); - } - return; - } - - // If a world load is already in progress (re-entrant call from - // gameHandler->update() processing SMSG_NEW_WORLD during warmup), - // defer this entry. The current load will pick it up when it finishes. - if (worldLoader_ && worldLoader_->isLoadingWorld()) { - LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)"); - worldLoader_->setPendingEntry(mapId, x, y, z); - return; - } - - // Full world loads are expensive and `loadOnlineWorldTerrain()` itself - // drives `gameHandler->update()` during warmup. Queue the load here so - // it runs after the current packet handler returns instead of recursing - // from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`. - LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")"); - if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); - }); - - auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional { - std::optional terrainFloor; - std::optional wmoFloor; - std::optional m2Floor; - - if (renderer && renderer->getTerrainManager()) { - terrainFloor = renderer->getTerrainManager()->getHeightAt(x, y); - } - if (renderer && renderer->getWMORenderer()) { - wmoFloor = renderer->getWMORenderer()->getFloorHeight(x, y, probeZ); - } - if (renderer && renderer->getM2Renderer()) { - m2Floor = renderer->getM2Renderer()->getFloorHeight(x, y, probeZ); - } - - std::optional best; - if (terrainFloor) best = terrainFloor; - if (wmoFloor && (!best || *wmoFloor > *best)) best = wmoFloor; - if (m2Floor && (!best || *m2Floor > *best)) best = m2Floor; - return best; - }; - - auto clearStuckMovement = [this]() { - if (renderer && renderer->getCameraController()) { - renderer->getCameraController()->clearMovementInputs(); - } - if (gameHandler) { - gameHandler->forceClearTaxiAndMovementState(); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_TURN); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_SWIM); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); - } - }; - - auto syncTeleportedPositionToServer = [this](const glm::vec3& renderPos) { - if (!gameHandler) return; - glm::vec3 canonical = core::coords::renderToCanonical(renderPos); - gameHandler->setPosition(canonical.x, canonical.y, canonical.z); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_TURN); - gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); - }; - - auto forceServerTeleportCommand = [this](const glm::vec3& renderPos) { - if (!gameHandler) return; - // Server-authoritative reset first, then teleport. - gameHandler->sendChatMessage(game::ChatType::SAY, ".revive", ""); - gameHandler->sendChatMessage(game::ChatType::SAY, ".dismount", ""); - - glm::vec3 canonical = core::coords::renderToCanonical(renderPos); - glm::vec3 serverPos = core::coords::canonicalToServer(canonical); - std::ostringstream cmd; - cmd.setf(std::ios::fixed); - cmd.precision(3); - cmd << ".go xyz " - << serverPos.x << " " - << serverPos.y << " " - << serverPos.z << " " - << gameHandler->getCurrentMapId() << " " - << gameHandler->getMovementInfo().orientation; - gameHandler->sendChatMessage(game::ChatType::SAY, cmd.str(), ""); - }; - - // /unstuck — nudge player forward and snap to floor at destination. - gameHandler->setUnstuckCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() { - if (!renderer || !renderer->getCameraController()) return; - worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - clearStuckMovement(); - auto* cc = renderer->getCameraController(); - auto* ft = cc->getFollowTargetMutable(); - if (!ft) return; - - glm::vec3 pos = *ft; - - // Always nudge forward first to escape stuck geometry (M2 models, collision seams). - if (gameHandler) { - float renderYaw = gameHandler->getMovementInfo().orientation + glm::radians(90.0f); - pos.x += std::cos(renderYaw) * 5.0f; - pos.y += std::sin(renderYaw) * 5.0f; - } - - // Sample floor at the DESTINATION position (after nudge). - // Pick the highest floor so we snap up to WMO floors when fallen below. - bool foundFloor = false; - if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) { - pos.z = *floor + 0.2f; - foundFloor = true; - } - - cc->teleportTo(pos); - if (!foundFloor) { - cc->setGrounded(false); // Let gravity pull player down to a surface - } - syncTeleportedPositionToServer(pos); - forceServerTeleportCommand(pos); - clearStuckMovement(); - LOG_INFO("Unstuck: nudged forward and snapped to floor"); - }); - - // /unstuckgy — stronger recovery: safe/home position, then sampled floor fallback. - gameHandler->setUnstuckGyCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() { - if (!renderer || !renderer->getCameraController()) return; - worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - clearStuckMovement(); - auto* cc = renderer->getCameraController(); - auto* ft = cc->getFollowTargetMutable(); - if (!ft) return; - - // Try last safe position first (nearby, terrain already loaded) - if (cc->hasLastSafePosition()) { - glm::vec3 safePos = cc->getLastSafePosition(); - safePos.z += 5.0f; - cc->teleportTo(safePos); - syncTeleportedPositionToServer(safePos); - forceServerTeleportCommand(safePos); - clearStuckMovement(); - LOG_INFO("Unstuck: teleported to last safe position"); - return; - } - - uint32_t bindMap = 0; - glm::vec3 bindPos(0.0f); - if (gameHandler && gameHandler->getHomeBind(bindMap, bindPos) && - bindMap == gameHandler->getCurrentMapId()) { - bindPos.z += 2.0f; - cc->teleportTo(bindPos); - syncTeleportedPositionToServer(bindPos); - forceServerTeleportCommand(bindPos); - clearStuckMovement(); - LOG_INFO("Unstuck: teleported to home bind position"); - return; - } - - // No safe/bind position — try current XY with a high floor probe. - glm::vec3 pos = *ft; - if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 120.0f)) { - pos.z = *floor + 0.5f; - cc->teleportTo(pos); - syncTeleportedPositionToServer(pos); - forceServerTeleportCommand(pos); - clearStuckMovement(); - LOG_INFO("Unstuck: teleported to sampled floor"); - return; - } - - // Last fallback: high snap to clear deeply bad geometry. - pos.z += 60.0f; - cc->teleportTo(pos); - syncTeleportedPositionToServer(pos); - forceServerTeleportCommand(pos); - clearStuckMovement(); - LOG_INFO("Unstuck: high fallback snap"); - }); - - // /unstuckhearth — teleport to hearthstone bind point (server-synced). - // Freezes player until terrain loads at destination to prevent falling through world. - gameHandler->setUnstuckHearthCallback([this, clearStuckMovement, forceServerTeleportCommand]() { - if (!renderer || !renderer->getCameraController() || !gameHandler) return; - - uint32_t bindMap = 0; - glm::vec3 bindPos(0.0f); - if (!gameHandler->getHomeBind(bindMap, bindPos)) { - LOG_WARNING("Unstuck hearth: no bind point available"); - return; - } - - worldEntryMovementGraceTimer_ = 10.0f; // long grace — terrain load check will clear it - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - clearStuckMovement(); - - auto* cc = renderer->getCameraController(); - glm::vec3 renderPos = core::coords::canonicalToRender(bindPos); - renderPos.z += 2.0f; - - // Freeze player in place (no gravity/movement) until terrain loads - cc->teleportTo(renderPos); - cc->setExternalFollow(true); - forceServerTeleportCommand(renderPos); - clearStuckMovement(); - - // Set pending state — update loop will unfreeze once terrain is loaded - hearthTeleportPending_ = true; - hearthTeleportPos_ = renderPos; - hearthTeleportTimer_ = 15.0f; // 15s safety timeout - LOG_INFO("Unstuck hearth: teleporting to bind point, waiting for terrain..."); - }); - - // Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry - if (renderer->getCameraController()) { - renderer->getCameraController()->setAutoUnstuckCallback([this, forceServerTeleportCommand]() { - if (!renderer || !renderer->getCameraController()) return; - auto* cc = renderer->getCameraController(); - - // Last resort: teleport to map entry point (terrain guaranteed loaded here) - glm::vec3 spawnPos = cc->getDefaultPosition(); - spawnPos.z += 5.0f; - cc->teleportTo(spawnPos); - forceServerTeleportCommand(spawnPos); - LOG_INFO("Auto-unstuck: teleported to map entry point (server synced)"); + // ── Entity spawn/despawn (creatures, players, game objects) ── + entitySpawnCallbacks_ = std::make_unique( + *entitySpawner_, *renderer, *gameHandler, + [this](uint64_t guid) { + uint64_t localGuid = gameHandler ? gameHandler->getPlayerGuid() : 0; + uint64_t activeGuid = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; + return (localGuid != 0 && guid == localGuid) || + (activeGuid != 0 && guid == activeGuid) || + (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_); }); - } - - // Bind point update (innkeeper) — position stored in gameHandler->getHomeBind() - gameHandler->setBindPointCallback([this](uint32_t mapId, float x, float y, float z) { - LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); - }); - - // Hearthstone preload callback: begin loading terrain at the bind point as soon as - // the player starts casting Hearthstone. The ~10 s cast gives enough time for - // the background streaming workers to bring tiles into the cache so the player - // lands on solid ground instead of falling through un-loaded terrain. - gameHandler->setHearthstonePreloadCallback([this](uint32_t mapId, float x, float y, float z) { - if (!renderer || !assetManager) return; - - auto* terrainMgr = renderer->getTerrainManager(); - if (!terrainMgr) return; - - // Resolve map name from the cached Map.dbc table - std::string mapName; - if (worldLoader_) { - mapName = worldLoader_->getMapNameById(mapId); - } - if (mapName.empty()) { - mapName = WorldLoader::mapIdToName(mapId); - } - if (mapName.empty()) mapName = "Azeroth"; - - uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF; - if (mapId == currentLoadedMap) { - // Same map: pre-enqueue tiles around the bind point so workers start - // loading them now. Uses render-space coords (canonicalToRender). - // Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time - // for workers to parse most of these before the player arrives. - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); - - std::vector> tiles; - tiles.reserve(81); - for (int dy = -4; dy <= 4; dy++) - for (int dx = -4; dx <= 4; dx++) - tiles.push_back({tileX + dx, tileY + dy}); - - terrainMgr->precacheTiles(tiles); - LOG_INFO("Hearthstone preload: enqueued ", tiles.size(), - " tiles around bind point (same map) tile=[", tileX, ",", tileY, "]"); - } else { - // Different map: warm the file cache so ADT parsing is fast when - // loadOnlineWorldTerrain runs its blocking load loop. - // homeBindPos_ is canonical; startWorldPreload expects server coords. - glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z)); - if (worldLoader_) { - worldLoader_->startWorldPreload(mapId, mapName, server.x, server.y); - } - LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName, - "' (id=", mapId, ")"); - } - }); - - // Faction hostility map is built in buildFactionHostilityMap() when character enters world - - // Creature spawn callback (online mode) - spawn creature models - gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { - if (!entitySpawner_) return; - // Queue spawns to avoid hanging when many creatures appear at once. - // Deduplicate so repeated updates don't flood pending queue. - if (entitySpawner_->isCreatureSpawned(guid)) return; - if (entitySpawner_->isCreaturePending(guid)) return; - entitySpawner_->queueCreatureSpawn(guid, displayId, x, y, z, orientation, scale); - }); - - // Player spawn callback (online mode) - spawn player models with correct textures - gameHandler->setPlayerSpawnCallback([this](uint64_t guid, - uint32_t /*displayId*/, - uint8_t raceId, - uint8_t genderId, - uint32_t appearanceBytes, - uint8_t facialFeatures, - float x, float y, float z, float orientation) { - if (!entitySpawner_) return; - LOG_WARNING("playerSpawnCallback: guid=0x", std::hex, guid, std::dec, - " race=", static_cast(raceId), " gender=", static_cast(genderId), - " pos=(", x, ",", y, ",", z, ")"); - // Skip local player — already spawned as the main character - uint64_t localGuid = gameHandler ? gameHandler->getPlayerGuid() : 0; - uint64_t activeGuid = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; - if ((localGuid != 0 && guid == localGuid) || - (activeGuid != 0 && guid == activeGuid) || - (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { - return; - } - if (entitySpawner_->isPlayerSpawned(guid)) return; - if (entitySpawner_->isPlayerPending(guid)) return; - entitySpawner_->queuePlayerSpawn(guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation); - }); - - // Online player equipment callback - apply armor geosets/skin overlays per player instance. - gameHandler->setPlayerEquipmentCallback([this](uint64_t guid, - const std::array& displayInfoIds, - const std::array& inventoryTypes) { - if (!entitySpawner_) return; - // Queue equipment compositing instead of doing it immediately — - // compositeWithRegions is expensive (file I/O + CPU blit + GPU upload) - // and causes frame stutters if multiple players update at once. - entitySpawner_->queuePlayerEquipment(guid, displayInfoIds, inventoryTypes); - }); - - // Creature despawn callback (online mode) - remove creature models - gameHandler->setCreatureDespawnCallback([this](uint64_t guid) { - if (!entitySpawner_) return; - entitySpawner_->despawnCreature(guid); - }); - - gameHandler->setPlayerDespawnCallback([this](uint64_t guid) { - if (!entitySpawner_) return; - entitySpawner_->despawnPlayer(guid); - }); - - // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) - gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { - if (!entitySpawner_) return; - entitySpawner_->queueGameObjectSpawn(guid, entry, displayId, x, y, z, orientation, scale); - }); - - // GameObject despawn callback (online mode) - remove static models - gameHandler->setGameObjectDespawnCallback([this](uint64_t guid) { - if (!entitySpawner_) return; - entitySpawner_->despawnGameObject(guid); - }); - - // GameObject custom animation callback (e.g. chest opening) - gameHandler->setGameObjectCustomAnimCallback([this](uint64_t guid, uint32_t animId) { - if (!entitySpawner_ || !renderer) return; - auto& goInstances = entitySpawner_->getGameObjectInstances(); - auto it = goInstances.find(guid); - if (it == goInstances.end()) return; - auto& info = it->second; - if (!info.isWmo) { - if (auto* m2r = renderer->getM2Renderer()) { - // Play the custom animation as a one-shot if model supports it - if (m2r->hasAnimation(info.instanceId, animId)) - m2r->setInstanceAnimation(info.instanceId, animId, false); - else - m2r->setInstanceAnimationFrozen(info.instanceId, false); - } - } - }); - - // GameObject state change callback — animate doors/chests opening/closing/destroying - gameHandler->setGameObjectStateCallback([this](uint64_t guid, uint8_t goState) { - if (!entitySpawner_ || !renderer) return; - auto& goInstances = entitySpawner_->getGameObjectInstances(); - auto it = goInstances.find(guid); - if (it == goInstances.end()) return; - auto& info = it->second; - if (info.isWmo) return; // WMOs don't have M2 animation sequences - auto* m2r = renderer->getM2Renderer(); - if (!m2r) return; - uint32_t instId = info.instanceId; - // GO states: 0=READY(closed), 1=OPEN, 2=DESTROYED/ACTIVE - if (goState == 1) { - // Opening: play OPEN(148) one-shot, fall back to unfreezing - if (m2r->hasAnimation(instId, 148)) - m2r->setInstanceAnimation(instId, 148, false); - else - m2r->setInstanceAnimationFrozen(instId, false); - } else if (goState == 2) { - // Destroyed: play DESTROY(149) one-shot - if (m2r->hasAnimation(instId, 149)) - m2r->setInstanceAnimation(instId, 149, false); - } else { - // Closed: play CLOSE(146) one-shot, else freeze - if (m2r->hasAnimation(instId, 146)) - m2r->setInstanceAnimation(instId, 146, false); - else - m2r->setInstanceAnimationFrozen(instId, true); - } - }); - - // Sprint aura callback — use SPRINT(143) animation when sprint-type buff is active - gameHandler->setSprintAuraCallback([this](bool active) { - if (!renderer) return; - 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*/) { - if (!renderer) return; - 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 || !renderer->getCameraController() || !gameHandler) 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(); - renderer->setCharging(true); - - // Start charge visual effect (red haze + dust) - glm::vec3 chargeDir = glm::normalize(endRender - startRender); - renderer->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); - } - } - } - }); - - // Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect - gameHandler->setLevelUpCallback([this](uint32_t newLevel) { - if (uiManager) { - uiManager->getGameScreen().toastManager().triggerDing(newLevel); - } - if (renderer) { - renderer->triggerLevelUpEffect(renderer->getCharacterPosition()); - } - }); - - // Achievement earned callback — show toast banner - gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { - if (uiManager) { - uiManager->getGameScreen().toastManager().triggerAchievementToast(achievementId, name); - } - }); - - // Server-triggered music callback (SMSG_PLAY_MUSIC) - // Resolves soundId → SoundEntries.dbc → MPQ path → MusicManager. - gameHandler->setPlayMusicCallback([this](uint32_t soundId) { - if (!assetManager || !renderer) return; - auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr; - if (!music) return; - - auto dbc = assetManager->loadDBC("SoundEntries.dbc"); - if (!dbc || !dbc->isLoaded()) return; - - int32_t idx = dbc->findRecordById(soundId); - if (idx < 0) return; - - // SoundEntries.dbc (WotLK): field 2 = Name (label), fields 3-12 = File[0..9], field 23 = DirectoryBase - const uint32_t row = static_cast(idx); - std::string dir = dbc->getString(row, 23); - for (uint32_t f = 3; f <= 12; ++f) { - std::string name = dbc->getString(row, f); - if (name.empty()) continue; - std::string path = dir.empty() ? name : dir + "\\" + name; - music->playMusic(path, /*loop=*/false); - return; - } - }); - - // SMSG_PLAY_SOUND: look up SoundEntries.dbc and play 2-D sound effect - gameHandler->setPlaySoundCallback([this](uint32_t soundId) { - if (!assetManager) return; - - auto dbc = assetManager->loadDBC("SoundEntries.dbc"); - if (!dbc || !dbc->isLoaded()) return; - - int32_t idx = dbc->findRecordById(soundId); - if (idx < 0) return; - - const uint32_t row = static_cast(idx); - std::string dir = dbc->getString(row, 23); - for (uint32_t f = 3; f <= 12; ++f) { - std::string name = dbc->getString(row, f); - if (name.empty()) continue; - std::string path = dir.empty() ? name : dir + "\\" + name; - audio::AudioEngine::instance().playSound2D(path); - return; - } - }); - - // SMSG_PLAY_OBJECT_SOUND / SMSG_PLAY_SPELL_IMPACT: play as 3D positional sound at source entity - gameHandler->setPlayPositionalSoundCallback([this](uint32_t soundId, uint64_t sourceGuid) { - if (!assetManager || !gameHandler) return; - - auto dbc = assetManager->loadDBC("SoundEntries.dbc"); - if (!dbc || !dbc->isLoaded()) return; - - int32_t idx = dbc->findRecordById(soundId); - if (idx < 0) return; - - const uint32_t row = static_cast(idx); - std::string dir = dbc->getString(row, 23); - for (uint32_t f = 3; f <= 12; ++f) { - std::string name = dbc->getString(row, f); - if (name.empty()) continue; - std::string path = dir.empty() ? name : dir + "\\" + name; - - // Play as 3D sound if source entity position is available. - // Entity stores canonical coords; listener uses render coords (camera). - auto entity = gameHandler->getEntityManager().getEntity(sourceGuid); - if (entity) { - glm::vec3 canonical{entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()}; - glm::vec3 pos = core::coords::canonicalToRender(canonical); - audio::AudioEngine::instance().playSound3D(path, pos); - } else { - audio::AudioEngine::instance().playSound2D(path); - } - return; - } - }); - - // Other player level-up callback — trigger 3D effect + chat notification - gameHandler->setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { - if (!gameHandler || !renderer) return; - - // Trigger 3D effect at the other player's position - auto entity = gameHandler->getEntityManager().getEntity(guid); - if (entity) { - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); - glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - renderer->triggerLevelUpEffect(renderPos); - } - - // Show chat message if in group - if (gameHandler->isInGroup()) { - std::string name = gameHandler->getCachedPlayerName(guid); - if (name.empty()) name = "A party member"; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = name + " has reached level " + std::to_string(newLevel) + "!"; - gameHandler->addLocalChatMessage(msg); - } - }); - - // Mount callback (online mode) - defer heavy model load to next frame - gameHandler->setMountCallback([this](uint32_t mountDisplayId) { - if (mountDisplayId == 0) { - // Dismount is instant (no loading needed) - if (renderer && renderer->getCharacterRenderer() && entitySpawner_->getMountInstanceId() != 0) { - renderer->getCharacterRenderer()->removeInstance(entitySpawner_->getMountInstanceId()); - entitySpawner_->clearMountState(); - } - entitySpawner_->setMountDisplayId(0); - if (renderer) renderer->clearMount(); - LOG_INFO("Dismounted"); - return; - } - // Queue the mount for processing in the next update() frame - entitySpawner_->setMountDisplayId(mountDisplayId); - }); - - // Taxi precache callback - preload terrain tiles along flight path - gameHandler->setTaxiPrecacheCallback([this](const std::vector& path) { - if (!renderer || !renderer->getTerrainManager()) return; - - std::set> uniqueTiles; - - // Sample waypoints along path and gather tiles. - // Denser sampling + neighbor coverage reduces in-flight stream spikes. - const size_t stride = 2; - for (size_t i = 0; i < path.size(); i += stride) { - const auto& waypoint = path[i]; - glm::vec3 renderPos = core::coords::canonicalToRender(waypoint); - int tileX = static_cast(32 - (renderPos.x / 533.33333f)); - int tileY = static_cast(32 - (renderPos.y / 533.33333f)); - - if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { - for (int dx = -1; dx <= 1; ++dx) { - for (int dy = -1; dy <= 1; ++dy) { - int nx = tileX + dx; - int ny = tileY + dy; - if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { - uniqueTiles.insert({nx, ny}); - } - } - } - } - } - // Ensure final destination tile is included. - if (!path.empty()) { - glm::vec3 renderPos = core::coords::canonicalToRender(path.back()); - int tileX = static_cast(32 - (renderPos.x / 533.33333f)); - int tileY = static_cast(32 - (renderPos.y / 533.33333f)); - if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { - for (int dx = -1; dx <= 1; ++dx) { - for (int dy = -1; dy <= 1; ++dy) { - int nx = tileX + dx; - int ny = tileY + dy; - if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { - uniqueTiles.insert({nx, ny}); - } - } - } - } - } - - std::vector> tilesToLoad(uniqueTiles.begin(), uniqueTiles.end()); - if (tilesToLoad.size() > 512) { - tilesToLoad.resize(512); - } - LOG_INFO("Precaching ", tilesToLoad.size(), " tiles for taxi route"); - renderer->getTerrainManager()->precacheTiles(tilesToLoad); - }); - - // Taxi orientation callback - update mount rotation during flight - gameHandler->setTaxiOrientationCallback([this](float yaw, float pitch, float roll) { - if (renderer && renderer->getCameraController()) { - // Taxi callback now provides render-space yaw directly. - float yawDegrees = glm::degrees(yaw); - renderer->getCameraController()->setFacingYaw(yawDegrees); - renderer->setCharacterYaw(yawDegrees); - // Set mount pitch and roll for realistic flight animation - renderer->setMountPitchRoll(pitch, roll); - } - }); - - // Taxi flight start callback - keep non-blocking to avoid hitching at takeoff. - gameHandler->setTaxiFlightStartCallback([this]() { - if (renderer && renderer->getTerrainManager() && renderer->getM2Renderer()) { - LOG_INFO("Taxi flight start: incremental terrain/M2 streaming active"); - uint32_t m2Count = renderer->getM2Renderer()->getModelCount(); - uint32_t instCount = renderer->getM2Renderer()->getInstanceCount(); - LOG_INFO("Current M2 VRAM state: ", m2Count, " models (", instCount, " instances)"); - } - }); - - // Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER - gameHandler->setOpenLfgCallback([this]() { - if (uiManager) uiManager->getGameScreen().openDungeonFinder(); - }); - - // Creature move callback (online mode) - update creature positions - gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { - if (!entitySpawner_) return; - if (!renderer || !renderer->getCharacterRenderer()) return; - uint32_t instanceId = 0; - bool isPlayer = false; - instanceId = entitySpawner_->getPlayerInstanceId(guid); - if (instanceId != 0) { isPlayer = true; } - else { - instanceId = entitySpawner_->getCreatureInstanceId(guid); - } - if (instanceId != 0) { - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - float durationSec = static_cast(durationMs) / 1000.0f; - renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec); - // Play Run animation (anim 5) for the duration of the spline move. - // WoW M2 animation IDs: 4=Walk, 5=Run. - // Don't override Death animation (1). The per-frame sync loop will return to - // Stand when movement stops. - if (durationMs > 0) { - // Player animation is managed by the local renderer state machine — - // don't reset it here or every server movement packet restarts the - // run cycle from frame 0, causing visible stutter. - if (!isPlayer) { - uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; - auto* cr = renderer->getCharacterRenderer(); - bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); - // Only start Run if not already running and not in Death animation. - if (!gotState || (curAnimId != rendering::anim::DEATH && curAnimId != rendering::anim::RUN)) { - cr->playAnimation(instanceId, rendering::anim::RUN, /*loop=*/true); - } - entitySpawner_->getCreatureWasMoving()[guid] = true; - } - } - } - }); - - gameHandler->setGameObjectMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { - if (!entitySpawner_) return; - auto& goInstMap = entitySpawner_->getGameObjectInstances(); - auto it = goInstMap.find(guid); - if (it == goInstMap.end() || !renderer) { - return; - } - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - auto& info = it->second; - if (info.isWmo) { - if (auto* wr = renderer->getWMORenderer()) { - glm::mat4 transform(1.0f); - transform = glm::translate(transform, renderPos); - transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); - wr->setInstanceTransform(info.instanceId, transform); - } - } else { - if (auto* mr = renderer->getM2Renderer()) { - glm::mat4 transform(1.0f); - transform = glm::translate(transform, renderPos); - mr->setInstanceTransform(info.instanceId, transform); - } - } - }); - - // Transport spawn callback (online mode) - register transports with TransportManager - gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { - if (!entitySpawner_) return; - if (!renderer) return; - - // Get the GameObject instance now so late queue processing can rely on stable IDs. - auto& goInstances2 = entitySpawner_->getGameObjectInstances(); - auto it = goInstances2.find(guid); - if (it == goInstances2.end()) { - LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec); - return; - } - - auto pendingIt = entitySpawner_->hasTransportRegistrationPending(guid); - if (pendingIt) { - entitySpawner_->updateTransportRegistration(guid, displayId, x, y, z, orientation); - } else { - entitySpawner_->queueTransportRegistration(guid, entry, displayId, x, y, z, orientation); - } - }); - - // Transport move callback (online mode) - update transport gameobject positions - gameHandler->setTransportMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { - if (!entitySpawner_) return; - LOG_DEBUG("Transport move callback: GUID=0x", std::hex, guid, std::dec, - " pos=(", x, ", ", y, ", ", z, ") orientation=", orientation); - - auto* transportManager = gameHandler->getTransportManager(); - if (!transportManager) { - LOG_WARNING("Transport move callback: TransportManager is null!"); - return; - } - - if (entitySpawner_->hasTransportRegistrationPending(guid)) { - entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); - LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec); - return; - } - - // Check if transport exists - if not, treat this as a late spawn (reconnection/server restart) - if (!transportManager->getTransport(guid)) { - LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec, - " - auto-spawning from position update"); - - // Get transport info from entity manager - auto entity = gameHandler->getEntityManager().getEntity(guid); - if (entity && entity->getType() == game::ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - uint32_t entry = go->getEntry(); - uint32_t displayId = go->getDisplayId(); - - // Find the WMO instance for this transport (should exist from earlier GameObject spawn) - auto& goInstances3 = entitySpawner_->getGameObjectInstances(); - auto it = goInstances3.find(guid); - if (it != goInstances3.end()) { - uint32_t wmoInstanceId = it->second.instanceId; - - // TransportAnimation.dbc is indexed by GameObject entry - uint32_t pathId = entry; - const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid); - - // Coordinates are already canonical (converted in game_handler.cpp) - glm::vec3 canonicalSpawnPos(x, y, z); - - // Check if we have a real usable path, otherwise remap/infer/fall back to stationary. - const bool shipOrZeppelinDisplay = - (displayId == 3015 || displayId == 3031 || displayId == 7546 || - displayId == 7446 || displayId == 1587 || displayId == 2454 || - displayId == 807 || displayId == 808); - bool hasUsablePath = transportManager->hasPathForEntry(entry); - if (shipOrZeppelinDisplay) { - hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); - } - - if (preferServerData) { - // Strict server-authoritative mode: no inferred/remapped fallback routes. - if (!hasUsablePath) { - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_INFO("Auto-spawned transport in strict server-first mode (stationary fallback): entry=", entry, - " displayId=", displayId, " wmoInstance=", wmoInstanceId); - } else { - LOG_INFO("Auto-spawned transport in server-first mode with entry DBC path: entry=", entry, - " displayId=", displayId, " wmoInstance=", wmoInstanceId); - } - } else if (!hasUsablePath) { - bool allowZOnly = (displayId == 455 || displayId == 462); - uint32_t inferredPath = transportManager->inferDbcPathForSpawn( - canonicalSpawnPos, 1200.0f, allowZOnly); - if (inferredPath != 0) { - pathId = inferredPath; - LOG_INFO("Auto-spawned transport with inferred path: entry=", entry, - " inferredPath=", pathId, " displayId=", displayId, - " wmoInstance=", wmoInstanceId); - } else { - uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); - if (remappedPath != 0) { - pathId = remappedPath; - LOG_INFO("Auto-spawned transport with remapped fallback path: entry=", entry, - " remappedPath=", pathId, " displayId=", displayId, - " wmoInstance=", wmoInstanceId); - } else { - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_INFO("Auto-spawned transport with stationary path: entry=", entry, - " displayId=", displayId, " wmoInstance=", wmoInstanceId); - } - } - } else { - LOG_INFO("Auto-spawned transport with real path: entry=", entry, - " displayId=", displayId, " wmoInstance=", wmoInstanceId); - } - - transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); - // Keep type in sync with the spawned instance; needed for M2 lift boarding/motion. - if (!it->second.isWmo) { - if (auto* tr = transportManager->getTransport(guid)) { - tr->isM2 = true; - } - } - } else { - entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); - LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, - " - WMO instance not found yet (queued move for replay)"); - return; - } - } else { - entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); - LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, - " - entity not found in EntityManager (queued move for replay)"); - return; - } - } - - // Update TransportManager's internal state (position, rotation, transform matrices) - // This also updates the WMO renderer automatically - // Coordinates are already canonical (converted in game_handler.cpp when entity was created) - glm::vec3 canonicalPos(x, y, z); - transportManager->updateServerTransport(guid, canonicalPos, orientation); - - // Move player with transport if riding it - if (gameHandler && gameHandler->isOnTransport() && gameHandler->getPlayerTransportGuid() == guid && renderer) { - auto* cc = renderer->getCameraController(); - if (cc) { - glm::vec3* ft = cc->getFollowTargetMutable(); - if (ft) { - // Get player world position from TransportManager (handles transform properly) - glm::vec3 offset = gameHandler->getPlayerTransportOffset(); - glm::vec3 worldPos = transportManager->getPlayerWorldPosition(guid, offset); - *ft = worldPos; - } - } - } - }); - - // NPC/player death callback (online mode) - play death animation - gameHandler->setNpcDeathCallback([this](uint64_t guid) { - if (!entitySpawner_) return; - entitySpawner_->markCreatureDead(guid); - if (!renderer || !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) { - if (!entitySpawner_) return; - entitySpawner_->unmarkCreatureDead(guid); - if (!renderer || !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 (!entitySpawner_) return; - if (!renderer || !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) { - if (!renderer) return; - 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 - if (!entitySpawner_) return; - 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) { - if (!renderer) return; - auto* ac = renderer->getAnimationController(); - if (ac) ac->setStunned(stunned); - }); - - // Stealth state callback — switches to stealth animation variants - gameHandler->setStealthStateCallback([this](bool stealthed) { - if (!renderer) return; - 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) { - if (!renderer) return; - 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) { - if (!entitySpawner_) return; - if (!renderer) return; - 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) { - if (!entitySpawner_) return; - const bool isSwimming = (moveFlags & static_cast(game::MovementFlags::SWIMMING)) != 0; - const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; - const bool isFlying = (moveFlags & static_cast(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) { - if (!entitySpawner_) return; - if (!renderer) return; - 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: - // Phase 1: SPELL_PRECAST (31) — one-shot wind-up - // Phase 2: READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills - // Phase 3: SPELL_CAST_DIRECTED/OMNI/AREA (53/54/33) — one-shot release at completion - // Channels use CHANNEL_CAST_DIRECTED/OMNI (124/125) or SPELL_CHANNEL_DIRECTED_OMNI (201). - // castType comes from the spell packet's targetGuid: - // DIRECTED — spell targets a specific unit (Frostbolt, Heal) - // OMNI — self-cast / no explicit target (Arcane Explosion, buffs) - // AREA — ground-targeted AoE (Blizzard, Rain of Fire) - gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool isChannel, - game::SpellCastType castType) { - if (!entitySpawner_) return; - if (!renderer) return; - auto* cr = renderer->getCharacterRenderer(); - 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 ids) -> uint32_t { - for (uint32_t id : ids) - if (cr->hasAnimation(instanceId, id)) return id; - return 0; - }; - - // Phase 1: Precast wind-up (one-shot, non-channels only) - uint32_t precastAnim = 0; - if (!isChannel) { - precastAnim = pickFirst({rendering::anim::SPELL_PRECAST}); - } - - // Phase 2: 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; - - // Phase 3: Finalization release (one-shot after cast completes) - // Animation chosen by spell target type: AREA → SPELL_CAST_AREA, - // DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI - uint32_t finalizeAnim = 0; - if (isLocalPlayer && !isChannel) { - 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) { - if (!renderer) return; - 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) { - if (!renderer) return; - 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) { - if (!renderer) return; - auto* ac = renderer->getAnimationController(); - if (!ac) return; - if (open) ac->startLooting(); - else ac->stopLooting(); - }); - - // NPC greeting callback - play voice line - gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) { - // Play NPC_WELCOME animation on the NPC - if (entitySpawner_ && renderer) { - auto* cr = renderer->getCharacterRenderer(); - if (cr) { - uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid); - if (instanceId != 0) cr->playAnimation(instanceId, rendering::anim::NPC_WELCOME, false); - } - } - if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { - // Convert canonical to render coords for 3D audio - glm::vec3 renderPos = core::coords::canonicalToRender(position); - - // Detect voice type from NPC display ID - audio::VoiceType voiceType = audio::VoiceType::GENERIC; - auto entity = gameHandler->getEntityManager().getEntity(guid); - if (entity && entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - uint32_t displayId = unit->getDisplayId(); - voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); - } - - audioCoordinator_->getNpcVoiceManager()->playGreeting(guid, voiceType, renderPos); - } - }); - - // NPC farewell callback - play farewell voice line - gameHandler->setNpcFarewellCallback([this](uint64_t guid, const glm::vec3& position) { - if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { - glm::vec3 renderPos = core::coords::canonicalToRender(position); - - audio::VoiceType voiceType = audio::VoiceType::GENERIC; - auto entity = gameHandler->getEntityManager().getEntity(guid); - if (entity && entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - uint32_t displayId = unit->getDisplayId(); - voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); - } - - audioCoordinator_->getNpcVoiceManager()->playFarewell(guid, voiceType, renderPos); - } - }); - - // NPC vendor callback - play vendor voice line - gameHandler->setNpcVendorCallback([this](uint64_t guid, const glm::vec3& position) { - if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { - glm::vec3 renderPos = core::coords::canonicalToRender(position); - - audio::VoiceType voiceType = audio::VoiceType::GENERIC; - auto entity = gameHandler->getEntityManager().getEntity(guid); - if (entity && entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - uint32_t displayId = unit->getDisplayId(); - voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); - } - - audioCoordinator_->getNpcVoiceManager()->playVendor(guid, voiceType, renderPos); - } - }); - - // NPC aggro callback - play combat start voice line - gameHandler->setNpcAggroCallback([this](uint64_t guid, const glm::vec3& position) { - if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { - glm::vec3 renderPos = core::coords::canonicalToRender(position); - - audio::VoiceType voiceType = audio::VoiceType::GENERIC; - auto entity = gameHandler->getEntityManager().getEntity(guid); - if (entity && entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - uint32_t displayId = unit->getDisplayId(); - voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); - } - - audioCoordinator_->getNpcVoiceManager()->playAggro(guid, voiceType, renderPos); - } - }); - - // "Create Character" button on character screen - uiManager->getCharacterScreen().setOnCreateCharacter([this]() { - uiManager->getCharacterCreateScreen().reset(); - // Apply expansion race/class constraints before showing the screen - if (expansionRegistry_ && expansionRegistry_->getActive()) { - auto* profile = expansionRegistry_->getActive(); - uiManager->getCharacterCreateScreen().setExpansionConstraints( - profile->races, profile->classes); - } - uiManager->getCharacterCreateScreen().initializePreview(assetManager.get()); - setState(AppState::CHARACTER_CREATION); - }); - - // "Back" button on character screen - uiManager->getCharacterScreen().setOnBack([this]() { - // Disconnect from world server and reset UI state for fresh realm selection - if (gameHandler) { - gameHandler->disconnect(); - } - uiManager->getRealmScreen().reset(); - uiManager->getCharacterScreen().reset(); - setState(AppState::REALM_SELECTION); - }); - - // "Delete Character" button on character screen - uiManager->getCharacterScreen().setOnDeleteCharacter([this](uint64_t guid) { - if (gameHandler) { - gameHandler->deleteCharacter(guid); - } - }); - - // Character delete result callback - gameHandler->setCharDeleteCallback([this](bool success) { - if (success) { - uiManager->getCharacterScreen().setStatus("Character deleted."); - // Refresh character list - gameHandler->requestCharacterList(); - } else { - uint8_t code = gameHandler ? gameHandler->getLastCharDeleteResult() : 0xFF; - uiManager->getCharacterScreen().setStatus( - "Delete failed (code " + std::to_string(static_cast(code)) + ").", true); - } - }); + entitySpawnCallbacks_->setupCallbacks(); + + // ── Animation: death, respawn, swing, hit, spell, emote, charge, etc. ── + animationCallbacks_ = std::make_unique( + *entitySpawner_, *renderer, *gameHandler); + animationCallbacks_->setupCallbacks(); + + // ── NPC interaction: greeting, farewell, vendor, aggro voice ── + npcInteractionCallbacks_ = std::make_unique( + *entitySpawner_, renderer.get(), *gameHandler, audioCoordinator_.get()); + npcInteractionCallbacks_->setupCallbacks(); + + // ── Audio: music, sound effects, level-up, achievement, LFG ── + audioCallbacks_ = std::make_unique( + *assetManager, audioCoordinator_.get(), renderer.get(), + uiManager.get(), *gameHandler); + audioCallbacks_->setupCallbacks(); + + // ── Transport: mount, taxi, transport spawn/move ── + transportCallbacks_ = std::make_unique( + *entitySpawner_, *renderer, *gameHandler, worldLoader_.get()); + transportCallbacks_->setupCallbacks(); } void Application::spawnPlayerCharacter() { diff --git a/src/core/audio_callback_handler.cpp b/src/core/audio_callback_handler.cpp new file mode 100644 index 00000000..088fc270 --- /dev/null +++ b/src/core/audio_callback_handler.cpp @@ -0,0 +1,132 @@ +#include "core/audio_callback_handler.hpp" +#include "core/coordinates.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "pipeline/dbc_loader.hpp" +#include "game/game_handler.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/music_manager.hpp" +#include "audio/audio_engine.hpp" +#include "ui/ui_manager.hpp" + +namespace wowee { namespace core { + +AudioCallbackHandler::AudioCallbackHandler( + pipeline::AssetManager& assetManager, + audio::AudioCoordinator* audioCoordinator, + rendering::Renderer* renderer, + ui::UIManager* uiManager, + game::GameHandler& gameHandler) + : assetManager_(assetManager) + , audioCoordinator_(audioCoordinator) + , renderer_(renderer) + , uiManager_(uiManager) + , gameHandler_(gameHandler) +{ +} + +std::optional AudioCallbackHandler::resolveSoundEntryPath(uint32_t soundId) const { + auto dbc = assetManager_.loadDBC("SoundEntries.dbc"); + if (!dbc || !dbc->isLoaded()) return std::nullopt; + + int32_t idx = dbc->findRecordById(soundId); + if (idx < 0) return std::nullopt; + + // SoundEntries.dbc (WotLK): field 2 = Name (label), fields 3-12 = File[0..9], field 23 = DirectoryBase + const uint32_t row = static_cast(idx); + std::string dir = dbc->getString(row, 23); + for (uint32_t f = 3; f <= 12; ++f) { + std::string name = dbc->getString(row, f); + if (name.empty()) continue; + return dir.empty() ? name : dir + "\\" + name; + } + return std::nullopt; +} + +void AudioCallbackHandler::setupCallbacks() { + // Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect + gameHandler_.setLevelUpCallback([this](uint32_t newLevel) { + if (uiManager_) { + uiManager_->getGameScreen().toastManager().triggerDing(newLevel); + } + if (renderer_) { + renderer_->triggerLevelUpEffect(renderer_->getCharacterPosition()); + } + }); + + // Achievement earned callback — show toast banner + gameHandler_.setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { + if (uiManager_) { + uiManager_->getGameScreen().toastManager().triggerAchievementToast(achievementId, name); + } + }); + + // Server-triggered music callback (SMSG_PLAY_MUSIC) + // Resolves soundId → SoundEntries.dbc → MPQ path → MusicManager. + gameHandler_.setPlayMusicCallback([this](uint32_t soundId) { + if (!renderer_) return; + auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr; + if (!music) return; + + auto path = resolveSoundEntryPath(soundId); + if (path) { + music->playMusic(*path, /*loop=*/false); + } + }); + + // SMSG_PLAY_SOUND: look up SoundEntries.dbc and play 2-D sound effect + gameHandler_.setPlaySoundCallback([this](uint32_t soundId) { + auto path = resolveSoundEntryPath(soundId); + if (path) { + audio::AudioEngine::instance().playSound2D(*path); + } + }); + + // SMSG_PLAY_OBJECT_SOUND / SMSG_PLAY_SPELL_IMPACT: play as 3D positional sound at source entity + gameHandler_.setPlayPositionalSoundCallback([this](uint32_t soundId, uint64_t sourceGuid) { + auto path = resolveSoundEntryPath(soundId); + if (!path) return; + + // Play as 3D sound if source entity position is available. + // Entity stores canonical coords; listener uses render coords (camera). + auto entity = gameHandler_.getEntityManager().getEntity(sourceGuid); + if (entity) { + glm::vec3 canonical{entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()}; + glm::vec3 pos = core::coords::canonicalToRender(canonical); + audio::AudioEngine::instance().playSound3D(*path, pos); + } else { + audio::AudioEngine::instance().playSound2D(*path); + } + }); + + // Other player level-up callback — trigger 3D effect + chat notification + gameHandler_.setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { + if (!renderer_) return; + + // Trigger 3D effect at the other player's position + auto entity = gameHandler_.getEntityManager().getEntity(guid); + if (entity) { + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + renderer_->triggerLevelUpEffect(renderPos); + } + + // Show chat message if in group + if (gameHandler_.isInGroup()) { + std::string name = gameHandler_.getCachedPlayerName(guid); + if (name.empty()) name = "A party member"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = name + " has reached level " + std::to_string(newLevel) + "!"; + gameHandler_.addLocalChatMessage(msg); + } + }); + + // Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER + gameHandler_.setOpenLfgCallback([this]() { + if (uiManager_) uiManager_->getGameScreen().openDungeonFinder(); + }); +} + +}} // namespace wowee::core diff --git a/src/core/entity_spawn_callback_handler.cpp b/src/core/entity_spawn_callback_handler.cpp new file mode 100644 index 00000000..f3f07157 --- /dev/null +++ b/src/core/entity_spawn_callback_handler.cpp @@ -0,0 +1,191 @@ +#include "core/entity_spawn_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/m2_renderer.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/animation/animation_ids.hpp" +#include "game/game_handler.hpp" + +namespace wowee { namespace core { + +EntitySpawnCallbackHandler::EntitySpawnCallbackHandler( + EntitySpawner& entitySpawner, + rendering::Renderer& renderer, + game::GameHandler& gameHandler, + std::function isLocalPlayerGuid) + : entitySpawner_(entitySpawner) + , renderer_(renderer) + , gameHandler_(gameHandler) + , isLocalPlayerGuid_(std::move(isLocalPlayerGuid)) +{ +} + +void EntitySpawnCallbackHandler::setupCallbacks() { + // Creature spawn callback (online mode) - spawn creature models + gameHandler_.setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { + // Queue spawns to avoid hanging when many creatures appear at once. + // Deduplicate so repeated updates don't flood pending queue. + if (entitySpawner_.isCreatureSpawned(guid)) return; + if (entitySpawner_.isCreaturePending(guid)) return; + entitySpawner_.queueCreatureSpawn(guid, displayId, x, y, z, orientation, scale); + }); + + // Player spawn callback (online mode) - spawn player models with correct textures + gameHandler_.setPlayerSpawnCallback([this](uint64_t guid, + uint32_t /*displayId*/, + uint8_t raceId, + uint8_t genderId, + uint32_t appearanceBytes, + uint8_t facialFeatures, + float x, float y, float z, float orientation) { + LOG_WARNING("playerSpawnCallback: guid=0x", std::hex, guid, std::dec, + " race=", static_cast(raceId), " gender=", static_cast(genderId), + " pos=(", x, ",", y, ",", z, ")"); + // Skip local player — already spawned as the main character + if (isLocalPlayerGuid_(guid)) return; + if (entitySpawner_.isPlayerSpawned(guid)) return; + if (entitySpawner_.isPlayerPending(guid)) return; + entitySpawner_.queuePlayerSpawn(guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation); + }); + + // Online player equipment callback - apply armor geosets/skin overlays per player instance. + gameHandler_.setPlayerEquipmentCallback([this](uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes) { + // Queue equipment compositing instead of doing it immediately — + // compositeWithRegions is expensive (file I/O + CPU blit + GPU upload) + // and causes frame stutters if multiple players update at once. + entitySpawner_.queuePlayerEquipment(guid, displayInfoIds, inventoryTypes); + }); + + // Creature despawn callback (online mode) - remove creature models + gameHandler_.setCreatureDespawnCallback([this](uint64_t guid) { + entitySpawner_.despawnCreature(guid); + }); + + gameHandler_.setPlayerDespawnCallback([this](uint64_t guid) { + entitySpawner_.despawnPlayer(guid); + }); + + // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) + gameHandler_.setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { + entitySpawner_.queueGameObjectSpawn(guid, entry, displayId, x, y, z, orientation, scale); + }); + + // GameObject despawn callback (online mode) - remove static models + gameHandler_.setGameObjectDespawnCallback([this](uint64_t guid) { + entitySpawner_.despawnGameObject(guid); + }); + + // GameObject custom animation callback (e.g. chest opening) + gameHandler_.setGameObjectCustomAnimCallback([this](uint64_t guid, uint32_t animId) { + auto& goInstances = entitySpawner_.getGameObjectInstances(); + auto it = goInstances.find(guid); + if (it == goInstances.end()) return; + auto& info = it->second; + if (!info.isWmo) { + if (auto* m2r = renderer_.getM2Renderer()) { + // Play the custom animation as a one-shot if model supports it + if (m2r->hasAnimation(info.instanceId, animId)) + m2r->setInstanceAnimation(info.instanceId, animId, false); + else + m2r->setInstanceAnimationFrozen(info.instanceId, false); + } + } + }); + + // GameObject state change callback — animate doors/chests opening/closing/destroying + gameHandler_.setGameObjectStateCallback([this](uint64_t guid, uint8_t goState) { + auto& goInstances = entitySpawner_.getGameObjectInstances(); + auto it = goInstances.find(guid); + if (it == goInstances.end()) return; + auto& info = it->second; + if (info.isWmo) return; // WMOs don't have M2 animation sequences + auto* m2r = renderer_.getM2Renderer(); + if (!m2r) return; + uint32_t instId = info.instanceId; + // GO states: 0=READY(closed), 1=OPEN, 2=DESTROYED/ACTIVE + if (goState == 1) { + // Opening: play OPEN(148) one-shot, fall back to unfreezing + if (m2r->hasAnimation(instId, 148)) + m2r->setInstanceAnimation(instId, 148, false); + else + m2r->setInstanceAnimationFrozen(instId, false); + } else if (goState == 2) { + // Destroyed: play DESTROY(149) one-shot + if (m2r->hasAnimation(instId, 149)) + m2r->setInstanceAnimation(instId, 149, false); + } else { + // Closed: play CLOSE(146) one-shot, else freeze + if (m2r->hasAnimation(instId, 146)) + m2r->setInstanceAnimation(instId, 146, false); + else + m2r->setInstanceAnimationFrozen(instId, true); + } + }); + + // Creature move callback (online mode) - update creature positions + gameHandler_.setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { + if (!renderer_.getCharacterRenderer()) return; + uint32_t instanceId = 0; + bool isPlayer = false; + instanceId = entitySpawner_.getPlayerInstanceId(guid); + if (instanceId != 0) { isPlayer = true; } + else { + instanceId = entitySpawner_.getCreatureInstanceId(guid); + } + if (instanceId != 0) { + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + float durationSec = static_cast(durationMs) / 1000.0f; + renderer_.getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec); + // Play Run animation (anim 5) for the duration of the spline move. + // WoW M2 animation IDs: 4=Walk, 5=Run. + // Don't override Death animation (1). The per-frame sync loop will return to + // Stand when movement stops. + if (durationMs > 0) { + // Player animation is managed by the local renderer state machine — + // don't reset it here or every server movement packet restarts the + // run cycle from frame 0, causing visible stutter. + if (!isPlayer) { + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + auto* cr = renderer_.getCharacterRenderer(); + bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); + // Only start Run if not already running and not in Death animation. + if (!gotState || (curAnimId != rendering::anim::DEATH && curAnimId != rendering::anim::RUN)) { + cr->playAnimation(instanceId, rendering::anim::RUN, /*loop=*/true); + } + entitySpawner_.getCreatureWasMoving()[guid] = true; + } + } + } + }); + + gameHandler_.setGameObjectMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { + auto& goInstMap = entitySpawner_.getGameObjectInstances(); + auto it = goInstMap.find(guid); + if (it == goInstMap.end()) { + return; + } + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + auto& info = it->second; + if (info.isWmo) { + if (auto* wr = renderer_.getWMORenderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); + wr->setInstanceTransform(info.instanceId, transform); + } + } else { + if (auto* mr = renderer_.getM2Renderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + mr->setInstanceTransform(info.instanceId, transform); + } + } + }); +} + +}} // namespace wowee::core diff --git a/src/core/npc_interaction_callback_handler.cpp b/src/core/npc_interaction_callback_handler.cpp new file mode 100644 index 00000000..f58887eb --- /dev/null +++ b/src/core/npc_interaction_callback_handler.cpp @@ -0,0 +1,84 @@ +#include "core/npc_interaction_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/animation/animation_ids.hpp" +#include "game/game_handler.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/npc_voice_manager.hpp" + +namespace wowee { namespace core { + +NPCInteractionCallbackHandler::NPCInteractionCallbackHandler( + EntitySpawner& entitySpawner, + rendering::Renderer* renderer, + game::GameHandler& gameHandler, + audio::AudioCoordinator* audioCoordinator) + : entitySpawner_(entitySpawner) + , renderer_(renderer) + , gameHandler_(gameHandler) + , audioCoordinator_(audioCoordinator) +{ +} + +audio::VoiceType NPCInteractionCallbackHandler::resolveNpcVoiceType(uint64_t guid) const { + audio::VoiceType voiceType = audio::VoiceType::GENERIC; + auto entity = gameHandler_.getEntityManager().getEntity(guid); + if (entity && entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + uint32_t displayId = unit->getDisplayId(); + voiceType = entitySpawner_.detectVoiceTypeFromDisplayId(displayId); + } + return voiceType; +} + +void NPCInteractionCallbackHandler::setupCallbacks() { + // NPC greeting callback - play voice line + gameHandler_.setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) { + // Play NPC_WELCOME animation on the NPC + if (renderer_) { + auto* cr = renderer_->getCharacterRenderer(); + if (cr) { + uint32_t instanceId = entitySpawner_.getCreatureInstanceId(guid); + if (instanceId != 0) cr->playAnimation(instanceId, rendering::anim::NPC_WELCOME, false); + } + } + if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { + // Convert canonical to render coords for 3D audio + glm::vec3 renderPos = core::coords::canonicalToRender(position); + audio::VoiceType voiceType = resolveNpcVoiceType(guid); + audioCoordinator_->getNpcVoiceManager()->playGreeting(guid, voiceType, renderPos); + } + }); + + // NPC farewell callback - play farewell voice line + gameHandler_.setNpcFarewellCallback([this](uint64_t guid, const glm::vec3& position) { + if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { + glm::vec3 renderPos = core::coords::canonicalToRender(position); + audio::VoiceType voiceType = resolveNpcVoiceType(guid); + audioCoordinator_->getNpcVoiceManager()->playFarewell(guid, voiceType, renderPos); + } + }); + + // NPC vendor callback - play vendor voice line + gameHandler_.setNpcVendorCallback([this](uint64_t guid, const glm::vec3& position) { + if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { + glm::vec3 renderPos = core::coords::canonicalToRender(position); + audio::VoiceType voiceType = resolveNpcVoiceType(guid); + audioCoordinator_->getNpcVoiceManager()->playVendor(guid, voiceType, renderPos); + } + }); + + // NPC aggro callback - play combat start voice line + gameHandler_.setNpcAggroCallback([this](uint64_t guid, const glm::vec3& position) { + if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) { + glm::vec3 renderPos = core::coords::canonicalToRender(position); + audio::VoiceType voiceType = resolveNpcVoiceType(guid); + audioCoordinator_->getNpcVoiceManager()->playAggro(guid, voiceType, renderPos); + } + }); +} + +}} // namespace wowee::core diff --git a/src/core/transport_callback_handler.cpp b/src/core/transport_callback_handler.cpp new file mode 100644 index 00000000..635f083b --- /dev/null +++ b/src/core/transport_callback_handler.cpp @@ -0,0 +1,275 @@ +#include "core/transport_callback_handler.hpp" +#include "core/entity_spawner.hpp" +#include "core/world_loader.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/terrain_manager.hpp" +#include "rendering/m2_renderer.hpp" +#include "game/game_handler.hpp" +#include "game/transport_manager.hpp" + +#include + +namespace wowee { namespace core { + +TransportCallbackHandler::TransportCallbackHandler( + EntitySpawner& entitySpawner, + rendering::Renderer& renderer, + game::GameHandler& gameHandler, + WorldLoader* worldLoader) + : entitySpawner_(entitySpawner) + , renderer_(renderer) + , gameHandler_(gameHandler) + , worldLoader_(worldLoader) +{ +} + +void TransportCallbackHandler::setupCallbacks() { + // Mount callback (online mode) - defer heavy model load to next frame + gameHandler_.setMountCallback([this](uint32_t mountDisplayId) { + if (mountDisplayId == 0) { + // Dismount is instant (no loading needed) + if (renderer_.getCharacterRenderer() && entitySpawner_.getMountInstanceId() != 0) { + renderer_.getCharacterRenderer()->removeInstance(entitySpawner_.getMountInstanceId()); + entitySpawner_.clearMountState(); + } + entitySpawner_.setMountDisplayId(0); + renderer_.clearMount(); + LOG_INFO("Dismounted"); + return; + } + // Queue the mount for processing in the next update() frame + entitySpawner_.setMountDisplayId(mountDisplayId); + }); + + // Taxi precache callback - preload terrain tiles along flight path + gameHandler_.setTaxiPrecacheCallback([this](const std::vector& path) { + if (!renderer_.getTerrainManager()) return; + + std::set> uniqueTiles; + + // Sample waypoints along path and gather tiles. + // Denser sampling + neighbor coverage reduces in-flight stream spikes. + const size_t stride = 2; + for (size_t i = 0; i < path.size(); i += stride) { + const auto& waypoint = path[i]; + glm::vec3 renderPos = core::coords::canonicalToRender(waypoint); + int tileX = static_cast(32 - (renderPos.x / 533.33333f)); + int tileY = static_cast(32 - (renderPos.y / 533.33333f)); + + if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { + for (int dx = -1; dx <= 1; ++dx) { + for (int dy = -1; dy <= 1; ++dy) { + int nx = tileX + dx; + int ny = tileY + dy; + if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { + uniqueTiles.insert({nx, ny}); + } + } + } + } + } + // Ensure final destination tile is included. + if (!path.empty()) { + glm::vec3 renderPos = core::coords::canonicalToRender(path.back()); + int tileX = static_cast(32 - (renderPos.x / 533.33333f)); + int tileY = static_cast(32 - (renderPos.y / 533.33333f)); + if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { + for (int dx = -1; dx <= 1; ++dx) { + for (int dy = -1; dy <= 1; ++dy) { + int nx = tileX + dx; + int ny = tileY + dy; + if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { + uniqueTiles.insert({nx, ny}); + } + } + } + } + } + + std::vector> tilesToLoad(uniqueTiles.begin(), uniqueTiles.end()); + if (tilesToLoad.size() > 512) { + tilesToLoad.resize(512); + } + LOG_INFO("Precaching ", tilesToLoad.size(), " tiles for taxi route"); + renderer_.getTerrainManager()->precacheTiles(tilesToLoad); + }); + + // Taxi orientation callback - update mount rotation during flight + gameHandler_.setTaxiOrientationCallback([this](float yaw, float pitch, float roll) { + if (renderer_.getCameraController()) { + // Taxi callback now provides render-space yaw directly. + float yawDegrees = glm::degrees(yaw); + renderer_.getCameraController()->setFacingYaw(yawDegrees); + renderer_.setCharacterYaw(yawDegrees); + // Set mount pitch and roll for realistic flight animation + renderer_.setMountPitchRoll(pitch, roll); + } + }); + + // Taxi flight start callback - keep non-blocking to avoid hitching at takeoff. + gameHandler_.setTaxiFlightStartCallback([this]() { + if (renderer_.getTerrainManager() && renderer_.getM2Renderer()) { + LOG_INFO("Taxi flight start: incremental terrain/M2 streaming active"); + uint32_t m2Count = renderer_.getM2Renderer()->getModelCount(); + uint32_t instCount = renderer_.getM2Renderer()->getInstanceCount(); + LOG_INFO("Current M2 VRAM state: ", m2Count, " models (", instCount, " instances)"); + } + }); + + // Transport spawn callback (online mode) - register transports with TransportManager + gameHandler_.setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { + // Get the GameObject instance now so late queue processing can rely on stable IDs. + auto& goInstances2 = entitySpawner_.getGameObjectInstances(); + auto it = goInstances2.find(guid); + if (it == goInstances2.end()) { + LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec); + return; + } + + auto pendingIt = entitySpawner_.hasTransportRegistrationPending(guid); + if (pendingIt) { + entitySpawner_.updateTransportRegistration(guid, displayId, x, y, z, orientation); + } else { + entitySpawner_.queueTransportRegistration(guid, entry, displayId, x, y, z, orientation); + } + }); + + // Transport move callback (online mode) - update transport gameobject positions + gameHandler_.setTransportMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { + LOG_DEBUG("Transport move callback: GUID=0x", std::hex, guid, std::dec, + " pos=(", x, ", ", y, ", ", z, ") orientation=", orientation); + + auto* transportManager = gameHandler_.getTransportManager(); + if (!transportManager) { + LOG_WARNING("Transport move callback: TransportManager is null!"); + return; + } + + if (entitySpawner_.hasTransportRegistrationPending(guid)) { + entitySpawner_.setTransportPendingMove(guid, x, y, z, orientation); + LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec); + return; + } + + // Check if transport exists - if not, treat this as a late spawn (reconnection/server restart) + if (!transportManager->getTransport(guid)) { + LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec, + " - auto-spawning from position update"); + + // Get transport info from entity manager + auto entity = gameHandler_.getEntityManager().getEntity(guid); + if (entity && entity->getType() == game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + uint32_t entry = go->getEntry(); + uint32_t displayId = go->getDisplayId(); + + // Find the WMO instance for this transport (should exist from earlier GameObject spawn) + auto& goInstances3 = entitySpawner_.getGameObjectInstances(); + auto it = goInstances3.find(guid); + if (it != goInstances3.end()) { + uint32_t wmoInstanceId = it->second.instanceId; + + // TransportAnimation.dbc is indexed by GameObject entry + uint32_t pathId = entry; + const bool preferServerData = gameHandler_.hasServerTransportUpdate(guid); + + // Coordinates are already canonical (converted in game_handler.cpp) + glm::vec3 canonicalSpawnPos(x, y, z); + + // Check if we have a real usable path, otherwise remap/infer/fall back to stationary. + const bool shipOrZeppelinDisplay = + (displayId == 3015 || displayId == 3031 || displayId == 7546 || + displayId == 7446 || displayId == 1587 || displayId == 2454 || + displayId == 807 || displayId == 808); + bool hasUsablePath = transportManager->hasPathForEntry(entry); + if (shipOrZeppelinDisplay) { + hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); + } + + if (preferServerData) { + // Strict server-authoritative mode: no inferred/remapped fallback routes. + if (!hasUsablePath) { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_INFO("Auto-spawned transport in strict server-first mode (stationary fallback): entry=", entry, + " displayId=", displayId, " wmoInstance=", wmoInstanceId); + } else { + LOG_INFO("Auto-spawned transport in server-first mode with entry DBC path: entry=", entry, + " displayId=", displayId, " wmoInstance=", wmoInstanceId); + } + } else if (!hasUsablePath) { + bool allowZOnly = (displayId == 455 || displayId == 462); + uint32_t inferredPath = transportManager->inferDbcPathForSpawn( + canonicalSpawnPos, 1200.0f, allowZOnly); + if (inferredPath != 0) { + pathId = inferredPath; + LOG_INFO("Auto-spawned transport with inferred path: entry=", entry, + " inferredPath=", pathId, " displayId=", displayId, + " wmoInstance=", wmoInstanceId); + } else { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_INFO("Auto-spawned transport with remapped fallback path: entry=", entry, + " remappedPath=", pathId, " displayId=", displayId, + " wmoInstance=", wmoInstanceId); + } else { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_INFO("Auto-spawned transport with stationary path: entry=", entry, + " displayId=", displayId, " wmoInstance=", wmoInstanceId); + } + } + } else { + LOG_INFO("Auto-spawned transport with real path: entry=", entry, + " displayId=", displayId, " wmoInstance=", wmoInstanceId); + } + + transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); + // Keep type in sync with the spawned instance; needed for M2 lift boarding/motion. + if (!it->second.isWmo) { + if (auto* tr = transportManager->getTransport(guid)) { + tr->isM2 = true; + } + } + } else { + entitySpawner_.setTransportPendingMove(guid, x, y, z, orientation); + LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, + " - WMO instance not found yet (queued move for replay)"); + return; + } + } else { + entitySpawner_.setTransportPendingMove(guid, x, y, z, orientation); + LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, + " - entity not found in EntityManager (queued move for replay)"); + return; + } + } + + // Update TransportManager's internal state (position, rotation, transform matrices) + // This also updates the WMO renderer automatically + // Coordinates are already canonical (converted in game_handler.cpp when entity was created) + glm::vec3 canonicalPos(x, y, z); + transportManager->updateServerTransport(guid, canonicalPos, orientation); + + // Move player with transport if riding it + if (gameHandler_.isOnTransport() && gameHandler_.getPlayerTransportGuid() == guid) { + auto* cc = renderer_.getCameraController(); + if (cc) { + glm::vec3* ft = cc->getFollowTargetMutable(); + if (ft) { + // Get player world position from TransportManager (handles transform properly) + glm::vec3 offset = gameHandler_.getPlayerTransportOffset(); + glm::vec3 worldPos = transportManager->getPlayerWorldPosition(guid, offset); + *ft = worldPos; + } + } + } + }); +} + +}} // namespace wowee::core diff --git a/src/core/ui_screen_callback_handler.cpp b/src/core/ui_screen_callback_handler.cpp new file mode 100644 index 00000000..1d6acf87 --- /dev/null +++ b/src/core/ui_screen_callback_handler.cpp @@ -0,0 +1,182 @@ +#include "core/ui_screen_callback_handler.hpp" +#include "core/application.hpp" // AppState +#include "core/logger.hpp" +#include "ui/ui_manager.hpp" +#include "auth/auth_handler.hpp" +#include "game/game_handler.hpp" +#include "game/expansion_profile.hpp" +#include "game/world_packets.hpp" +#include "pipeline/asset_manager.hpp" + +namespace wowee { namespace core { + +UIScreenCallbackHandler::UIScreenCallbackHandler( + ui::UIManager& uiManager, + game::GameHandler& gameHandler, + auth::AuthHandler& authHandler, + game::ExpansionRegistry* expansionRegistry, + pipeline::AssetManager* assetManager, + SetStateFn setState) + : uiManager_(uiManager) + , gameHandler_(gameHandler) + , authHandler_(authHandler) + , expansionRegistry_(expansionRegistry) + , assetManager_(assetManager) + , setState_(std::move(setState)) +{ +} + +void UIScreenCallbackHandler::setupCallbacks() { + // Authentication screen callback + uiManager_.getAuthScreen().setOnSuccess([this]() { + LOG_INFO("Authentication successful, transitioning to realm selection"); + setState_(AppState::REALM_SELECTION); + }); + + // Realm selection callback + uiManager_.getRealmScreen().setOnRealmSelected([this](const std::string& realmName, const std::string& realmAddress) { + LOG_INFO("Realm selected: ", realmName, " (", realmAddress, ")"); + + // Parse realm address (format: "hostname:port") + std::string host = realmAddress; + uint16_t port = 8085; // Default world server port + + size_t colonPos = realmAddress.find(':'); + if (colonPos != std::string::npos) { + host = realmAddress.substr(0, colonPos); + try { port = static_cast(std::stoi(realmAddress.substr(colonPos + 1))); } + catch (...) { LOG_WARNING("Invalid port in realm address: ", realmAddress); } + } + + // Connect to world server + const auto& sessionKey = authHandler_.getSessionKey(); + std::string accountName = authHandler_.getUsername(); + if (accountName.empty()) { + LOG_WARNING("Auth username missing; falling back to TESTACCOUNT"); + accountName = "TESTACCOUNT"; + } + + uint32_t realmId = 0; + uint16_t realmBuild = 0; + { + // WotLK AUTH_SESSION includes a RealmID field; some servers reject if it's wrong/zero. + const auto& realms = authHandler_.getRealms(); + for (const auto& r : realms) { + if (r.name == realmName && r.address == realmAddress) { + realmId = r.id; + realmBuild = r.build; + break; + } + } + LOG_INFO("Selected realmId=", realmId, " realmBuild=", realmBuild); + } + + uint32_t clientBuild = 12340; // default WotLK + if (expansionRegistry_) { + auto* profile = expansionRegistry_->getActive(); + if (profile) clientBuild = profile->worldBuild; + } + // Prefer realm-reported build when available (e.g. vanilla servers + // that report build 5875 in the realm list) + if (realmBuild != 0) { + clientBuild = realmBuild; + LOG_INFO("Using realm-reported build: ", clientBuild); + } + if (gameHandler_.connect(host, port, sessionKey, accountName, clientBuild, realmId)) { + LOG_INFO("Connected to world server, transitioning to character selection"); + setState_(AppState::CHARACTER_SELECTION); + } else { + LOG_ERROR("Failed to connect to world server"); + } + }); + + // Realm screen back button - return to login + uiManager_.getRealmScreen().setOnBack([this]() { + authHandler_.disconnect(); + uiManager_.getRealmScreen().reset(); + setState_(AppState::AUTHENTICATION); + }); + + // Character selection callback + uiManager_.getCharacterScreen().setOnCharacterSelected([this](uint64_t characterGuid) { + LOG_INFO("Character selected: GUID=0x", std::hex, characterGuid, std::dec); + // Always set the active character GUID + gameHandler_.setActiveCharacterGuid(characterGuid); + // Keep CHARACTER_SELECTION active until world entry is fully loaded. + // This avoids exposing pre-load hitching before the loading screen/intro. + }); + + // Character create screen callbacks + uiManager_.getCharacterCreateScreen().setOnCreate([this](const game::CharCreateData& data) { + pendingCreatedCharacterName_ = data.name; // Store name for auto-selection + gameHandler_.createCharacter(data); + }); + + uiManager_.getCharacterCreateScreen().setOnCancel([this]() { + setState_(AppState::CHARACTER_SELECTION); + }); + + // Character create result callback + gameHandler_.setCharCreateCallback([this](bool success, const std::string& msg) { + if (success) { + // Auto-select the newly created character + if (!pendingCreatedCharacterName_.empty()) { + uiManager_.getCharacterScreen().selectCharacterByName(pendingCreatedCharacterName_); + pendingCreatedCharacterName_.clear(); + } + setState_(AppState::CHARACTER_SELECTION); + } else { + uiManager_.getCharacterCreateScreen().setStatus(msg, true); + pendingCreatedCharacterName_.clear(); + } + }); + + // Character login failure callback + gameHandler_.setCharLoginFailCallback([this](const std::string& reason) { + LOG_WARNING("Character login failed: ", reason); + setState_(AppState::CHARACTER_SELECTION); + uiManager_.getCharacterScreen().setStatus("Login failed: " + reason, true); + }); + + // "Create Character" button on character screen + uiManager_.getCharacterScreen().setOnCreateCharacter([this]() { + uiManager_.getCharacterCreateScreen().reset(); + // Apply expansion race/class constraints before showing the screen + if (expansionRegistry_ && expansionRegistry_->getActive()) { + auto* profile = expansionRegistry_->getActive(); + uiManager_.getCharacterCreateScreen().setExpansionConstraints( + profile->races, profile->classes); + } + uiManager_.getCharacterCreateScreen().initializePreview(assetManager_); + setState_(AppState::CHARACTER_CREATION); + }); + + // "Back" button on character screen + uiManager_.getCharacterScreen().setOnBack([this]() { + // Disconnect from world server and reset UI state for fresh realm selection + gameHandler_.disconnect(); + uiManager_.getRealmScreen().reset(); + uiManager_.getCharacterScreen().reset(); + setState_(AppState::REALM_SELECTION); + }); + + // "Delete Character" button on character screen + uiManager_.getCharacterScreen().setOnDeleteCharacter([this](uint64_t guid) { + gameHandler_.deleteCharacter(guid); + }); + + // Character delete result callback + gameHandler_.setCharDeleteCallback([this](bool success) { + if (success) { + uiManager_.getCharacterScreen().setStatus("Character deleted."); + // Refresh character list + gameHandler_.requestCharacterList(); + } else { + uint8_t code = gameHandler_.getLastCharDeleteResult(); + uiManager_.getCharacterScreen().setStatus( + "Delete failed (code " + std::to_string(static_cast(code)) + ").", true); + } + }); +} + +}} // namespace wowee::core diff --git a/src/core/world_entry_callback_handler.cpp b/src/core/world_entry_callback_handler.cpp new file mode 100644 index 00000000..fab21c52 --- /dev/null +++ b/src/core/world_entry_callback_handler.cpp @@ -0,0 +1,456 @@ +#include "core/world_entry_callback_handler.hpp" +#include "core/coordinates.hpp" +#include "core/entity_spawner.hpp" +#include "core/world_loader.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "rendering/camera_controller.hpp" +#include "rendering/terrain_manager.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/m2_renderer.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" + +#include +#include + +namespace wowee { namespace core { + +WorldEntryCallbackHandler::WorldEntryCallbackHandler( + rendering::Renderer& renderer, + game::GameHandler& gameHandler, + WorldLoader* worldLoader, + EntitySpawner* entitySpawner, + audio::AudioCoordinator* audioCoordinator, + pipeline::AssetManager* assetManager) + : renderer_(renderer) + , gameHandler_(gameHandler) + , worldLoader_(worldLoader) + , entitySpawner_(entitySpawner) + , audioCoordinator_(audioCoordinator) + , assetManager_(assetManager) +{ +} + +// ── helpers ────────────────────────────────────────────────────── + +// Sample best floor height at (x, y) from terrain, WMO, and M2 (eliminates 3x duplication) +std::optional WorldEntryCallbackHandler::sampleBestFloorAt(float x, float y, float probeZ) const { + std::optional terrainFloor; + std::optional wmoFloor; + std::optional m2Floor; + + if (renderer_.getTerrainManager()) { + terrainFloor = renderer_.getTerrainManager()->getHeightAt(x, y); + } + if (renderer_.getWMORenderer()) { + wmoFloor = renderer_.getWMORenderer()->getFloorHeight(x, y, probeZ); + } + if (renderer_.getM2Renderer()) { + m2Floor = renderer_.getM2Renderer()->getFloorHeight(x, y, probeZ); + } + + std::optional best; + if (terrainFloor) best = terrainFloor; + if (wmoFloor && (!best || *wmoFloor > *best)) best = wmoFloor; + if (m2Floor && (!best || *m2Floor > *best)) best = m2Floor; + return best; +} + +// Clear stuck movement state on player +void WorldEntryCallbackHandler::clearStuckMovement() { + if (renderer_.getCameraController()) { + renderer_.getCameraController()->clearMovementInputs(); + } + gameHandler_.forceClearTaxiAndMovementState(); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_STOP); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_STOP_TURN); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_STOP_SWIM); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); +} + +// Sync teleported render position to server +void WorldEntryCallbackHandler::syncTeleportedPositionToServer(const glm::vec3& renderPos) { + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); + gameHandler_.setPosition(canonical.x, canonical.y, canonical.z); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_STOP); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_STOP_TURN); + gameHandler_.sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); +} + +// Force server-side teleport via GM command +void WorldEntryCallbackHandler::forceServerTeleportCommand(const glm::vec3& renderPos) { + // Server-authoritative reset first, then teleport. + gameHandler_.sendChatMessage(game::ChatType::SAY, ".revive", ""); + gameHandler_.sendChatMessage(game::ChatType::SAY, ".dismount", ""); + + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); + glm::vec3 serverPos = core::coords::canonicalToServer(canonical); + std::ostringstream cmd; + cmd.setf(std::ios::fixed); + cmd.precision(3); + cmd << ".go xyz " + << serverPos.x << " " + << serverPos.y << " " + << serverPos.z << " " + << gameHandler_.getCurrentMapId() << " " + << gameHandler_.getMovementInfo().orientation; + gameHandler_.sendChatMessage(game::ChatType::SAY, cmd.str(), ""); +} + +// Precache tiles in a radius around a position (eliminates repeated tile-loop code) +static void precacheNearbyTiles(rendering::TerrainManager* terrainMgr, + const glm::vec3& renderPos, int radius) { + if (!terrainMgr) return; + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + int side = 2 * radius + 1; + std::vector> tiles; + tiles.reserve(side * side); + for (int dy = -radius; dy <= radius; dy++) + for (int dx = -radius; dx <= radius; dx++) + tiles.push_back({tileX + dx, tileY + dy}); + terrainMgr->precacheTiles(tiles); +} + +// ── callbacks ──────────────────────────────────────────────────── + +void WorldEntryCallbackHandler::setupCallbacks() { + // World entry callback (online mode) - load terrain when entering world + gameHandler_.setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) { + LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")" + " initial=", isInitialEntry); + renderer_.resetCombatVisualState(); + + // Reconnect to the same map: terrain stays loaded but all online entities are stale. + // Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world. + uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF; + if (entitySpawner_ && mapId == currentLoadedMap && renderer_.getTerrainManager() && isInitialEntry) { + LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)"); + + // Pending spawn queues and failure caches — clear so previously-failed GUIDs can retry. + // Dead creature guids will be re-populated from fresh server state. + entitySpawner_->clearAllQueues(); + + // Properly despawn all tracked instances from the renderer + entitySpawner_->despawnAllCreatures(); + entitySpawner_->despawnAllPlayers(); + entitySpawner_->despawnAllGameObjects(); + + // Update player position and re-queue nearby tiles (same logic as teleport) + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + renderer_.getCharacterPosition() = renderPos; + if (renderer_.getCameraController()) { + auto* ft = renderer_.getCameraController()->getFollowTargetMutable(); + if (ft) *ft = renderPos; + renderer_.getCameraController()->clearMovementInputs(); + renderer_.getCameraController()->suppressMovementFor(1.0f); + renderer_.getCameraController()->suspendGravityFor(10.0f); + } + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + renderer_.getTerrainManager()->processReadyTiles(); + precacheNearbyTiles(renderer_.getTerrainManager(), renderPos, 8); + return; + } + + // Same-map teleport (taxi landing, GM teleport, hearthstone on same continent): + if (mapId == currentLoadedMap && renderer_.getTerrainManager()) { + // Check if teleport is far enough to need terrain loading (>500 render units) + glm::vec3 oldPos = renderer_.getCharacterPosition(); + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + float teleportDistSq = glm::dot(renderPos - oldPos, renderPos - oldPos); + bool farTeleport = (teleportDistSq > 500.0f * 500.0f); + + if (farTeleport) { + // Far same-map teleport (hearthstone, etc.): defer full world reload + // to next frame to avoid blocking the packet handler for 20+ seconds. + LOG_WARNING("Far same-map teleport (dist=", std::sqrt(teleportDistSq), + "), deferring world reload to next frame"); + // Update position immediately so the player doesn't keep moving at old location + renderer_.getCharacterPosition() = renderPos; + if (renderer_.getCameraController()) { + auto* ft = renderer_.getCameraController()->getFollowTargetMutable(); + if (ft) *ft = renderPos; + renderer_.getCameraController()->clearMovementInputs(); + renderer_.getCameraController()->suppressMovementFor(1.0f); + renderer_.getCameraController()->suspendGravityFor(10.0f); + } + if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); + return; + } + LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload"); + // canonical and renderPos already computed above for distance check + renderer_.getCharacterPosition() = renderPos; + if (renderer_.getCameraController()) { + auto* ft = renderer_.getCameraController()->getFollowTargetMutable(); + if (ft) *ft = renderPos; + } + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + // Stop any movement that was active before the teleport + if (renderer_.getCameraController()) { + renderer_.getCameraController()->clearMovementInputs(); + renderer_.getCameraController()->suppressMovementFor(0.5f); + } + // Kick off async upload for any tiles that finished background + // parsing. Use the bounded processReadyTiles() instead of + // processAllReadyTiles() to avoid multi-second main-thread stalls + // when many tiles are ready (the rest will finalize over subsequent + // frames via the normal terrain update loop). + renderer_.getTerrainManager()->processReadyTiles(); + + // Queue all remaining tiles within the load radius (8 tiles = 17x17) + // at the new position. precacheTiles skips already-loaded/pending tiles, + // so this only enqueues tiles that aren't yet in the pipeline. + // This ensures background workers immediately start loading everything + // visible from the new position (hearthstone may land far from old location). + precacheNearbyTiles(renderer_.getTerrainManager(), renderPos, 8); + return; + } + + // If a world load is already in progress (re-entrant call from + // gameHandler->update() processing SMSG_NEW_WORLD during warmup), + // defer this entry. The current load will pick it up when it finishes. + if (worldLoader_ && worldLoader_->isLoadingWorld()) { + LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)"); + worldLoader_->setPendingEntry(mapId, x, y, z); + return; + } + + // Full world loads are expensive and `loadOnlineWorldTerrain()` itself + // drives `gameHandler->update()` during warmup. Queue the load here so + // it runs after the current packet handler returns instead of recursing + // from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`. + LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); + }); + + // /unstuck — nudge player forward and snap to floor at destination. + gameHandler_.setUnstuckCallback([this]() { + if (!renderer_.getCameraController()) return; + worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + clearStuckMovement(); + auto* cc = renderer_.getCameraController(); + auto* ft = cc->getFollowTargetMutable(); + if (!ft) return; + + glm::vec3 pos = *ft; + + // Always nudge forward first to escape stuck geometry (M2 models, collision seams). + float renderYaw = gameHandler_.getMovementInfo().orientation + glm::radians(90.0f); + pos.x += std::cos(renderYaw) * 5.0f; + pos.y += std::sin(renderYaw) * 5.0f; + + // Sample floor at the DESTINATION position (after nudge). + // Pick the highest floor so we snap up to WMO floors when fallen below. + bool foundFloor = false; + if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) { + pos.z = *floor + 0.2f; + foundFloor = true; + } + + cc->teleportTo(pos); + if (!foundFloor) { + cc->setGrounded(false); // Let gravity pull player down to a surface + } + syncTeleportedPositionToServer(pos); + forceServerTeleportCommand(pos); + clearStuckMovement(); + LOG_INFO("Unstuck: nudged forward and snapped to floor"); + }); + + // /unstuckgy — stronger recovery: safe/home position, then sampled floor fallback. + gameHandler_.setUnstuckGyCallback([this]() { + if (!renderer_.getCameraController()) return; + worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + clearStuckMovement(); + auto* cc = renderer_.getCameraController(); + auto* ft = cc->getFollowTargetMutable(); + if (!ft) return; + + // Try last safe position first (nearby, terrain already loaded) + if (cc->hasLastSafePosition()) { + glm::vec3 safePos = cc->getLastSafePosition(); + safePos.z += 5.0f; + cc->teleportTo(safePos); + syncTeleportedPositionToServer(safePos); + forceServerTeleportCommand(safePos); + clearStuckMovement(); + LOG_INFO("Unstuck: teleported to last safe position"); + return; + } + + uint32_t bindMap = 0; + glm::vec3 bindPos(0.0f); + if (gameHandler_.getHomeBind(bindMap, bindPos) && + bindMap == gameHandler_.getCurrentMapId()) { + bindPos.z += 2.0f; + cc->teleportTo(bindPos); + syncTeleportedPositionToServer(bindPos); + forceServerTeleportCommand(bindPos); + clearStuckMovement(); + LOG_INFO("Unstuck: teleported to home bind position"); + return; + } + + // No safe/bind position — try current XY with a high floor probe. + glm::vec3 pos = *ft; + if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 120.0f)) { + pos.z = *floor + 0.5f; + cc->teleportTo(pos); + syncTeleportedPositionToServer(pos); + forceServerTeleportCommand(pos); + clearStuckMovement(); + LOG_INFO("Unstuck: teleported to sampled floor"); + return; + } + + // Last fallback: high snap to clear deeply bad geometry. + pos.z += 60.0f; + cc->teleportTo(pos); + syncTeleportedPositionToServer(pos); + forceServerTeleportCommand(pos); + clearStuckMovement(); + LOG_INFO("Unstuck: high fallback snap"); + }); + + // /unstuckhearth — teleport to hearthstone bind point (server-synced). + // Freezes player until terrain loads at destination to prevent falling through world. + gameHandler_.setUnstuckHearthCallback([this]() { + if (!renderer_.getCameraController()) return; + + uint32_t bindMap = 0; + glm::vec3 bindPos(0.0f); + if (!gameHandler_.getHomeBind(bindMap, bindPos)) { + LOG_WARNING("Unstuck hearth: no bind point available"); + return; + } + + worldEntryMovementGraceTimer_ = 10.0f; // long grace — terrain load check will clear it + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + clearStuckMovement(); + + auto* cc = renderer_.getCameraController(); + glm::vec3 renderPos = core::coords::canonicalToRender(bindPos); + renderPos.z += 2.0f; + + // Freeze player in place (no gravity/movement) until terrain loads + cc->teleportTo(renderPos); + cc->setExternalFollow(true); + forceServerTeleportCommand(renderPos); + clearStuckMovement(); + + // Set pending state — update loop will unfreeze once terrain is loaded + hearthTeleportPending_ = true; + hearthTeleportPos_ = renderPos; + hearthTeleportTimer_ = 15.0f; // 15s safety timeout + LOG_INFO("Unstuck hearth: teleporting to bind point, waiting for terrain..."); + }); + + // Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry + if (renderer_.getCameraController()) { + renderer_.getCameraController()->setAutoUnstuckCallback([this]() { + if (!renderer_.getCameraController()) return; + auto* cc = renderer_.getCameraController(); + + // Last resort: teleport to map entry point (terrain guaranteed loaded here) + glm::vec3 spawnPos = cc->getDefaultPosition(); + spawnPos.z += 5.0f; + cc->teleportTo(spawnPos); + forceServerTeleportCommand(spawnPos); + LOG_INFO("Auto-unstuck: teleported to map entry point (server synced)"); + }); + } + + // Bind point update (innkeeper) — position stored in gameHandler->getHomeBind() + gameHandler_.setBindPointCallback([this](uint32_t mapId, float x, float y, float z) { + LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + }); + + // Hearthstone preload callback: begin loading terrain at the bind point as soon as + // the player starts casting Hearthstone. The ~10 s cast gives enough time for + // the background streaming workers to bring tiles into the cache so the player + // lands on solid ground instead of falling through un-loaded terrain. + gameHandler_.setHearthstonePreloadCallback([this](uint32_t mapId, float x, float y, float z) { + auto* terrainMgr = renderer_.getTerrainManager(); + if (!terrainMgr || !assetManager_) return; + + // Resolve map name from the cached Map.dbc table + std::string mapName; + if (worldLoader_) { + mapName = worldLoader_->getMapNameById(mapId); + } + if (mapName.empty()) { + mapName = WorldLoader::mapIdToName(mapId); + } + if (mapName.empty()) mapName = "Azeroth"; + + uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF; + if (mapId == currentLoadedMap) { + // Same map: pre-enqueue tiles around the bind point so workers start + // loading them now. Uses render-space coords (canonicalToRender). + // Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time + // for workers to parse most of these before the player arrives. + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + precacheNearbyTiles(terrainMgr, renderPos, 4); + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + LOG_INFO("Hearthstone preload: enqueued 81" + " tiles around bind point (same map) tile=[", tileX, ",", tileY, "]"); + } else { + // Different map: warm the file cache so ADT parsing is fast when + // loadOnlineWorldTerrain runs its blocking load loop. + // homeBindPos_ is canonical; startWorldPreload expects server coords. + glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z)); + if (worldLoader_) { + worldLoader_->startWorldPreload(mapId, mapName, server.x, server.y); + } + LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName, + "' (id=", mapId, ")"); + } + }); +} + +// ── per-frame update ───────────────────────────────────────────── + +void WorldEntryCallbackHandler::update(float deltaTime) { + // Hearth teleport: keep player frozen until terrain loads at destination + if (hearthTeleportPending_ && renderer_.getTerrainManager()) { + hearthTeleportTimer_ -= deltaTime; + auto terrainH = renderer_.getTerrainManager()->getHeightAt( + hearthTeleportPos_.x, hearthTeleportPos_.y); + if (terrainH || hearthTeleportTimer_ <= 0.0f) { + // Terrain loaded (or timeout) — snap to floor and release + if (terrainH) { + hearthTeleportPos_.z = *terrainH + 0.5f; + renderer_.getCameraController()->teleportTo(hearthTeleportPos_); + } + renderer_.getCameraController()->setExternalFollow(false); + worldEntryMovementGraceTimer_ = 1.0f; + hearthTeleportPending_ = false; + LOG_INFO("Unstuck hearth: terrain loaded, player released", + terrainH ? "" : " (timeout)"); + } + } +} + +void WorldEntryCallbackHandler::resetState() { + hearthTeleportPending_ = false; + hearthTeleportPos_ = glm::vec3(0.0f); + hearthTeleportTimer_ = 0.0f; + worldEntryMovementGraceTimer_ = 0.0f; + lastTaxiFlight_ = false; + taxiLandingClampTimer_ = 0.0f; +} + +}} // namespace wowee::core diff --git a/src/core/world_loader.cpp b/src/core/world_loader.cpp index 6811c26a..0ca7de64 100644 --- a/src/core/world_loader.cpp +++ b/src/core/world_loader.cpp @@ -3,6 +3,7 @@ #include "core/world_loader.hpp" #include "core/application.hpp" +#include "core/world_entry_callback_handler.hpp" #include "rendering/animation/animation_ids.hpp" #include "core/entity_spawner.hpp" #include "core/appearance_composer.hpp" @@ -173,9 +174,11 @@ void WorldLoader::processPendingEntry() { auto entry = *pendingWorldEntry_; pendingWorldEntry_.reset(); LOG_WARNING("Processing deferred world entry: map ", entry.mapId); - app_.worldEntryMovementGraceTimer_ = 2.0f; - app_.taxiLandingClampTimer_ = 0.0f; - app_.lastTaxiFlight_ = false; + if (app_.worldEntryCallbacks_) { + app_.worldEntryCallbacks_->setWorldEntryMovementGraceTimer(2.0f); + app_.worldEntryCallbacks_->setTaxiLandingClampTimer(0.0f); + app_.worldEntryCallbacks_->setLastTaxiFlight(false); + } // Clear camera movement inputs before loading terrain if (renderer_ && renderer_->getCameraController()) { renderer_->getCameraController()->clearMovementInputs(); @@ -1075,9 +1078,11 @@ void WorldLoader::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float auto entry = *pendingWorldEntry_; pendingWorldEntry_.reset(); LOG_WARNING("Processing deferred world entry: map ", entry.mapId); - app_.worldEntryMovementGraceTimer_ = 2.0f; - app_.taxiLandingClampTimer_ = 0.0f; - app_.lastTaxiFlight_ = false; + if (app_.worldEntryCallbacks_) { + app_.worldEntryCallbacks_->setWorldEntryMovementGraceTimer(2.0f); + app_.worldEntryCallbacks_->setTaxiLandingClampTimer(0.0f); + app_.worldEntryCallbacks_->setLastTaxiFlight(false); + } // Recursive call — sets loadedMapId_ and IN_GAME state for the final map. loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); return; // The recursive call handles setState(IN_GAME).