refactor(core): decompose Application::setupUICallbacks() into 7 domain handlers

Extract ~1,700 lines / 60+ inline [this]-capturing lambdas from the monolithic
Application::setupUICallbacks() into 7 focused callback handler classes following
the ToastManager/ChatPanel::setupCallbacks() pattern already in the codebase.

New handlers (include/core/ + src/core/):
  - NPCInteractionCallbackHandler  NPC greeting/farewell/vendor/aggro voice
  - AudioCallbackHandler           Music, positional sound, level-up, achievement, LFG
  - EntitySpawnCallbackHandler     Creature/player/GO spawn, despawn, move, state
  - AnimationCallbackHandler       Death, respawn, combat, emotes, charge, sprint, vehicle
  - TransportCallbackHandler       Mount, taxi, transport spawn/move
  - WorldEntryCallbackHandler      World entry, unstuck, hearthstone, bind point
  - UIScreenCallbackHandler        Auth, realm selection, char selection/creation/deletion

application.cpp:  4,462 → 2,791 lines  (−1,671)
setupUICallbacks: ~1,700 → ~50 lines (thin orchestrator)

Deduplication:
  resolveSoundEntryPath()   — was 3× copy-paste of SoundEntries.dbc lookup
  resolveNpcVoiceType()     — was 4× copy-paste of display-ID→voice detection
  precacheNearbyTiles()     — was 3× copy-paste of 17×17 tile loop
  4 helper lambdas          — promoted to private methods on WorldEntryCallbackHandler

State migration out of Application:
  charge* (6 vars)          → AnimationCallbackHandler
  hearth*/worldEntry*/taxi* → WorldEntryCallbackHandler
  pendingCreatedCharacterName_ → UIScreenCallbackHandler

Bug fixes:
  - Duplicate `namespace core {` in application.hpp caused wowee::std pollution
  - AppState forward decl in ui_screen_callback_handler.hpp was at wrong scope
  - world_loader.cpp accessed moved member vars directly via friend; now uses handler API
This commit is contained in:
Paul 2026-04-05 16:48:17 +03:00
parent a23c2172a8
commit 6dcc06697b
18 changed files with 2293 additions and 1765 deletions

View file

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

View file

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

View file

@ -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> worldLoader_;
std::unique_ptr<audio::AudioCoordinator> audioCoordinator_;
// Callback handlers (extracted from setupUICallbacks)
std::unique_ptr<NPCInteractionCallbackHandler> npcInteractionCallbacks_;
std::unique_ptr<AudioCallbackHandler> audioCallbacks_;
std::unique_ptr<EntitySpawnCallbackHandler> entitySpawnCallbacks_;
std::unique_ptr<AnimationCallbackHandler> animationCallbacks_;
std::unique_ptr<TransportCallbackHandler> transportCallbacks_;
std::unique_ptr<WorldEntryCallbackHandler> worldEntryCallbacks_;
std::unique_ptr<UIScreenCallbackHandler> 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<std::string> 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)

View file

@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <string>
#include <optional>
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<std::string> resolveSoundEntryPath(uint32_t soundId) const;
pipeline::AssetManager& assetManager_;
audio::AudioCoordinator* audioCoordinator_;
rendering::Renderer* renderer_;
ui::UIManager* uiManager_;
game::GameHandler& gameHandler_;
};
} // namespace core
} // namespace wowee

View file

@ -0,0 +1,34 @@
#pragma once
#include <cstdint>
#include <array>
#include <functional>
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<bool(uint64_t)> isLocalPlayerGuid);
void setupCallbacks();
private:
EntitySpawner& entitySpawner_;
rendering::Renderer& renderer_;
game::GameHandler& gameHandler_;
std::function<bool(uint64_t)> isLocalPlayerGuid_;
};
} // namespace core
} // namespace wowee

View file

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

View file

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

View file

@ -0,0 +1,46 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
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<void(AppState)>;
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

View file

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

View file

