mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-03 20:03:50 +00:00
- Refactor Lua addon integration: - Update CMakeLists.txt for addon build paths - Enhance addons API headers and Lua engine interface - Add new Lua API addon modules (`lua_api_helpers`, `lua_api_registrations`, `lua_services`, `lua_action_api`, `lua_inventory_api`, `lua_quest_api`, `lua_social_api`, `lua_spell_api`, `lua_system_api`, `lua_unit_api`) - Update implementation in addon_manager.cpp, lua_engine.cpp, application.cpp, game_handler.cpp
4166 lines
204 KiB
C++
4166 lines
204 KiB
C++
#include "core/application.hpp"
|
|
#include "core/coordinates.hpp"
|
|
#include <unordered_set>
|
|
#include <cmath>
|
|
#include <chrono>
|
|
#include "core/spawn_presets.hpp"
|
|
#include "core/logger.hpp"
|
|
#include "core/memory_monitor.hpp"
|
|
#include "rendering/renderer.hpp"
|
|
#include "rendering/vk_context.hpp"
|
|
#include "audio/npc_voice_manager.hpp"
|
|
#include "rendering/camera.hpp"
|
|
#include "rendering/camera_controller.hpp"
|
|
#include "rendering/terrain_renderer.hpp"
|
|
#include "rendering/terrain_manager.hpp"
|
|
#include "rendering/performance_hud.hpp"
|
|
#include "rendering/water_renderer.hpp"
|
|
#include "rendering/skybox.hpp"
|
|
#include "rendering/celestial.hpp"
|
|
#include "rendering/starfield.hpp"
|
|
#include "rendering/clouds.hpp"
|
|
#include "rendering/lens_flare.hpp"
|
|
#include "rendering/weather.hpp"
|
|
#include "rendering/character_renderer.hpp"
|
|
#include "rendering/wmo_renderer.hpp"
|
|
#include "rendering/m2_renderer.hpp"
|
|
#include "rendering/minimap.hpp"
|
|
#include "rendering/quest_marker_renderer.hpp"
|
|
#include "rendering/loading_screen.hpp"
|
|
#include "audio/music_manager.hpp"
|
|
#include "audio/footstep_manager.hpp"
|
|
#include "audio/activity_sound_manager.hpp"
|
|
#include "audio/audio_engine.hpp"
|
|
#include "audio/audio_coordinator.hpp"
|
|
#include "addons/addon_manager.hpp"
|
|
#include <imgui.h>
|
|
#include "pipeline/m2_loader.hpp"
|
|
#include "pipeline/wmo_loader.hpp"
|
|
#include "pipeline/wdt_loader.hpp"
|
|
#include "pipeline/dbc_loader.hpp"
|
|
#include "ui/ui_manager.hpp"
|
|
#include "ui/ui_services.hpp"
|
|
#include "auth/auth_handler.hpp"
|
|
#include "game/game_handler.hpp"
|
|
#include "game/transport_manager.hpp"
|
|
#include "game/world.hpp"
|
|
#include "game/expansion_profile.hpp"
|
|
#include "game/packet_parsers.hpp"
|
|
#include "pipeline/asset_manager.hpp"
|
|
#include "pipeline/dbc_layout.hpp"
|
|
|
|
#include <SDL2/SDL.h>
|
|
#include <cstdlib>
|
|
#include <climits>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <optional>
|
|
#include <sstream>
|
|
#include <set>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
|
|
#include <thread>
|
|
#ifdef __linux__
|
|
#include <sched.h>
|
|
#include <pthread.h>
|
|
#elif defined(_WIN32)
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#include <windows.h>
|
|
#elif defined(__APPLE__)
|
|
#include <mach/mach.h>
|
|
#include <mach/thread_policy.h>
|
|
#include <pthread.h>
|
|
#endif
|
|
|
|
namespace wowee {
|
|
namespace core {
|
|
|
|
namespace {
|
|
bool envFlagEnabled(const char* key, bool defaultValue = false) {
|
|
const char* raw = std::getenv(key);
|
|
if (!raw || !*raw) return defaultValue;
|
|
return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' ||
|
|
raw[0] == 'n' || raw[0] == 'N');
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Application* Application::instance = nullptr;
|
|
|
|
Application::Application() {
|
|
instance = this;
|
|
}
|
|
|
|
Application::~Application() {
|
|
shutdown();
|
|
instance = nullptr;
|
|
}
|
|
|
|
bool Application::initialize() {
|
|
LOG_INFO("Initializing Wowee Native Client");
|
|
|
|
// Initialize memory monitoring for dynamic cache sizing
|
|
core::MemoryMonitor::getInstance().initialize();
|
|
|
|
// Create window
|
|
WindowConfig windowConfig;
|
|
windowConfig.title = "Wowee";
|
|
windowConfig.width = 1280;
|
|
windowConfig.height = 720;
|
|
windowConfig.vsync = false;
|
|
|
|
window = std::make_unique<Window>(windowConfig);
|
|
if (!window->initialize()) {
|
|
LOG_FATAL("Failed to initialize window");
|
|
return false;
|
|
}
|
|
|
|
// Create renderer
|
|
renderer = std::make_unique<rendering::Renderer>();
|
|
if (!renderer->initialize(window.get())) {
|
|
LOG_FATAL("Failed to initialize renderer");
|
|
return false;
|
|
}
|
|
|
|
// Create and initialize audio coordinator (owns all audio managers)
|
|
audioCoordinator_ = std::make_unique<audio::AudioCoordinator>();
|
|
audioCoordinator_->initialize();
|
|
renderer->setAudioCoordinator(audioCoordinator_.get());
|
|
|
|
// Create UI manager
|
|
uiManager = std::make_unique<ui::UIManager>();
|
|
if (!uiManager->initialize(window.get())) {
|
|
LOG_FATAL("Failed to initialize UI manager");
|
|
return false;
|
|
}
|
|
|
|
// Create subsystems
|
|
authHandler = std::make_unique<auth::AuthHandler>();
|
|
world = std::make_unique<game::World>();
|
|
|
|
// Create and initialize expansion registry
|
|
expansionRegistry_ = std::make_unique<game::ExpansionRegistry>();
|
|
|
|
// Create DBC layout
|
|
dbcLayout_ = std::make_unique<pipeline::DBCLayout>();
|
|
|
|
// Create asset manager
|
|
assetManager = std::make_unique<pipeline::AssetManager>();
|
|
|
|
// Populate game services — all subsystems now available
|
|
gameServices_.renderer = renderer.get();
|
|
gameServices_.audioCoordinator = audioCoordinator_.get();
|
|
gameServices_.assetManager = assetManager.get();
|
|
gameServices_.expansionRegistry = expansionRegistry_.get();
|
|
|
|
// Create game handler with explicit service dependencies
|
|
gameHandler = std::make_unique<game::GameHandler>(gameServices_);
|
|
|
|
// Try to get WoW data path from environment variable
|
|
const char* dataPathEnv = std::getenv("WOW_DATA_PATH");
|
|
std::string dataPath = dataPathEnv ? dataPathEnv : "./Data";
|
|
|
|
// Scan for available expansion profiles
|
|
expansionRegistry_->initialize(dataPath);
|
|
|
|
// Load expansion-specific opcode table
|
|
if (gameHandler && expansionRegistry_) {
|
|
auto* profile = expansionRegistry_->getActive();
|
|
if (profile) {
|
|
std::string opcodesPath = profile->dataPath + "/opcodes.json";
|
|
if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) {
|
|
LOG_ERROR("Failed to load opcodes from ", opcodesPath);
|
|
}
|
|
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
|
|
|
|
// Load expansion-specific update field table
|
|
std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
|
|
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
|
|
LOG_ERROR("Failed to load update fields from ", updateFieldsPath);
|
|
}
|
|
game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable());
|
|
|
|
// Create expansion-specific packet parsers
|
|
gameHandler->setPacketParsers(game::createPacketParsers(profile->id));
|
|
|
|
// Load expansion-specific DBC layouts
|
|
if (dbcLayout_) {
|
|
std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json";
|
|
if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) {
|
|
LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath);
|
|
}
|
|
pipeline::setActiveDBCLayout(dbcLayout_.get());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try expansion-specific asset path first, fall back to base Data/
|
|
std::string assetPath = dataPath;
|
|
if (expansionRegistry_) {
|
|
auto* profile = expansionRegistry_->getActive();
|
|
if (profile && !profile->dataPath.empty()) {
|
|
// Enable expansion-specific CSV DBC lookup (Data/expansions/<id>/db/*.csv).
|
|
assetManager->setExpansionDataPath(profile->dataPath);
|
|
|
|
std::string expansionManifest = profile->dataPath + "/manifest.json";
|
|
if (std::filesystem::exists(expansionManifest)) {
|
|
assetPath = profile->dataPath;
|
|
LOG_INFO("Using expansion-specific asset path: ", assetPath);
|
|
// Register base Data/ as fallback so world terrain files are found
|
|
// even when the expansion path only contains DBC overrides.
|
|
if (assetPath != dataPath) {
|
|
assetManager->setBaseFallbackPath(dataPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
LOG_INFO("Attempting to load WoW assets from: ", assetPath);
|
|
if (assetManager->initialize(assetPath)) {
|
|
LOG_INFO("Asset manager initialized successfully");
|
|
// Eagerly load creature display DBC lookups so first spawn doesn't stall
|
|
entitySpawner_ = std::make_unique<EntitySpawner>(
|
|
renderer.get(), assetManager.get(), gameHandler.get(),
|
|
dbcLayout_.get(), &gameServices_);
|
|
entitySpawner_->initialize();
|
|
|
|
appearanceComposer_ = std::make_unique<AppearanceComposer>(
|
|
renderer.get(), assetManager.get(), gameHandler.get(),
|
|
dbcLayout_.get(), entitySpawner_.get());
|
|
|
|
// Wire AppearanceComposer to UI components (Phase A singleton breaking)
|
|
if (uiManager) {
|
|
uiManager->setAppearanceComposer(appearanceComposer_.get());
|
|
|
|
// Wire all services to UI components (Phase B singleton breaking)
|
|
ui::UIServices uiServices;
|
|
uiServices.window = window.get();
|
|
uiServices.renderer = renderer.get();
|
|
uiServices.assetManager = assetManager.get();
|
|
uiServices.gameHandler = gameHandler.get();
|
|
uiServices.expansionRegistry = expansionRegistry_.get();
|
|
uiServices.addonManager = addonManager_.get(); // May be nullptr here, re-wire later
|
|
uiServices.audioCoordinator = audioCoordinator_.get();
|
|
uiServices.entitySpawner = entitySpawner_.get();
|
|
uiServices.appearanceComposer = appearanceComposer_.get();
|
|
uiServices.worldLoader = worldLoader_.get();
|
|
uiManager->setServices(uiServices);
|
|
}
|
|
|
|
// Ensure the main in-world CharacterRenderer can load textures immediately.
|
|
// Previously this was only wired during terrain initialization, which meant early spawns
|
|
// (before terrain load) would render with white fallback textures (notably hair).
|
|
if (renderer && renderer->getCharacterRenderer()) {
|
|
renderer->getCharacterRenderer()->setAssetManager(assetManager.get());
|
|
}
|
|
|
|
// Load transport paths from TransportAnimation.dbc and TaxiPathNode.dbc
|
|
if (gameHandler && gameHandler->getTransportManager()) {
|
|
gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get());
|
|
gameHandler->getTransportManager()->loadTaxiPathNodeDBC(assetManager.get());
|
|
}
|
|
|
|
// Initialize addon system
|
|
addonManager_ = std::make_unique<addons::AddonManager>();
|
|
addons::LuaServices luaSvc;
|
|
luaSvc.window = window.get();
|
|
luaSvc.audioCoordinator = audioCoordinator_.get();
|
|
luaSvc.expansionRegistry = expansionRegistry_.get();
|
|
if (addonManager_->initialize(gameHandler.get(), luaSvc)) {
|
|
std::string addonsDir = assetPath + "/interface/AddOns";
|
|
addonManager_->scanAddons(addonsDir);
|
|
// Wire Lua errors to UI error display
|
|
addonManager_->getLuaEngine()->setLuaErrorCallback([gh = gameHandler.get()](const std::string& err) {
|
|
if (gh) gh->addUIError(err);
|
|
});
|
|
// Wire chat messages to addon event dispatch
|
|
gameHandler->setAddonChatCallback([this](const game::MessageChatData& msg) {
|
|
if (!addonManager_ || !addonsLoaded_) return;
|
|
// Map ChatType to WoW event name
|
|
const char* eventName = nullptr;
|
|
switch (msg.type) {
|
|
case game::ChatType::SAY: eventName = "CHAT_MSG_SAY"; break;
|
|
case game::ChatType::YELL: eventName = "CHAT_MSG_YELL"; break;
|
|
case game::ChatType::WHISPER: eventName = "CHAT_MSG_WHISPER"; break;
|
|
case game::ChatType::PARTY: eventName = "CHAT_MSG_PARTY"; break;
|
|
case game::ChatType::GUILD: eventName = "CHAT_MSG_GUILD"; break;
|
|
case game::ChatType::OFFICER: eventName = "CHAT_MSG_OFFICER"; break;
|
|
case game::ChatType::RAID: eventName = "CHAT_MSG_RAID"; break;
|
|
case game::ChatType::RAID_WARNING: eventName = "CHAT_MSG_RAID_WARNING"; break;
|
|
case game::ChatType::BATTLEGROUND: eventName = "CHAT_MSG_BATTLEGROUND"; break;
|
|
case game::ChatType::SYSTEM: eventName = "CHAT_MSG_SYSTEM"; break;
|
|
case game::ChatType::CHANNEL: eventName = "CHAT_MSG_CHANNEL"; break;
|
|
case game::ChatType::EMOTE:
|
|
case game::ChatType::TEXT_EMOTE: eventName = "CHAT_MSG_EMOTE"; break;
|
|
case game::ChatType::ACHIEVEMENT: eventName = "CHAT_MSG_ACHIEVEMENT"; break;
|
|
case game::ChatType::GUILD_ACHIEVEMENT: eventName = "CHAT_MSG_GUILD_ACHIEVEMENT"; break;
|
|
case game::ChatType::WHISPER_INFORM: eventName = "CHAT_MSG_WHISPER_INFORM"; break;
|
|
case game::ChatType::RAID_LEADER: eventName = "CHAT_MSG_RAID_LEADER"; break;
|
|
case game::ChatType::BATTLEGROUND_LEADER: eventName = "CHAT_MSG_BATTLEGROUND_LEADER"; break;
|
|
case game::ChatType::MONSTER_SAY: eventName = "CHAT_MSG_MONSTER_SAY"; break;
|
|
case game::ChatType::MONSTER_YELL: eventName = "CHAT_MSG_MONSTER_YELL"; break;
|
|
case game::ChatType::MONSTER_EMOTE: eventName = "CHAT_MSG_MONSTER_EMOTE"; break;
|
|
case game::ChatType::MONSTER_WHISPER: eventName = "CHAT_MSG_MONSTER_WHISPER"; break;
|
|
case game::ChatType::RAID_BOSS_EMOTE: eventName = "CHAT_MSG_RAID_BOSS_EMOTE"; break;
|
|
case game::ChatType::RAID_BOSS_WHISPER: eventName = "CHAT_MSG_RAID_BOSS_WHISPER"; break;
|
|
case game::ChatType::BG_SYSTEM_NEUTRAL: eventName = "CHAT_MSG_BG_SYSTEM_NEUTRAL"; break;
|
|
case game::ChatType::BG_SYSTEM_ALLIANCE: eventName = "CHAT_MSG_BG_SYSTEM_ALLIANCE"; break;
|
|
case game::ChatType::BG_SYSTEM_HORDE: eventName = "CHAT_MSG_BG_SYSTEM_HORDE"; break;
|
|
case game::ChatType::MONSTER_PARTY: eventName = "CHAT_MSG_MONSTER_PARTY"; break;
|
|
case game::ChatType::AFK: eventName = "CHAT_MSG_AFK"; break;
|
|
case game::ChatType::DND: eventName = "CHAT_MSG_DND"; break;
|
|
case game::ChatType::LOOT: eventName = "CHAT_MSG_LOOT"; break;
|
|
case game::ChatType::SKILL: eventName = "CHAT_MSG_SKILL"; break;
|
|
default: break;
|
|
}
|
|
if (eventName) {
|
|
addonManager_->fireEvent(eventName, {msg.message, msg.senderName});
|
|
}
|
|
});
|
|
// Wire generic game events to addon dispatch
|
|
gameHandler->setAddonEventCallback([this](const std::string& event, const std::vector<std::string>& args) {
|
|
if (addonManager_ && addonsLoaded_) {
|
|
addonManager_->fireEvent(event, args);
|
|
}
|
|
});
|
|
// Wire spell icon path resolver for Lua API (GetSpellInfo, UnitBuff icon, etc.)
|
|
{
|
|
auto spellIconPaths = std::make_shared<std::unordered_map<uint32_t, std::string>>();
|
|
auto spellIconIds = std::make_shared<std::unordered_map<uint32_t, uint32_t>>();
|
|
auto loaded = std::make_shared<bool>(false);
|
|
auto* am = assetManager.get();
|
|
gameHandler->setSpellIconPathResolver([spellIconPaths, spellIconIds, loaded, am](uint32_t spellId) -> std::string {
|
|
if (!am) return {};
|
|
// Lazy-load SpellIcon.dbc + Spell.dbc icon IDs on first call
|
|
if (!*loaded) {
|
|
*loaded = true;
|
|
auto iconDbc = am->loadDBC("SpellIcon.dbc");
|
|
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
|
|
if (iconDbc && iconDbc->isLoaded()) {
|
|
for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) {
|
|
uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0);
|
|
std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1);
|
|
if (!path.empty() && id > 0) (*spellIconPaths)[id] = path;
|
|
}
|
|
}
|
|
auto spellDbc = am->loadDBC("Spell.dbc");
|
|
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
|
|
if (spellDbc && spellDbc->isLoaded()) {
|
|
uint32_t fieldCount = spellDbc->getFieldCount();
|
|
uint32_t iconField = 133; // WotLK default
|
|
uint32_t idField = 0;
|
|
if (spellL) {
|
|
uint32_t layoutIcon = (*spellL)["IconID"];
|
|
if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) {
|
|
iconField = layoutIcon;
|
|
idField = (*spellL)["ID"];
|
|
}
|
|
}
|
|
for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) {
|
|
uint32_t id = spellDbc->getUInt32(i, idField);
|
|
uint32_t iconId = spellDbc->getUInt32(i, iconField);
|
|
if (id > 0 && iconId > 0) (*spellIconIds)[id] = iconId;
|
|
}
|
|
}
|
|
}
|
|
auto iit = spellIconIds->find(spellId);
|
|
if (iit == spellIconIds->end()) return {};
|
|
auto pit = spellIconPaths->find(iit->second);
|
|
if (pit == spellIconPaths->end()) return {};
|
|
return pit->second;
|
|
});
|
|
}
|
|
// Wire item icon path resolver: displayInfoId -> "Interface\\Icons\\INV_..."
|
|
{
|
|
auto iconNames = std::make_shared<std::unordered_map<uint32_t, std::string>>();
|
|
auto loaded = std::make_shared<bool>(false);
|
|
auto* am = assetManager.get();
|
|
gameHandler->setItemIconPathResolver([iconNames, loaded, am](uint32_t displayInfoId) -> std::string {
|
|
if (!am || displayInfoId == 0) return {};
|
|
if (!*loaded) {
|
|
*loaded = true;
|
|
auto dbc = am->loadDBC("ItemDisplayInfo.dbc");
|
|
const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
|
if (dbc && dbc->isLoaded()) {
|
|
uint32_t iconField = dispL ? (*dispL)["InventoryIcon"] : 5;
|
|
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
|
|
uint32_t id = dbc->getUInt32(i, 0); // field 0 = ID
|
|
std::string name = dbc->getString(i, iconField);
|
|
if (id > 0 && !name.empty()) (*iconNames)[id] = name;
|
|
}
|
|
LOG_INFO("Loaded ", iconNames->size(), " item icon names from ItemDisplayInfo.dbc");
|
|
}
|
|
}
|
|
auto it = iconNames->find(displayInfoId);
|
|
if (it == iconNames->end()) return {};
|
|
return "Interface\\Icons\\" + it->second;
|
|
});
|
|
}
|
|
// Wire spell data resolver: spellId -> {castTimeMs, minRange, maxRange}
|
|
{
|
|
auto castTimeMap = std::make_shared<std::unordered_map<uint32_t, uint32_t>>();
|
|
auto rangeMap = std::make_shared<std::unordered_map<uint32_t, std::pair<float,float>>>();
|
|
auto spellCastIdx = std::make_shared<std::unordered_map<uint32_t, uint32_t>>(); // spellId→castTimeIdx
|
|
auto spellRangeIdx = std::make_shared<std::unordered_map<uint32_t, uint32_t>>(); // spellId→rangeIdx
|
|
struct SpellCostEntry { uint32_t manaCost = 0; uint8_t powerType = 0; };
|
|
auto spellCostMap = std::make_shared<std::unordered_map<uint32_t, SpellCostEntry>>();
|
|
auto loaded = std::make_shared<bool>(false);
|
|
auto* am = assetManager.get();
|
|
gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, spellCostMap, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo {
|
|
if (!am) return {};
|
|
if (!*loaded) {
|
|
*loaded = true;
|
|
// Load SpellCastTimes.dbc
|
|
auto ctDbc = am->loadDBC("SpellCastTimes.dbc");
|
|
if (ctDbc && ctDbc->isLoaded()) {
|
|
for (uint32_t i = 0; i < ctDbc->getRecordCount(); ++i) {
|
|
uint32_t id = ctDbc->getUInt32(i, 0);
|
|
int32_t base = static_cast<int32_t>(ctDbc->getUInt32(i, 1));
|
|
if (id > 0 && base > 0) (*castTimeMap)[id] = static_cast<uint32_t>(base);
|
|
}
|
|
}
|
|
// Load SpellRange.dbc
|
|
const auto* srL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") : nullptr;
|
|
uint32_t minRField = srL ? (*srL)["MinRange"] : 1;
|
|
uint32_t maxRField = srL ? (*srL)["MaxRange"] : 4;
|
|
auto rDbc = am->loadDBC("SpellRange.dbc");
|
|
if (rDbc && rDbc->isLoaded()) {
|
|
for (uint32_t i = 0; i < rDbc->getRecordCount(); ++i) {
|
|
uint32_t id = rDbc->getUInt32(i, 0);
|
|
float minR = rDbc->getFloat(i, minRField);
|
|
float maxR = rDbc->getFloat(i, maxRField);
|
|
if (id > 0) (*rangeMap)[id] = {minR, maxR};
|
|
}
|
|
}
|
|
// Load Spell.dbc: extract castTimeIndex and rangeIndex per spell
|
|
auto sDbc = am->loadDBC("Spell.dbc");
|
|
const auto* spL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
|
|
if (sDbc && sDbc->isLoaded()) {
|
|
uint32_t idF = spL ? (*spL)["ID"] : 0;
|
|
uint32_t ctF = spL ? (*spL)["CastingTimeIndex"] : 134; // WotLK default
|
|
uint32_t rF = spL ? (*spL)["RangeIndex"] : 132;
|
|
uint32_t ptF = UINT32_MAX, mcF = UINT32_MAX;
|
|
if (spL) {
|
|
try { ptF = (*spL)["PowerType"]; } catch (...) {}
|
|
try { mcF = (*spL)["ManaCost"]; } catch (...) {}
|
|
}
|
|
uint32_t fc = sDbc->getFieldCount();
|
|
for (uint32_t i = 0; i < sDbc->getRecordCount(); ++i) {
|
|
uint32_t id = sDbc->getUInt32(i, idF);
|
|
if (id == 0) continue;
|
|
uint32_t ct = sDbc->getUInt32(i, ctF);
|
|
uint32_t ri = sDbc->getUInt32(i, rF);
|
|
if (ct > 0) (*spellCastIdx)[id] = ct;
|
|
if (ri > 0) (*spellRangeIdx)[id] = ri;
|
|
// Extract power cost
|
|
uint32_t mc = (mcF < fc) ? sDbc->getUInt32(i, mcF) : 0;
|
|
uint8_t pt = (ptF < fc) ? static_cast<uint8_t>(sDbc->getUInt32(i, ptF)) : 0;
|
|
if (mc > 0) (*spellCostMap)[id] = {mc, pt};
|
|
}
|
|
}
|
|
LOG_INFO("SpellDataResolver: loaded ", spellCastIdx->size(), " cast indices, ",
|
|
spellRangeIdx->size(), " range indices");
|
|
}
|
|
game::GameHandler::SpellDataInfo info;
|
|
auto ciIt = spellCastIdx->find(spellId);
|
|
if (ciIt != spellCastIdx->end()) {
|
|
auto ctIt = castTimeMap->find(ciIt->second);
|
|
if (ctIt != castTimeMap->end()) info.castTimeMs = ctIt->second;
|
|
}
|
|
auto riIt = spellRangeIdx->find(spellId);
|
|
if (riIt != spellRangeIdx->end()) {
|
|
auto rIt = rangeMap->find(riIt->second);
|
|
if (rIt != rangeMap->end()) {
|
|
info.minRange = rIt->second.first;
|
|
info.maxRange = rIt->second.second;
|
|
}
|
|
}
|
|
auto mcIt = spellCostMap->find(spellId);
|
|
if (mcIt != spellCostMap->end()) {
|
|
info.manaCost = mcIt->second.manaCost;
|
|
info.powerType = mcIt->second.powerType;
|
|
}
|
|
return info;
|
|
});
|
|
}
|
|
// Wire random property/suffix name resolver for item display
|
|
{
|
|
auto propNames = std::make_shared<std::unordered_map<int32_t, std::string>>();
|
|
auto propLoaded = std::make_shared<bool>(false);
|
|
auto* amPtr = assetManager.get();
|
|
gameHandler->setRandomPropertyNameResolver([propNames, propLoaded, amPtr](int32_t id) -> std::string {
|
|
if (!amPtr || id == 0) return {};
|
|
if (!*propLoaded) {
|
|
*propLoaded = true;
|
|
// ItemRandomProperties.dbc: ID=0, Name=4 (string)
|
|
if (auto dbc = amPtr->loadDBC("ItemRandomProperties.dbc"); dbc && dbc->isLoaded()) {
|
|
uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1;
|
|
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
|
|
int32_t rid = static_cast<int32_t>(dbc->getUInt32(r, 0));
|
|
std::string name = dbc->getString(r, nameField);
|
|
if (!name.empty() && rid > 0) (*propNames)[rid] = name;
|
|
}
|
|
}
|
|
// ItemRandomSuffix.dbc: ID=0, Name=4 (string) — stored as negative IDs
|
|
if (auto dbc = amPtr->loadDBC("ItemRandomSuffix.dbc"); dbc && dbc->isLoaded()) {
|
|
uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1;
|
|
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
|
|
int32_t rid = static_cast<int32_t>(dbc->getUInt32(r, 0));
|
|
std::string name = dbc->getString(r, nameField);
|
|
if (!name.empty() && rid > 0) (*propNames)[-rid] = name;
|
|
}
|
|
}
|
|
}
|
|
auto it = propNames->find(id);
|
|
return (it != propNames->end()) ? it->second : std::string{};
|
|
});
|
|
}
|
|
LOG_INFO("Addon system initialized, found ", addonManager_->getAddons().size(), " addon(s)");
|
|
} else {
|
|
LOG_WARNING("Failed to initialize addon system");
|
|
addonManager_.reset();
|
|
}
|
|
|
|
// Initialize world loader (handles terrain streaming, world preload, map transitions)
|
|
worldLoader_ = std::make_unique<WorldLoader>(
|
|
*this, renderer.get(), assetManager.get(), gameHandler.get(),
|
|
entitySpawner_.get(), appearanceComposer_.get(), window.get(),
|
|
world.get(), addonManager_.get());
|
|
|
|
// Re-wire UIServices now that all services (addonManager_, worldLoader_) are available
|
|
if (uiManager) {
|
|
ui::UIServices uiServices;
|
|
uiServices.window = window.get();
|
|
uiServices.renderer = renderer.get();
|
|
uiServices.assetManager = assetManager.get();
|
|
uiServices.gameHandler = gameHandler.get();
|
|
uiServices.expansionRegistry = expansionRegistry_.get();
|
|
uiServices.addonManager = addonManager_.get();
|
|
uiServices.audioCoordinator = audioCoordinator_.get();
|
|
uiServices.entitySpawner = entitySpawner_.get();
|
|
uiServices.appearanceComposer = appearanceComposer_.get();
|
|
uiServices.worldLoader = worldLoader_.get();
|
|
uiManager->setServices(uiServices);
|
|
}
|
|
|
|
// Start background preload for last-played character's world.
|
|
// Warms the file cache so terrain tile loading is faster at Enter World.
|
|
{
|
|
auto lastWorld = worldLoader_->loadLastWorldInfo();
|
|
if (lastWorld.valid) {
|
|
worldLoader_->startWorldPreload(lastWorld.mapId, lastWorld.mapName, lastWorld.x, lastWorld.y);
|
|
}
|
|
}
|
|
|
|
} else {
|
|
LOG_WARNING("Failed to initialize asset manager - asset loading will be unavailable");
|
|
LOG_WARNING("Set WOW_DATA_PATH environment variable to your WoW Data directory");
|
|
}
|
|
|
|
// Set up UI callbacks
|
|
setupUICallbacks();
|
|
|
|
LOG_INFO("Application initialized successfully");
|
|
running = true;
|
|
return true;
|
|
}
|
|
|
|
void Application::run() {
|
|
LOG_INFO("Starting main loop");
|
|
|
|
// Pin main thread to a dedicated CPU core to reduce scheduling jitter
|
|
{
|
|
int numCores = static_cast<int>(std::thread::hardware_concurrency());
|
|
if (numCores >= 2) {
|
|
#ifdef __linux__
|
|
cpu_set_t cpuset;
|
|
CPU_ZERO(&cpuset);
|
|
CPU_SET(0, &cpuset);
|
|
int rc = pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
|
|
if (rc == 0) {
|
|
LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)");
|
|
} else {
|
|
LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", rc, ")");
|
|
}
|
|
#elif defined(_WIN32)
|
|
DWORD_PTR mask = 1; // Core 0
|
|
DWORD_PTR prev = SetThreadAffinityMask(GetCurrentThread(), mask);
|
|
if (prev != 0) {
|
|
LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)");
|
|
} else {
|
|
LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", GetLastError(), ")");
|
|
}
|
|
#elif defined(__APPLE__)
|
|
// macOS doesn't support hard pinning — use affinity tags to hint
|
|
// that the main thread should stay on its own core group
|
|
thread_affinity_policy_data_t policy = { 1 }; // tag 1 = main thread group
|
|
kern_return_t kr = thread_policy_set(
|
|
pthread_mach_thread_np(pthread_self()),
|
|
THREAD_AFFINITY_POLICY,
|
|
reinterpret_cast<thread_policy_t>(&policy),
|
|
THREAD_AFFINITY_POLICY_COUNT);
|
|
if (kr == KERN_SUCCESS) {
|
|
LOG_INFO("Main thread affinity tag set (", numCores, " cores available)");
|
|
} else {
|
|
LOG_WARNING("Failed to set main thread affinity tag (error ", kr, ")");
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
const bool frameProfileEnabled = envFlagEnabled("WOWEE_FRAME_PROFILE", false);
|
|
if (frameProfileEnabled) {
|
|
LOG_INFO("Frame timing profile enabled (WOWEE_FRAME_PROFILE=1)");
|
|
}
|
|
|
|
auto lastTime = std::chrono::high_resolution_clock::now();
|
|
std::atomic<bool> watchdogRunning{true};
|
|
std::atomic<int64_t> watchdogHeartbeatMs{
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch()).count()
|
|
};
|
|
// Signal flag: watchdog sets this when a stall is detected, main loop
|
|
// handles the actual SDL calls. SDL2 video functions must only be called
|
|
// from the main thread (the one that called SDL_Init); calling them from
|
|
// a background thread is UB on macOS (Cocoa) and unsafe on other platforms.
|
|
std::atomic<bool> watchdogRequestRelease{false};
|
|
std::thread watchdogThread([&watchdogRunning, &watchdogHeartbeatMs, &watchdogRequestRelease]() {
|
|
bool signalledForCurrentStall = false;
|
|
while (watchdogRunning.load(std::memory_order_acquire)) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
|
const int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
const int64_t lastBeatMs = watchdogHeartbeatMs.load(std::memory_order_acquire);
|
|
const int64_t stallMs = nowMs - lastBeatMs;
|
|
|
|
if (stallMs > 1500) {
|
|
if (!signalledForCurrentStall) {
|
|
watchdogRequestRelease.store(true, std::memory_order_release);
|
|
LOG_WARNING("Main-loop stall detected (", stallMs,
|
|
"ms) — requesting mouse capture release");
|
|
signalledForCurrentStall = true;
|
|
}
|
|
} else {
|
|
signalledForCurrentStall = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
while (running && !window->shouldClose()) {
|
|
watchdogHeartbeatMs.store(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch()).count(),
|
|
std::memory_order_release);
|
|
|
|
// Handle watchdog mouse-release request on the main thread where
|
|
// SDL video calls are safe (required by SDL2 threading model).
|
|
if (watchdogRequestRelease.exchange(false, std::memory_order_acq_rel)) {
|
|
SDL_SetRelativeMouseMode(SDL_FALSE);
|
|
SDL_ShowCursor(SDL_ENABLE);
|
|
if (window && window->getSDLWindow()) {
|
|
SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE);
|
|
}
|
|
LOG_WARNING("Watchdog: force-released mouse capture on main thread");
|
|
}
|
|
|
|
// Calculate delta time
|
|
auto currentTime = std::chrono::high_resolution_clock::now();
|
|
std::chrono::duration<float> deltaTimeDuration = currentTime - lastTime;
|
|
float deltaTime = deltaTimeDuration.count();
|
|
lastTime = currentTime;
|
|
|
|
// Cap delta time to prevent large jumps
|
|
if (deltaTime > 0.1f) {
|
|
deltaTime = 0.1f;
|
|
}
|
|
|
|
// Poll events
|
|
SDL_Event event;
|
|
while (SDL_PollEvent(&event)) {
|
|
// Pass event to UI manager first
|
|
if (uiManager) {
|
|
uiManager->processEvent(event);
|
|
}
|
|
|
|
// Pass mouse events to camera controller (skip when UI has mouse focus)
|
|
if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) {
|
|
if (event.type == SDL_MOUSEMOTION) {
|
|
renderer->getCameraController()->processMouseMotion(event.motion);
|
|
}
|
|
else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) {
|
|
renderer->getCameraController()->processMouseButton(event.button);
|
|
}
|
|
else if (event.type == SDL_MOUSEWHEEL) {
|
|
renderer->getCameraController()->processMouseWheel(static_cast<float>(event.wheel.y));
|
|
}
|
|
}
|
|
|
|
// Handle window events
|
|
if (event.type == SDL_QUIT) {
|
|
window->setShouldClose(true);
|
|
}
|
|
else if (event.type == SDL_WINDOWEVENT) {
|
|
if (event.window.event == SDL_WINDOWEVENT_RESIZED) {
|
|
int newWidth = event.window.data1;
|
|
int newHeight = event.window.data2;
|
|
window->setSize(newWidth, newHeight);
|
|
// Vulkan viewport set in command buffer, not globally
|
|
if (renderer && renderer->getCamera()) {
|
|
renderer->getCamera()->setAspectRatio(static_cast<float>(newWidth) / newHeight);
|
|
}
|
|
// Notify addons so UI layouts can adapt to the new size
|
|
if (addonManager_)
|
|
addonManager_->fireEvent("DISPLAY_SIZE_CHANGED");
|
|
}
|
|
}
|
|
// Debug controls
|
|
else if (event.type == SDL_KEYDOWN) {
|
|
// Skip non-function-key input when UI (chat) has keyboard focus
|
|
bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard;
|
|
auto sc = event.key.keysym.scancode;
|
|
bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12);
|
|
if (uiHasKeyboard && !isFKey) {
|
|
continue; // Let ImGui handle the keystroke
|
|
}
|
|
|
|
// F1: Toggle performance HUD
|
|
if (event.key.keysym.scancode == SDL_SCANCODE_F1) {
|
|
if (renderer && renderer->getPerformanceHUD()) {
|
|
renderer->getPerformanceHUD()->toggle();
|
|
bool enabled = renderer->getPerformanceHUD()->isEnabled();
|
|
LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF");
|
|
}
|
|
}
|
|
// F4: Toggle shadows
|
|
else if (event.key.keysym.scancode == SDL_SCANCODE_F4) {
|
|
if (renderer) {
|
|
bool enabled = !renderer->areShadowsEnabled();
|
|
renderer->setShadowsEnabled(enabled);
|
|
LOG_INFO("Shadows: ", enabled ? "ON" : "OFF");
|
|
}
|
|
}
|
|
// F8: Debug WMO floor at current position
|
|
else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) {
|
|
if (renderer && renderer->getWMORenderer()) {
|
|
glm::vec3 pos = renderer->getCharacterPosition();
|
|
LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")");
|
|
renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update input
|
|
Input::getInstance().update();
|
|
|
|
// Update application state
|
|
try {
|
|
update(deltaTime);
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM during Application::update (state=", static_cast<int>(state),
|
|
", dt=", deltaTime, "): ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception during Application::update (state=", static_cast<int>(state),
|
|
", dt=", deltaTime, "): ", e.what());
|
|
throw;
|
|
}
|
|
// Render
|
|
try {
|
|
render();
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM during Application::render (state=", static_cast<int>(state), "): ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception during Application::render (state=", static_cast<int>(state), "): ", e.what());
|
|
throw;
|
|
}
|
|
// Swap buffers
|
|
try {
|
|
window->swapBuffers();
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM during swapBuffers: ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception during swapBuffers: ", e.what());
|
|
throw;
|
|
}
|
|
|
|
// Exit gracefully on GPU device lost (unrecoverable)
|
|
if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) {
|
|
LOG_ERROR("GPU device lost — exiting application");
|
|
window->setShouldClose(true);
|
|
}
|
|
|
|
// Soft frame rate cap when vsync is off to prevent 100% CPU usage.
|
|
// Target ~240 FPS max (~4.2ms per frame); vsync handles its own pacing.
|
|
if (!window->isVsyncEnabled() && deltaTime < 0.004f) {
|
|
float sleepMs = (0.004f - deltaTime) * 1000.0f;
|
|
if (sleepMs > 0.5f)
|
|
std::this_thread::sleep_for(std::chrono::microseconds(
|
|
static_cast<int64_t>(sleepMs * 900.0f))); // 90% of target to account for sleep overshoot
|
|
}
|
|
}
|
|
} catch (...) {
|
|
watchdogRunning.store(false, std::memory_order_release);
|
|
if (watchdogThread.joinable()) {
|
|
watchdogThread.join();
|
|
}
|
|
throw;
|
|
}
|
|
|
|
watchdogRunning.store(false, std::memory_order_release);
|
|
if (watchdogThread.joinable()) {
|
|
watchdogThread.join();
|
|
}
|
|
|
|
LOG_INFO("Main loop ended");
|
|
}
|
|
|
|
void Application::shutdown() {
|
|
LOG_WARNING("Shutting down application...");
|
|
|
|
// Hide the window immediately so the OS doesn't think the app is frozen
|
|
// during the (potentially slow) resource cleanup below.
|
|
if (window && window->getSDLWindow()) {
|
|
SDL_HideWindow(window->getSDLWindow());
|
|
}
|
|
|
|
// Stop background world preloader before destroying AssetManager
|
|
if (worldLoader_) {
|
|
worldLoader_->cancelWorldPreload();
|
|
};
|
|
|
|
// Save floor cache before renderer is destroyed
|
|
if (renderer && renderer->getWMORenderer()) {
|
|
size_t cacheSize = renderer->getWMORenderer()->getFloorCacheSize();
|
|
if (cacheSize > 0) {
|
|
LOG_WARNING("Saving WMO floor cache (", cacheSize, " entries)...");
|
|
renderer->getWMORenderer()->saveFloorCache();
|
|
LOG_WARNING("Floor cache saved.");
|
|
}
|
|
}
|
|
|
|
// Explicitly shut down the renderer before destroying it — this ensures
|
|
// all sub-renderers free their VMA allocations in the correct order,
|
|
// before VkContext::shutdown() calls vmaDestroyAllocator().
|
|
LOG_WARNING("Shutting down renderer...");
|
|
if (renderer) {
|
|
renderer->shutdown();
|
|
}
|
|
LOG_WARNING("Renderer shutdown complete, resetting...");
|
|
renderer.reset();
|
|
|
|
// Shutdown audio coordinator after renderer (renderer may reference audio during shutdown)
|
|
if (audioCoordinator_) {
|
|
audioCoordinator_->shutdown();
|
|
}
|
|
audioCoordinator_.reset();
|
|
|
|
LOG_WARNING("Resetting world...");
|
|
world.reset();
|
|
LOG_WARNING("Resetting gameHandler...");
|
|
gameHandler.reset();
|
|
gameServices_ = {};
|
|
LOG_WARNING("Resetting authHandler...");
|
|
authHandler.reset();
|
|
LOG_WARNING("Resetting assetManager...");
|
|
assetManager.reset();
|
|
LOG_WARNING("Resetting uiManager...");
|
|
uiManager.reset();
|
|
LOG_WARNING("Resetting window...");
|
|
window.reset();
|
|
|
|
running = false;
|
|
LOG_WARNING("Application shutdown complete");
|
|
}
|
|
|
|
void Application::setState(AppState newState) {
|
|
if (state == newState) {
|
|
return;
|
|
}
|
|
|
|
LOG_INFO("State transition: ", static_cast<int>(state), " -> ", static_cast<int>(newState));
|
|
state = newState;
|
|
|
|
// Handle state transitions
|
|
switch (newState) {
|
|
case AppState::AUTHENTICATION:
|
|
// Show auth screen
|
|
break;
|
|
case AppState::REALM_SELECTION:
|
|
// Show realm screen
|
|
break;
|
|
case AppState::CHARACTER_CREATION:
|
|
// Show character create screen
|
|
break;
|
|
case AppState::CHARACTER_SELECTION:
|
|
// Show character screen
|
|
if (uiManager && assetManager) {
|
|
uiManager->getCharacterScreen().setAssetManager(assetManager.get());
|
|
}
|
|
// Ensure no stale in-world player model leaks into the next login attempt.
|
|
// If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync.
|
|
if (addonManager_ && addonsLoaded_) {
|
|
addonManager_->fireEvent("PLAYER_LEAVING_WORLD");
|
|
addonManager_->saveAllSavedVariables();
|
|
}
|
|
npcsSpawned = false;
|
|
playerCharacterSpawned = false;
|
|
addonsLoaded_ = false;
|
|
if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
|
|
wasAutoAttacking_ = false;
|
|
if (worldLoader_) worldLoader_->resetLoadedMap();
|
|
spawnedPlayerGuid_ = 0;
|
|
spawnedAppearanceBytes_ = 0;
|
|
spawnedFacialFeatures_ = 0;
|
|
if (renderer && renderer->getCharacterRenderer()) {
|
|
uint32_t oldInst = renderer->getCharacterInstanceId();
|
|
if (oldInst > 0) {
|
|
renderer->setCharacterFollow(0);
|
|
renderer->clearMount();
|
|
renderer->getCharacterRenderer()->removeInstance(oldInst);
|
|
}
|
|
}
|
|
break;
|
|
case AppState::IN_GAME: {
|
|
// Wire up movement opcodes from camera controller
|
|
if (renderer && renderer->getCameraController()) {
|
|
auto* cc = renderer->getCameraController();
|
|
cc->setMovementCallback([this](uint32_t opcode) {
|
|
if (gameHandler) {
|
|
gameHandler->sendMovement(static_cast<game::Opcode>(opcode));
|
|
}
|
|
});
|
|
cc->setStandUpCallback([this]() {
|
|
if (gameHandler) {
|
|
gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND)
|
|
}
|
|
});
|
|
cc->setAutoFollowCancelCallback([this]() {
|
|
if (gameHandler) {
|
|
gameHandler->cancelFollow();
|
|
}
|
|
});
|
|
cc->setUseWoWSpeed(true);
|
|
}
|
|
if (gameHandler) {
|
|
gameHandler->setMeleeSwingCallback([this]() {
|
|
if (renderer) {
|
|
renderer->triggerMeleeSwing();
|
|
}
|
|
});
|
|
gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) {
|
|
if (renderer && renderer->getCameraController()) {
|
|
renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed);
|
|
}
|
|
});
|
|
gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) {
|
|
if (renderer && renderer->getCameraController()) {
|
|
renderer->getCameraController()->triggerShake(magnitude, frequency, duration);
|
|
}
|
|
});
|
|
gameHandler->setAutoFollowCallback([this](const glm::vec3* renderPos) {
|
|
if (renderer && renderer->getCameraController()) {
|
|
if (renderPos) {
|
|
renderer->getCameraController()->setAutoFollow(renderPos);
|
|
} else {
|
|
renderer->getCameraController()->cancelAutoFollow();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// Load quest marker models
|
|
loadQuestMarkerModels();
|
|
break;
|
|
}
|
|
case AppState::DISCONNECTED:
|
|
// Back to auth
|
|
break;
|
|
}
|
|
}
|
|
|
|
void Application::reloadExpansionData() {
|
|
if (!expansionRegistry_ || !gameHandler) return;
|
|
auto* profile = expansionRegistry_->getActive();
|
|
if (!profile) return;
|
|
|
|
LOG_INFO("Reloading expansion data for: ", profile->name);
|
|
|
|
std::string opcodesPath = profile->dataPath + "/opcodes.json";
|
|
if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) {
|
|
LOG_ERROR("Failed to load opcodes from ", opcodesPath);
|
|
}
|
|
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
|
|
|
|
std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
|
|
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
|
|
LOG_ERROR("Failed to load update fields from ", updateFieldsPath);
|
|
}
|
|
game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable());
|
|
|
|
gameHandler->setPacketParsers(game::createPacketParsers(profile->id));
|
|
|
|
if (dbcLayout_) {
|
|
std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json";
|
|
if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) {
|
|
LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath);
|
|
}
|
|
pipeline::setActiveDBCLayout(dbcLayout_.get());
|
|
}
|
|
|
|
// Update expansion data path for CSV DBC lookups and clear DBC cache
|
|
if (assetManager && !profile->dataPath.empty()) {
|
|
assetManager->setExpansionDataPath(profile->dataPath);
|
|
assetManager->clearDBCCache();
|
|
}
|
|
|
|
// Reset map name cache so it reloads from new expansion's Map.dbc
|
|
if (worldLoader_) worldLoader_->resetMapNameCache();
|
|
|
|
// Reset game handler DBC caches so they reload from new expansion data
|
|
if (gameHandler) {
|
|
gameHandler->resetDbcCaches();
|
|
}
|
|
|
|
// Rebuild creature display lookups with the new expansion's DBC layout
|
|
if (entitySpawner_) entitySpawner_->rebuildLookups();
|
|
}
|
|
|
|
void Application::logoutToLogin() {
|
|
LOG_INFO("Logout requested");
|
|
|
|
// Disconnect TransportManager from WMORenderer before tearing down
|
|
if (gameHandler && gameHandler->getTransportManager()) {
|
|
gameHandler->getTransportManager()->setWMORenderer(nullptr);
|
|
}
|
|
|
|
if (gameHandler) {
|
|
gameHandler->disconnect();
|
|
}
|
|
|
|
// --- Per-session flags ---
|
|
npcsSpawned = false;
|
|
playerCharacterSpawned = false;
|
|
if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
|
|
wasAutoAttacking_ = false;
|
|
if (worldLoader_) worldLoader_->resetLoadedMap();
|
|
lastTaxiFlight_ = false;
|
|
taxiLandingClampTimer_ = 0.0f;
|
|
worldEntryMovementGraceTimer_ = 0.0f;
|
|
facingSendCooldown_ = 0.0f;
|
|
lastSentCanonicalYaw_ = 1000.0f;
|
|
taxiStreamCooldown_ = 0.0f;
|
|
idleYawned_ = false;
|
|
|
|
// --- Charge state ---
|
|
chargeActive_ = false;
|
|
chargeTimer_ = 0.0f;
|
|
chargeDuration_ = 0.0f;
|
|
chargeTargetGuid_ = 0;
|
|
|
|
// --- Player identity ---
|
|
spawnedPlayerGuid_ = 0;
|
|
spawnedAppearanceBytes_ = 0;
|
|
spawnedFacialFeatures_ = 0;
|
|
|
|
// --- Reset all EntitySpawner state (mount, creatures, players, GOs, queues, caches) ---
|
|
if (entitySpawner_) entitySpawner_->resetAllState();
|
|
|
|
world.reset();
|
|
|
|
if (renderer) {
|
|
renderer->resetCombatVisualState();
|
|
// Remove old player model so it doesn't persist into next session
|
|
if (auto* charRenderer = renderer->getCharacterRenderer()) {
|
|
charRenderer->removeInstance(1);
|
|
}
|
|
// Clear all world geometry renderers
|
|
if (auto* wmo = renderer->getWMORenderer()) {
|
|
wmo->clearInstances();
|
|
}
|
|
if (auto* m2 = renderer->getM2Renderer()) {
|
|
m2->clear();
|
|
}
|
|
// Clear terrain tile tracking + water surfaces so next world entry starts fresh.
|
|
// Use softReset() instead of unloadAll() to avoid blocking on worker thread joins.
|
|
if (auto* terrain = renderer->getTerrainManager()) {
|
|
terrain->softReset();
|
|
}
|
|
if (auto* questMarkers = renderer->getQuestMarkerRenderer()) {
|
|
questMarkers->clear();
|
|
}
|
|
renderer->clearMount();
|
|
renderer->setCharacterFollow(0);
|
|
if (auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr) {
|
|
music->stopMusic(0.0f);
|
|
}
|
|
}
|
|
|
|
// Clear stale realm/character selection so switching servers starts fresh
|
|
if (uiManager) {
|
|
uiManager->getRealmScreen().reset();
|
|
uiManager->getCharacterScreen().reset();
|
|
}
|
|
setState(AppState::AUTHENTICATION);
|
|
}
|
|
|
|
void Application::update(float deltaTime) {
|
|
const char* updateCheckpoint = "enter";
|
|
try {
|
|
// Update based on current state
|
|
updateCheckpoint = "state switch";
|
|
switch (state) {
|
|
case AppState::AUTHENTICATION:
|
|
updateCheckpoint = "auth: enter";
|
|
if (authHandler) {
|
|
authHandler->update(deltaTime);
|
|
}
|
|
break;
|
|
|
|
case AppState::REALM_SELECTION:
|
|
updateCheckpoint = "realm_selection: enter";
|
|
if (authHandler) {
|
|
authHandler->update(deltaTime);
|
|
}
|
|
break;
|
|
|
|
case AppState::CHARACTER_CREATION:
|
|
updateCheckpoint = "char_creation: enter";
|
|
if (gameHandler) {
|
|
gameHandler->update(deltaTime);
|
|
}
|
|
if (uiManager) {
|
|
uiManager->getCharacterCreateScreen().update(deltaTime);
|
|
}
|
|
break;
|
|
|
|
case AppState::CHARACTER_SELECTION:
|
|
updateCheckpoint = "char_selection: enter";
|
|
if (gameHandler) {
|
|
gameHandler->update(deltaTime);
|
|
}
|
|
break;
|
|
|
|
case AppState::IN_GAME: {
|
|
updateCheckpoint = "in_game: enter";
|
|
const char* inGameStep = "begin";
|
|
try {
|
|
auto runInGameStage = [&](const char* stageName, auto&& fn) {
|
|
auto stageStart = std::chrono::steady_clock::now();
|
|
try {
|
|
fn();
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM during IN_GAME update stage '", stageName, "': ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception during IN_GAME update stage '", stageName, "': ", e.what());
|
|
throw;
|
|
}
|
|
auto stageEnd = std::chrono::steady_clock::now();
|
|
float stageMs = std::chrono::duration<float, std::milli>(stageEnd - stageStart).count();
|
|
if (stageMs > 50.0f) {
|
|
LOG_WARNING("SLOW update stage '", stageName, "': ", stageMs, "ms");
|
|
}
|
|
};
|
|
inGameStep = "gameHandler update";
|
|
updateCheckpoint = "in_game: gameHandler update";
|
|
runInGameStage("gameHandler->update", [&] {
|
|
if (gameHandler) {
|
|
gameHandler->update(deltaTime);
|
|
}
|
|
});
|
|
if (addonManager_ && addonsLoaded_) {
|
|
addonManager_->update(deltaTime);
|
|
}
|
|
// Always unsheath on combat engage.
|
|
inGameStep = "auto-unsheathe";
|
|
updateCheckpoint = "in_game: auto-unsheathe";
|
|
if (gameHandler) {
|
|
const bool autoAttacking = gameHandler->isAutoAttacking();
|
|
if (autoAttacking && !wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isWeaponsSheathed()) {
|
|
appearanceComposer_->setWeaponsSheathed(false);
|
|
appearanceComposer_->loadEquippedWeapons();
|
|
}
|
|
wasAutoAttacking_ = autoAttacking;
|
|
}
|
|
|
|
// Toggle weapon sheathe state with Z (ignored while UI captures keyboard).
|
|
inGameStep = "weapon-toggle input";
|
|
updateCheckpoint = "in_game: weapon-toggle input";
|
|
{
|
|
const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
|
|
auto& input = Input::getInstance();
|
|
if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z) && appearanceComposer_) {
|
|
appearanceComposer_->toggleWeaponsSheathed();
|
|
appearanceComposer_->loadEquippedWeapons();
|
|
}
|
|
}
|
|
|
|
inGameStep = "world update";
|
|
updateCheckpoint = "in_game: world update";
|
|
runInGameStage("world->update", [&] {
|
|
if (world) {
|
|
world->update(deltaTime);
|
|
}
|
|
});
|
|
inGameStep = "spawn/equipment queues";
|
|
updateCheckpoint = "in_game: spawn/equipment queues";
|
|
runInGameStage("spawn/equipment queues", [&] {
|
|
if (entitySpawner_) entitySpawner_->update();
|
|
if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) {
|
|
cr->processPendingNormalMaps(4);
|
|
}
|
|
});
|
|
// Self-heal missing creature visuals: if a nearby UNIT exists in
|
|
// entity state but has no render instance, queue a spawn retry.
|
|
inGameStep = "creature resync scan";
|
|
updateCheckpoint = "in_game: creature resync scan";
|
|
if (gameHandler) {
|
|
static float creatureResyncTimer = 0.0f;
|
|
creatureResyncTimer += deltaTime;
|
|
if (creatureResyncTimer >= 3.0f) {
|
|
creatureResyncTimer = 0.0f;
|
|
|
|
glm::vec3 playerPos(0.0f);
|
|
bool havePlayerPos = false;
|
|
uint64_t playerGuid = gameHandler->getPlayerGuid();
|
|
if (auto playerEntity = gameHandler->getEntityManager().getEntity(playerGuid)) {
|
|
playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
|
|
havePlayerPos = true;
|
|
}
|
|
|
|
const float kResyncRadiusSq = 260.0f * 260.0f;
|
|
for (const auto& pair : gameHandler->getEntityManager().getEntities()) {
|
|
uint64_t guid = pair.first;
|
|
const auto& entity = pair.second;
|
|
if (!entity || guid == playerGuid) continue;
|
|
if (entity->getType() != game::ObjectType::UNIT) continue;
|
|
auto unit = std::dynamic_pointer_cast<game::Unit>(entity);
|
|
if (!unit || unit->getDisplayId() == 0) continue;
|
|
if (entitySpawner_->isCreatureSpawned(guid) || entitySpawner_->isCreaturePending(guid)) continue;
|
|
|
|
if (havePlayerPos) {
|
|
glm::vec3 pos(unit->getX(), unit->getY(), unit->getZ());
|
|
glm::vec3 delta = pos - playerPos;
|
|
float distSq = glm::dot(delta, delta);
|
|
if (distSq > kResyncRadiusSq) continue;
|
|
}
|
|
|
|
float retryScale = 1.0f;
|
|
{
|
|
using game::fieldIndex; using game::UF;
|
|
uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X);
|
|
if (si != 0xFFFF) {
|
|
uint32_t raw = unit->getField(si);
|
|
if (raw != 0) {
|
|
float s2 = 1.0f;
|
|
std::memcpy(&s2, &raw, sizeof(float));
|
|
if (s2 > 0.01f && s2 < 100.0f) retryScale = s2;
|
|
}
|
|
}
|
|
}
|
|
entitySpawner_->queueCreatureSpawn(guid, unit->getDisplayId(),
|
|
unit->getX(), unit->getY(), unit->getZ(),
|
|
unit->getOrientation(), retryScale);
|
|
}
|
|
}
|
|
}
|
|
|
|
inGameStep = "gameobject/transport queues";
|
|
updateCheckpoint = "in_game: gameobject/transport queues";
|
|
runInGameStage("gameobject/transport queues", [&] {
|
|
// GO/transport queues handled by entitySpawner_->update() above
|
|
});
|
|
inGameStep = "pending mount";
|
|
updateCheckpoint = "in_game: pending mount";
|
|
runInGameStage("processPendingMount", [&] {
|
|
// Mount processing handled by entitySpawner_->update() above
|
|
});
|
|
// Update 3D quest markers above NPCs
|
|
inGameStep = "quest markers";
|
|
updateCheckpoint = "in_game: quest markers";
|
|
runInGameStage("updateQuestMarkers", [&] {
|
|
updateQuestMarkers();
|
|
});
|
|
// Sync server run speed to camera controller
|
|
inGameStep = "post-update sync";
|
|
updateCheckpoint = "in_game: post-update sync";
|
|
runInGameStage("post-update sync", [&] {
|
|
if (renderer && gameHandler && renderer->getCameraController()) {
|
|
renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed());
|
|
renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed());
|
|
renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed());
|
|
renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed());
|
|
renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed());
|
|
renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed());
|
|
renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed());
|
|
renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate());
|
|
renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted());
|
|
renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled());
|
|
renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling());
|
|
renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking());
|
|
renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying());
|
|
renderer->getCameraController()->setHoverActive(gameHandler->isHovering());
|
|
|
|
// Sync camera forward pitch to movement packets during flight / swimming.
|
|
// The server writes the pitch field when FLYING or SWIMMING flags are set;
|
|
// without this sync it would always be 0 (horizontal), causing other
|
|
// players to see the character flying flat even when pitching up/down.
|
|
if (gameHandler->isPlayerFlying() || gameHandler->isSwimming()) {
|
|
if (auto* cam = renderer->getCamera()) {
|
|
glm::vec3 fwd = cam->getForward();
|
|
float len = glm::length(fwd);
|
|
if (len > 1e-4f) {
|
|
float pitchRad = std::asin(std::clamp(fwd.z / len, -1.0f, 1.0f));
|
|
gameHandler->setMovementPitch(pitchRad);
|
|
// Tilt the mount/character model to match flight direction
|
|
// (taxi flight uses setTaxiOrientationCallback for this instead)
|
|
if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) {
|
|
renderer->setMountPitchRoll(pitchRad, 0.0f);
|
|
}
|
|
}
|
|
}
|
|
} else if (gameHandler->isMounted()) {
|
|
// Reset mount pitch when not flying
|
|
renderer->setMountPitchRoll(0.0f, 0.0f);
|
|
}
|
|
}
|
|
|
|
bool onTaxi = gameHandler &&
|
|
(gameHandler->isOnTaxiFlight() ||
|
|
gameHandler->isTaxiMountActive() ||
|
|
gameHandler->isTaxiActivationPending());
|
|
bool onTransportNow = gameHandler && gameHandler->isOnTransport();
|
|
// Clear stale client-side transport state when the tracked transport no longer exists.
|
|
if (onTransportNow && gameHandler->getTransportManager()) {
|
|
auto* currentTracked = gameHandler->getTransportManager()->getTransport(
|
|
gameHandler->getPlayerTransportGuid());
|
|
if (!currentTracked) {
|
|
gameHandler->clearPlayerTransport();
|
|
onTransportNow = false;
|
|
}
|
|
}
|
|
// M2 transports (trams) use position-delta approach: player keeps normal
|
|
// movement and the transport's frame-to-frame delta is applied on top.
|
|
// Only WMO transports (ships) use full external-driven mode.
|
|
bool isM2Transport = false;
|
|
if (onTransportNow && gameHandler->getTransportManager()) {
|
|
auto* tr = gameHandler->getTransportManager()->getTransport(gameHandler->getPlayerTransportGuid());
|
|
isM2Transport = (tr && tr->isM2);
|
|
}
|
|
bool onWMOTransport = onTransportNow && !isM2Transport;
|
|
if (worldEntryMovementGraceTimer_ > 0.0f) {
|
|
worldEntryMovementGraceTimer_ -= 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)");
|
|
}
|
|
}
|
|
if (renderer && renderer->getCameraController()) {
|
|
const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_;
|
|
// 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 &&
|
|
!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 (lastTaxiFlight_ && !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 (landingClampActive) {
|
|
if (renderer && gameHandler) {
|
|
glm::vec3 p = renderer->getCharacterPosition();
|
|
std::optional<float> terrainFloor;
|
|
std::optional<float> wmoFloor;
|
|
std::optional<float> m2Floor;
|
|
if (renderer->getTerrainManager()) {
|
|
terrainFloor = renderer->getTerrainManager()->getHeightAt(p.x, p.y);
|
|
}
|
|
if (renderer->getWMORenderer()) {
|
|
// Probe from above so we can recover when current Z is already below floor.
|
|
wmoFloor = renderer->getWMORenderer()->getFloorHeight(p.x, p.y, p.z + 40.0f);
|
|
}
|
|
if (renderer->getM2Renderer()) {
|
|
// Include M2 floors (bridges/platforms) in landing recovery.
|
|
m2Floor = renderer->getM2Renderer()->getFloorHeight(p.x, p.y, p.z + 40.0f);
|
|
}
|
|
|
|
std::optional<float> targetFloor;
|
|
if (terrainFloor) targetFloor = terrainFloor;
|
|
if (wmoFloor && (!targetFloor || *wmoFloor > *targetFloor)) targetFloor = wmoFloor;
|
|
if (m2Floor && (!targetFloor || *m2Floor > *targetFloor)) targetFloor = m2Floor;
|
|
|
|
if (targetFloor) {
|
|
// Floor found — snap player to it and start countdown to release
|
|
float targetZ = *targetFloor + 0.10f;
|
|
if (std::abs(p.z - targetZ) > 0.05f) {
|
|
p.z = targetZ;
|
|
renderer->getCharacterPosition() = p;
|
|
glm::vec3 canonical = core::coords::renderToCanonical(p);
|
|
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
|
|
gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
|
|
}
|
|
taxiLandingClampTimer_ -= deltaTime;
|
|
}
|
|
// No floor found: don't decrement timer, keep player frozen until terrain loads
|
|
}
|
|
}
|
|
bool idleOrbit = renderer->getCameraController()->isIdleOrbit();
|
|
if (idleOrbit && !idleYawned_ && renderer) {
|
|
renderer->playEmote("yawn");
|
|
idleYawned_ = true;
|
|
} else if (!idleOrbit) {
|
|
idleYawned_ = false;
|
|
}
|
|
}
|
|
if (renderer) {
|
|
renderer->setTaxiFlight(onTaxi);
|
|
}
|
|
if (renderer && renderer->getTerrainManager()) {
|
|
renderer->getTerrainManager()->setStreamingEnabled(true);
|
|
// Taxi flights move fast (32 u/s) — load further ahead so terrain is ready
|
|
// before the camera arrives. Keep updates frequent to spot new tiles early.
|
|
renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.033f : 0.033f);
|
|
renderer->getTerrainManager()->setLoadRadius(onTaxi ? 8 : 4);
|
|
renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7);
|
|
renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi);
|
|
}
|
|
lastTaxiFlight_ = onTaxi;
|
|
|
|
// Sync character render position ↔ canonical WoW coords each frame
|
|
if (renderer && gameHandler) {
|
|
// For position sync branching, only WMO transports use the dedicated
|
|
// onTransport branch. M2 transports use the normal movement else branch
|
|
// with a position-delta correction applied on top.
|
|
bool onTransport = onWMOTransport;
|
|
|
|
static bool wasOnTransport = false;
|
|
bool onTransportNowDbg = gameHandler->isOnTransport();
|
|
if (onTransportNowDbg != wasOnTransport) {
|
|
LOG_DEBUG("Transport state changed: onTransport=", onTransportNowDbg,
|
|
" isM2=", isM2Transport,
|
|
" guid=0x", std::hex, gameHandler->getPlayerTransportGuid(), std::dec);
|
|
wasOnTransport = onTransportNowDbg;
|
|
}
|
|
|
|
if (onTaxi) {
|
|
auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid());
|
|
glm::vec3 canonical(0.0f);
|
|
bool haveCanonical = false;
|
|
if (playerEntity) {
|
|
canonical = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
|
|
haveCanonical = true;
|
|
} else {
|
|
// Fallback for brief entity gaps during taxi start/updates:
|
|
// movementInfo is still updated by client taxi simulation.
|
|
const auto& move = gameHandler->getMovementInfo();
|
|
canonical = glm::vec3(move.x, move.y, move.z);
|
|
haveCanonical = true;
|
|
}
|
|
if (haveCanonical) {
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
|
renderer->getCharacterPosition() = renderPos;
|
|
if (renderer->getCameraController()) {
|
|
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
|
|
if (followTarget) {
|
|
*followTarget = renderPos;
|
|
}
|
|
}
|
|
}
|
|
} else if (onTransport) {
|
|
// WMO transport mode (ships): compose world position from transform + local offset
|
|
glm::vec3 canonical = gameHandler->getComposedWorldPosition();
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
|
renderer->getCharacterPosition() = renderPos;
|
|
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
|
|
if (renderer->getCameraController()) {
|
|
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
|
|
if (followTarget) {
|
|
*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 {
|
|
glm::vec3 renderPos = renderer->getCharacterPosition();
|
|
|
|
// M2 transport riding: resolve in canonical space and lock once per frame.
|
|
// This avoids visible jitter from mixed render/canonical delta application.
|
|
if (isM2Transport && gameHandler->getTransportManager()) {
|
|
auto* tr = gameHandler->getTransportManager()->getTransport(
|
|
gameHandler->getPlayerTransportGuid());
|
|
if (tr) {
|
|
// Keep passenger locked to elevator vertical motion while grounded.
|
|
// Without this, floor clamping can hold world-Z static unless the
|
|
// player is jumping, which makes lifts appear to not move vertically.
|
|
glm::vec3 tentativeCanonical = core::coords::renderToCanonical(renderPos);
|
|
glm::vec3 localOffset = gameHandler->getPlayerTransportOffset();
|
|
localOffset.x = tentativeCanonical.x - tr->position.x;
|
|
localOffset.y = tentativeCanonical.y - tr->position.y;
|
|
if (renderer->getCameraController() &&
|
|
!renderer->getCameraController()->isGrounded()) {
|
|
// While airborne (jump/fall), allow local Z offset to change.
|
|
localOffset.z = tentativeCanonical.z - tr->position.z;
|
|
}
|
|
gameHandler->setPlayerTransportOffset(localOffset);
|
|
|
|
glm::vec3 lockedCanonical = tr->position + localOffset;
|
|
renderPos = core::coords::canonicalToRender(lockedCanonical);
|
|
renderer->getCharacterPosition() = renderPos;
|
|
}
|
|
}
|
|
|
|
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
|
|
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
|
|
|
|
// Sync orientation: camera yaw (degrees) → WoW orientation (radians)
|
|
float yawDeg = renderer->getCharacterYaw();
|
|
// Keep all game-side orientation in canonical space.
|
|
// We historically sent serverYaw = radians(yawDeg - 90). With the new
|
|
// canonical<->server mapping (serverYaw = PI/2 - canonicalYaw), the
|
|
// equivalent canonical yaw is radians(180 - yawDeg).
|
|
float canonicalYaw = core::coords::normalizeAngleRad(glm::radians(180.0f - yawDeg));
|
|
gameHandler->setOrientation(canonicalYaw);
|
|
|
|
// Send MSG_MOVE_SET_FACING when the player changes facing direction
|
|
// (e.g. via mouse-look). Without this, the server predicts movement in
|
|
// the old facing and position-corrects on the next heartbeat — the
|
|
// micro-teleporting the GM observed.
|
|
// Skip while keyboard-turning: the server tracks that via TURN_LEFT/RIGHT flags.
|
|
facingSendCooldown_ -= deltaTime;
|
|
const auto& mi = gameHandler->getMovementInfo();
|
|
constexpr uint32_t kTurnFlags =
|
|
static_cast<uint32_t>(game::MovementFlags::TURN_LEFT) |
|
|
static_cast<uint32_t>(game::MovementFlags::TURN_RIGHT);
|
|
bool keyboardTurning = (mi.flags & kTurnFlags) != 0;
|
|
if (!keyboardTurning && facingSendCooldown_ <= 0.0f) {
|
|
float yawDiff = core::coords::normalizeAngleRad(canonicalYaw - lastSentCanonicalYaw_);
|
|
if (std::abs(yawDiff) > glm::radians(3.0f)) {
|
|
gameHandler->sendMovement(game::Opcode::MSG_MOVE_SET_FACING);
|
|
lastSentCanonicalYaw_ = canonicalYaw;
|
|
facingSendCooldown_ = 0.1f; // max 10 Hz
|
|
}
|
|
}
|
|
|
|
// Client-side transport boarding detection (for M2 transports like trams
|
|
// and lifts where the server doesn't send transport attachment data).
|
|
// Thunder Bluff elevators use model origins that can be far from the deck
|
|
// the player stands on, so they need wider attachment bounds.
|
|
if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) {
|
|
auto* tm = gameHandler->getTransportManager();
|
|
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
|
|
constexpr float kM2BoardHorizDistSq = 12.0f * 12.0f;
|
|
constexpr float kM2BoardVertDist = 15.0f;
|
|
constexpr float kTbLiftBoardHorizDistSq = 22.0f * 22.0f;
|
|
constexpr float kTbLiftBoardVertDist = 14.0f;
|
|
|
|
uint64_t bestGuid = 0;
|
|
float bestScore = 1e30f;
|
|
for (auto& [guid, transport] : tm->getTransports()) {
|
|
if (!transport.isM2) continue;
|
|
const bool isThunderBluffLift =
|
|
(transport.entry >= 20649u && transport.entry <= 20657u);
|
|
const float maxHorizDistSq = isThunderBluffLift
|
|
? kTbLiftBoardHorizDistSq
|
|
: kM2BoardHorizDistSq;
|
|
const float maxVertDist = isThunderBluffLift
|
|
? kTbLiftBoardVertDist
|
|
: kM2BoardVertDist;
|
|
glm::vec3 diff = playerCanonical - transport.position;
|
|
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
|
|
float vertDist = std::abs(diff.z);
|
|
if (horizDistSq < maxHorizDistSq && vertDist < maxVertDist) {
|
|
float score = horizDistSq + vertDist * vertDist;
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
bestGuid = guid;
|
|
}
|
|
}
|
|
}
|
|
if (bestGuid != 0) {
|
|
auto* tr = tm->getTransport(bestGuid);
|
|
if (tr) {
|
|
gameHandler->setPlayerOnTransport(bestGuid, playerCanonical - tr->position);
|
|
LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, bestGuid, std::dec);
|
|
}
|
|
}
|
|
}
|
|
|
|
// M2 transport disembark: player walked far enough from transport center
|
|
if (isM2Transport && gameHandler->getTransportManager()) {
|
|
auto* tm = gameHandler->getTransportManager();
|
|
auto* tr = tm->getTransport(gameHandler->getPlayerTransportGuid());
|
|
if (tr) {
|
|
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
|
|
glm::vec3 diff = playerCanonical - tr->position;
|
|
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
|
|
const bool isThunderBluffLift =
|
|
(tr->entry >= 20649u && tr->entry <= 20657u);
|
|
constexpr float kM2DisembarkHorizDistSq = 15.0f * 15.0f;
|
|
constexpr float kTbLiftDisembarkHorizDistSq = 28.0f * 28.0f;
|
|
constexpr float kM2DisembarkVertDist = 18.0f;
|
|
constexpr float kTbLiftDisembarkVertDist = 16.0f;
|
|
const float disembarkHorizDistSq = isThunderBluffLift
|
|
? kTbLiftDisembarkHorizDistSq
|
|
: kM2DisembarkHorizDistSq;
|
|
const float disembarkVertDist = isThunderBluffLift
|
|
? kTbLiftDisembarkVertDist
|
|
: kM2DisembarkVertDist;
|
|
if (horizDistSq > disembarkHorizDistSq || std::abs(diff.z) > disembarkVertDist) {
|
|
gameHandler->clearPlayerTransport();
|
|
LOG_DEBUG("M2 transport disembark");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Keep creature render instances aligned with authoritative entity positions.
|
|
// This prevents desync where target circles move with server entities but
|
|
// creature models remain at stale spawn positions.
|
|
inGameStep = "creature render sync";
|
|
updateCheckpoint = "in_game: creature render sync";
|
|
auto creatureSyncStart = std::chrono::steady_clock::now();
|
|
if (renderer && gameHandler && renderer->getCharacterRenderer()) {
|
|
auto* charRenderer = renderer->getCharacterRenderer();
|
|
static float npcWeaponRetryTimer = 0.0f;
|
|
npcWeaponRetryTimer += deltaTime;
|
|
const bool npcWeaponRetryTick = (npcWeaponRetryTimer >= 1.0f);
|
|
if (npcWeaponRetryTick) npcWeaponRetryTimer = 0.0f;
|
|
int weaponAttachesThisTick = 0;
|
|
glm::vec3 playerPos(0.0f);
|
|
glm::vec3 playerRenderPos(0.0f);
|
|
bool havePlayerPos = false;
|
|
float playerCollisionRadius = 0.65f;
|
|
if (auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) {
|
|
playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
|
|
playerRenderPos = core::coords::canonicalToRender(playerPos);
|
|
havePlayerPos = true;
|
|
glm::vec3 pc;
|
|
float pr = 0.0f;
|
|
if (getRenderBoundsForGuid(gameHandler->getPlayerGuid(), pc, pr)) {
|
|
playerCollisionRadius = std::clamp(pr * 0.35f, 0.45f, 1.1f);
|
|
}
|
|
}
|
|
const float syncRadiusSq = 320.0f * 320.0f;
|
|
auto& _creatureInstances = entitySpawner_->getCreatureInstances();
|
|
auto& _creatureWeaponsAttached = entitySpawner_->getCreatureWeaponsAttached();
|
|
auto& _creatureWeaponAttachAttempts = entitySpawner_->getCreatureWeaponAttachAttempts();
|
|
auto& _creatureModelIds = entitySpawner_->getCreatureModelIds();
|
|
auto& _modelIdIsWolfLike = entitySpawner_->getModelIdIsWolfLike();
|
|
auto& _creatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache();
|
|
auto& _creatureSwimmingState = entitySpawner_->getCreatureSwimmingState();
|
|
auto& _creatureWalkingState = entitySpawner_->getCreatureWalkingState();
|
|
auto& _creatureFlyingState = entitySpawner_->getCreatureFlyingState();
|
|
auto& _creatureWasMoving = entitySpawner_->getCreatureWasMoving();
|
|
auto& _creatureWasSwimming = entitySpawner_->getCreatureWasSwimming();
|
|
auto& _creatureWasFlying = entitySpawner_->getCreatureWasFlying();
|
|
auto& _creatureWasWalking = entitySpawner_->getCreatureWasWalking();
|
|
for (const auto& [guid, instanceId] : _creatureInstances) {
|
|
auto entity = gameHandler->getEntityManager().getEntity(guid);
|
|
if (!entity || entity->getType() != game::ObjectType::UNIT) continue;
|
|
|
|
if (npcWeaponRetryTick &&
|
|
weaponAttachesThisTick < EntitySpawner::MAX_WEAPON_ATTACHES_PER_TICK &&
|
|
!_creatureWeaponsAttached.count(guid)) {
|
|
uint8_t attempts = 0;
|
|
auto itAttempts = _creatureWeaponAttachAttempts.find(guid);
|
|
if (itAttempts != _creatureWeaponAttachAttempts.end()) attempts = itAttempts->second;
|
|
if (attempts < 30) {
|
|
weaponAttachesThisTick++;
|
|
if (entitySpawner_->tryAttachCreatureVirtualWeapons(guid, instanceId)) {
|
|
_creatureWeaponsAttached.insert(guid);
|
|
_creatureWeaponAttachAttempts.erase(guid);
|
|
} else {
|
|
_creatureWeaponAttachAttempts[guid] = static_cast<uint8_t>(attempts + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Distance check uses getLatestX/Y/Z (server-authoritative destination) to
|
|
// avoid false-culling entities that moved while getX/Y/Z was stale.
|
|
// Position sync still uses getX/Y/Z to preserve smooth interpolation for
|
|
// nearby entities; distant entities (> 150u) have planarDist≈0 anyway
|
|
// so the renderer remains driven correctly by creatureMoveCallback_.
|
|
glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
|
|
float canonDistSq = 0.0f;
|
|
if (havePlayerPos) {
|
|
glm::vec3 d = latestCanonical - playerPos;
|
|
canonDistSq = glm::dot(d, d);
|
|
if (canonDistSq > syncRadiusSq) continue;
|
|
}
|
|
|
|
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
|
|
|
// Visual collision guard: keep hostile melee units from rendering inside the
|
|
// player's model while attacking. This is client-side only (no server position change).
|
|
// Only check for creatures within 8 units (melee range) — saves expensive
|
|
// getRenderBoundsForGuid/getModelData calls for distant creatures.
|
|
bool clipGuardEligible = false;
|
|
bool isCombatTarget = false;
|
|
if (havePlayerPos && canonDistSq < 64.0f) { // 8² = melee range
|
|
auto unit = std::static_pointer_cast<game::Unit>(entity);
|
|
const uint64_t currentTargetGuid = gameHandler->hasTarget() ? gameHandler->getTargetGuid() : 0;
|
|
const uint64_t autoAttackGuid = gameHandler->getAutoAttackTargetGuid();
|
|
isCombatTarget = (guid == currentTargetGuid || guid == autoAttackGuid);
|
|
clipGuardEligible = unit->getHealth() > 0 &&
|
|
(unit->isHostile() ||
|
|
gameHandler->isAggressiveTowardPlayer(guid) ||
|
|
isCombatTarget);
|
|
}
|
|
if (clipGuardEligible) {
|
|
float creatureCollisionRadius = 0.8f;
|
|
glm::vec3 cc;
|
|
float cr = 0.0f;
|
|
if (getRenderBoundsForGuid(guid, cc, cr)) {
|
|
creatureCollisionRadius = std::clamp(cr * 0.45f, 0.65f, 1.9f);
|
|
}
|
|
|
|
float minSep = std::max(playerCollisionRadius + creatureCollisionRadius, 1.9f);
|
|
if (isCombatTarget) {
|
|
// Stronger spacing for the actively engaged attacker to avoid bite-overlap.
|
|
minSep = std::max(minSep, 2.2f);
|
|
}
|
|
|
|
// Species/model-specific spacing for wolf-like creatures (their lunge anims
|
|
// often put head/torso inside the player capsule).
|
|
auto mit = _creatureModelIds.find(guid);
|
|
if (mit != _creatureModelIds.end()) {
|
|
uint32_t mid = mit->second;
|
|
auto wolfIt = _modelIdIsWolfLike.find(mid);
|
|
if (wolfIt == _modelIdIsWolfLike.end()) {
|
|
bool isWolf = false;
|
|
if (const auto* md = charRenderer->getModelData(mid)) {
|
|
std::string modelName = md->name;
|
|
std::transform(modelName.begin(), modelName.end(), modelName.begin(),
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
isWolf = (modelName.find("wolf") != std::string::npos ||
|
|
modelName.find("worg") != std::string::npos);
|
|
}
|
|
wolfIt = _modelIdIsWolfLike.emplace(mid, isWolf).first;
|
|
}
|
|
if (wolfIt->second) {
|
|
minSep = std::max(minSep, 2.45f);
|
|
}
|
|
}
|
|
|
|
glm::vec2 d2(renderPos.x - playerRenderPos.x, renderPos.y - playerRenderPos.y);
|
|
float distSq2 = glm::dot(d2, d2);
|
|
if (distSq2 < (minSep * minSep)) {
|
|
glm::vec2 dir2(1.0f, 0.0f);
|
|
if (distSq2 > 1e-6f) {
|
|
dir2 = d2 * (1.0f / std::sqrt(distSq2));
|
|
}
|
|
glm::vec2 clamped2 = glm::vec2(playerRenderPos.x, playerRenderPos.y) + dir2 * minSep;
|
|
renderPos.x = clamped2.x;
|
|
renderPos.y = clamped2.y;
|
|
}
|
|
}
|
|
|
|
auto posIt = _creatureRenderPosCache.find(guid);
|
|
if (posIt == _creatureRenderPosCache.end()) {
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
|
_creatureRenderPosCache[guid] = renderPos;
|
|
} else {
|
|
const glm::vec3 prevPos = posIt->second;
|
|
float ddx2 = renderPos.x - prevPos.x;
|
|
float ddy2 = renderPos.y - prevPos.y;
|
|
float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2;
|
|
float dz = std::abs(renderPos.z - prevPos.z);
|
|
|
|
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
|
|
const bool deadOrCorpse = unitPtr->getHealth() == 0;
|
|
const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f);
|
|
// isEntityMoving() reflects server-authoritative move state set by
|
|
// startMoveTo() in handleMonsterMove, regardless of distance-cull.
|
|
// This correctly detects movement for distant creatures (> 150u)
|
|
// where updateMovement() is not called and getX/Y/Z() stays stale.
|
|
// Use isActivelyMoving() (not isEntityMoving()) so the
|
|
// Run/Walk animation stops when the creature reaches its
|
|
// destination, rather than persisting through the dead-
|
|
// reckoning overrun window.
|
|
const bool entityIsMoving = entity->isActivelyMoving();
|
|
constexpr float kMoveThreshSq = 0.03f * 0.03f;
|
|
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq || dz > 0.08f);
|
|
if (deadOrCorpse || largeCorrection) {
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
|
} else if (planarDistSq > kMoveThreshSq || dz > 0.08f) {
|
|
// Position changed in entity coords → drive renderer toward it.
|
|
float planarDist = std::sqrt(planarDistSq);
|
|
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
|
|
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
|
|
}
|
|
// When entity is moving but getX/Y/Z is stale (distance-culled),
|
|
// don't call moveInstanceTo — creatureMoveCallback_ already drove
|
|
// the renderer to the correct destination via the spline packet.
|
|
posIt->second = renderPos;
|
|
|
|
// Drive movement animation: Walk/Run/Swim (4/5/42) when moving,
|
|
// Stand/SwimIdle (0/41) when idle. Walk(4) selected when WALKING flag is set.
|
|
// WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim.
|
|
// Only switch on transitions to avoid resetting animation time.
|
|
// Don't override Death (1) animation.
|
|
const bool isSwimmingNow = _creatureSwimmingState.count(guid) > 0;
|
|
const bool isWalkingNow = _creatureWalkingState.count(guid) > 0;
|
|
const bool isFlyingNow = _creatureFlyingState.count(guid) > 0;
|
|
bool prevMoving = _creatureWasMoving[guid];
|
|
bool prevSwimming = _creatureWasSwimming[guid];
|
|
bool prevFlying = _creatureWasFlying[guid];
|
|
bool prevWalking = _creatureWasWalking[guid];
|
|
// Trigger animation update on any locomotion-state transition, not just
|
|
// moving/idle — e.g. creature lands while still moving → FlyForward→Run,
|
|
// or server changes WALKING flag while creature is already running → Walk.
|
|
const bool stateChanged = (isMovingNow != prevMoving) ||
|
|
(isSwimmingNow != prevSwimming) ||
|
|
(isFlyingNow != prevFlying) ||
|
|
(isWalkingNow != prevWalking && isMovingNow);
|
|
if (stateChanged) {
|
|
_creatureWasMoving[guid] = isMovingNow;
|
|
_creatureWasSwimming[guid] = isSwimmingNow;
|
|
_creatureWasFlying[guid] = isFlyingNow;
|
|
_creatureWasWalking[guid] = isWalkingNow;
|
|
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
|
|
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
|
|
if (!gotState || curAnimId != 1 /*Death*/) {
|
|
uint32_t targetAnim;
|
|
if (isMovingNow) {
|
|
if (isFlyingNow) targetAnim = 159u; // FlyForward
|
|
else if (isSwimmingNow) targetAnim = 42u; // Swim
|
|
else if (isWalkingNow) targetAnim = 4u; // Walk
|
|
else targetAnim = 5u; // Run
|
|
} else {
|
|
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
|
|
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
|
|
else targetAnim = 0u; // Stand
|
|
}
|
|
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
|
|
}
|
|
}
|
|
}
|
|
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
|
|
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
|
|
}
|
|
}
|
|
{
|
|
float csMs = std::chrono::duration<float, std::milli>(
|
|
std::chrono::steady_clock::now() - creatureSyncStart).count();
|
|
if (csMs > 5.0f) {
|
|
LOG_WARNING("SLOW update stage 'creature render sync': ", csMs, "ms (",
|
|
entitySpawner_->getCreatureInstances().size(), " creatures)");
|
|
}
|
|
}
|
|
|
|
// --- Online player render sync (position, orientation, animation) ---
|
|
// Mirrors the creature sync loop above but without collision guard or
|
|
// weapon-attach logic. Without this, online players never transition
|
|
// back to Stand after movement stops ("run in place" bug).
|
|
auto playerSyncStart = std::chrono::steady_clock::now();
|
|
if (renderer && gameHandler && renderer->getCharacterRenderer()) {
|
|
auto* charRenderer = renderer->getCharacterRenderer();
|
|
glm::vec3 pPos(0.0f);
|
|
bool havePPos = false;
|
|
if (auto pe = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) {
|
|
pPos = glm::vec3(pe->getX(), pe->getY(), pe->getZ());
|
|
havePPos = true;
|
|
}
|
|
const float pSyncRadiusSq = 320.0f * 320.0f;
|
|
|
|
auto& _playerInstances = entitySpawner_->getPlayerInstances();
|
|
auto& _pCreatureWasMoving = entitySpawner_->getCreatureWasMoving();
|
|
auto& _pCreatureWasSwimming = entitySpawner_->getCreatureWasSwimming();
|
|
auto& _pCreatureWasFlying = entitySpawner_->getCreatureWasFlying();
|
|
auto& _pCreatureWasWalking = entitySpawner_->getCreatureWasWalking();
|
|
auto& _pCreatureSwimmingState = entitySpawner_->getCreatureSwimmingState();
|
|
auto& _pCreatureWalkingState = entitySpawner_->getCreatureWalkingState();
|
|
auto& _pCreatureFlyingState = entitySpawner_->getCreatureFlyingState();
|
|
auto& _pCreatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache();
|
|
for (const auto& [guid, instanceId] : _playerInstances) {
|
|
auto entity = gameHandler->getEntityManager().getEntity(guid);
|
|
if (!entity || entity->getType() != game::ObjectType::PLAYER) continue;
|
|
|
|
// Distance cull
|
|
if (havePPos) {
|
|
glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
|
|
glm::vec3 d = latestCanonical - pPos;
|
|
if (glm::dot(d, d) > pSyncRadiusSq) continue;
|
|
}
|
|
|
|
// Position sync
|
|
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
|
|
|
auto posIt = _pCreatureRenderPosCache.find(guid);
|
|
if (posIt == _pCreatureRenderPosCache.end()) {
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
|
_pCreatureRenderPosCache[guid] = renderPos;
|
|
} else {
|
|
const glm::vec3 prevPos = posIt->second;
|
|
float ddx2 = renderPos.x - prevPos.x;
|
|
float ddy2 = renderPos.y - prevPos.y;
|
|
float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2;
|
|
float dz = std::abs(renderPos.z - prevPos.z);
|
|
|
|
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
|
|
const bool deadOrCorpse = unitPtr->getHealth() == 0;
|
|
const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f);
|
|
const bool entityIsMoving = entity->isActivelyMoving();
|
|
constexpr float kMoveThreshSq2 = 0.03f * 0.03f;
|
|
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq2 || dz > 0.08f);
|
|
|
|
if (deadOrCorpse || largeCorrection) {
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
|
} else if (planarDistSq > kMoveThreshSq2 || dz > 0.08f) {
|
|
float planarDist = std::sqrt(planarDistSq);
|
|
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
|
|
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
|
|
}
|
|
posIt->second = renderPos;
|
|
|
|
// Drive movement animation (same logic as creatures)
|
|
const bool isSwimmingNow = _pCreatureSwimmingState.count(guid) > 0;
|
|
const bool isWalkingNow = _pCreatureWalkingState.count(guid) > 0;
|
|
const bool isFlyingNow = _pCreatureFlyingState.count(guid) > 0;
|
|
bool prevMoving = _pCreatureWasMoving[guid];
|
|
bool prevSwimming = _pCreatureWasSwimming[guid];
|
|
bool prevFlying = _pCreatureWasFlying[guid];
|
|
bool prevWalking = _pCreatureWasWalking[guid];
|
|
const bool stateChanged = (isMovingNow != prevMoving) ||
|
|
(isSwimmingNow != prevSwimming) ||
|
|
(isFlyingNow != prevFlying) ||
|
|
(isWalkingNow != prevWalking && isMovingNow);
|
|
if (stateChanged) {
|
|
_pCreatureWasMoving[guid] = isMovingNow;
|
|
_pCreatureWasSwimming[guid] = isSwimmingNow;
|
|
_pCreatureWasFlying[guid] = isFlyingNow;
|
|
_pCreatureWasWalking[guid] = isWalkingNow;
|
|
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
|
|
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
|
|
if (!gotState || curAnimId != 1 /*Death*/) {
|
|
uint32_t targetAnim;
|
|
if (isMovingNow) {
|
|
if (isFlyingNow) targetAnim = 159u; // FlyForward
|
|
else if (isSwimmingNow) targetAnim = 42u; // Swim
|
|
else if (isWalkingNow) targetAnim = 4u; // Walk
|
|
else targetAnim = 5u; // Run
|
|
} else {
|
|
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
|
|
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
|
|
else targetAnim = 0u; // Stand
|
|
}
|
|
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Orientation sync
|
|
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
|
|
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
|
|
}
|
|
}
|
|
{
|
|
float psMs = std::chrono::duration<float, std::milli>(
|
|
std::chrono::steady_clock::now() - playerSyncStart).count();
|
|
if (psMs > 5.0f) {
|
|
LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (",
|
|
entitySpawner_->getPlayerInstances().size(), " players)");
|
|
}
|
|
}
|
|
|
|
// Movement heartbeat is sent from GameHandler::update() to avoid
|
|
// duplicate packets from multiple update loops.
|
|
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM inside AppState::IN_GAME at step '", inGameStep, "': ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception inside AppState::IN_GAME at step '", inGameStep, "': ", e.what());
|
|
throw;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case AppState::DISCONNECTED:
|
|
// Handle disconnection
|
|
break;
|
|
}
|
|
|
|
// Process any pending world entry request via WorldLoader
|
|
if (worldLoader_ && state != AppState::DISCONNECTED) {
|
|
worldLoader_->processPendingEntry();
|
|
}
|
|
|
|
// Update renderer (camera, etc.) only when in-game
|
|
updateCheckpoint = "renderer update";
|
|
if (renderer && state == AppState::IN_GAME) {
|
|
auto rendererUpdateStart = std::chrono::steady_clock::now();
|
|
try {
|
|
renderer->update(deltaTime);
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM during Application::update stage 'renderer->update': ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception during Application::update stage 'renderer->update': ", e.what());
|
|
throw;
|
|
}
|
|
float ruMs = std::chrono::duration<float, std::milli>(
|
|
std::chrono::steady_clock::now() - rendererUpdateStart).count();
|
|
if (ruMs > 50.0f) {
|
|
LOG_WARNING("SLOW update stage 'renderer->update': ", ruMs, "ms");
|
|
}
|
|
}
|
|
// Update UI
|
|
updateCheckpoint = "ui update";
|
|
if (uiManager) {
|
|
try {
|
|
uiManager->update(deltaTime);
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM during Application::update stage 'uiManager->update': ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception during Application::update stage 'uiManager->update': ", e.what());
|
|
throw;
|
|
}
|
|
}
|
|
} catch (const std::bad_alloc& e) {
|
|
LOG_ERROR("OOM in Application::update checkpoint '", updateCheckpoint, "': ", e.what());
|
|
throw;
|
|
} catch (const std::exception& e) {
|
|
LOG_ERROR("Exception in Application::update checkpoint '", updateCheckpoint, "': ", e.what());
|
|
throw;
|
|
}
|
|
}
|
|
|
|
void Application::render() {
|
|
if (!renderer) {
|
|
return;
|
|
}
|
|
|
|
renderer->beginFrame();
|
|
|
|
// Only render 3D world when in-game
|
|
if (state == AppState::IN_GAME) {
|
|
if (world) {
|
|
renderer->renderWorld(world.get(), gameHandler.get());
|
|
} else {
|
|
renderer->renderWorld(nullptr, gameHandler.get());
|
|
}
|
|
}
|
|
|
|
// Render performance HUD (within ImGui frame, before UI ends the frame)
|
|
if (renderer) {
|
|
renderer->renderHUD();
|
|
}
|
|
|
|
// Render UI on top (ends ImGui frame with ImGui::Render())
|
|
if (uiManager) {
|
|
uiManager->render(state, authHandler.get(), gameHandler.get());
|
|
}
|
|
|
|
renderer->endFrame();
|
|
}
|
|
|
|
void Application::setupUICallbacks() {
|
|
// 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]() {
|
|
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<std::pair<int,int>> 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<std::pair<int,int>> 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<float> {
|
|
std::optional<float> terrainFloor;
|
|
std::optional<float> wmoFloor;
|
|
std::optional<float> 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<float> 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)");
|
|
});
|
|
}
|
|
|
|
// 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<std::pair<int,int>> 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<int>(raceId), " gender=", static_cast<int>(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<uint32_t, 19>& displayInfoIds,
|
|
const std::array<uint8_t, 19>& 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_) return;
|
|
auto& goInstances = entitySpawner_->getGameObjectInstances();
|
|
auto it = goInstances.find(guid);
|
|
if (it == goInstances.end() || !renderer) return;
|
|
auto& info = it->second;
|
|
if (!info.isWmo) {
|
|
if (auto* m2r = renderer->getM2Renderer()) {
|
|
m2r->setInstanceAnimationFrozen(info.instanceId, false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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<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;
|
|
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<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;
|
|
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<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;
|
|
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<glm::vec3>& path) {
|
|
if (!renderer || !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 && 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<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 != 1 /*Death*/ && curAnimId != 5u /*Run*/)) {
|
|
cr->playAnimation(instanceId, 5u, /*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<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 && 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 && 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, 1, false); // Death
|
|
}
|
|
});
|
|
|
|
// NPC/player respawn callback (online mode) - reset to idle animation
|
|
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) {
|
|
renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle
|
|
}
|
|
});
|
|
|
|
// NPC/player swing callback (online mode) - play attack animation
|
|
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();
|
|
// Try weapon-appropriate attack anim: 17=1H, 18=2H, 16=unarmed fallback
|
|
static const uint32_t attackAnims[] = {17, 18, 16};
|
|
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, 16, false);
|
|
}
|
|
});
|
|
|
|
// 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 (1)
|
|
uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f;
|
|
if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == 1) 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<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
|
|
gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) {
|
|
if (!entitySpawner_) return;
|
|
if (!renderer || emoteAnim == 0) 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) {
|
|
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)
|
|
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) {
|
|
if (!entitySpawner_) return;
|
|
if (!renderer) return;
|
|
auto* cr = renderer->getCharacterRenderer();
|
|
if (!cr) return;
|
|
// Animation 3 = SpellCast (one-shot; return-to-idle handled by character_renderer)
|
|
const uint32_t castAnim = 3;
|
|
// Check player character
|
|
{
|
|
uint32_t charInstId = renderer->getCharacterInstanceId();
|
|
if (charInstId != 0 && guid == gameHandler->getPlayerGuid()) {
|
|
if (start) cr->playAnimation(charInstId, castAnim, false);
|
|
// On finish: playAnimation(castAnim, loop=false) will auto-return to Stand
|
|
return;
|
|
}
|
|
}
|
|
// Check creatures and other online players
|
|
{
|
|
uint32_t cInst = entitySpawner_->getCreatureInstanceId(guid);
|
|
if (cInst != 0) {
|
|
if (start) cr->playAnimation(cInst, castAnim, false);
|
|
return;
|
|
}
|
|
}
|
|
{
|
|
uint32_t pInst = entitySpawner_->getPlayerInstanceId(guid);
|
|
if (pInst != 0) {
|
|
if (start) cr->playAnimation(pInst, castAnim, false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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 — map server stand state to M2 animation on player
|
|
// and sync camera sit flag so movement is blocked while sitting
|
|
gameHandler->setStandStateCallback([this](uint8_t standState) {
|
|
if (!renderer) return;
|
|
|
|
// Sync camera controller sitting flag: block movement while sitting/kneeling
|
|
if (auto* cc = renderer->getCameraController()) {
|
|
cc->setSitting(standState >= 1 && standState <= 8 && standState != 7);
|
|
}
|
|
|
|
auto* cr = renderer->getCharacterRenderer();
|
|
if (!cr) return;
|
|
uint32_t charInstId = renderer->getCharacterInstanceId();
|
|
if (charInstId == 0) return;
|
|
// WoW stand state → M2 animation ID mapping
|
|
// 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72
|
|
// Do not force Stand(0) here: locomotion state machine already owns standing/running.
|
|
// Forcing Stand on packet timing causes visible run-cycle hitching while steering.
|
|
uint32_t animId = 0;
|
|
if (standState == 0) {
|
|
return;
|
|
} else if (standState >= 1 && standState <= 6) {
|
|
animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height)
|
|
} else if (standState == 7) {
|
|
animId = 1; // Death
|
|
} else if (standState == 8) {
|
|
animId = 72; // Kneel
|
|
}
|
|
// Loop sit/kneel (not death) so the held-pose frame stays visible
|
|
const bool loop = (animId != 1);
|
|
cr->playAnimation(charInstId, animId, loop);
|
|
});
|
|
|
|
// NPC greeting callback - play voice line
|
|
gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) {
|
|
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<game::Unit>(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<game::Unit>(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<game::Unit>(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<game::Unit>(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<int>(code)) + ").", true);
|
|
}
|
|
});
|
|
}
|
|
|
|
void Application::spawnPlayerCharacter() {
|
|
if (playerCharacterSpawned) return;
|
|
if (!renderer || !renderer->getCharacterRenderer() || !renderer->getCamera()) return;
|
|
|
|
auto* charRenderer = renderer->getCharacterRenderer();
|
|
auto* camera = renderer->getCamera();
|
|
bool loaded = false;
|
|
std::string m2Path = appearanceComposer_->getPlayerModelPath(playerRace_, playerGender_);
|
|
std::string modelDir;
|
|
std::string baseName;
|
|
{
|
|
size_t slash = m2Path.rfind('\\');
|
|
if (slash != std::string::npos) {
|
|
modelDir = m2Path.substr(0, slash + 1);
|
|
baseName = m2Path.substr(slash + 1);
|
|
} else {
|
|
baseName = m2Path;
|
|
}
|
|
size_t dot = baseName.rfind('.');
|
|
if (dot != std::string::npos) {
|
|
baseName = baseName.substr(0, dot);
|
|
}
|
|
}
|
|
|
|
// Try loading selected character model from MPQ
|
|
if (assetManager && assetManager->isInitialized()) {
|
|
auto m2Data = assetManager->readFile(m2Path);
|
|
if (!m2Data.empty()) {
|
|
auto model = pipeline::M2Loader::load(m2Data);
|
|
|
|
// Load skin file for submesh/batch data
|
|
std::string skinPath = modelDir + baseName + "00.skin";
|
|
auto skinData = assetManager->readFile(skinPath);
|
|
if (!skinData.empty() && model.version >= 264) {
|
|
pipeline::M2Loader::loadSkin(skinData, model);
|
|
}
|
|
|
|
if (model.isValid()) {
|
|
// Log texture slots
|
|
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
|
auto& tex = model.textures[ti];
|
|
LOG_INFO(" Texture ", ti, ": type=", tex.type, " name='", tex.filename, "'");
|
|
}
|
|
|
|
// Resolve textures from CharSections.dbc via AppearanceComposer
|
|
PlayerTextureInfo texInfo;
|
|
bool useCharSections = true;
|
|
if (appearanceComposer_) {
|
|
uint32_t appearanceBytes = 0;
|
|
if (gameHandler) {
|
|
const game::Character* activeChar = gameHandler->getActiveCharacter();
|
|
if (activeChar) {
|
|
appearanceBytes = activeChar->appearanceBytes;
|
|
}
|
|
}
|
|
texInfo = appearanceComposer_->resolvePlayerTextures(model, playerRace_, playerGender_, appearanceBytes);
|
|
}
|
|
|
|
// Load external .anim files for sequences with external data.
|
|
// Sequences WITH flag 0x20 have their animation data inline in the M2 file.
|
|
// Sequences WITHOUT flag 0x20 store data in external .anim files.
|
|
for (uint32_t si = 0; si < model.sequences.size(); si++) {
|
|
if (!(model.sequences[si].flags & 0x20)) {
|
|
// File naming: <ModelPath><AnimId>-<VariationIndex>.anim
|
|
// e.g. Character\Human\Male\HumanMale0097-00.anim
|
|
char animFileName[256];
|
|
snprintf(animFileName, sizeof(animFileName),
|
|
"%s%s%04u-%02u.anim",
|
|
modelDir.c_str(),
|
|
baseName.c_str(),
|
|
model.sequences[si].id,
|
|
model.sequences[si].variationIndex);
|
|
auto animFileData = assetManager->readFileOptional(animFileName);
|
|
if (!animFileData.empty()) {
|
|
pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model);
|
|
}
|
|
}
|
|
}
|
|
|
|
charRenderer->loadModel(model, 1);
|
|
|
|
// Apply composited textures via AppearanceComposer (saves skin state for re-compositing)
|
|
if (useCharSections && appearanceComposer_) {
|
|
appearanceComposer_->compositePlayerSkin(1, texInfo);
|
|
}
|
|
|
|
loaded = true;
|
|
LOG_INFO("Loaded character model: ", m2Path, " (", model.vertices.size(), " verts, ",
|
|
model.bones.size(), " bones, ", model.sequences.size(), " anims, ",
|
|
model.indices.size(), " indices, ", model.batches.size(), " batches");
|
|
// Log all animation sequence IDs
|
|
for (size_t i = 0; i < model.sequences.size(); i++) {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: create a simple cube if MPQ not available
|
|
if (!loaded) {
|
|
pipeline::M2Model testModel;
|
|
float size = 2.0f;
|
|
glm::vec3 cubePos[] = {
|
|
{-size, -size, -size}, { size, -size, -size},
|
|
{ size, size, -size}, {-size, size, -size},
|
|
{-size, -size, size}, { size, -size, size},
|
|
{ size, size, size}, {-size, size, size}
|
|
};
|
|
for (const auto& pos : cubePos) {
|
|
pipeline::M2Vertex v;
|
|
v.position = pos;
|
|
v.normal = glm::normalize(pos);
|
|
v.texCoords[0] = glm::vec2(0.0f);
|
|
v.boneWeights[0] = 255;
|
|
v.boneWeights[1] = v.boneWeights[2] = v.boneWeights[3] = 0;
|
|
v.boneIndices[0] = 0;
|
|
v.boneIndices[1] = v.boneIndices[2] = v.boneIndices[3] = 0;
|
|
testModel.vertices.push_back(v);
|
|
}
|
|
uint16_t cubeIndices[] = {
|
|
0,1,2, 0,2,3, 4,6,5, 4,7,6,
|
|
0,4,5, 0,5,1, 2,6,7, 2,7,3,
|
|
0,3,7, 0,7,4, 1,5,6, 1,6,2
|
|
};
|
|
for (uint16_t idx : cubeIndices)
|
|
testModel.indices.push_back(idx);
|
|
|
|
pipeline::M2Bone bone;
|
|
bone.keyBoneId = -1;
|
|
bone.flags = 0;
|
|
bone.parentBone = -1;
|
|
bone.submeshId = 0;
|
|
bone.pivot = glm::vec3(0.0f);
|
|
testModel.bones.push_back(bone);
|
|
|
|
pipeline::M2Sequence seq{};
|
|
seq.id = 0;
|
|
seq.duration = 1000;
|
|
testModel.sequences.push_back(seq);
|
|
|
|
testModel.name = "TestCube";
|
|
testModel.globalFlags = 0;
|
|
charRenderer->loadModel(testModel, 1);
|
|
LOG_INFO("Loaded fallback cube model (no MPQ data)");
|
|
}
|
|
|
|
// Spawn character at the camera controller's default position (matches hearthstone).
|
|
// Most presets snap to floor; explicit WMO-floor presets keep their authored Z.
|
|
auto* camCtrl = renderer->getCameraController();
|
|
glm::vec3 spawnPos = camCtrl ? camCtrl->getDefaultPosition()
|
|
: (camera->getPosition() - glm::vec3(0.0f, 0.0f, 5.0f));
|
|
if (spawnSnapToGround && renderer->getTerrainManager()) {
|
|
auto terrainH = renderer->getTerrainManager()->getHeightAt(spawnPos.x, spawnPos.y);
|
|
if (terrainH) {
|
|
spawnPos.z = *terrainH + 0.1f;
|
|
}
|
|
}
|
|
uint32_t instanceId = charRenderer->createInstance(1, spawnPos,
|
|
glm::vec3(0.0f), 1.0f); // Scale 1.0 = normal WoW character size
|
|
|
|
if (instanceId > 0) {
|
|
// Set up third-person follow
|
|
renderer->getCharacterPosition() = spawnPos;
|
|
renderer->setCharacterFollow(instanceId);
|
|
|
|
// Build default geosets for the active character via AppearanceComposer
|
|
uint8_t hairStyleId = 0;
|
|
uint8_t facialId = 0;
|
|
if (gameHandler) {
|
|
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
|
|
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
|
|
facialId = ch->facialFeatures;
|
|
}
|
|
}
|
|
auto activeGeosets = appearanceComposer_
|
|
? appearanceComposer_->buildDefaultPlayerGeosets(hairStyleId, facialId)
|
|
: std::unordered_set<uint16_t>{};
|
|
charRenderer->setActiveGeosets(instanceId, activeGeosets);
|
|
|
|
// Play idle animation (Stand = animation ID 0)
|
|
charRenderer->playAnimation(instanceId, 0, true);
|
|
LOG_INFO("Spawned player character at (",
|
|
static_cast<int>(spawnPos.x), ", ",
|
|
static_cast<int>(spawnPos.y), ", ",
|
|
static_cast<int>(spawnPos.z), ")");
|
|
playerCharacterSpawned = true;
|
|
|
|
// Set voice profile to match character race/gender
|
|
if (auto* asm_ = audioCoordinator_ ? audioCoordinator_->getActivitySoundManager() : nullptr) {
|
|
const char* raceFolder = "Human";
|
|
const char* raceBase = "Human";
|
|
switch (playerRace_) {
|
|
case game::Race::HUMAN: raceFolder = "Human"; raceBase = "Human"; break;
|
|
case game::Race::ORC: raceFolder = "Orc"; raceBase = "Orc"; break;
|
|
case game::Race::DWARF: raceFolder = "Dwarf"; raceBase = "Dwarf"; break;
|
|
case game::Race::NIGHT_ELF: raceFolder = "NightElf"; raceBase = "NightElf"; break;
|
|
case game::Race::UNDEAD: raceFolder = "Scourge"; raceBase = "Scourge"; break;
|
|
case game::Race::TAUREN: raceFolder = "Tauren"; raceBase = "Tauren"; break;
|
|
case game::Race::GNOME: raceFolder = "Gnome"; raceBase = "Gnome"; break;
|
|
case game::Race::TROLL: raceFolder = "Troll"; raceBase = "Troll"; break;
|
|
case game::Race::BLOOD_ELF: raceFolder = "BloodElf"; raceBase = "BloodElf"; break;
|
|
case game::Race::DRAENEI: raceFolder = "Draenei"; raceBase = "Draenei"; break;
|
|
default: break;
|
|
}
|
|
bool useFemaleVoice = (playerGender_ == game::Gender::FEMALE);
|
|
if (playerGender_ == game::Gender::NONBINARY && gameHandler) {
|
|
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
|
|
useFemaleVoice = ch->useFemaleModel;
|
|
}
|
|
}
|
|
asm_->setCharacterVoiceProfile(std::string(raceFolder), std::string(raceBase), !useFemaleVoice);
|
|
}
|
|
|
|
// Track which character's appearance this instance represents so we can
|
|
// respawn if the user logs into a different character without restarting.
|
|
spawnedPlayerGuid_ = gameHandler ? gameHandler->getActiveCharacterGuid() : 0;
|
|
spawnedAppearanceBytes_ = 0;
|
|
spawnedFacialFeatures_ = 0;
|
|
if (gameHandler) {
|
|
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
|
|
spawnedAppearanceBytes_ = ch->appearanceBytes;
|
|
spawnedFacialFeatures_ = ch->facialFeatures;
|
|
}
|
|
}
|
|
|
|
// Set up camera controller for first-person player hiding
|
|
if (renderer->getCameraController()) {
|
|
renderer->getCameraController()->setCharacterRenderer(charRenderer, instanceId);
|
|
}
|
|
|
|
// Load equipped weapons (sword + shield)
|
|
if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
|
|
}
|
|
}
|
|
|
|
void Application::buildFactionHostilityMap(uint8_t playerRace) {
|
|
if (!assetManager || !assetManager->isInitialized() || !gameHandler) return;
|
|
|
|
auto ftDbc = assetManager->loadDBC("FactionTemplate.dbc");
|
|
auto fDbc = assetManager->loadDBC("Faction.dbc");
|
|
if (!ftDbc || !ftDbc->isLoaded()) return;
|
|
|
|
// Race enum → race mask bit: race 1=0x1, 2=0x2, 3=0x4, 4=0x8, 5=0x10, 6=0x20, 7=0x40, 8=0x80, 10=0x200, 11=0x400
|
|
uint32_t playerRaceMask = 0;
|
|
if (playerRace >= 1 && playerRace <= 8) {
|
|
playerRaceMask = 1u << (playerRace - 1);
|
|
} else if (playerRace == 10) {
|
|
playerRaceMask = 0x200; // Blood Elf
|
|
} else if (playerRace == 11) {
|
|
playerRaceMask = 0x400; // Draenei
|
|
}
|
|
|
|
// Race → player faction template ID
|
|
// Human=1, Orc=2, Dwarf=3, NightElf=4, Undead=5, Tauren=6, Gnome=115, Troll=116, BloodElf=1610, Draenei=1629
|
|
uint32_t playerFtId = 0;
|
|
switch (playerRace) {
|
|
case 1: playerFtId = 1; break; // Human
|
|
case 2: playerFtId = 2; break; // Orc
|
|
case 3: playerFtId = 3; break; // Dwarf
|
|
case 4: playerFtId = 4; break; // Night Elf
|
|
case 5: playerFtId = 5; break; // Undead
|
|
case 6: playerFtId = 6; break; // Tauren
|
|
case 7: playerFtId = 115; break; // Gnome
|
|
case 8: playerFtId = 116; break; // Troll
|
|
case 10: playerFtId = 1610; break; // Blood Elf
|
|
case 11: playerFtId = 1629; break; // Draenei
|
|
default: playerFtId = 1; break;
|
|
}
|
|
|
|
// Build set of hostile parent faction IDs from Faction.dbc base reputation
|
|
const auto* facL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr;
|
|
const auto* ftL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("FactionTemplate") : nullptr;
|
|
std::unordered_set<uint32_t> hostileParentFactions;
|
|
if (fDbc && fDbc->isLoaded()) {
|
|
const uint32_t facID = facL ? (*facL)["ID"] : 0;
|
|
const uint32_t facRaceMask0 = facL ? (*facL)["ReputationRaceMask0"] : 2;
|
|
const uint32_t facBase0 = facL ? (*facL)["ReputationBase0"] : 10;
|
|
for (uint32_t i = 0; i < fDbc->getRecordCount(); i++) {
|
|
uint32_t factionId = fDbc->getUInt32(i, facID);
|
|
for (int slot = 0; slot < 4; slot++) {
|
|
uint32_t raceMask = fDbc->getUInt32(i, facRaceMask0 + slot);
|
|
if (raceMask & playerRaceMask) {
|
|
int32_t baseRep = fDbc->getInt32(i, facBase0 + slot);
|
|
if (baseRep < 0) {
|
|
hostileParentFactions.insert(factionId);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
LOG_INFO("Faction.dbc: ", hostileParentFactions.size(), " factions hostile to race ", static_cast<int>(playerRace));
|
|
}
|
|
|
|
// Get player faction template data
|
|
const uint32_t ftID = ftL ? (*ftL)["ID"] : 0;
|
|
const uint32_t ftFaction = ftL ? (*ftL)["Faction"] : 1;
|
|
const uint32_t ftFG = ftL ? (*ftL)["FactionGroup"] : 3;
|
|
const uint32_t ftFriend = ftL ? (*ftL)["FriendGroup"] : 4;
|
|
const uint32_t ftEnemy = ftL ? (*ftL)["EnemyGroup"] : 5;
|
|
const uint32_t ftEnemy0 = ftL ? (*ftL)["Enemy0"] : 6;
|
|
uint32_t playerFriendGroup = 0;
|
|
uint32_t playerEnemyGroup = 0;
|
|
uint32_t playerFactionId = 0;
|
|
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
|
|
if (ftDbc->getUInt32(i, ftID) == playerFtId) {
|
|
playerFriendGroup = ftDbc->getUInt32(i, ftFriend) | ftDbc->getUInt32(i, ftFG);
|
|
playerEnemyGroup = ftDbc->getUInt32(i, ftEnemy);
|
|
playerFactionId = ftDbc->getUInt32(i, ftFaction);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Build hostility map for each faction template
|
|
std::unordered_map<uint32_t, bool> factionMap;
|
|
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
|
|
uint32_t id = ftDbc->getUInt32(i, ftID);
|
|
uint32_t parentFaction = ftDbc->getUInt32(i, ftFaction);
|
|
uint32_t factionGroup = ftDbc->getUInt32(i, ftFG);
|
|
uint32_t friendGroup = ftDbc->getUInt32(i, ftFriend);
|
|
uint32_t enemyGroup = ftDbc->getUInt32(i, ftEnemy);
|
|
|
|
// 1. Symmetric group check
|
|
bool hostile = (enemyGroup & playerFriendGroup) != 0
|
|
|| (factionGroup & playerEnemyGroup) != 0;
|
|
|
|
// 2. Monster factionGroup bit (8)
|
|
if (!hostile && (factionGroup & 8) != 0) {
|
|
hostile = true;
|
|
}
|
|
|
|
// 3. Individual enemy faction IDs
|
|
if (!hostile && playerFactionId > 0) {
|
|
for (uint32_t e = ftEnemy0; e <= ftEnemy0 + 3; e++) {
|
|
if (ftDbc->getUInt32(i, e) == playerFactionId) {
|
|
hostile = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Parent faction base reputation check (Faction.dbc)
|
|
if (!hostile && parentFaction > 0) {
|
|
if (hostileParentFactions.count(parentFaction)) {
|
|
hostile = true;
|
|
}
|
|
}
|
|
|
|
// 5. If explicitly friendly (friendGroup includes player), override to non-hostile
|
|
if (hostile && (friendGroup & playerFriendGroup) != 0) {
|
|
hostile = false;
|
|
}
|
|
|
|
factionMap[id] = hostile;
|
|
}
|
|
|
|
uint32_t hostileCount = 0;
|
|
for (const auto& [fid, h] : factionMap) { if (h) hostileCount++; }
|
|
gameHandler->setFactionHostileMap(std::move(factionMap));
|
|
LOG_INFO("Faction hostility for race ", static_cast<int>(playerRace), " (FT ", playerFtId, "): ",
|
|
hostileCount, "/", ftDbc->getRecordCount(),
|
|
" hostile (friendGroup=0x", std::hex, playerFriendGroup, ", enemyGroup=0x", playerEnemyGroup, std::dec, ")");
|
|
}
|
|
|
|
// Render bounds/position queries — delegates to EntitySpawner
|
|
bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const {
|
|
if (entitySpawner_) return entitySpawner_->getRenderBoundsForGuid(guid, outCenter, outRadius);
|
|
return false;
|
|
}
|
|
|
|
bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const {
|
|
if (entitySpawner_) return entitySpawner_->getRenderFootZForGuid(guid, outFootZ);
|
|
return false;
|
|
}
|
|
|
|
bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const {
|
|
if (entitySpawner_) return entitySpawner_->getRenderPositionForGuid(guid, outPos);
|
|
return false;
|
|
}
|
|
|
|
void Application::loadQuestMarkerModels() {
|
|
if (!assetManager || !renderer) return;
|
|
|
|
// Quest markers are billboard sprites; the renderer's QuestMarkerRenderer handles
|
|
// texture loading and pipeline setup during world initialization.
|
|
// Calling initialize() here is a no-op if already done; harmless if called early.
|
|
if (auto* qmr = renderer->getQuestMarkerRenderer()) {
|
|
if (auto* vkCtx = renderer->getVkContext()) {
|
|
VkDescriptorSetLayout pfl = renderer->getPerFrameSetLayout();
|
|
if (pfl != VK_NULL_HANDLE) {
|
|
qmr->initialize(vkCtx, pfl, assetManager.get());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void Application::updateQuestMarkers() {
|
|
if (!gameHandler || !renderer) {
|
|
return;
|
|
}
|
|
|
|
auto* questMarkerRenderer = renderer->getQuestMarkerRenderer();
|
|
if (!questMarkerRenderer) {
|
|
static bool logged = false;
|
|
if (!logged) {
|
|
LOG_WARNING("QuestMarkerRenderer not available!");
|
|
logged = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const auto& questStatuses = gameHandler->getNpcQuestStatuses();
|
|
|
|
// Clear all markers (we'll re-add active ones)
|
|
questMarkerRenderer->clear();
|
|
|
|
static bool firstRun = true;
|
|
int markersAdded = 0;
|
|
|
|
// Add markers for NPCs with quest status
|
|
for (const auto& [guid, status] : questStatuses) {
|
|
// Determine marker type
|
|
int markerType = -1; // -1 = no marker
|
|
|
|
using game::QuestGiverStatus;
|
|
float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests)
|
|
switch (status) {
|
|
case QuestGiverStatus::AVAILABLE:
|
|
markerType = 0; // Yellow !
|
|
break;
|
|
case QuestGiverStatus::AVAILABLE_LOW:
|
|
markerType = 0; // Grey ! (same texture, desaturated in shader)
|
|
markerGrayscale = 1.0f;
|
|
break;
|
|
case QuestGiverStatus::REWARD:
|
|
case QuestGiverStatus::REWARD_REP:
|
|
markerType = 1; // Yellow ?
|
|
break;
|
|
case QuestGiverStatus::INCOMPLETE:
|
|
markerType = 2; // Grey ?
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (markerType < 0) continue;
|
|
|
|
// Get NPC entity position
|
|
auto entity = gameHandler->getEntityManager().getEntity(guid);
|
|
if (!entity) continue;
|
|
if (entity->getType() == game::ObjectType::UNIT) {
|
|
auto unit = std::static_pointer_cast<game::Unit>(entity);
|
|
std::string name = unit->getName();
|
|
std::transform(name.begin(), name.end(), name.begin(),
|
|
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
|
if (name.find("spirit healer") != std::string::npos ||
|
|
name.find("spirit guide") != std::string::npos) {
|
|
continue; // Spirit healers/guides use their own white visual cue.
|
|
}
|
|
}
|
|
|
|
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
|
|
glm::vec3 renderPos = coords::canonicalToRender(canonical);
|
|
|
|
// Get NPC bounding height for proper marker positioning
|
|
glm::vec3 boundsCenter;
|
|
float boundsRadius = 0.0f;
|
|
float boundingHeight = 2.0f; // Default
|
|
if (getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
|
|
boundingHeight = boundsRadius * 2.0f;
|
|
}
|
|
|
|
// Set the marker (renderer will handle positioning, bob, glow, etc.)
|
|
questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale);
|
|
markersAdded++;
|
|
}
|
|
|
|
if (firstRun && markersAdded > 0) {
|
|
LOG_DEBUG("Quest markers: Added ", markersAdded, " markers on first run");
|
|
firstRun = false;
|
|
}
|
|
}
|
|
|
|
void Application::setupTestTransport() {
|
|
if (!entitySpawner_) return;
|
|
if (entitySpawner_->isTestTransportSetup()) return;
|
|
if (!gameHandler || !renderer || !assetManager) return;
|
|
|
|
auto* transportManager = gameHandler->getTransportManager();
|
|
auto* wmoRenderer = renderer->getWMORenderer();
|
|
if (!transportManager || !wmoRenderer) return;
|
|
|
|
LOG_INFO("========================================");
|
|
LOG_INFO(" SETTING UP TEST TRANSPORT");
|
|
LOG_INFO("========================================");
|
|
|
|
// Connect transport manager to WMO renderer
|
|
transportManager->setWMORenderer(wmoRenderer);
|
|
|
|
// Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents)
|
|
if (renderer->getM2Renderer()) {
|
|
wmoRenderer->setM2Renderer(renderer->getM2Renderer());
|
|
LOG_INFO("WMORenderer connected to M2Renderer for test transport doodad transforms");
|
|
}
|
|
|
|
// Define a simple circular path around Stormwind harbor (canonical coordinates)
|
|
// These coordinates are approximate - adjust based on actual harbor layout
|
|
std::vector<glm::vec3> harborPath = {
|
|
{-8833.0f, 628.0f, 94.0f}, // Start point (Stormwind harbor)
|
|
{-8900.0f, 650.0f, 94.0f}, // Move west
|
|
{-8950.0f, 700.0f, 94.0f}, // Northwest
|
|
{-8950.0f, 780.0f, 94.0f}, // North
|
|
{-8900.0f, 830.0f, 94.0f}, // Northeast
|
|
{-8833.0f, 850.0f, 94.0f}, // East
|
|
{-8766.0f, 830.0f, 94.0f}, // Southeast
|
|
{-8716.0f, 780.0f, 94.0f}, // South
|
|
{-8716.0f, 700.0f, 94.0f}, // Southwest
|
|
{-8766.0f, 650.0f, 94.0f}, // Back to start direction
|
|
};
|
|
|
|
// Register the path with transport manager
|
|
uint32_t pathId = 1;
|
|
float speed = 12.0f; // 12 units/sec (slower than taxi for a leisurely boat ride)
|
|
transportManager->loadPathFromNodes(pathId, harborPath, true, speed);
|
|
LOG_INFO("Registered transport path ", pathId, " with ", harborPath.size(), " waypoints, speed=", speed);
|
|
|
|
// Try transport WMOs in manifest-backed paths first.
|
|
std::vector<std::string> transportCandidates = {
|
|
"World\\wmo\\transports\\transport_ship\\transportship.wmo",
|
|
"World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo",
|
|
"World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo",
|
|
"World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo",
|
|
// Legacy fallbacks
|
|
"Transports\\Transportship\\Transportship.wmo",
|
|
"Transports\\Boat\\Boat.wmo",
|
|
};
|
|
|
|
std::string transportWmoPath;
|
|
std::vector<uint8_t> wmoData;
|
|
for (const auto& candidate : transportCandidates) {
|
|
wmoData = assetManager->readFile(candidate);
|
|
if (!wmoData.empty()) {
|
|
transportWmoPath = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (wmoData.empty()) {
|
|
LOG_WARNING("No transport WMO found - test transport disabled");
|
|
LOG_INFO("Expected under World\\wmo\\transports\\...");
|
|
return;
|
|
}
|
|
|
|
LOG_INFO("Using transport WMO: ", transportWmoPath);
|
|
|
|
// Load WMO model
|
|
pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData);
|
|
LOG_INFO("Transport WMO root loaded: ", transportWmoPath, " nGroups=", wmoModel.nGroups);
|
|
|
|
// Load WMO groups
|
|
int loadedGroups = 0;
|
|
if (wmoModel.nGroups > 0) {
|
|
std::string basePath = transportWmoPath.substr(0, transportWmoPath.size() - 4);
|
|
|
|
for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) {
|
|
char groupSuffix[16];
|
|
snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi);
|
|
std::string groupPath = basePath + groupSuffix;
|
|
std::vector<uint8_t> groupData = assetManager->readFile(groupPath);
|
|
|
|
if (!groupData.empty()) {
|
|
pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi);
|
|
loadedGroups++;
|
|
} else {
|
|
LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (loadedGroups == 0 && wmoModel.nGroups > 0) {
|
|
LOG_WARNING("Failed to load any WMO groups for transport");
|
|
return;
|
|
}
|
|
|
|
// Load WMO into renderer
|
|
uint32_t wmoModelId = 99999; // Use high ID to avoid conflicts
|
|
if (!wmoRenderer->loadModel(wmoModel, wmoModelId)) {
|
|
LOG_WARNING("Failed to load transport WMO model into renderer");
|
|
return;
|
|
}
|
|
|
|
// Create WMO instance at first waypoint (convert canonical to render coords)
|
|
glm::vec3 startCanonical = harborPath[0];
|
|
glm::vec3 startRender = core::coords::canonicalToRender(startCanonical);
|
|
|
|
uint32_t wmoInstanceId = wmoRenderer->createInstance(wmoModelId, startRender,
|
|
glm::vec3(0.0f, 0.0f, 0.0f), 1.0f);
|
|
|
|
if (wmoInstanceId == 0) {
|
|
LOG_WARNING("Failed to create transport WMO instance");
|
|
return;
|
|
}
|
|
|
|
// Register transport with transport manager
|
|
uint64_t transportGuid = 0x1000000000000001ULL; // Fake GUID for test
|
|
transportManager->registerTransport(transportGuid, wmoInstanceId, pathId, startCanonical);
|
|
|
|
// Optional: Set deck bounds (rough estimate for a ship deck)
|
|
transportManager->setDeckBounds(transportGuid,
|
|
glm::vec3(-15.0f, -30.0f, 0.0f),
|
|
glm::vec3(15.0f, 30.0f, 10.0f));
|
|
|
|
entitySpawner_->setTestTransportSetup(true);
|
|
LOG_INFO("========================================");
|
|
LOG_INFO("Test transport registered:");
|
|
LOG_INFO(" GUID: 0x", std::hex, transportGuid, std::dec);
|
|
LOG_INFO(" WMO Instance: ", wmoInstanceId);
|
|
LOG_INFO(" Path: ", pathId, " (", harborPath.size(), " waypoints)");
|
|
LOG_INFO(" Speed: ", speed, " units/sec");
|
|
LOG_INFO("========================================");
|
|
LOG_INFO("");
|
|
LOG_INFO("To board the transport, use console command:");
|
|
LOG_INFO(" /transport board");
|
|
LOG_INFO("To disembark:");
|
|
LOG_INFO(" /transport leave");
|
|
LOG_INFO("========================================");
|
|
}
|
|
|
|
} // namespace core
|
|
} // namespace wowee
|