@ -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 <cmath>
#include <algorithm>
namespace wowee { namespace core {
AnimationCallbackHandler::AnimationCallbackHandler(
EntitySpawner& entitySpawner,
rendering::Renderer& renderer,
game::GameHandler& gameHandler)
: entitySpawner_(entitySpawner)
, renderer_(renderer)
, gameHandler_(gameHandler)
{
}
void AnimationCallbackHandler::resetChargeState() {
chargeActive_ = false;
chargeTimer_ = 0.0f;
chargeDuration_ = 0.0f;
chargeTargetGuid_ = 0;
}
bool AnimationCallbackHandler::updateCharge(float deltaTime) {
if (!chargeActive_) return false;
// Warrior Charge: lerp position from start to end using smoothstep
chargeTimer_ += deltaTime;
float t = std::min(chargeTimer_ / chargeDuration_, 1.0f);
// smoothstep for natural acceleration/deceleration
float s = t * t * (3.0f - 2.0f * t);
glm::vec3 renderPos = chargeStartPos_ + (chargeEndPos_ - chargeStartPos_) * s;
renderer_.getCharacterPosition() = renderPos;
// Keep facing toward target and emit charge effect
glm::vec3 dir = chargeEndPos_ - chargeStartPos_;
float dirLenSq = glm::dot(dir, dir);
if (dirLenSq > 1e-4f) {
dir *= glm::inversesqrt(dirLenSq);
float yawDeg = glm::degrees(std::atan2(dir.x, dir.y));
renderer_.setCharacterYaw(yawDeg);
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<uint32_t>(game::MovementFlags::SWIMMING)) != 0;
const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0;
const bool isFlying = (moveFlags & static_cast<uint32_t>(game::MovementFlags::FLYING)) != 0;
auto& swimState = entitySpawner_.getCreatureSwimmingState();
auto& walkState = entitySpawner_.getCreatureWalkingState();
auto& flyState = entitySpawner_.getCreatureFlyingState();
if (isSwimming) swimState[guid] = true;
else swimState.erase(guid);
if (isWalking) walkState[guid] = true;
else walkState.erase(guid);
if (isFlying) flyState[guid] = true;
else flyState.erase(guid);
});
// Emote animation callback — play server-driven emote animations on NPCs and other players.
// When emoteAnim is 0, the NPC's emote state was cleared → revert to STAND.
// Non-zero values from UNIT_NPC_EMOTESTATE updates are persistent (played looping).
gameHandler_.setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) {
auto* cr = renderer_.getCharacterRenderer();
if (!cr) return;
// Look up creature instance first, then online players
uint32_t emoteInstanceId = entitySpawner_.getCreatureInstanceId(guid);
if (emoteInstanceId != 0) {
if (emoteAnim == 0) {
// Emote state cleared → return to idle
cr->playAnimation(emoteInstanceId, rendering::anim::STAND, true);
} else {
cr->playAnimation(emoteInstanceId, emoteAnim, false);
}
return;
}
emoteInstanceId = entitySpawner_.getPlayerInstanceId(guid);
if (emoteInstanceId != 0) {
cr->playAnimation(emoteInstanceId, emoteAnim, false);
}
});
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
// WoW-accurate 3-phase spell animation sequence:
// 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<uint32_t> ids) -> uint32_t {
for (uint32_t id : ids)
if (cr->hasAnimation(instanceId, id)) return id;
return 0;
};
// Phase 1: Precast wind-up (one-shot, non-channels only)
uint32_t precastAnim = 0;
if (!isChannel) {
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

File diff suppressed because it is too large Load diff

View file

@ -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<std::string> 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<uint32_t>(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

View file

@ -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<bool(uint64_t)> 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<int>(raceId), " gender=", static_cast<int>(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<uint32_t, 19>& displayInfoIds,
const std::array<uint8_t, 19>& 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<float>(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

View file

@ -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<game::Unit>(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

View file

@ -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 <set>
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<glm::vec3>& path) {
if (!renderer_.getTerrainManager()) return;
std::set<std::pair<int, int>> 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<int>(32 - (renderPos.x / 533.33333f));
int tileY = static_cast<int>(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<int>(32 - (renderPos.x / 533.33333f));
int tileY = static_cast<int>(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<std::pair<int, int>> 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<game::GameObject>(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<glm::vec3> 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<glm::vec3> 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

View file

@ -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<uint16_t>(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<int>(code)) + ").", true);
}
});
}
}} // namespace wowee::core

View file

@ -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 <cmath>
#include <sstream>
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<float> WorldEntryCallbackHandler::sampleBestFloorAt(float x, float y, float probeZ) const {
std::optional<float> terrainFloor;
std::optional<float> wmoFloor;
std::optional<float> 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<float> 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<std::pair<int,int>> 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

View file

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