2026-02-02 12:24:50 -08:00
|
|
|
#include "core/application.hpp"
|
2026-02-04 17:37:28 -08:00
|
|
|
#include "core/coordinates.hpp"
|
2026-02-06 17:15:46 -08:00
|
|
|
#include <unordered_set>
|
2026-02-07 23:34:28 -08:00
|
|
|
#include <cmath>
|
2026-03-02 14:45:49 -08:00
|
|
|
#include <chrono>
|
2026-02-04 18:27:52 -08:00
|
|
|
#include "core/spawn_presets.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "core/logger.hpp"
|
2026-02-08 23:15:26 -08:00
|
|
|
#include "core/memory_monitor.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "rendering/renderer.hpp"
|
2026-03-02 08:47:06 -08:00
|
|
|
#include "rendering/vk_context.hpp"
|
2026-02-09 01:29:44 -08:00
|
|
|
#include "audio/npc_voice_manager.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#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"
|
2026-02-07 19:44:03 -08:00
|
|
|
#include "rendering/m2_renderer.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "rendering/minimap.hpp"
|
2026-02-09 23:41:38 -08:00
|
|
|
#include "rendering/quest_marker_renderer.hpp"
|
2026-02-03 13:33:31 -08:00
|
|
|
#include "rendering/loading_screen.hpp"
|
2026-02-05 15:59:06 -08:00
|
|
|
#include "audio/music_manager.hpp"
|
2026-02-05 17:55:30 -08:00
|
|
|
#include "audio/footstep_manager.hpp"
|
|
|
|
|
#include "audio/activity_sound_manager.hpp"
|
2026-02-19 21:13:13 -08:00
|
|
|
#include "audio/audio_engine.hpp"
|
2026-04-01 20:38:37 +03:00
|
|
|
#include "audio/audio_coordinator.hpp"
|
2026-03-20 11:12:07 -07:00
|
|
|
#include "addons/addon_manager.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include <imgui.h>
|
|
|
|
|
#include "pipeline/m2_loader.hpp"
|
|
|
|
|
#include "pipeline/wmo_loader.hpp"
|
2026-02-26 17:56:11 -08:00
|
|
|
#include "pipeline/wdt_loader.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "pipeline/dbc_loader.hpp"
|
|
|
|
|
#include "ui/ui_manager.hpp"
|
`chore(application): extract appearance controller and unify UI flow`
- Refactor UI application architecture: extracted appearance controller into ui_services.hpp + implementation updates
- Update UI components and managers to use new service layer:
- `action_bar_panel`, `auth_screen`, `character_screen`, `chat_panel`, `combat_ui`, `dialog_manager`, `game_screen`, `settings_panel`, `social_panel`, `toast_manager`, `ui_manager`, `window_manager`
- Adjust core application entrypoints:
- application.cpp
- Update component implementations for new controller flow:
- action_bar_panel.cpp, `chat_panel.cpp`, `combat_ui.cpp`, `dialog_manager.cpp`, `game_screen.cpp`, `settings_panel.cpp`, `social_panel.cpp`, `toast_manager.cpp`, `window_manager.cpp`
These staged changes implement a major architectural refactor for UI/appearance controller separation
2026-04-01 20:59:17 +03:00
|
|
|
#include "ui/ui_services.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "auth/auth_handler.hpp"
|
|
|
|
|
#include "game/game_handler.hpp"
|
2026-02-10 21:29:10 -08:00
|
|
|
#include "game/transport_manager.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "game/world.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
#include "game/expansion_profile.hpp"
|
|
|
|
|
#include "game/packet_parsers.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
#include "pipeline/asset_manager.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
#include "pipeline/dbc_layout.hpp"
|
2026-02-15 04:18:34 -08:00
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
#include <SDL2/SDL.h>
|
|
|
|
|
#include <cstdlib>
|
2026-03-07 18:40:24 -08:00
|
|
|
#include <climits>
|
2026-02-04 17:37:28 -08:00
|
|
|
#include <algorithm>
|
|
|
|
|
#include <cctype>
|
|
|
|
|
#include <optional>
|
|
|
|
|
#include <sstream>
|
2026-02-02 12:24:50 -08:00
|
|
|
#include <set>
|
2026-02-12 22:56:36 -08:00
|
|
|
#include <filesystem>
|
2026-03-07 13:44:09 -08:00
|
|
|
#include <fstream>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-25 03:39:45 -08:00
|
|
|
#include <thread>
|
|
|
|
|
#ifdef __linux__
|
|
|
|
|
#include <sched.h>
|
|
|
|
|
#include <pthread.h>
|
2026-02-25 03:41:18 -08:00
|
|
|
#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>
|
2026-02-25 03:39:45 -08:00
|
|
|
#endif
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
namespace wowee {
|
|
|
|
|
namespace core {
|
|
|
|
|
|
2026-02-22 07:45:49 -08:00
|
|
|
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');
|
|
|
|
|
}
|
2026-03-27 18:42:48 -07:00
|
|
|
|
2026-02-22 07:45:49 -08:00
|
|
|
} // namespace
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
Application* Application::instance = nullptr;
|
|
|
|
|
|
|
|
|
|
Application::Application() {
|
|
|
|
|
instance = this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Application::~Application() {
|
|
|
|
|
shutdown();
|
|
|
|
|
instance = nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Application::initialize() {
|
2026-02-02 23:22:58 -08:00
|
|
|
LOG_INFO("Initializing Wowee Native Client");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-08 23:15:26 -08:00
|
|
|
// Initialize memory monitoring for dynamic cache sizing
|
|
|
|
|
core::MemoryMonitor::getInstance().initialize();
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Create window
|
|
|
|
|
WindowConfig windowConfig;
|
2026-02-02 23:03:45 -08:00
|
|
|
windowConfig.title = "Wowee";
|
|
|
|
|
windowConfig.width = 1280;
|
|
|
|
|
windowConfig.height = 720;
|
|
|
|
|
windowConfig.vsync = false;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:21:21 +03:00
|
|
|
// Create and initialize audio coordinator (owns all audio managers)
|
|
|
|
|
audioCoordinator_ = std::make_unique<audio::AudioCoordinator>();
|
|
|
|
|
audioCoordinator_->initialize();
|
|
|
|
|
renderer->setAudioCoordinator(audioCoordinator_.get());
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// 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>();
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
// Create and initialize expansion registry
|
|
|
|
|
expansionRegistry_ = std::make_unique<game::ExpansionRegistry>();
|
|
|
|
|
|
|
|
|
|
// Create DBC layout
|
|
|
|
|
dbcLayout_ = std::make_unique<pipeline::DBCLayout>();
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Create asset manager
|
|
|
|
|
assetManager = std::make_unique<pipeline::AssetManager>();
|
|
|
|
|
|
[refactor] Break Application::getInstance() from GameHandler
Introduce `GameServices` struct — an explicit dependency bundle that
`Application` populates and passes to `GameHandler` at construction time.
Eliminates all 47 hidden `Application::getInstance()` calls in
`src/game/*.cpp`, completing SOLID-D (dependency-inversion) cleanup.
Changes:
- New `include/game/game_services.hpp` — `struct GameServices` carrying
pointers to `Renderer`, `AssetManager`, `ExpansionRegistry`, and two
taxi-mount display IDs
- `GameHandler(GameServices&)` replaces default constructor; exposes
`services() const` accessor for domain handlers
- `Application` holds `game::GameServices gameServices_`; populates it
after all subsystems are created, then constructs `GameHandler`
(fixes latent init-order bug: `GameHandler` was previously created
before `AssetManager` / `ExpansionRegistry`)
- `game_handler.cpp`: duplicate `isActiveExpansion` / `isClassicLikeExpansion` /
`isPreWotlk` anonymous-namespace helpers removed; `game_utils.hpp`
included instead
- All domain handlers (`InventoryHandler`, `SpellHandler`, `MovementHandler`,
`CombatHandler`, `QuestHandler`, `SocialHandler`, `WardenHandler`) replace
`Application::getInstance().getXxx()` with `owner_.services().xxx`
2026-03-30 09:17:42 +03:00
|
|
|
// Populate game services — all subsystems now available
|
|
|
|
|
gameServices_.renderer = renderer.get();
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
gameServices_.audioCoordinator = audioCoordinator_.get();
|
[refactor] Break Application::getInstance() from GameHandler
Introduce `GameServices` struct — an explicit dependency bundle that
`Application` populates and passes to `GameHandler` at construction time.
Eliminates all 47 hidden `Application::getInstance()` calls in
`src/game/*.cpp`, completing SOLID-D (dependency-inversion) cleanup.
Changes:
- New `include/game/game_services.hpp` — `struct GameServices` carrying
pointers to `Renderer`, `AssetManager`, `ExpansionRegistry`, and two
taxi-mount display IDs
- `GameHandler(GameServices&)` replaces default constructor; exposes
`services() const` accessor for domain handlers
- `Application` holds `game::GameServices gameServices_`; populates it
after all subsystems are created, then constructs `GameHandler`
(fixes latent init-order bug: `GameHandler` was previously created
before `AssetManager` / `ExpansionRegistry`)
- `game_handler.cpp`: duplicate `isActiveExpansion` / `isClassicLikeExpansion` /
`isPreWotlk` anonymous-namespace helpers removed; `game_utils.hpp`
included instead
- All domain handlers (`InventoryHandler`, `SpellHandler`, `MovementHandler`,
`CombatHandler`, `QuestHandler`, `SocialHandler`, `WardenHandler`) replace
`Application::getInstance().getXxx()` with `owner_.services().xxx`
2026-03-30 09:17:42 +03:00
|
|
|
gameServices_.assetManager = assetManager.get();
|
|
|
|
|
gameServices_.expansionRegistry = expansionRegistry_.get();
|
|
|
|
|
|
|
|
|
|
// Create game handler with explicit service dependencies
|
|
|
|
|
gameHandler = std::make_unique<game::GameHandler>(gameServices_);
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Try to get WoW data path from environment variable
|
|
|
|
|
const char* dataPathEnv = std::getenv("WOW_DATA_PATH");
|
|
|
|
|
std::string dataPath = dataPathEnv ? dataPathEnv : "./Data";
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
// 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)) {
|
2026-02-20 00:39:20 -08:00
|
|
|
LOG_ERROR("Failed to load opcodes from ", opcodesPath);
|
2026-02-12 22:56:36 -08:00
|
|
|
}
|
|
|
|
|
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
|
|
|
|
|
|
|
|
|
|
// Load expansion-specific update field table
|
|
|
|
|
std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
|
|
|
|
|
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
|
2026-02-20 00:39:20 -08:00
|
|
|
LOG_ERROR("Failed to load update fields from ", updateFieldsPath);
|
2026-02-12 22:56:36 -08:00
|
|
|
}
|
|
|
|
|
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)) {
|
2026-02-20 00:39:20 -08:00
|
|
|
LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath);
|
2026-02-12 22:56:36 -08:00
|
|
|
}
|
|
|
|
|
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()) {
|
2026-02-13 00:10:01 -08:00
|
|
|
// Enable expansion-specific CSV DBC lookup (Data/expansions/<id>/db/*.csv).
|
|
|
|
|
assetManager->setExpansionDataPath(profile->dataPath);
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
std::string expansionManifest = profile->dataPath + "/manifest.json";
|
|
|
|
|
if (std::filesystem::exists(expansionManifest)) {
|
|
|
|
|
assetPath = profile->dataPath;
|
|
|
|
|
LOG_INFO("Using expansion-specific asset path: ", assetPath);
|
2026-03-10 07:25:04 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Attempting to load WoW assets from: ", assetPath);
|
|
|
|
|
if (assetManager->initialize(assetPath)) {
|
2026-02-02 12:24:50 -08:00
|
|
|
LOG_INFO("Asset manager initialized successfully");
|
2026-02-06 14:37:31 -08:00
|
|
|
// Eagerly load creature display DBC lookups so first spawn doesn't stall
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_ = std::make_unique<EntitySpawner>(
|
|
|
|
|
renderer.get(), assetManager.get(), gameHandler.get(),
|
|
|
|
|
dbcLayout_.get(), &gameServices_);
|
|
|
|
|
entitySpawner_->initialize();
|
2026-02-11 00:54:38 -08:00
|
|
|
|
2026-04-01 13:31:48 +03:00
|
|
|
appearanceComposer_ = std::make_unique<AppearanceComposer>(
|
|
|
|
|
renderer.get(), assetManager.get(), gameHandler.get(),
|
|
|
|
|
dbcLayout_.get(), entitySpawner_.get());
|
|
|
|
|
|
2026-04-01 20:38:37 +03:00
|
|
|
// Wire AppearanceComposer to UI components (Phase A singleton breaking)
|
|
|
|
|
if (uiManager) {
|
|
|
|
|
uiManager->setAppearanceComposer(appearanceComposer_.get());
|
`chore(application): extract appearance controller and unify UI flow`
- Refactor UI application architecture: extracted appearance controller into ui_services.hpp + implementation updates
- Update UI components and managers to use new service layer:
- `action_bar_panel`, `auth_screen`, `character_screen`, `chat_panel`, `combat_ui`, `dialog_manager`, `game_screen`, `settings_panel`, `social_panel`, `toast_manager`, `ui_manager`, `window_manager`
- Adjust core application entrypoints:
- application.cpp
- Update component implementations for new controller flow:
- action_bar_panel.cpp, `chat_panel.cpp`, `combat_ui.cpp`, `dialog_manager.cpp`, `game_screen.cpp`, `settings_panel.cpp`, `social_panel.cpp`, `toast_manager.cpp`, `window_manager.cpp`
These staged changes implement a major architectural refactor for UI/appearance controller separation
2026-04-01 20:59:17 +03:00
|
|
|
|
|
|
|
|
// 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);
|
2026-04-01 20:38:37 +03:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 20:20:43 -08:00
|
|
|
// Load transport paths from TransportAnimation.dbc and TaxiPathNode.dbc
|
2026-02-11 00:54:38 -08:00
|
|
|
if (gameHandler && gameHandler->getTransportManager()) {
|
|
|
|
|
gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get());
|
2026-02-14 20:20:43 -08:00
|
|
|
gameHandler->getTransportManager()->loadTaxiPathNodeDBC(assetManager.get());
|
2026-02-11 00:54:38 -08:00
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
2026-03-20 11:12:07 -07:00
|
|
|
// Initialize addon system
|
|
|
|
|
addonManager_ = std::make_unique<addons::AddonManager>();
|
`chore(lua): refactor addon Lua engine API + progress docs`
- 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
2026-04-03 07:31:06 +03:00
|
|
|
addons::LuaServices luaSvc;
|
|
|
|
|
luaSvc.window = window.get();
|
|
|
|
|
luaSvc.audioCoordinator = audioCoordinator_.get();
|
|
|
|
|
luaSvc.expansionRegistry = expansionRegistry_.get();
|
|
|
|
|
if (addonManager_->initialize(gameHandler.get(), luaSvc)) {
|
2026-03-20 11:12:07 -07:00
|
|
|
std::string addonsDir = assetPath + "/interface/AddOns";
|
|
|
|
|
addonManager_->scanAddons(addonsDir);
|
2026-03-21 06:00:06 -07:00
|
|
|
// Wire Lua errors to UI error display
|
|
|
|
|
addonManager_->getLuaEngine()->setLuaErrorCallback([gh = gameHandler.get()](const std::string& err) {
|
|
|
|
|
if (gh) gh->addUIError(err);
|
|
|
|
|
});
|
feat: fire CHAT_MSG_* events to Lua addons for all chat types
Wire chat messages to the addon event system via AddonChatCallback.
Every chat message now fires the corresponding WoW event:
- CHAT_MSG_SAY, CHAT_MSG_YELL, CHAT_MSG_WHISPER
- CHAT_MSG_PARTY, CHAT_MSG_GUILD, CHAT_MSG_OFFICER
- CHAT_MSG_RAID, CHAT_MSG_RAID_WARNING, CHAT_MSG_BATTLEGROUND
- CHAT_MSG_SYSTEM, CHAT_MSG_CHANNEL, CHAT_MSG_EMOTE
Event handlers receive (eventName, message, senderName) arguments.
Addons can now filter, react to, or log chat messages in real-time.
2026-03-20 11:29:53 -07:00
|
|
|
// 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;
|
2026-03-21 04:28:15 -07:00
|
|
|
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;
|
2026-03-21 04:57:19 -07:00
|
|
|
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;
|
feat: fire CHAT_MSG_* events to Lua addons for all chat types
Wire chat messages to the addon event system via AddonChatCallback.
Every chat message now fires the corresponding WoW event:
- CHAT_MSG_SAY, CHAT_MSG_YELL, CHAT_MSG_WHISPER
- CHAT_MSG_PARTY, CHAT_MSG_GUILD, CHAT_MSG_OFFICER
- CHAT_MSG_RAID, CHAT_MSG_RAID_WARNING, CHAT_MSG_BATTLEGROUND
- CHAT_MSG_SYSTEM, CHAT_MSG_CHANNEL, CHAT_MSG_EMOTE
Event handlers receive (eventName, message, senderName) arguments.
Addons can now filter, react to, or log chat messages in real-time.
2026-03-20 11:29:53 -07:00
|
|
|
default: break;
|
|
|
|
|
}
|
|
|
|
|
if (eventName) {
|
|
|
|
|
addonManager_->fireEvent(eventName, {msg.message, msg.senderName});
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-20 11:51:46 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-20 13:58:54 -07:00
|
|
|
// 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;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-21 02:53:07 -07:00
|
|
|
// 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;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-21 04:16:12 -07:00
|
|
|
// 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
|
2026-03-21 04:20:58 -07:00
|
|
|
struct SpellCostEntry { uint32_t manaCost = 0; uint8_t powerType = 0; };
|
|
|
|
|
auto spellCostMap = std::make_shared<std::unordered_map<uint32_t, SpellCostEntry>>();
|
2026-03-21 04:16:12 -07:00
|
|
|
auto loaded = std::make_shared<bool>(false);
|
|
|
|
|
auto* am = assetManager.get();
|
2026-03-21 04:20:58 -07:00
|
|
|
gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, spellCostMap, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo {
|
2026-03-21 04:16:12 -07:00
|
|
|
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;
|
2026-03-21 04:20:58 -07:00
|
|
|
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();
|
2026-03-21 04:16:12 -07:00
|
|
|
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;
|
2026-03-21 04:20:58 -07:00
|
|
|
// 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};
|
2026-03-21 04:16:12 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-21 04:20:58 -07:00
|
|
|
auto mcIt = spellCostMap->find(spellId);
|
|
|
|
|
if (mcIt != spellCostMap->end()) {
|
|
|
|
|
info.manaCost = mcIt->second.manaCost;
|
|
|
|
|
info.powerType = mcIt->second.powerType;
|
|
|
|
|
}
|
2026-03-21 04:16:12 -07:00
|
|
|
return info;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-20 19:18:30 -07:00
|
|
|
// 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{};
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-20 11:12:07 -07:00
|
|
|
LOG_INFO("Addon system initialized, found ", addonManager_->getAddons().size(), " addon(s)");
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("Failed to initialize addon system");
|
|
|
|
|
addonManager_.reset();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:06:26 +03:00
|
|
|
// 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());
|
|
|
|
|
|
`chore(application): extract appearance controller and unify UI flow`
- Refactor UI application architecture: extracted appearance controller into ui_services.hpp + implementation updates
- Update UI components and managers to use new service layer:
- `action_bar_panel`, `auth_screen`, `character_screen`, `chat_panel`, `combat_ui`, `dialog_manager`, `game_screen`, `settings_panel`, `social_panel`, `toast_manager`, `ui_manager`, `window_manager`
- Adjust core application entrypoints:
- application.cpp
- Update component implementations for new controller flow:
- action_bar_panel.cpp, `chat_panel.cpp`, `combat_ui.cpp`, `dialog_manager.cpp`, `game_screen.cpp`, `settings_panel.cpp`, `social_panel.cpp`, `toast_manager.cpp`, `window_manager.cpp`
These staged changes implement a major architectural refactor for UI/appearance controller separation
2026-04-01 20:59:17 +03:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:06:26 +03:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
} 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");
|
2026-02-25 03:39:45 -08:00
|
|
|
|
|
|
|
|
// Pin main thread to a dedicated CPU core to reduce scheduling jitter
|
|
|
|
|
{
|
|
|
|
|
int numCores = static_cast<int>(std::thread::hardware_concurrency());
|
|
|
|
|
if (numCores >= 2) {
|
2026-02-25 03:41:18 -08:00
|
|
|
#ifdef __linux__
|
2026-02-25 03:39:45 -08:00
|
|
|
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, ")");
|
|
|
|
|
}
|
2026-02-25 03:41:18 -08:00
|
|
|
#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
|
2026-02-25 03:39:45 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 07:45:49 -08:00
|
|
|
const bool frameProfileEnabled = envFlagEnabled("WOWEE_FRAME_PROFILE", false);
|
|
|
|
|
if (frameProfileEnabled) {
|
|
|
|
|
LOG_INFO("Frame timing profile enabled (WOWEE_FRAME_PROFILE=1)");
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
auto lastTime = std::chrono::high_resolution_clock::now();
|
2026-03-14 07:29:39 -07:00
|
|
|
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()
|
|
|
|
|
};
|
2026-03-29 21:15:49 -07:00
|
|
|
// 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;
|
2026-03-14 07:29:39 -07:00
|
|
|
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) {
|
2026-03-29 21:15:49 -07:00
|
|
|
if (!signalledForCurrentStall) {
|
|
|
|
|
watchdogRequestRelease.store(true, std::memory_order_release);
|
2026-03-14 07:29:39 -07:00
|
|
|
LOG_WARNING("Main-loop stall detected (", stallMs,
|
2026-03-29 21:15:49 -07:00
|
|
|
"ms) — requesting mouse capture release");
|
|
|
|
|
signalledForCurrentStall = true;
|
2026-03-14 07:29:39 -07:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-03-29 21:15:49 -07:00
|
|
|
signalledForCurrentStall = false;
|
2026-03-14 07:29:39 -07:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-03-14 07:29:39 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-03-29 21:15:49 -07:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 07:29:39 -07:00
|
|
|
// 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;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 07:29:39 -07:00
|
|
|
// Poll events
|
|
|
|
|
SDL_Event event;
|
|
|
|
|
while (SDL_PollEvent(&event)) {
|
|
|
|
|
// Pass event to UI manager first
|
|
|
|
|
if (uiManager) {
|
|
|
|
|
uiManager->processEvent(event);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 07:29:39 -07:00
|
|
|
// 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));
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 07:29:39 -07:00
|
|
|
// Handle window events
|
|
|
|
|
if (event.type == SDL_QUIT) {
|
|
|
|
|
window->setShouldClose(true);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-03-14 07:29:39 -07:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-22 19:29:06 -07:00
|
|
|
// Notify addons so UI layouts can adapt to the new size
|
|
|
|
|
if (addonManager_)
|
|
|
|
|
addonManager_->fireEvent("DISPLAY_SIZE_CHANGED");
|
2026-02-05 16:11:24 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-14 07:29:39 -07:00
|
|
|
// 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");
|
|
|
|
|
}
|
2026-02-19 20:36:25 -08:00
|
|
|
}
|
2026-03-14 07:29:39 -07:00
|
|
|
// 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");
|
|
|
|
|
}
|
2026-02-19 20:36:25 -08:00
|
|
|
}
|
2026-03-14 07:29:39 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-03-04 19:47:01 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 07:29:39 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-03-20 18:51:05 -07:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
}
|
2026-03-14 07:29:39 -07:00
|
|
|
} catch (...) {
|
|
|
|
|
watchdogRunning.store(false, std::memory_order_release);
|
|
|
|
|
if (watchdogThread.joinable()) {
|
|
|
|
|
watchdogThread.join();
|
2026-02-22 07:26:54 -08:00
|
|
|
}
|
2026-03-14 07:29:39 -07:00
|
|
|
throw;
|
|
|
|
|
}
|
2026-03-02 08:47:06 -08:00
|
|
|
|
2026-03-14 07:29:39 -07:00
|
|
|
watchdogRunning.store(false, std::memory_order_release);
|
|
|
|
|
if (watchdogThread.joinable()) {
|
|
|
|
|
watchdogThread.join();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Main loop ended");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Application::shutdown() {
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Shutting down application...");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-03-17 09:04:47 -07:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 13:44:09 -08:00
|
|
|
// Stop background world preloader before destroying AssetManager
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) {
|
|
|
|
|
worldLoader_->cancelWorldPreload();
|
|
|
|
|
};
|
2026-03-07 13:44:09 -08:00
|
|
|
|
2026-02-05 17:20:30 -08:00
|
|
|
// Save floor cache before renderer is destroyed
|
|
|
|
|
if (renderer && renderer->getWMORenderer()) {
|
2026-02-05 17:26:18 -08:00
|
|
|
size_t cacheSize = renderer->getWMORenderer()->getFloorCacheSize();
|
|
|
|
|
if (cacheSize > 0) {
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Saving WMO floor cache (", cacheSize, " entries)...");
|
2026-02-05 17:35:17 -08:00
|
|
|
renderer->getWMORenderer()->saveFloorCache();
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Floor cache saved.");
|
2026-02-05 17:26:18 -08:00
|
|
|
}
|
2026-02-05 17:20:30 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 05:58:45 -08:00
|
|
|
// 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().
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Shutting down renderer...");
|
2026-02-22 05:58:45 -08:00
|
|
|
if (renderer) {
|
|
|
|
|
renderer->shutdown();
|
|
|
|
|
}
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Renderer shutdown complete, resetting...");
|
2026-02-04 16:07:28 -08:00
|
|
|
renderer.reset();
|
|
|
|
|
|
2026-04-02 00:21:21 +03:00
|
|
|
// Shutdown audio coordinator after renderer (renderer may reference audio during shutdown)
|
|
|
|
|
if (audioCoordinator_) {
|
|
|
|
|
audioCoordinator_->shutdown();
|
|
|
|
|
}
|
|
|
|
|
audioCoordinator_.reset();
|
|
|
|
|
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Resetting world...");
|
2026-02-02 12:24:50 -08:00
|
|
|
world.reset();
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Resetting gameHandler...");
|
2026-02-02 12:24:50 -08:00
|
|
|
gameHandler.reset();
|
2026-03-30 13:40:40 -07:00
|
|
|
gameServices_ = {};
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Resetting authHandler...");
|
2026-02-02 12:24:50 -08:00
|
|
|
authHandler.reset();
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Resetting assetManager...");
|
2026-02-02 12:24:50 -08:00
|
|
|
assetManager.reset();
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Resetting uiManager...");
|
2026-02-02 12:24:50 -08:00
|
|
|
uiManager.reset();
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Resetting window...");
|
2026-02-02 12:24:50 -08:00
|
|
|
window.reset();
|
|
|
|
|
|
|
|
|
|
running = false;
|
2026-02-26 13:38:29 -08:00
|
|
|
LOG_WARNING("Application shutdown complete");
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-05 14:13:48 -08:00
|
|
|
case AppState::CHARACTER_CREATION:
|
|
|
|
|
// Show character create screen
|
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
case AppState::CHARACTER_SELECTION:
|
|
|
|
|
// Show character screen
|
2026-02-12 14:55:27 -08:00
|
|
|
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.
|
2026-03-20 11:23:38 -07:00
|
|
|
if (addonManager_ && addonsLoaded_) {
|
|
|
|
|
addonManager_->fireEvent("PLAYER_LEAVING_WORLD");
|
2026-03-20 12:22:50 -07:00
|
|
|
addonManager_->saveAllSavedVariables();
|
2026-03-20 11:23:38 -07:00
|
|
|
}
|
2026-02-12 14:55:27 -08:00
|
|
|
npcsSpawned = false;
|
|
|
|
|
playerCharacterSpawned = false;
|
2026-03-20 11:12:07 -07:00
|
|
|
addonsLoaded_ = false;
|
2026-04-01 13:31:48 +03:00
|
|
|
if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
|
2026-02-12 14:55:27 -08:00
|
|
|
wasAutoAttacking_ = false;
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) worldLoader_->resetLoadedMap();
|
2026-02-12 14:55:27 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
break;
|
2026-02-08 03:05:38 -08:00
|
|
|
case AppState::IN_GAME: {
|
2026-02-02 12:24:50 -08:00
|
|
|
// Wire up movement opcodes from camera controller
|
|
|
|
|
if (renderer && renderer->getCameraController()) {
|
|
|
|
|
auto* cc = renderer->getCameraController();
|
|
|
|
|
cc->setMovementCallback([this](uint32_t opcode) {
|
2026-02-06 23:52:16 -08:00
|
|
|
if (gameHandler) {
|
2026-02-02 12:24:50 -08:00
|
|
|
gameHandler->sendMovement(static_cast<game::Opcode>(opcode));
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-10 19:49:33 -07:00
|
|
|
cc->setStandUpCallback([this]() {
|
|
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND)
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-27 17:54:56 -07:00
|
|
|
cc->setAutoFollowCancelCallback([this]() {
|
|
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->cancelFollow();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-03 20:40:59 -08:00
|
|
|
cc->setUseWoWSpeed(true);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-02-05 14:01:26 -08:00
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->setMeleeSwingCallback([this]() {
|
|
|
|
|
if (renderer) {
|
|
|
|
|
renderer->triggerMeleeSwing();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-10 12:28:11 -07:00
|
|
|
gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) {
|
|
|
|
|
if (renderer && renderer->getCameraController()) {
|
|
|
|
|
renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-12 19:37:53 -07:00
|
|
|
gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) {
|
|
|
|
|
if (renderer && renderer->getCameraController()) {
|
|
|
|
|
renderer->getCameraController()->triggerShake(magnitude, frequency, duration);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-27 17:54:56 -07:00
|
|
|
gameHandler->setAutoFollowCallback([this](const glm::vec3* renderPos) {
|
|
|
|
|
if (renderer && renderer->getCameraController()) {
|
|
|
|
|
if (renderPos) {
|
|
|
|
|
renderer->getCameraController()->setAutoFollow(renderPos);
|
|
|
|
|
} else {
|
|
|
|
|
renderer->getCameraController()->cancelAutoFollow();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-05 14:01:26 -08:00
|
|
|
}
|
2026-02-09 23:05:23 -08:00
|
|
|
// Load quest marker models
|
|
|
|
|
loadQuestMarkerModels();
|
2026-02-02 12:24:50 -08:00
|
|
|
break;
|
2026-02-08 03:05:38 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
case AppState::DISCONNECTED:
|
|
|
|
|
// Back to auth
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
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)) {
|
2026-02-20 00:39:20 -08:00
|
|
|
LOG_ERROR("Failed to load opcodes from ", opcodesPath);
|
2026-02-13 16:53:28 -08:00
|
|
|
}
|
|
|
|
|
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
|
|
|
|
|
|
|
|
|
|
std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
|
|
|
|
|
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
|
2026-02-20 00:39:20 -08:00
|
|
|
LOG_ERROR("Failed to load update fields from ", updateFieldsPath);
|
2026-02-13 16:53:28 -08:00
|
|
|
}
|
|
|
|
|
game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable());
|
|
|
|
|
|
|
|
|
|
gameHandler->setPacketParsers(game::createPacketParsers(profile->id));
|
|
|
|
|
|
|
|
|
|
if (dbcLayout_) {
|
|
|
|
|
std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json";
|
|
|
|
|
if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) {
|
2026-02-20 00:39:20 -08:00
|
|
|
LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath);
|
2026-02-13 16:53:28 -08:00
|
|
|
}
|
|
|
|
|
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();
|
|
|
|
|
}
|
2026-02-14 00:00:26 -08:00
|
|
|
|
|
|
|
|
// Reset map name cache so it reloads from new expansion's Map.dbc
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) worldLoader_->resetMapNameCache();
|
2026-02-14 19:27:35 -08:00
|
|
|
|
|
|
|
|
// Reset game handler DBC caches so they reload from new expansion data
|
|
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->resetDbcCaches();
|
|
|
|
|
}
|
2026-02-17 01:00:04 -08:00
|
|
|
|
|
|
|
|
// Rebuild creature display lookups with the new expansion's DBC layout
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_) entitySpawner_->rebuildLookups();
|
2026-02-13 16:53:28 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:59:06 -08:00
|
|
|
void Application::logoutToLogin() {
|
|
|
|
|
LOG_INFO("Logout requested");
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
|
|
|
|
|
// Disconnect TransportManager from WMORenderer before tearing down
|
|
|
|
|
if (gameHandler && gameHandler->getTransportManager()) {
|
|
|
|
|
gameHandler->getTransportManager()->setWMORenderer(nullptr);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:59:06 -08:00
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->disconnect();
|
|
|
|
|
}
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
|
|
|
|
|
// --- Per-session flags ---
|
2026-02-05 15:59:06 -08:00
|
|
|
npcsSpawned = false;
|
2026-02-06 20:49:17 -08:00
|
|
|
playerCharacterSpawned = false;
|
2026-04-01 13:31:48 +03:00
|
|
|
if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
|
2026-02-12 00:15:51 -08:00
|
|
|
wasAutoAttacking_ = false;
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) worldLoader_->resetLoadedMap();
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
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;
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
// --- Reset all EntitySpawner state (mount, creatures, players, GOs, queues, caches) ---
|
|
|
|
|
if (entitySpawner_) entitySpawner_->resetAllState();
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
|
2026-02-05 15:59:06 -08:00
|
|
|
world.reset();
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
|
2026-02-05 15:59:06 -08:00
|
|
|
if (renderer) {
|
2026-03-14 09:19:16 -07:00
|
|
|
renderer->resetCombatVisualState();
|
2026-02-06 20:49:17 -08:00
|
|
|
// Remove old player model so it doesn't persist into next session
|
|
|
|
|
if (auto* charRenderer = renderer->getCharacterRenderer()) {
|
|
|
|
|
charRenderer->removeInstance(1);
|
|
|
|
|
}
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
// Clear all world geometry renderers
|
|
|
|
|
if (auto* wmo = renderer->getWMORenderer()) {
|
|
|
|
|
wmo->clearInstances();
|
|
|
|
|
}
|
|
|
|
|
if (auto* m2 = renderer->getM2Renderer()) {
|
|
|
|
|
m2->clear();
|
|
|
|
|
}
|
2026-02-25 13:37:09 -08:00
|
|
|
// Clear terrain tile tracking + water surfaces so next world entry starts fresh.
|
|
|
|
|
// Use softReset() instead of unloadAll() to avoid blocking on worker thread joins.
|
2026-02-25 13:26:08 -08:00
|
|
|
if (auto* terrain = renderer->getTerrainManager()) {
|
2026-02-25 13:37:09 -08:00
|
|
|
terrain->softReset();
|
2026-02-25 13:26:08 -08:00
|
|
|
}
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
if (auto* questMarkers = renderer->getQuestMarkerRenderer()) {
|
|
|
|
|
questMarkers->clear();
|
|
|
|
|
}
|
|
|
|
|
renderer->clearMount();
|
|
|
|
|
renderer->setCharacterFollow(0);
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
if (auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr) {
|
2026-02-05 15:59:06 -08:00
|
|
|
music->stopMusic(0.0f);
|
|
|
|
|
}
|
|
|
|
|
}
|
Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session. On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.
Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers). Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
|
|
|
|
2026-02-14 19:24:31 -08:00
|
|
|
// Clear stale realm/character selection so switching servers starts fresh
|
|
|
|
|
if (uiManager) {
|
|
|
|
|
uiManager->getRealmScreen().reset();
|
|
|
|
|
uiManager->getCharacterScreen().reset();
|
|
|
|
|
}
|
2026-02-05 15:59:06 -08:00
|
|
|
setState(AppState::AUTHENTICATION);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
void Application::update(float deltaTime) {
|
2026-02-22 07:26:54 -08:00
|
|
|
const char* updateCheckpoint = "enter";
|
|
|
|
|
try {
|
2026-02-02 12:24:50 -08:00
|
|
|
// Update based on current state
|
2026-02-22 07:26:54 -08:00
|
|
|
updateCheckpoint = "state switch";
|
2026-02-02 12:24:50 -08:00
|
|
|
switch (state) {
|
|
|
|
|
case AppState::AUTHENTICATION:
|
2026-02-22 07:44:32 -08:00
|
|
|
updateCheckpoint = "auth: enter";
|
2026-02-02 12:24:50 -08:00
|
|
|
if (authHandler) {
|
|
|
|
|
authHandler->update(deltaTime);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case AppState::REALM_SELECTION:
|
2026-02-22 07:44:32 -08:00
|
|
|
updateCheckpoint = "realm_selection: enter";
|
2026-02-05 13:59:33 -08:00
|
|
|
if (authHandler) {
|
|
|
|
|
authHandler->update(deltaTime);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
break;
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
case AppState::CHARACTER_CREATION:
|
2026-02-22 07:44:32 -08:00
|
|
|
updateCheckpoint = "char_creation: enter";
|
2026-02-05 14:18:41 -08:00
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->update(deltaTime);
|
|
|
|
|
}
|
2026-02-05 14:55:42 -08:00
|
|
|
if (uiManager) {
|
|
|
|
|
uiManager->getCharacterCreateScreen().update(deltaTime);
|
|
|
|
|
}
|
2026-02-05 14:13:48 -08:00
|
|
|
break;
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
case AppState::CHARACTER_SELECTION:
|
2026-02-22 07:44:32 -08:00
|
|
|
updateCheckpoint = "char_selection: enter";
|
2026-02-05 14:18:41 -08:00
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->update(deltaTime);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
break;
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
case AppState::IN_GAME: {
|
2026-02-22 07:44:32 -08:00
|
|
|
updateCheckpoint = "in_game: enter";
|
2026-02-22 07:26:54 -08:00
|
|
|
const char* inGameStep = "begin";
|
|
|
|
|
try {
|
|
|
|
|
auto runInGameStage = [&](const char* stageName, auto&& fn) {
|
2026-03-07 13:44:09 -08:00
|
|
|
auto stageStart = std::chrono::steady_clock::now();
|
2026-02-22 07:26:54 -08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-07 13:44:09 -08:00
|
|
|
auto stageEnd = std::chrono::steady_clock::now();
|
|
|
|
|
float stageMs = std::chrono::duration<float, std::milli>(stageEnd - stageStart).count();
|
2026-03-07 18:43:13 -08:00
|
|
|
if (stageMs > 50.0f) {
|
2026-03-07 13:44:09 -08:00
|
|
|
LOG_WARNING("SLOW update stage '", stageName, "': ", stageMs, "ms");
|
|
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
};
|
|
|
|
|
inGameStep = "gameHandler update";
|
|
|
|
|
updateCheckpoint = "in_game: gameHandler update";
|
|
|
|
|
runInGameStage("gameHandler->update", [&] {
|
|
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->update(deltaTime);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-20 12:07:22 -07:00
|
|
|
if (addonManager_ && addonsLoaded_) {
|
|
|
|
|
addonManager_->update(deltaTime);
|
|
|
|
|
}
|
2026-02-12 00:15:51 -08:00
|
|
|
// Always unsheath on combat engage.
|
2026-02-22 07:26:54 -08:00
|
|
|
inGameStep = "auto-unsheathe";
|
|
|
|
|
updateCheckpoint = "in_game: auto-unsheathe";
|
2026-02-12 00:15:51 -08:00
|
|
|
if (gameHandler) {
|
|
|
|
|
const bool autoAttacking = gameHandler->isAutoAttacking();
|
2026-04-01 13:31:48 +03:00
|
|
|
if (autoAttacking && !wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isWeaponsSheathed()) {
|
|
|
|
|
appearanceComposer_->setWeaponsSheathed(false);
|
|
|
|
|
appearanceComposer_->loadEquippedWeapons();
|
2026-02-12 00:15:51 -08:00
|
|
|
}
|
|
|
|
|
wasAutoAttacking_ = autoAttacking;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 00:14:39 -08:00
|
|
|
// Toggle weapon sheathe state with Z (ignored while UI captures keyboard).
|
2026-02-22 07:26:54 -08:00
|
|
|
inGameStep = "weapon-toggle input";
|
|
|
|
|
updateCheckpoint = "in_game: weapon-toggle input";
|
2026-02-12 00:14:39 -08:00
|
|
|
{
|
|
|
|
|
const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
|
|
|
|
|
auto& input = Input::getInstance();
|
2026-04-01 13:31:48 +03:00
|
|
|
if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z) && appearanceComposer_) {
|
|
|
|
|
appearanceComposer_->toggleWeaponsSheathed();
|
|
|
|
|
appearanceComposer_->loadEquippedWeapons();
|
2026-02-12 00:14:39 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 07:26:54 -08:00
|
|
|
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", [&] {
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_) entitySpawner_->update();
|
2026-03-07 17:16:38 -08:00
|
|
|
if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) {
|
2026-03-07 18:40:24 -08:00
|
|
|
cr->processPendingNormalMaps(4);
|
2026-03-07 17:16:38 -08:00
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
});
|
2026-02-11 21:14:35 -08:00
|
|
|
// Self-heal missing creature visuals: if a nearby UNIT exists in
|
|
|
|
|
// entity state but has no render instance, queue a spawn retry.
|
2026-02-22 07:26:54 -08:00
|
|
|
inGameStep = "creature resync scan";
|
|
|
|
|
updateCheckpoint = "in_game: creature resync scan";
|
2026-02-11 21:14:35 -08:00
|
|
|
if (gameHandler) {
|
|
|
|
|
static float creatureResyncTimer = 0.0f;
|
|
|
|
|
creatureResyncTimer += deltaTime;
|
2026-02-25 12:16:55 -08:00
|
|
|
if (creatureResyncTimer >= 3.0f) {
|
2026-02-11 21:14:35 -08:00
|
|
|
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;
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_->isCreatureSpawned(guid) || entitySpawner_->isCreaturePending(guid)) continue;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
float retryScale = 1.0f;
|
feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.
- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
|
|
|
{
|
|
|
|
|
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));
|
2026-03-31 22:01:55 +03:00
|
|
|
if (s2 > 0.01f && s2 < 100.0f) retryScale = s2;
|
feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.
- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->queueCreatureSpawn(guid, unit->getDisplayId(),
|
|
|
|
|
unit->getX(), unit->getY(), unit->getZ(),
|
|
|
|
|
unit->getOrientation(), retryScale);
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 07:26:54 -08:00
|
|
|
inGameStep = "gameobject/transport queues";
|
|
|
|
|
updateCheckpoint = "in_game: gameobject/transport queues";
|
|
|
|
|
runInGameStage("gameobject/transport queues", [&] {
|
2026-03-31 22:01:55 +03:00
|
|
|
// GO/transport queues handled by entitySpawner_->update() above
|
2026-02-22 07:26:54 -08:00
|
|
|
});
|
|
|
|
|
inGameStep = "pending mount";
|
|
|
|
|
updateCheckpoint = "in_game: pending mount";
|
|
|
|
|
runInGameStage("processPendingMount", [&] {
|
2026-03-31 22:01:55 +03:00
|
|
|
// Mount processing handled by entitySpawner_->update() above
|
2026-02-22 07:26:54 -08:00
|
|
|
});
|
2026-02-09 23:05:23 -08:00
|
|
|
// Update 3D quest markers above NPCs
|
2026-02-22 07:26:54 -08:00
|
|
|
inGameStep = "quest markers";
|
|
|
|
|
updateCheckpoint = "in_game: quest markers";
|
|
|
|
|
runInGameStage("updateQuestMarkers", [&] {
|
|
|
|
|
updateQuestMarkers();
|
|
|
|
|
});
|
2026-02-07 17:59:40 -08:00
|
|
|
// Sync server run speed to camera controller
|
2026-02-22 07:26:54 -08:00
|
|
|
inGameStep = "post-update sync";
|
|
|
|
|
updateCheckpoint = "in_game: post-update sync";
|
|
|
|
|
runInGameStage("post-update sync", [&] {
|
|
|
|
|
if (renderer && gameHandler && renderer->getCameraController()) {
|
|
|
|
|
renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed());
|
2026-03-10 13:11:50 -07:00
|
|
|
renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed());
|
|
|
|
|
renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed());
|
2026-03-10 13:51:47 -07:00
|
|
|
renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed());
|
2026-03-10 13:25:10 -07:00
|
|
|
renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed());
|
2026-03-10 14:05:50 -07:00
|
|
|
renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed());
|
2026-03-10 13:28:53 -07:00
|
|
|
renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed());
|
2026-03-10 14:18:25 -07:00
|
|
|
renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate());
|
2026-03-10 13:01:44 -07:00
|
|
|
renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted());
|
2026-03-10 13:07:34 -07:00
|
|
|
renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled());
|
2026-03-10 13:14:52 -07:00
|
|
|
renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling());
|
2026-03-10 13:18:04 -07:00
|
|
|
renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking());
|
physics: implement player-controlled flying mount physics
When CAN_FLY + FLYING movement flags are both set (flying mounts, Druid
Flight Form), the CameraController now uses 3D pitch-following movement
instead of ground physics:
- Forward/back follows the camera's 3D look direction (ascend when
looking up, descend when looking down)
- Space = ascend vertically, X (while mounted) = descend
- No gravity, no grounding, no jump coyote time
- Fall-damage checks suppressed (grounded=true)
Also wire up all remaining server movement state flags to CameraController:
- Feather Fall: cap terminal velocity at -2 m/s
- Water Walk: clamp to water surface, skip swim entry
- Flying: 3D movement with no gravity
All states synced each frame from GameHandler via isPlayerFlying(),
isFeatherFalling(), isWaterWalking(), isGravityDisabled().
2026-03-10 13:23:38 -07:00
|
|
|
renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying());
|
2026-03-10 13:39:23 -07:00
|
|
|
renderer->getCameraController()->setHoverActive(gameHandler->isHovering());
|
2026-03-10 14:46:17 -07:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
2026-02-22 07:26:54 -08:00
|
|
|
bool onTaxi = gameHandler &&
|
|
|
|
|
(gameHandler->isOnTaxiFlight() ||
|
|
|
|
|
gameHandler->isTaxiMountActive() ||
|
|
|
|
|
gameHandler->isTaxiActivationPending());
|
|
|
|
|
bool onTransportNow = gameHandler && gameHandler->isOnTransport();
|
2026-03-14 09:19:16 -07:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-06 23:01:11 -08:00
|
|
|
// 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;
|
2026-02-22 07:26:54 -08:00
|
|
|
if (worldEntryMovementGraceTimer_ > 0.0f) {
|
|
|
|
|
worldEntryMovementGraceTimer_ -= deltaTime;
|
2026-03-05 15:12:51 -08:00
|
|
|
// Clear stale movement from before teleport each frame
|
|
|
|
|
// until grace period expires (keys may still be held)
|
|
|
|
|
if (renderer && renderer->getCameraController())
|
|
|
|
|
renderer->getCameraController()->clearMovementInputs();
|
2026-02-22 07:26:54 -08:00
|
|
|
}
|
2026-03-07 22:03:28 -08:00
|
|
|
// 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)");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
if (renderer && renderer->getCameraController()) {
|
2026-03-06 23:01:11 -08:00
|
|
|
const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_;
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
// Keep physics frozen (externalFollow) during landing clamp when terrain
|
|
|
|
|
// hasn't loaded yet — prevents gravity from pulling player through void.
|
2026-03-07 22:03:28 -08:00
|
|
|
bool hearthFreeze = hearthTeleportPending_;
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f &&
|
|
|
|
|
worldEntryMovementGraceTimer_ <= 0.0f &&
|
|
|
|
|
!gameHandler->isMounted();
|
2026-03-07 22:03:28 -08:00
|
|
|
renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive || hearthFreeze);
|
2026-02-12 00:04:53 -08:00
|
|
|
renderer->getCameraController()->setExternalMoving(externallyDrivenMotion);
|
|
|
|
|
if (externallyDrivenMotion) {
|
2026-02-11 19:28:15 -08:00
|
|
|
// Drop any stale local movement toggles while server drives taxi motion.
|
|
|
|
|
renderer->getCameraController()->clearMovementInputs();
|
2026-02-11 21:14:35 -08:00
|
|
|
taxiLandingClampTimer_ = 0.0f;
|
2026-02-11 19:28:15 -08:00
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
if (lastTaxiFlight_ && !onTaxi) {
|
|
|
|
|
renderer->getCameraController()->clearMovementInputs();
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
// Keep clamping until terrain loads at landing position.
|
|
|
|
|
// Timer only counts down once a valid floor is found.
|
|
|
|
|
taxiLandingClampTimer_ = 2.0f;
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
if (landingClampActive) {
|
2026-02-11 21:14:35 -08:00
|
|
|
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) {
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
// Floor found — snap player to it and start countdown to release
|
2026-02-11 21:14:35 -08:00
|
|
|
float targetZ = *targetFloor + 0.10f;
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
if (std::abs(p.z - targetZ) > 0.05f) {
|
2026-02-11 21:14:35 -08:00
|
|
|
p.z = targetZ;
|
|
|
|
|
renderer->getCharacterPosition() = p;
|
|
|
|
|
glm::vec3 canonical = core::coords::renderToCanonical(p);
|
|
|
|
|
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
|
2026-02-20 02:50:59 -08:00
|
|
|
gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
taxiLandingClampTimer_ -= deltaTime;
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
// No floor found: don't decrement timer, keep player frozen until terrain loads
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
}
|
2026-02-08 03:39:02 -08:00
|
|
|
bool idleOrbit = renderer->getCameraController()->isIdleOrbit();
|
|
|
|
|
if (idleOrbit && !idleYawned_ && renderer) {
|
|
|
|
|
renderer->playEmote("yawn");
|
|
|
|
|
idleYawned_ = true;
|
|
|
|
|
} else if (!idleOrbit) {
|
|
|
|
|
idleYawned_ = false;
|
|
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
}
|
|
|
|
|
if (renderer) {
|
|
|
|
|
renderer->setTaxiFlight(onTaxi);
|
|
|
|
|
}
|
|
|
|
|
if (renderer && renderer->getTerrainManager()) {
|
2026-02-08 03:05:38 -08:00
|
|
|
renderer->getTerrainManager()->setStreamingEnabled(true);
|
2026-02-26 03:06:17 -08:00
|
|
|
// 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);
|
2026-03-09 20:58:49 -07:00
|
|
|
renderer->getTerrainManager()->setLoadRadius(onTaxi ? 8 : 4);
|
|
|
|
|
renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7);
|
2026-02-11 19:28:15 -08:00
|
|
|
renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi);
|
2026-02-22 07:26:54 -08:00
|
|
|
}
|
|
|
|
|
lastTaxiFlight_ = onTaxi;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-22 07:26:54 -08:00
|
|
|
// Sync character render position ↔ canonical WoW coords each frame
|
|
|
|
|
if (renderer && gameHandler) {
|
2026-03-06 23:01:11 -08:00
|
|
|
// 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;
|
2026-02-10 21:29:10 -08:00
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
static bool wasOnTransport = false;
|
2026-03-06 23:01:11 -08:00
|
|
|
bool onTransportNowDbg = gameHandler->isOnTransport();
|
|
|
|
|
if (onTransportNowDbg != wasOnTransport) {
|
|
|
|
|
LOG_DEBUG("Transport state changed: onTransport=", onTransportNowDbg,
|
|
|
|
|
" isM2=", isM2Transport,
|
2026-02-11 00:54:38 -08:00
|
|
|
" guid=0x", std::hex, gameHandler->getPlayerTransportGuid(), std::dec);
|
2026-03-06 23:01:11 -08:00
|
|
|
wasOnTransport = onTransportNowDbg;
|
2026-02-11 00:54:38 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
if (onTaxi) {
|
|
|
|
|
auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid());
|
2026-02-11 21:14:35 -08:00
|
|
|
glm::vec3 canonical(0.0f);
|
|
|
|
|
bool haveCanonical = false;
|
2026-02-08 03:05:38 -08:00
|
|
|
if (playerEntity) {
|
2026-02-11 21:14:35 -08:00
|
|
|
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) {
|
2026-02-08 03:05:38 -08:00
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
|
|
|
|
renderer->getCharacterPosition() = renderPos;
|
2026-02-11 21:14:35 -08:00
|
|
|
if (renderer->getCameraController()) {
|
|
|
|
|
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
|
|
|
|
|
if (followTarget) {
|
|
|
|
|
*followTarget = renderPos;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
}
|
2026-02-10 21:29:10 -08:00
|
|
|
} else if (onTransport) {
|
2026-03-06 23:01:11 -08:00
|
|
|
// WMO transport mode (ships): compose world position from transform + local offset
|
2026-02-10 21:29:10 -08:00
|
|
|
glm::vec3 canonical = gameHandler->getComposedWorldPosition();
|
|
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
|
|
|
|
renderer->getCharacterPosition() = renderPos;
|
2026-02-12 00:04:53 -08:00
|
|
|
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
|
2026-02-10 21:29:10 -08:00
|
|
|
if (renderer->getCameraController()) {
|
|
|
|
|
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
|
|
|
|
|
if (followTarget) {
|
|
|
|
|
*followTarget = renderPos;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 21:13:13 -08:00
|
|
|
} 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_;
|
2026-03-27 16:33:16 -07:00
|
|
|
float dirLenSq = glm::dot(dir, dir);
|
|
|
|
|
if (dirLenSq > 1e-4f) {
|
|
|
|
|
dir *= glm::inversesqrt(dirLenSq);
|
2026-02-19 21:13:13 -08:00
|
|
|
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);
|
|
|
|
|
|
2026-02-19 21:31:37 -08:00
|
|
|
// Snap to melee range of target's CURRENT position (it may have moved)
|
2026-02-19 21:13:13 -08:00
|
|
|
if (chargeTargetGuid_ != 0) {
|
2026-02-19 21:31:37 -08:00
|
|
|
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;
|
2026-03-27 16:33:16 -07:00
|
|
|
float dSq = glm::dot(toTarget, toTarget);
|
|
|
|
|
if (dSq > 2.25f) {
|
2026-02-19 21:31:37 -08:00
|
|
|
// Place us 1.5 units from target (well within 8-unit melee range)
|
2026-03-27 16:33:16 -07:00
|
|
|
glm::vec3 snapPos = targetRender - toTarget * (1.5f * glm::inversesqrt(dSq));
|
2026-02-19 21:31:37 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 21:13:13 -08:00
|
|
|
gameHandler->startAutoAttack(chargeTargetGuid_);
|
|
|
|
|
renderer->triggerMeleeSwing();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send movement heartbeat so server knows our new position
|
2026-02-20 02:50:59 -08:00
|
|
|
gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-19 21:13:13 -08:00
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
} else {
|
|
|
|
|
glm::vec3 renderPos = renderer->getCharacterPosition();
|
2026-03-06 23:01:11 -08:00
|
|
|
|
2026-03-14 09:19:16 -07:00
|
|
|
// M2 transport riding: resolve in canonical space and lock once per frame.
|
|
|
|
|
// This avoids visible jitter from mixed render/canonical delta application.
|
2026-03-06 23:01:11 -08:00
|
|
|
if (isM2Transport && gameHandler->getTransportManager()) {
|
|
|
|
|
auto* tr = gameHandler->getTransportManager()->getTransport(
|
|
|
|
|
gameHandler->getPlayerTransportGuid());
|
|
|
|
|
if (tr) {
|
2026-03-14 09:02:20 -07:00
|
|
|
// 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;
|
2026-03-06 23:01:11 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
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();
|
2026-02-12 15:08:21 -08:00
|
|
|
// 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);
|
2026-02-19 16:40:17 -08:00
|
|
|
|
2026-02-20 02:50:59 -08:00
|
|
|
// Send MSG_MOVE_SET_FACING when the player changes facing direction
|
2026-02-19 16:40:17 -08:00
|
|
|
// (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)) {
|
2026-02-20 02:50:59 -08:00
|
|
|
gameHandler->sendMovement(game::Opcode::MSG_MOVE_SET_FACING);
|
2026-02-19 16:40:17 -08:00
|
|
|
lastSentCanonicalYaw_ = canonicalYaw;
|
|
|
|
|
facingSendCooldown_ = 0.1f; // max 10 Hz
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-06 23:01:11 -08:00
|
|
|
|
|
|
|
|
// Client-side transport boarding detection (for M2 transports like trams
|
2026-03-14 08:09:23 -07:00
|
|
|
// 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.
|
2026-03-06 23:01:11 -08:00
|
|
|
if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) {
|
|
|
|
|
auto* tm = gameHandler->getTransportManager();
|
|
|
|
|
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
|
2026-03-14 08:09:23 -07:00
|
|
|
constexpr float kM2BoardHorizDistSq = 12.0f * 12.0f;
|
|
|
|
|
constexpr float kM2BoardVertDist = 15.0f;
|
2026-03-14 09:19:16 -07:00
|
|
|
constexpr float kTbLiftBoardHorizDistSq = 22.0f * 22.0f;
|
|
|
|
|
constexpr float kTbLiftBoardVertDist = 14.0f;
|
2026-03-06 23:01:11 -08:00
|
|
|
|
2026-03-14 09:02:20 -07:00
|
|
|
uint64_t bestGuid = 0;
|
|
|
|
|
float bestScore = 1e30f;
|
2026-03-06 23:01:11 -08:00
|
|
|
for (auto& [guid, transport] : tm->getTransports()) {
|
|
|
|
|
if (!transport.isM2) continue;
|
2026-03-14 08:09:23 -07:00
|
|
|
const bool isThunderBluffLift =
|
|
|
|
|
(transport.entry >= 20649u && transport.entry <= 20657u);
|
|
|
|
|
const float maxHorizDistSq = isThunderBluffLift
|
|
|
|
|
? kTbLiftBoardHorizDistSq
|
|
|
|
|
: kM2BoardHorizDistSq;
|
|
|
|
|
const float maxVertDist = isThunderBluffLift
|
|
|
|
|
? kTbLiftBoardVertDist
|
|
|
|
|
: kM2BoardVertDist;
|
2026-03-06 23:01:11 -08:00
|
|
|
glm::vec3 diff = playerCanonical - transport.position;
|
|
|
|
|
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
|
|
|
|
|
float vertDist = std::abs(diff.z);
|
2026-03-14 08:09:23 -07:00
|
|
|
if (horizDistSq < maxHorizDistSq && vertDist < maxVertDist) {
|
2026-03-14 09:02:20 -07:00
|
|
|
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);
|
2026-03-06 23:01:11 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-03-14 08:09:23 -07:00
|
|
|
const bool isThunderBluffLift =
|
|
|
|
|
(tr->entry >= 20649u && tr->entry <= 20657u);
|
|
|
|
|
constexpr float kM2DisembarkHorizDistSq = 15.0f * 15.0f;
|
2026-03-14 09:19:16 -07:00
|
|
|
constexpr float kTbLiftDisembarkHorizDistSq = 28.0f * 28.0f;
|
|
|
|
|
constexpr float kM2DisembarkVertDist = 18.0f;
|
|
|
|
|
constexpr float kTbLiftDisembarkVertDist = 16.0f;
|
2026-03-14 08:09:23 -07:00
|
|
|
const float disembarkHorizDistSq = isThunderBluffLift
|
|
|
|
|
? kTbLiftDisembarkHorizDistSq
|
|
|
|
|
: kM2DisembarkHorizDistSq;
|
2026-03-14 09:19:16 -07:00
|
|
|
const float disembarkVertDist = isThunderBluffLift
|
|
|
|
|
? kTbLiftDisembarkVertDist
|
|
|
|
|
: kM2DisembarkVertDist;
|
|
|
|
|
if (horizDistSq > disembarkHorizDistSq || std::abs(diff.z) > disembarkVertDist) {
|
2026-03-06 23:01:11 -08:00
|
|
|
gameHandler->clearPlayerTransport();
|
|
|
|
|
LOG_DEBUG("M2 transport disembark");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
}
|
|
|
|
|
});
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-18 04:02:08 -08:00
|
|
|
// 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.
|
2026-02-22 07:26:54 -08:00
|
|
|
inGameStep = "creature render sync";
|
|
|
|
|
updateCheckpoint = "in_game: creature render sync";
|
2026-03-07 13:44:09 -08:00
|
|
|
auto creatureSyncStart = std::chrono::steady_clock::now();
|
2026-02-18 04:02:08 -08:00
|
|
|
if (renderer && gameHandler && renderer->getCharacterRenderer()) {
|
|
|
|
|
auto* charRenderer = renderer->getCharacterRenderer();
|
2026-02-20 23:04:57 -08:00
|
|
|
static float npcWeaponRetryTimer = 0.0f;
|
|
|
|
|
npcWeaponRetryTimer += deltaTime;
|
|
|
|
|
const bool npcWeaponRetryTick = (npcWeaponRetryTimer >= 1.0f);
|
|
|
|
|
if (npcWeaponRetryTick) npcWeaponRetryTimer = 0.0f;
|
2026-03-07 11:44:14 -08:00
|
|
|
int weaponAttachesThisTick = 0;
|
2026-02-18 04:02:08 -08:00
|
|
|
glm::vec3 playerPos(0.0f);
|
2026-02-20 16:27:21 -08:00
|
|
|
glm::vec3 playerRenderPos(0.0f);
|
2026-02-18 04:02:08 -08:00
|
|
|
bool havePlayerPos = false;
|
2026-02-20 16:27:21 -08:00
|
|
|
float playerCollisionRadius = 0.65f;
|
2026-02-18 04:02:08 -08:00
|
|
|
if (auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) {
|
|
|
|
|
playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
|
2026-02-20 16:27:21 -08:00
|
|
|
playerRenderPos = core::coords::canonicalToRender(playerPos);
|
2026-02-18 04:02:08 -08:00
|
|
|
havePlayerPos = true;
|
2026-02-20 16:27:21 -08:00
|
|
|
glm::vec3 pc;
|
|
|
|
|
float pr = 0.0f;
|
|
|
|
|
if (getRenderBoundsForGuid(gameHandler->getPlayerGuid(), pc, pr)) {
|
|
|
|
|
playerCollisionRadius = std::clamp(pr * 0.35f, 0.45f, 1.1f);
|
|
|
|
|
}
|
2026-02-18 04:02:08 -08:00
|
|
|
}
|
|
|
|
|
const float syncRadiusSq = 320.0f * 320.0f;
|
2026-03-31 22:01:55 +03:00
|
|
|
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) {
|
2026-02-18 04:02:08 -08:00
|
|
|
auto entity = gameHandler->getEntityManager().getEntity(guid);
|
|
|
|
|
if (!entity || entity->getType() != game::ObjectType::UNIT) continue;
|
|
|
|
|
|
2026-03-07 11:44:14 -08:00
|
|
|
if (npcWeaponRetryTick &&
|
2026-03-31 22:01:55 +03:00
|
|
|
weaponAttachesThisTick < EntitySpawner::MAX_WEAPON_ATTACHES_PER_TICK &&
|
|
|
|
|
!_creatureWeaponsAttached.count(guid)) {
|
2026-02-20 23:04:57 -08:00
|
|
|
uint8_t attempts = 0;
|
2026-03-31 22:01:55 +03:00
|
|
|
auto itAttempts = _creatureWeaponAttachAttempts.find(guid);
|
|
|
|
|
if (itAttempts != _creatureWeaponAttachAttempts.end()) attempts = itAttempts->second;
|
2026-02-20 23:04:57 -08:00
|
|
|
if (attempts < 30) {
|
2026-03-07 11:44:14 -08:00
|
|
|
weaponAttachesThisTick++;
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_->tryAttachCreatureVirtualWeapons(guid, instanceId)) {
|
|
|
|
|
_creatureWeaponsAttached.insert(guid);
|
|
|
|
|
_creatureWeaponAttachAttempts.erase(guid);
|
2026-02-20 23:04:57 -08:00
|
|
|
} else {
|
2026-03-31 22:01:55 +03:00
|
|
|
_creatureWeaponAttachAttempts[guid] = static_cast<uint8_t>(attempts + 1);
|
2026-02-20 23:04:57 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 16:21:09 -07:00
|
|
|
// 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());
|
2026-03-07 13:44:09 -08:00
|
|
|
float canonDistSq = 0.0f;
|
2026-02-18 04:02:08 -08:00
|
|
|
if (havePlayerPos) {
|
2026-03-10 16:21:09 -07:00
|
|
|
glm::vec3 d = latestCanonical - playerPos;
|
2026-03-07 13:44:09 -08:00
|
|
|
canonDistSq = glm::dot(d, d);
|
|
|
|
|
if (canonDistSq > syncRadiusSq) continue;
|
2026-02-18 04:02:08 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 16:21:09 -07:00
|
|
|
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
|
2026-02-18 04:02:08 -08:00
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
2026-02-20 16:27:21 -08:00
|
|
|
|
|
|
|
|
// 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).
|
2026-03-07 13:44:09 -08:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-02-20 16:27:21 -08:00
|
|
|
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).
|
2026-03-31 22:01:55 +03:00
|
|
|
auto mit = _creatureModelIds.find(guid);
|
|
|
|
|
if (mit != _creatureModelIds.end()) {
|
2026-03-07 11:44:14 -08:00
|
|
|
uint32_t mid = mit->second;
|
2026-03-31 22:01:55 +03:00
|
|
|
auto wolfIt = _modelIdIsWolfLike.find(mid);
|
|
|
|
|
if (wolfIt == _modelIdIsWolfLike.end()) {
|
2026-03-07 11:44:14 -08:00
|
|
|
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);
|
2026-02-20 16:27:21 -08:00
|
|
|
}
|
2026-03-31 22:01:55 +03:00
|
|
|
wolfIt = _modelIdIsWolfLike.emplace(mid, isWolf).first;
|
2026-03-07 11:44:14 -08:00
|
|
|
}
|
|
|
|
|
if (wolfIt->second) {
|
|
|
|
|
minSep = std::max(minSep, 2.45f);
|
2026-02-20 16:27:21 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
auto posIt = _creatureRenderPosCache.find(guid);
|
|
|
|
|
if (posIt == _creatureRenderPosCache.end()) {
|
2026-02-20 16:40:22 -08:00
|
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
2026-03-31 22:01:55 +03:00
|
|
|
_creatureRenderPosCache[guid] = renderPos;
|
2026-02-20 16:40:22 -08:00
|
|
|
} else {
|
|
|
|
|
const glm::vec3 prevPos = posIt->second;
|
2026-03-27 16:33:16 -07:00
|
|
|
float ddx2 = renderPos.x - prevPos.x;
|
|
|
|
|
float ddy2 = renderPos.y - prevPos.y;
|
|
|
|
|
float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2;
|
2026-02-20 16:40:22 -08:00
|
|
|
float dz = std::abs(renderPos.z - prevPos.z);
|
|
|
|
|
|
2026-03-07 13:44:09 -08:00
|
|
|
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
|
|
|
|
|
const bool deadOrCorpse = unitPtr->getHealth() == 0;
|
2026-03-27 16:33:16 -07:00
|
|
|
const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f);
|
2026-03-10 16:08:24 -07:00
|
|
|
// 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.
|
2026-03-18 08:22:50 -07:00
|
|
|
// 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();
|
2026-03-27 16:33:16 -07:00
|
|
|
constexpr float kMoveThreshSq = 0.03f * 0.03f;
|
|
|
|
|
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq || dz > 0.08f);
|
2026-02-20 16:40:22 -08:00
|
|
|
if (deadOrCorpse || largeCorrection) {
|
|
|
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
2026-03-27 16:33:16 -07:00
|
|
|
} else if (planarDistSq > kMoveThreshSq || dz > 0.08f) {
|
2026-03-10 16:08:24 -07:00
|
|
|
// Position changed in entity coords → drive renderer toward it.
|
2026-03-27 16:33:16 -07:00
|
|
|
float planarDist = std::sqrt(planarDistSq);
|
2026-02-20 16:40:22 -08:00
|
|
|
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
|
|
|
|
|
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
|
|
|
|
|
}
|
2026-03-10 16:08:24 -07:00
|
|
|
// 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.
|
2026-02-20 16:40:22 -08:00
|
|
|
posIt->second = renderPos;
|
2026-03-10 10:06:56 -07:00
|
|
|
|
2026-03-10 10:55:23 -07:00
|
|
|
// 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.
|
2026-03-10 10:36:45 -07:00
|
|
|
// WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim.
|
2026-03-10 10:06:56 -07:00
|
|
|
// Only switch on transitions to avoid resetting animation time.
|
|
|
|
|
// Don't override Death (1) animation.
|
2026-03-31 22:01:55 +03:00
|
|
|
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];
|
2026-03-10 12:03:33 -07:00
|
|
|
// Trigger animation update on any locomotion-state transition, not just
|
2026-03-10 12:04:59 -07:00
|
|
|
// 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) ||
|
2026-03-10 12:03:33 -07:00
|
|
|
(isSwimmingNow != prevSwimming) ||
|
2026-03-10 12:04:59 -07:00
|
|
|
(isFlyingNow != prevFlying) ||
|
|
|
|
|
(isWalkingNow != prevWalking && isMovingNow);
|
2026-03-10 12:03:33 -07:00
|
|
|
if (stateChanged) {
|
2026-03-31 22:01:55 +03:00
|
|
|
_creatureWasMoving[guid] = isMovingNow;
|
|
|
|
|
_creatureWasSwimming[guid] = isSwimmingNow;
|
|
|
|
|
_creatureWasFlying[guid] = isFlyingNow;
|
|
|
|
|
_creatureWasWalking[guid] = isWalkingNow;
|
2026-03-10 10:06:56 -07:00
|
|
|
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
|
|
|
|
|
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
|
|
|
|
|
if (!gotState || curAnimId != 1 /*Death*/) {
|
2026-03-10 10:36:45 -07:00
|
|
|
uint32_t targetAnim;
|
2026-03-10 11:56:50 -07:00
|
|
|
if (isMovingNow) {
|
2026-03-10 11:58:19 -07:00
|
|
|
if (isFlyingNow) targetAnim = 159u; // FlyForward
|
|
|
|
|
else if (isSwimmingNow) targetAnim = 42u; // Swim
|
|
|
|
|
else if (isWalkingNow) targetAnim = 4u; // Walk
|
|
|
|
|
else targetAnim = 5u; // Run
|
2026-03-10 11:56:50 -07:00
|
|
|
} else {
|
2026-03-10 11:58:19 -07:00
|
|
|
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
|
|
|
|
|
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
|
|
|
|
|
else targetAnim = 0u; // Stand
|
2026-03-10 11:56:50 -07:00
|
|
|
}
|
2026-03-10 10:36:45 -07:00
|
|
|
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
|
2026-03-10 10:06:56 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 16:40:22 -08:00
|
|
|
}
|
2026-02-18 04:02:08 -08:00
|
|
|
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
|
|
|
|
|
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-07 13:44:09 -08:00
|
|
|
{
|
|
|
|
|
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 (",
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->getCreatureInstances().size(), " creatures)");
|
2026-03-07 13:44:09 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-18 04:02:08 -08:00
|
|
|
|
2026-03-18 08:33:45 -07:00
|
|
|
// --- 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;
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
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) {
|
2026-03-18 08:33:45 -07:00
|
|
|
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);
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
auto posIt = _pCreatureRenderPosCache.find(guid);
|
|
|
|
|
if (posIt == _pCreatureRenderPosCache.end()) {
|
2026-03-18 08:33:45 -07:00
|
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
2026-03-31 22:01:55 +03:00
|
|
|
_pCreatureRenderPosCache[guid] = renderPos;
|
2026-03-18 08:33:45 -07:00
|
|
|
} else {
|
|
|
|
|
const glm::vec3 prevPos = posIt->second;
|
2026-03-27 16:33:16 -07:00
|
|
|
float ddx2 = renderPos.x - prevPos.x;
|
|
|
|
|
float ddy2 = renderPos.y - prevPos.y;
|
|
|
|
|
float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2;
|
2026-03-18 08:33:45 -07:00
|
|
|
float dz = std::abs(renderPos.z - prevPos.z);
|
|
|
|
|
|
|
|
|
|
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
|
|
|
|
|
const bool deadOrCorpse = unitPtr->getHealth() == 0;
|
2026-03-27 16:33:16 -07:00
|
|
|
const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f);
|
2026-03-18 08:33:45 -07:00
|
|
|
const bool entityIsMoving = entity->isActivelyMoving();
|
2026-03-27 16:33:16 -07:00
|
|
|
constexpr float kMoveThreshSq2 = 0.03f * 0.03f;
|
|
|
|
|
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq2 || dz > 0.08f);
|
2026-03-18 08:33:45 -07:00
|
|
|
|
|
|
|
|
if (deadOrCorpse || largeCorrection) {
|
|
|
|
|
charRenderer->setInstancePosition(instanceId, renderPos);
|
2026-03-27 16:33:16 -07:00
|
|
|
} else if (planarDistSq > kMoveThreshSq2 || dz > 0.08f) {
|
|
|
|
|
float planarDist = std::sqrt(planarDistSq);
|
2026-03-18 08:33:45 -07:00
|
|
|
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)
|
2026-03-31 22:01:55 +03:00
|
|
|
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];
|
2026-03-18 08:33:45 -07:00
|
|
|
const bool stateChanged = (isMovingNow != prevMoving) ||
|
|
|
|
|
(isSwimmingNow != prevSwimming) ||
|
|
|
|
|
(isFlyingNow != prevFlying) ||
|
|
|
|
|
(isWalkingNow != prevWalking && isMovingNow);
|
|
|
|
|
if (stateChanged) {
|
2026-03-31 22:01:55 +03:00
|
|
|
_pCreatureWasMoving[guid] = isMovingNow;
|
|
|
|
|
_pCreatureWasSwimming[guid] = isSwimmingNow;
|
|
|
|
|
_pCreatureWasFlying[guid] = isFlyingNow;
|
|
|
|
|
_pCreatureWasWalking[guid] = isWalkingNow;
|
2026-03-18 08:33:45 -07:00
|
|
|
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 (",
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->getPlayerInstances().size(), " players)");
|
2026-03-18 08:33:45 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
// Movement heartbeat is sent from GameHandler::update() to avoid
|
|
|
|
|
// duplicate packets from multiple update loops.
|
2026-02-10 19:30:45 -08:00
|
|
|
|
2026-02-22 07:26:54 -08:00
|
|
|
} 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;
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
break;
|
2026-02-08 03:05:38 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
case AppState::DISCONNECTED:
|
|
|
|
|
// Handle disconnection
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:06:26 +03:00
|
|
|
// Process any pending world entry request via WorldLoader
|
|
|
|
|
if (worldLoader_ && state != AppState::DISCONNECTED) {
|
|
|
|
|
worldLoader_->processPendingEntry();
|
2026-03-15 01:21:23 -07:00
|
|
|
}
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
// Update renderer (camera, etc.) only when in-game
|
2026-02-22 07:26:54 -08:00
|
|
|
updateCheckpoint = "renderer update";
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
if (renderer && state == AppState::IN_GAME) {
|
2026-03-07 13:44:09 -08:00
|
|
|
auto rendererUpdateStart = std::chrono::steady_clock::now();
|
2026-02-22 07:26:54 -08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-07 13:44:09 -08:00
|
|
|
float ruMs = std::chrono::duration<float, std::milli>(
|
|
|
|
|
std::chrono::steady_clock::now() - rendererUpdateStart).count();
|
2026-03-07 18:43:13 -08:00
|
|
|
if (ruMs > 50.0f) {
|
2026-03-07 13:44:09 -08:00
|
|
|
LOG_WARNING("SLOW update stage 'renderer->update': ", ruMs, "ms");
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
// Update UI
|
2026-02-22 07:26:54 -08:00
|
|
|
updateCheckpoint = "ui update";
|
2026-02-02 12:24:50 -08:00
|
|
|
if (uiManager) {
|
2026-02-22 07:26:54 -08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
} 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;
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Application::render() {
|
|
|
|
|
if (!renderer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderer->beginFrame();
|
|
|
|
|
|
2026-02-17 15:37:02 -08:00
|
|
|
// Only render 3D world when in-game
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
if (state == AppState::IN_GAME) {
|
|
|
|
|
if (world) {
|
2026-02-10 19:30:45 -08:00
|
|
|
renderer->renderWorld(world.get(), gameHandler.get());
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
} else {
|
2026-02-10 19:30:45 -08:00
|
|
|
renderer->renderWorld(nullptr, gameHandler.get());
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-03-27 10:14:49 -07:00
|
|
|
try { port = static_cast<uint16_t>(std::stoi(realmAddress.substr(colonPos + 1))); }
|
|
|
|
|
catch (...) { LOG_WARNING("Invalid port in realm address: ", realmAddress); }
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Connect to world server
|
|
|
|
|
const auto& sessionKey = authHandler->getSessionKey();
|
2026-02-05 18:18:15 -08:00
|
|
|
std::string accountName = authHandler->getUsername();
|
|
|
|
|
if (accountName.empty()) {
|
|
|
|
|
LOG_WARNING("Auth username missing; falling back to TESTACCOUNT");
|
|
|
|
|
accountName = "TESTACCOUNT";
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-13 01:51:49 -08:00
|
|
|
uint32_t realmId = 0;
|
2026-02-27 05:05:44 -08:00
|
|
|
uint16_t realmBuild = 0;
|
2026-02-13 01:51:49 -08:00
|
|
|
{
|
|
|
|
|
// 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;
|
2026-02-27 05:05:44 -08:00
|
|
|
realmBuild = r.build;
|
2026-02-13 01:51:49 -08:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-27 05:05:44 -08:00
|
|
|
LOG_INFO("Selected realmId=", realmId, " realmBuild=", realmBuild);
|
2026-02-13 01:51:49 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
uint32_t clientBuild = 12340; // default WotLK
|
|
|
|
|
if (expansionRegistry_) {
|
|
|
|
|
auto* profile = expansionRegistry_->getActive();
|
2026-02-13 16:53:28 -08:00
|
|
|
if (profile) clientBuild = profile->worldBuild;
|
2026-02-12 22:56:36 -08:00
|
|
|
}
|
2026-02-27 05:05:44 -08:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-02-13 01:51:49 -08:00
|
|
|
if (gameHandler->connect(host, port, sessionKey, accountName, clientBuild, realmId)) {
|
2026-02-02 12:24:50 -08:00
|
|
|
LOG_INFO("Connected to world server, transitioning to character selection");
|
|
|
|
|
setState(AppState::CHARACTER_SELECTION);
|
|
|
|
|
} else {
|
|
|
|
|
LOG_ERROR("Failed to connect to world server");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-17 03:50:36 -08:00
|
|
|
// Realm screen back button - return to login
|
|
|
|
|
uiManager->getRealmScreen().setOnBack([this]() {
|
|
|
|
|
if (authHandler) {
|
|
|
|
|
authHandler->disconnect();
|
|
|
|
|
}
|
|
|
|
|
uiManager->getRealmScreen().reset();
|
|
|
|
|
setState(AppState::AUTHENTICATION);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Character selection callback
|
|
|
|
|
uiManager->getCharacterScreen().setOnCharacterSelected([this](uint64_t characterGuid) {
|
|
|
|
|
LOG_INFO("Character selected: GUID=0x", std::hex, characterGuid, std::dec);
|
2026-02-05 22:47:21 -08:00
|
|
|
// Always set the active character GUID
|
|
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->setActiveCharacterGuid(characterGuid);
|
|
|
|
|
}
|
2026-02-20 17:29:09 -08:00
|
|
|
// Keep CHARACTER_SELECTION active until world entry is fully loaded.
|
|
|
|
|
// This avoids exposing pre-load hitching before the loading screen/intro.
|
2026-02-05 14:13:48 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Character create screen callbacks
|
|
|
|
|
uiManager->getCharacterCreateScreen().setOnCreate([this](const game::CharCreateData& data) {
|
2026-02-09 22:51:13 -08:00
|
|
|
pendingCreatedCharacterName_ = data.name; // Store name for auto-selection
|
2026-02-05 14:13:48 -08:00
|
|
|
gameHandler->createCharacter(data);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
uiManager->getCharacterCreateScreen().setOnCancel([this]() {
|
2026-02-06 23:52:16 -08:00
|
|
|
setState(AppState::CHARACTER_SELECTION);
|
2026-02-05 14:13:48 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Character create result callback
|
|
|
|
|
gameHandler->setCharCreateCallback([this](bool success, const std::string& msg) {
|
|
|
|
|
if (success) {
|
2026-02-09 22:51:13 -08:00
|
|
|
// Auto-select the newly created character
|
|
|
|
|
if (!pendingCreatedCharacterName_.empty()) {
|
|
|
|
|
uiManager->getCharacterScreen().selectCharacterByName(pendingCreatedCharacterName_);
|
|
|
|
|
pendingCreatedCharacterName_.clear();
|
|
|
|
|
}
|
2026-02-06 23:52:16 -08:00
|
|
|
setState(AppState::CHARACTER_SELECTION);
|
2026-02-05 14:13:48 -08:00
|
|
|
} else {
|
|
|
|
|
uiManager->getCharacterCreateScreen().setStatus(msg, true);
|
2026-02-09 22:51:13 -08:00
|
|
|
pendingCreatedCharacterName_.clear();
|
2026-02-05 14:13:48 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-17 13:59:29 -08:00
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-05 21:28:21 -08:00
|
|
|
// World entry callback (online mode) - load terrain when entering world
|
2026-03-10 08:35:36 -07:00
|
|
|
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);
|
2026-03-14 09:19:16 -07:00
|
|
|
if (renderer) {
|
|
|
|
|
renderer->resetCombatVisualState();
|
|
|
|
|
}
|
2026-02-17 02:23:41 -08:00
|
|
|
|
2026-03-10 08:50:25 -07:00
|
|
|
// 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.
|
2026-04-01 20:06:26 +03:00
|
|
|
uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF;
|
|
|
|
|
if (entitySpawner_ && mapId == currentLoadedMap && renderer && renderer->getTerrainManager() && isInitialEntry) {
|
2026-03-10 08:50:25 -07:00
|
|
|
LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)");
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
// 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();
|
2026-03-10 08:50:25 -07:00
|
|
|
|
|
|
|
|
// Properly despawn all tracked instances from the renderer
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->despawnAllCreatures();
|
|
|
|
|
entitySpawner_->despawnAllPlayers();
|
|
|
|
|
entitySpawner_->despawnAllGameObjects();
|
2026-03-10 08:50:25 -07:00
|
|
|
|
|
|
|
|
// 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);
|
2026-03-28 15:50:13 -07:00
|
|
|
renderer->getCameraController()->suspendGravityFor(10.0f);
|
2026-03-10 08:50:25 -07:00
|
|
|
}
|
|
|
|
|
worldEntryMovementGraceTimer_ = 2.0f;
|
|
|
|
|
taxiLandingClampTimer_ = 0.0f;
|
|
|
|
|
lastTaxiFlight_ = false;
|
2026-03-18 07:54:05 -07:00
|
|
|
renderer->getTerrainManager()->processReadyTiles();
|
2026-03-10 08:50:25 -07:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 14:55:58 -07:00
|
|
|
// Same-map teleport (taxi landing, GM teleport, hearthstone on same continent):
|
2026-04-01 20:06:26 +03:00
|
|
|
if (mapId == currentLoadedMap && renderer && renderer->getTerrainManager()) {
|
2026-03-28 14:55:58 -07:00
|
|
|
// Check if teleport is far enough to need terrain loading (>500 render units)
|
|
|
|
|
glm::vec3 oldPos = renderer->getCharacterPosition();
|
2026-02-17 02:23:41 -08:00
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
|
|
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
2026-03-28 14:55:58 -07:00
|
|
|
float teleportDistSq = glm::dot(renderPos - oldPos, renderPos - oldPos);
|
|
|
|
|
bool farTeleport = (teleportDistSq > 500.0f * 500.0f);
|
|
|
|
|
|
|
|
|
|
if (farTeleport) {
|
2026-03-28 15:38:44 -07:00
|
|
|
// Far same-map teleport (hearthstone, etc.): defer full world reload
|
|
|
|
|
// to next frame to avoid blocking the packet handler for 20+ seconds.
|
2026-03-28 14:55:58 -07:00
|
|
|
LOG_WARNING("Far same-map teleport (dist=", std::sqrt(teleportDistSq),
|
2026-03-28 15:38:44 -07:00
|
|
|
"), 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);
|
2026-03-28 15:50:13 -07:00
|
|
|
renderer->getCameraController()->suspendGravityFor(10.0f);
|
2026-03-28 15:38:44 -07:00
|
|
|
}
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z);
|
2026-03-28 14:55:58 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload");
|
|
|
|
|
// canonical and renderPos already computed above for distance check
|
2026-02-17 02:23:41 -08:00
|
|
|
renderer->getCharacterPosition() = renderPos;
|
|
|
|
|
if (renderer->getCameraController()) {
|
|
|
|
|
auto* ft = renderer->getCameraController()->getFollowTargetMutable();
|
|
|
|
|
if (ft) *ft = renderPos;
|
|
|
|
|
}
|
|
|
|
|
worldEntryMovementGraceTimer_ = 2.0f;
|
|
|
|
|
taxiLandingClampTimer_ = 0.0f;
|
|
|
|
|
lastTaxiFlight_ = false;
|
2026-03-05 15:12:51 -08:00
|
|
|
// Stop any movement that was active before the teleport
|
2026-03-06 20:38:58 -08:00
|
|
|
if (renderer->getCameraController()) {
|
2026-03-05 15:12:51 -08:00
|
|
|
renderer->getCameraController()->clearMovementInputs();
|
2026-03-06 20:38:58 -08:00
|
|
|
renderer->getCameraController()->suppressMovementFor(0.5f);
|
|
|
|
|
}
|
2026-03-18 07:54:05 -07:00
|
|
|
// 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();
|
2026-03-10 05:18:45 -07:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
2026-02-17 02:23:41 -08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 08:06:35 -08:00
|
|
|
// If a world load is already in progress (re-entrant call from
|
|
|
|
|
// gameHandler->update() processing SMSG_NEW_WORLD during warmup),
|
2026-03-15 01:21:23 -07:00
|
|
|
// defer this entry. The current load will pick it up when it finishes.
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_ && worldLoader_->isLoadingWorld()) {
|
2026-03-02 08:06:35 -08:00
|
|
|
LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)");
|
2026-04-01 20:06:26 +03:00
|
|
|
worldLoader_->setPendingEntry(mapId, x, y, z);
|
2026-03-02 08:06:35 -08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
// 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, ")");
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z);
|
2026-02-05 21:28:21 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
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();
|
2026-02-20 02:50:59 -08:00
|
|
|
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);
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
2026-02-20 02:50:59 -08:00
|
|
|
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);
|
2026-02-11 21:14:35 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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(), "");
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-14 21:15:28 -08:00
|
|
|
// /unstuck — nudge player forward and snap to floor at destination.
|
2026-02-11 21:14:35 -08:00
|
|
|
gameHandler->setUnstuckCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() {
|
2026-02-07 16:59:20 -08:00
|
|
|
if (!renderer || !renderer->getCameraController()) return;
|
2026-02-11 21:14:35 -08:00
|
|
|
worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f);
|
|
|
|
|
taxiLandingClampTimer_ = 0.0f;
|
|
|
|
|
lastTaxiFlight_ = false;
|
|
|
|
|
clearStuckMovement();
|
2026-02-07 16:59:20 -08:00
|
|
|
auto* cc = renderer->getCameraController();
|
|
|
|
|
auto* ft = cc->getFollowTargetMutable();
|
|
|
|
|
if (!ft) return;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
2026-02-08 15:32:04 -08:00
|
|
|
glm::vec3 pos = *ft;
|
2026-02-14 21:15:28 -08:00
|
|
|
|
|
|
|
|
// 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;
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 21:15:28 -08:00
|
|
|
// Sample floor at the DESTINATION position (after nudge).
|
2026-03-06 20:00:27 -08:00
|
|
|
// Pick the highest floor so we snap up to WMO floors when fallen below.
|
|
|
|
|
bool foundFloor = false;
|
2026-02-11 21:14:35 -08:00
|
|
|
if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) {
|
|
|
|
|
pos.z = *floor + 0.2f;
|
2026-03-06 20:00:27 -08:00
|
|
|
foundFloor = true;
|
2026-02-11 21:14:35 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:32:04 -08:00
|
|
|
cc->teleportTo(pos);
|
2026-03-06 20:00:27 -08:00
|
|
|
if (!foundFloor) {
|
|
|
|
|
cc->setGrounded(false); // Let gravity pull player down to a surface
|
|
|
|
|
}
|
2026-02-11 21:14:35 -08:00
|
|
|
syncTeleportedPositionToServer(pos);
|
|
|
|
|
forceServerTeleportCommand(pos);
|
|
|
|
|
clearStuckMovement();
|
2026-02-14 21:15:28 -08:00
|
|
|
LOG_INFO("Unstuck: nudged forward and snapped to floor");
|
2026-02-07 16:59:20 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
// /unstuckgy — stronger recovery: safe/home position, then sampled floor fallback.
|
|
|
|
|
gameHandler->setUnstuckGyCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() {
|
2026-02-08 15:32:04 -08:00
|
|
|
if (!renderer || !renderer->getCameraController()) return;
|
2026-02-11 21:14:35 -08:00
|
|
|
worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f);
|
|
|
|
|
taxiLandingClampTimer_ = 0.0f;
|
|
|
|
|
lastTaxiFlight_ = false;
|
|
|
|
|
clearStuckMovement();
|
2026-02-08 03:24:12 -08:00
|
|
|
auto* cc = renderer->getCameraController();
|
|
|
|
|
auto* ft = cc->getFollowTargetMutable();
|
|
|
|
|
if (!ft) return;
|
|
|
|
|
|
2026-02-08 15:32:04 -08:00
|
|
|
// Try last safe position first (nearby, terrain already loaded)
|
|
|
|
|
if (cc->hasLastSafePosition()) {
|
|
|
|
|
glm::vec3 safePos = cc->getLastSafePosition();
|
|
|
|
|
safePos.z += 5.0f;
|
|
|
|
|
cc->teleportTo(safePos);
|
2026-02-11 21:14:35 -08:00
|
|
|
syncTeleportedPositionToServer(safePos);
|
|
|
|
|
forceServerTeleportCommand(safePos);
|
|
|
|
|
clearStuckMovement();
|
2026-02-08 15:32:04 -08:00
|
|
|
LOG_INFO("Unstuck: teleported to last safe position");
|
|
|
|
|
return;
|
2026-02-08 03:24:12 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
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.
|
2026-02-08 15:32:04 -08:00
|
|
|
glm::vec3 pos = *ft;
|
2026-02-11 21:14:35 -08:00
|
|
|
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;
|
2026-02-08 15:32:04 -08:00
|
|
|
cc->teleportTo(pos);
|
2026-02-11 21:14:35 -08:00
|
|
|
syncTeleportedPositionToServer(pos);
|
|
|
|
|
forceServerTeleportCommand(pos);
|
|
|
|
|
clearStuckMovement();
|
|
|
|
|
LOG_INFO("Unstuck: high fallback snap");
|
2026-02-08 03:24:12 -08:00
|
|
|
});
|
|
|
|
|
|
2026-03-07 22:03:28 -08:00
|
|
|
// /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...");
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-08 15:32:04 -08:00
|
|
|
// Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry
|
|
|
|
|
if (renderer->getCameraController()) {
|
2026-03-07 22:03:28 -08:00
|
|
|
renderer->getCameraController()->setAutoUnstuckCallback([this, forceServerTeleportCommand]() {
|
2026-02-08 15:32:04 -08:00
|
|
|
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);
|
2026-03-07 22:03:28 -08:00
|
|
|
forceServerTeleportCommand(spawnPos);
|
|
|
|
|
LOG_INFO("Auto-unstuck: teleported to map entry point (server synced)");
|
2026-02-08 15:32:04 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bind point update (innkeeper) — position stored in gameHandler->getHomeBind()
|
2026-02-08 03:32:00 -08:00
|
|
|
gameHandler->setBindPointCallback([this](uint32_t mapId, float x, float y, float z) {
|
|
|
|
|
LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")");
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 21:57:42 -07:00
|
|
|
// 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;
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) {
|
|
|
|
|
mapName = worldLoader_->getMapNameById(mapId);
|
|
|
|
|
}
|
|
|
|
|
if (mapName.empty()) {
|
|
|
|
|
mapName = WorldLoader::mapIdToName(mapId);
|
2026-03-09 21:57:42 -07:00
|
|
|
}
|
|
|
|
|
if (mapName.empty()) mapName = "Azeroth";
|
|
|
|
|
|
2026-04-01 20:06:26 +03:00
|
|
|
uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF;
|
|
|
|
|
if (mapId == currentLoadedMap) {
|
2026-03-09 21:57:42 -07:00
|
|
|
// Same map: pre-enqueue tiles around the bind point so workers start
|
|
|
|
|
// loading them now. Uses render-space coords (canonicalToRender).
|
2026-03-10 05:18:45 -07:00
|
|
|
// Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time
|
|
|
|
|
// for workers to parse most of these before the player arrives.
|
2026-03-09 21:57:42 -07:00
|
|
|
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;
|
2026-03-10 05:18:45 -07:00
|
|
|
tiles.reserve(81);
|
|
|
|
|
for (int dy = -4; dy <= 4; dy++)
|
|
|
|
|
for (int dx = -4; dx <= 4; dx++)
|
2026-03-09 21:57:42 -07:00
|
|
|
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));
|
2026-04-01 20:06:26 +03:00
|
|
|
if (worldLoader_) {
|
|
|
|
|
worldLoader_->startWorldPreload(mapId, mapName, server.x, server.y);
|
|
|
|
|
}
|
2026-03-09 21:57:42 -07:00
|
|
|
LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName,
|
|
|
|
|
"' (id=", mapId, ")");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-06 17:27:20 -08:00
|
|
|
// Faction hostility map is built in buildFactionHostilityMap() when character enters world
|
2026-02-06 14:24:38 -08:00
|
|
|
|
2026-02-05 21:55:52 -08:00
|
|
|
// Creature spawn callback (online mode) - spawn creature models
|
feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.
- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
|
|
|
gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-02-11 18:25:04 -08:00
|
|
|
// Queue spawns to avoid hanging when many creatures appear at once.
|
|
|
|
|
// Deduplicate so repeated updates don't flood pending queue.
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_->isCreatureSpawned(guid)) return;
|
|
|
|
|
if (entitySpawner_->isCreaturePending(guid)) return;
|
|
|
|
|
entitySpawner_->queueCreatureSpawn(guid, displayId, x, y, z, orientation, scale);
|
2026-02-05 21:55:52 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-13 19:40:54 -08:00
|
|
|
// 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) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-28 11:18:36 -07:00
|
|
|
LOG_WARNING("playerSpawnCallback: guid=0x", std::hex, guid, std::dec,
|
|
|
|
|
" race=", static_cast<int>(raceId), " gender=", static_cast<int>(genderId),
|
|
|
|
|
" pos=(", x, ",", y, ",", z, ")");
|
2026-02-15 20:53:01 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_->isPlayerSpawned(guid)) return;
|
|
|
|
|
if (entitySpawner_->isPlayerPending(guid)) return;
|
|
|
|
|
entitySpawner_->queuePlayerSpawn(guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation);
|
2026-02-13 19:40:54 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-13 20:10:19 -08:00
|
|
|
// 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) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-02-16 00:51:59 -08:00
|
|
|
// 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.
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->queuePlayerEquipment(guid, displayInfoIds, inventoryTypes);
|
2026-02-13 20:10:19 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-05 21:55:52 -08:00
|
|
|
// Creature despawn callback (online mode) - remove creature models
|
|
|
|
|
gameHandler->setCreatureDespawnCallback([this](uint64_t guid) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->despawnCreature(guid);
|
2026-02-05 21:55:52 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-13 19:40:54 -08:00
|
|
|
gameHandler->setPlayerDespawnCallback([this](uint64_t guid) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->despawnPlayer(guid);
|
2026-02-13 19:40:54 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
// GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.)
|
feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.
- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
|
|
|
gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->queueGameObjectSpawn(guid, entry, displayId, x, y, z, orientation, scale);
|
2026-02-07 19:44:03 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GameObject despawn callback (online mode) - remove static models
|
|
|
|
|
gameHandler->setGameObjectDespawnCallback([this](uint64_t guid) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->despawnGameObject(guid);
|
2026-02-07 19:44:03 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-23 05:39:02 -08:00
|
|
|
// GameObject custom animation callback (e.g. chest opening)
|
|
|
|
|
gameHandler->setGameObjectCustomAnimCallback([this](uint64_t guid, uint32_t /*animId*/) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
auto& goInstances = entitySpawner_->getGameObjectInstances();
|
|
|
|
|
auto it = goInstances.find(guid);
|
|
|
|
|
if (it == goInstances.end() || !renderer) return;
|
2026-02-23 05:39:02 -08:00
|
|
|
auto& info = it->second;
|
|
|
|
|
if (!info.isWmo) {
|
|
|
|
|
if (auto* m2r = renderer->getM2Renderer()) {
|
|
|
|
|
m2r->setInstanceAnimationFrozen(info.instanceId, false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 21:13:13 -08:00
|
|
|
// 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;
|
2026-03-27 16:33:16 -07:00
|
|
|
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;
|
2026-02-19 21:13:13 -08:00
|
|
|
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);
|
2026-02-20 02:50:59 -08:00
|
|
|
gameHandler->sendMovement(game::Opcode::MSG_MOVE_SET_FACING);
|
2026-02-19 21:13:13 -08:00
|
|
|
|
|
|
|
|
// Set charge state
|
|
|
|
|
chargeActive_ = true;
|
|
|
|
|
chargeTimer_ = 0.0f;
|
2026-03-27 16:33:16 -07:00
|
|
|
chargeDuration_ = std::max(std::sqrt(distSq) / 25.0f, 0.3f); // ~25 units/sec
|
2026-02-19 21:13:13 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 20:36:25 -08:00
|
|
|
// Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect
|
2026-02-17 17:23:42 -08:00
|
|
|
gameHandler->setLevelUpCallback([this](uint32_t newLevel) {
|
|
|
|
|
if (uiManager) {
|
2026-03-31 09:18:17 +03:00
|
|
|
uiManager->getGameScreen().toastManager().triggerDing(newLevel);
|
2026-02-17 17:23:42 -08:00
|
|
|
}
|
2026-02-19 20:36:25 -08:00
|
|
|
if (renderer) {
|
|
|
|
|
renderer->triggerLevelUpEffect(renderer->getCharacterPosition());
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 13:53:42 -07:00
|
|
|
// Achievement earned callback — show toast banner
|
2026-03-10 20:53:21 -07:00
|
|
|
gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) {
|
2026-03-09 13:53:42 -07:00
|
|
|
if (uiManager) {
|
2026-03-31 09:18:17 +03:00
|
|
|
uiManager->getGameScreen().toastManager().triggerAchievementToast(achievementId, name);
|
2026-03-09 13:53:42 -07:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 15:46:19 -07:00
|
|
|
// Server-triggered music callback (SMSG_PLAY_MUSIC)
|
|
|
|
|
// Resolves soundId → SoundEntries.dbc → MPQ path → MusicManager.
|
|
|
|
|
gameHandler->setPlayMusicCallback([this](uint32_t soundId) {
|
|
|
|
|
if (!assetManager || !renderer) return;
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr;
|
2026-03-09 15:46:19 -07:00
|
|
|
if (!music) return;
|
|
|
|
|
|
|
|
|
|
auto dbc = assetManager->loadDBC("SoundEntries.dbc");
|
|
|
|
|
if (!dbc || !dbc->isLoaded()) return;
|
|
|
|
|
|
|
|
|
|
int32_t idx = dbc->findRecordById(soundId);
|
|
|
|
|
if (idx < 0) return;
|
|
|
|
|
|
2026-03-09 16:01:29 -07:00
|
|
|
// SoundEntries.dbc (WotLK): field 2 = Name (label), fields 3-12 = File[0..9], field 23 = DirectoryBase
|
2026-03-09 15:46:19 -07:00
|
|
|
const uint32_t row = static_cast<uint32_t>(idx);
|
2026-03-09 16:01:29 -07:00
|
|
|
std::string dir = dbc->getString(row, 23);
|
|
|
|
|
for (uint32_t f = 3; f <= 12; ++f) {
|
2026-03-09 15:46:19 -07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 16:11:19 -07:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 16:16:39 -07:00
|
|
|
// 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;
|
|
|
|
|
|
2026-03-24 10:17:47 -07:00
|
|
|
// Play as 3D sound if source entity position is available.
|
|
|
|
|
// Entity stores canonical coords; listener uses render coords (camera).
|
2026-03-09 16:16:39 -07:00
|
|
|
auto entity = gameHandler->getEntityManager().getEntity(sourceGuid);
|
|
|
|
|
if (entity) {
|
2026-03-24 10:17:47 -07:00
|
|
|
glm::vec3 canonical{entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()};
|
|
|
|
|
glm::vec3 pos = core::coords::canonicalToRender(canonical);
|
2026-03-09 16:16:39 -07:00
|
|
|
audio::AudioEngine::instance().playSound3D(path, pos);
|
|
|
|
|
} else {
|
|
|
|
|
audio::AudioEngine::instance().playSound2D(path);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 20:36:25 -08:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-02-17 17:23:42 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
// Mount callback (online mode) - defer heavy model load to next frame
|
2026-02-07 17:59:40 -08:00
|
|
|
gameHandler->setMountCallback([this](uint32_t mountDisplayId) {
|
|
|
|
|
if (mountDisplayId == 0) {
|
2026-02-07 18:33:14 -08:00
|
|
|
// Dismount is instant (no loading needed)
|
2026-03-31 22:01:55 +03:00
|
|
|
if (renderer && renderer->getCharacterRenderer() && entitySpawner_->getMountInstanceId() != 0) {
|
|
|
|
|
renderer->getCharacterRenderer()->removeInstance(entitySpawner_->getMountInstanceId());
|
|
|
|
|
entitySpawner_->clearMountState();
|
2026-02-07 17:59:40 -08:00
|
|
|
}
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->setMountDisplayId(0);
|
2026-02-07 18:33:14 -08:00
|
|
|
if (renderer) renderer->clearMount();
|
2026-02-07 17:59:40 -08:00
|
|
|
LOG_INFO("Dismounted");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-07 18:33:14 -08:00
|
|
|
// Queue the mount for processing in the next update() frame
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->setMountDisplayId(mountDisplayId);
|
2026-02-07 17:59:40 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-08 21:32:38 -08:00
|
|
|
// 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;
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
// Sample waypoints along path and gather tiles.
|
2026-02-11 22:27:02 -08:00
|
|
|
// Denser sampling + neighbor coverage reduces in-flight stream spikes.
|
|
|
|
|
const size_t stride = 2;
|
2026-02-11 19:28:15 -08:00
|
|
|
for (size_t i = 0; i < path.size(); i += stride) {
|
|
|
|
|
const auto& waypoint = path[i];
|
2026-02-08 21:32:38 -08:00
|
|
|
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));
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) {
|
2026-02-11 22:27:02 -08:00
|
|
|
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});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 19:28:15 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 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) {
|
2026-02-11 22:27:02 -08:00
|
|
|
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});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 21:32:38 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<std::pair<int, int>> tilesToLoad(uniqueTiles.begin(), uniqueTiles.end());
|
2026-02-11 22:27:02 -08:00
|
|
|
if (tilesToLoad.size() > 512) {
|
|
|
|
|
tilesToLoad.resize(512);
|
|
|
|
|
}
|
2026-02-08 21:32:38 -08:00
|
|
|
LOG_INFO("Precaching ", tilesToLoad.size(), " tiles for taxi route");
|
|
|
|
|
renderer->getTerrainManager()->precacheTiles(tilesToLoad);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-08 22:00:33 -08:00
|
|
|
// Taxi orientation callback - update mount rotation during flight
|
2026-02-08 22:05:38 -08:00
|
|
|
gameHandler->setTaxiOrientationCallback([this](float yaw, float pitch, float roll) {
|
2026-02-08 22:00:33 -08:00
|
|
|
if (renderer && renderer->getCameraController()) {
|
2026-02-11 19:28:15 -08:00
|
|
|
// Taxi callback now provides render-space yaw directly.
|
2026-02-08 22:05:38 -08:00
|
|
|
float yawDegrees = glm::degrees(yaw);
|
2026-02-08 22:00:33 -08:00
|
|
|
renderer->getCameraController()->setFacingYaw(yawDegrees);
|
2026-02-11 19:28:15 -08:00
|
|
|
renderer->setCharacterYaw(yawDegrees);
|
2026-02-08 22:05:38 -08:00
|
|
|
// Set mount pitch and roll for realistic flight animation
|
|
|
|
|
renderer->setMountPitchRoll(pitch, roll);
|
2026-02-08 22:00:33 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
// Taxi flight start callback - keep non-blocking to avoid hitching at takeoff.
|
2026-02-08 22:00:33 -08:00
|
|
|
gameHandler->setTaxiFlightStartCallback([this]() {
|
2026-02-08 22:08:42 -08:00
|
|
|
if (renderer && renderer->getTerrainManager() && renderer->getM2Renderer()) {
|
2026-02-11 19:28:15 -08:00
|
|
|
LOG_INFO("Taxi flight start: incremental terrain/M2 streaming active");
|
2026-02-08 22:08:42 -08:00
|
|
|
uint32_t m2Count = renderer->getM2Renderer()->getModelCount();
|
|
|
|
|
uint32_t instCount = renderer->getM2Renderer()->getInstanceCount();
|
2026-02-11 19:28:15 -08:00
|
|
|
LOG_INFO("Current M2 VRAM state: ", m2Count, " models (", instCount, " instances)");
|
2026-02-08 22:00:33 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-12 21:24:42 -07:00
|
|
|
// Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER
|
|
|
|
|
gameHandler->setOpenLfgCallback([this]() {
|
|
|
|
|
if (uiManager) uiManager->getGameScreen().openDungeonFinder();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
// Creature move callback (online mode) - update creature positions
|
|
|
|
|
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-02-13 19:40:54 -08:00
|
|
|
if (!renderer || !renderer->getCharacterRenderer()) return;
|
|
|
|
|
uint32_t instanceId = 0;
|
2026-03-10 10:09:02 -07:00
|
|
|
bool isPlayer = false;
|
2026-03-31 22:01:55 +03:00
|
|
|
instanceId = entitySpawner_->getPlayerInstanceId(guid);
|
|
|
|
|
if (instanceId != 0) { isPlayer = true; }
|
2026-02-13 19:40:54 -08:00
|
|
|
else {
|
2026-03-31 22:01:55 +03:00
|
|
|
instanceId = entitySpawner_->getCreatureInstanceId(guid);
|
2026-02-13 19:40:54 -08:00
|
|
|
}
|
|
|
|
|
if (instanceId != 0) {
|
2026-02-06 13:47:03 -08:00
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
|
|
|
|
|
float durationSec = static_cast<float>(durationMs) / 1000.0f;
|
2026-02-13 19:40:54 -08:00
|
|
|
renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec);
|
2026-03-10 10:19:13 -07:00
|
|
|
// Play Run animation (anim 5) for the duration of the spline move.
|
|
|
|
|
// WoW M2 animation IDs: 4=Walk, 5=Run.
|
2026-03-10 10:09:02 -07:00
|
|
|
// Don't override Death animation (1). The per-frame sync loop will return to
|
|
|
|
|
// Stand when movement stops.
|
|
|
|
|
if (durationMs > 0) {
|
2026-03-10 22:26:50 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->getCreatureWasMoving()[guid] = true;
|
2026-03-10 10:09:02 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
gameHandler->setGameObjectMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
auto& goInstMap = entitySpawner_->getGameObjectInstances();
|
|
|
|
|
auto it = goInstMap.find(guid);
|
|
|
|
|
if (it == goInstMap.end() || !renderer) {
|
2026-02-12 00:04:53 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
// 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) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-15 01:21:23 -07:00
|
|
|
if (!renderer) return;
|
2026-02-11 00:54:38 -08:00
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
// Get the GameObject instance now so late queue processing can rely on stable IDs.
|
2026-03-31 22:01:55 +03:00
|
|
|
auto& goInstances2 = entitySpawner_->getGameObjectInstances();
|
|
|
|
|
auto it = goInstances2.find(guid);
|
|
|
|
|
if (it == goInstances2.end()) {
|
2026-02-11 00:54:38 -08:00
|
|
|
LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
auto pendingIt = entitySpawner_->hasTransportRegistrationPending(guid);
|
|
|
|
|
if (pendingIt) {
|
|
|
|
|
entitySpawner_->updateTransportRegistration(guid, displayId, x, y, z, orientation);
|
2026-02-11 00:54:38 -08:00
|
|
|
} else {
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->queueTransportRegistration(guid, entry, displayId, x, y, z, orientation);
|
2026-02-12 15:35:31 -08:00
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Transport move callback (online mode) - update transport gameobject positions
|
|
|
|
|
gameHandler->setTransportMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-02-20 20:31:04 -08:00
|
|
|
LOG_DEBUG("Transport move callback: GUID=0x", std::hex, guid, std::dec,
|
2026-02-11 00:54:38 -08:00
|
|
|
" pos=(", x, ", ", y, ", ", z, ") orientation=", orientation);
|
|
|
|
|
|
|
|
|
|
auto* transportManager = gameHandler->getTransportManager();
|
|
|
|
|
if (!transportManager) {
|
|
|
|
|
LOG_WARNING("Transport move callback: TransportManager is null!");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_->hasTransportRegistrationPending(guid)) {
|
|
|
|
|
entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation);
|
2026-03-15 01:21:23 -07:00
|
|
|
LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
// Check if transport exists - if not, treat this as a late spawn (reconnection/server restart)
|
|
|
|
|
if (!transportManager->getTransport(guid)) {
|
2026-02-20 20:31:04 -08:00
|
|
|
LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec,
|
2026-02-11 00:54:38 -08:00
|
|
|
" - 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)
|
2026-03-31 22:01:55 +03:00
|
|
|
auto& goInstances3 = entitySpawner_->getGameObjectInstances();
|
|
|
|
|
auto it = goInstances3.find(guid);
|
|
|
|
|
if (it != goInstances3.end()) {
|
2026-02-11 00:54:38 -08:00
|
|
|
uint32_t wmoInstanceId = it->second.instanceId;
|
|
|
|
|
|
|
|
|
|
// TransportAnimation.dbc is indexed by GameObject entry
|
|
|
|
|
uint32_t pathId = entry;
|
2026-02-12 00:04:53 -08:00
|
|
|
const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid);
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
|
|
|
// Coordinates are already canonical (converted in game_handler.cpp)
|
|
|
|
|
glm::vec3 canonicalSpawnPos(x, y, z);
|
|
|
|
|
|
2026-02-11 17:30:57 -08:00
|
|
|
// 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 ||
|
2026-02-12 15:11:23 -08:00
|
|
|
displayId == 807 || displayId == 808);
|
2026-02-11 17:30:57 -08:00
|
|
|
bool hasUsablePath = transportManager->hasPathForEntry(entry);
|
|
|
|
|
if (shipOrZeppelinDisplay) {
|
|
|
|
|
hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
if (preferServerData) {
|
2026-02-12 02:27:59 -08:00
|
|
|
// Strict server-authoritative mode: no inferred/remapped fallback routes.
|
2026-02-12 00:04:53 -08:00
|
|
|
if (!hasUsablePath) {
|
2026-02-12 02:27:59 -08:00
|
|
|
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);
|
2026-02-12 00:04:53 -08:00
|
|
|
} else {
|
|
|
|
|
LOG_INFO("Auto-spawned transport in server-first mode with entry DBC path: entry=", entry,
|
|
|
|
|
" displayId=", displayId, " wmoInstance=", wmoInstanceId);
|
|
|
|
|
}
|
|
|
|
|
} else if (!hasUsablePath) {
|
2026-02-12 15:38:39 -08:00
|
|
|
bool allowZOnly = (displayId == 455 || displayId == 462);
|
|
|
|
|
uint32_t inferredPath = transportManager->inferDbcPathForSpawn(
|
|
|
|
|
canonicalSpawnPos, 1200.0f, allowZOnly);
|
2026-02-11 17:30:57 -08:00
|
|
|
if (inferredPath != 0) {
|
|
|
|
|
pathId = inferredPath;
|
|
|
|
|
LOG_INFO("Auto-spawned transport with inferred path: entry=", entry,
|
|
|
|
|
" inferredPath=", pathId, " displayId=", displayId,
|
2026-02-11 15:24:05 -08:00
|
|
|
" wmoInstance=", wmoInstanceId);
|
|
|
|
|
} else {
|
2026-02-11 17:30:57 -08:00
|
|
|
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,
|
2026-02-11 15:24:05 -08:00
|
|
|
" 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
} else {
|
|
|
|
|
LOG_INFO("Auto-spawned transport with real path: entry=", entry,
|
|
|
|
|
" displayId=", displayId, " wmoInstance=", wmoInstanceId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 20:20:43 -08:00
|
|
|
transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry);
|
2026-03-14 09:02:20 -07:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
} else {
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation);
|
2026-02-18 22:36:34 -08:00
|
|
|
LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec,
|
|
|
|
|
" - WMO instance not found yet (queued move for replay)");
|
2026-02-11 00:54:38 -08:00
|
|
|
return;
|
2026-02-08 00:59:40 -08:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation);
|
2026-02-18 22:36:34 -08:00
|
|
|
LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec,
|
|
|
|
|
" - entity not found in EntityManager (queued move for replay)");
|
2026-02-11 00:54:38 -08:00
|
|
|
return;
|
2026-02-08 00:59:40 -08:00
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
// 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;
|
2026-02-08 00:59:40 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 08:43:19 -07:00
|
|
|
// NPC/player death callback (online mode) - play death animation
|
2026-02-06 13:47:03 -08:00
|
|
|
gameHandler->setNpcDeathCallback([this](uint64_t guid) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->markCreatureDead(guid);
|
2026-03-18 08:43:19 -07:00
|
|
|
if (!renderer || !renderer->getCharacterRenderer()) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid);
|
|
|
|
|
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
|
2026-03-18 08:43:19 -07:00
|
|
|
if (instanceId != 0) {
|
|
|
|
|
renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death
|
2026-02-06 13:47:03 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 08:43:19 -07:00
|
|
|
// NPC/player respawn callback (online mode) - reset to idle animation
|
2026-02-06 16:47:07 -08:00
|
|
|
gameHandler->setNpcRespawnCallback([this](uint64_t guid) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->unmarkCreatureDead(guid);
|
2026-03-18 08:43:19 -07:00
|
|
|
if (!renderer || !renderer->getCharacterRenderer()) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid);
|
|
|
|
|
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
|
2026-03-18 08:43:19 -07:00
|
|
|
if (instanceId != 0) {
|
|
|
|
|
renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle
|
2026-02-06 16:47:07 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 08:43:19 -07:00
|
|
|
// NPC/player swing callback (online mode) - play attack animation
|
2026-02-06 13:47:03 -08:00
|
|
|
gameHandler->setNpcSwingCallback([this](uint64_t guid) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-18 08:43:19 -07:00
|
|
|
if (!renderer || !renderer->getCharacterRenderer()) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid);
|
|
|
|
|
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
|
2026-03-18 08:43:19 -07:00
|
|
|
if (instanceId != 0) {
|
2026-03-27 18:14:29 -07:00
|
|
|
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);
|
2026-02-06 13:47:03 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 10:55:23 -07:00
|
|
|
// 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.
|
2026-03-10 10:36:45 -07:00
|
|
|
// animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync.
|
2026-03-10 10:30:50 -07:00
|
|
|
gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-10 10:30:50 -07:00
|
|
|
if (!renderer) return;
|
|
|
|
|
auto* cr = renderer->getCharacterRenderer();
|
|
|
|
|
if (!cr) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
uint32_t instanceId = entitySpawner_->getPlayerInstanceId(guid);
|
|
|
|
|
if (instanceId == 0) instanceId = entitySpawner_->getCreatureInstanceId(guid);
|
2026-03-10 10:30:50 -07:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 10:55:23 -07:00
|
|
|
// 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) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-10 10:55:23 -07:00
|
|
|
const bool isSwimming = (moveFlags & static_cast<uint32_t>(game::MovementFlags::SWIMMING)) != 0;
|
|
|
|
|
const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0;
|
2026-03-10 11:56:50 -07:00
|
|
|
const bool isFlying = (moveFlags & static_cast<uint32_t>(game::MovementFlags::FLYING)) != 0;
|
2026-03-31 22:01:55 +03:00
|
|
|
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);
|
2026-03-10 10:55:23 -07:00
|
|
|
});
|
|
|
|
|
|
2026-03-10 09:25:58 -07:00
|
|
|
// Emote animation callback — play server-driven emote animations on NPCs and other players
|
|
|
|
|
gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-10 09:25:58 -07:00
|
|
|
if (!renderer || emoteAnim == 0) return;
|
|
|
|
|
auto* cr = renderer->getCharacterRenderer();
|
|
|
|
|
if (!cr) return;
|
|
|
|
|
// Look up creature instance first, then online players
|
2026-03-31 22:01:55 +03:00
|
|
|
uint32_t emoteInstanceId = entitySpawner_->getCreatureInstanceId(guid);
|
|
|
|
|
if (emoteInstanceId != 0) {
|
|
|
|
|
cr->playAnimation(emoteInstanceId, emoteAnim, false);
|
|
|
|
|
return;
|
2026-03-10 09:25:58 -07:00
|
|
|
}
|
2026-03-31 22:01:55 +03:00
|
|
|
emoteInstanceId = entitySpawner_->getPlayerInstanceId(guid);
|
|
|
|
|
if (emoteInstanceId != 0) {
|
|
|
|
|
cr->playAnimation(emoteInstanceId, emoteAnim, false);
|
2026-03-10 09:25:58 -07:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 09:42:17 -07:00
|
|
|
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
|
|
|
|
|
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-10 09:42:17 -07:00
|
|
|
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
|
|
|
|
|
{
|
2026-03-31 22:01:55 +03:00
|
|
|
uint32_t cInst = entitySpawner_->getCreatureInstanceId(guid);
|
|
|
|
|
if (cInst != 0) {
|
|
|
|
|
if (start) cr->playAnimation(cInst, castAnim, false);
|
2026-03-10 09:42:17 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
{
|
2026-03-31 22:01:55 +03:00
|
|
|
uint32_t pInst = entitySpawner_->getPlayerInstanceId(guid);
|
|
|
|
|
if (pInst != 0) {
|
|
|
|
|
if (start) cr->playAnimation(pInst, castAnim, false);
|
2026-03-10 09:42:17 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 09:57:24 -07:00
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 09:46:46 -07:00
|
|
|
// Stand state animation callback — map server stand state to M2 animation on player
|
2026-03-10 09:51:15 -07:00
|
|
|
// and sync camera sit flag so movement is blocked while sitting
|
2026-03-10 09:46:46 -07:00
|
|
|
gameHandler->setStandStateCallback([this](uint8_t standState) {
|
|
|
|
|
if (!renderer) return;
|
2026-03-10 09:51:15 -07:00
|
|
|
|
|
|
|
|
// Sync camera controller sitting flag: block movement while sitting/kneeling
|
|
|
|
|
if (auto* cc = renderer->getCameraController()) {
|
|
|
|
|
cc->setSitting(standState >= 1 && standState <= 8 && standState != 7);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 09:46:46 -07:00
|
|
|
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
|
2026-03-14 08:27:32 -07:00
|
|
|
// 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.
|
2026-03-10 09:46:46 -07:00
|
|
|
uint32_t animId = 0;
|
|
|
|
|
if (standState == 0) {
|
2026-03-14 08:27:32 -07:00
|
|
|
return;
|
2026-03-10 09:46:46 -07:00
|
|
|
} 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
|
|
|
|
|
}
|
2026-03-10 09:51:15 -07:00
|
|
|
// Loop sit/kneel (not death) so the held-pose frame stays visible
|
2026-03-10 09:46:46 -07:00
|
|
|
const bool loop = (animId != 1);
|
|
|
|
|
cr->playAnimation(charInstId, animId, loop);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-09 01:29:44 -08:00
|
|
|
// NPC greeting callback - play voice line
|
|
|
|
|
gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) {
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
|
2026-02-09 01:29:44 -08:00
|
|
|
// Convert canonical to render coords for 3D audio
|
|
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(position);
|
2026-02-09 02:22:20 -08:00
|
|
|
|
|
|
|
|
// 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();
|
2026-03-31 22:01:55 +03:00
|
|
|
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
|
2026-02-09 02:22:20 -08:00
|
|
|
}
|
|
|
|
|
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
audioCoordinator_->getNpcVoiceManager()->playGreeting(guid, voiceType, renderPos);
|
2026-02-09 01:29:44 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
// NPC farewell callback - play farewell voice line
|
|
|
|
|
gameHandler->setNpcFarewellCallback([this](uint64_t guid, const glm::vec3& position) {
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
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();
|
2026-03-31 22:01:55 +03:00
|
|
|
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
}
|
|
|
|
|
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
audioCoordinator_->getNpcVoiceManager()->playFarewell(guid, voiceType, renderPos);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// NPC vendor callback - play vendor voice line
|
|
|
|
|
gameHandler->setNpcVendorCallback([this](uint64_t guid, const glm::vec3& position) {
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
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();
|
2026-03-31 22:01:55 +03:00
|
|
|
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
}
|
|
|
|
|
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
audioCoordinator_->getNpcVoiceManager()->playVendor(guid, voiceType, renderPos);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// NPC aggro callback - play combat start voice line
|
|
|
|
|
gameHandler->setNpcAggroCallback([this](uint64_t guid, const glm::vec3& position) {
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
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();
|
2026-03-31 22:01:55 +03:00
|
|
|
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
}
|
|
|
|
|
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
audioCoordinator_->getNpcVoiceManager()->playAggro(guid, voiceType, renderPos);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
// "Create Character" button on character screen
|
|
|
|
|
uiManager->getCharacterScreen().setOnCreateCharacter([this]() {
|
|
|
|
|
uiManager->getCharacterCreateScreen().reset();
|
2026-02-14 00:57:33 -08:00
|
|
|
// Apply expansion race/class constraints before showing the screen
|
|
|
|
|
if (expansionRegistry_ && expansionRegistry_->getActive()) {
|
|
|
|
|
auto* profile = expansionRegistry_->getActive();
|
|
|
|
|
uiManager->getCharacterCreateScreen().setExpansionConstraints(
|
|
|
|
|
profile->races, profile->classes);
|
|
|
|
|
}
|
2026-02-05 14:55:42 -08:00
|
|
|
uiManager->getCharacterCreateScreen().initializePreview(assetManager.get());
|
2026-02-05 14:13:48 -08:00
|
|
|
setState(AppState::CHARACTER_CREATION);
|
2026-02-02 12:24:50 -08:00
|
|
|
});
|
2026-02-06 03:24:46 -08:00
|
|
|
|
|
|
|
|
// "Back" button on character screen
|
|
|
|
|
uiManager->getCharacterScreen().setOnBack([this]() {
|
2026-02-14 19:24:31 -08:00
|
|
|
// Disconnect from world server and reset UI state for fresh realm selection
|
|
|
|
|
if (gameHandler) {
|
|
|
|
|
gameHandler->disconnect();
|
|
|
|
|
}
|
|
|
|
|
uiManager->getRealmScreen().reset();
|
|
|
|
|
uiManager->getCharacterScreen().reset();
|
2026-02-06 23:52:16 -08:00
|
|
|
setState(AppState::REALM_SELECTION);
|
2026-02-06 03:24:46 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// "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
|
2026-02-06 23:52:16 -08:00
|
|
|
gameHandler->requestCharacterList();
|
2026-02-06 03:24:46 -08:00
|
|
|
} else {
|
2026-02-06 18:34:45 -08:00
|
|
|
uint8_t code = gameHandler ? gameHandler->getLastCharDeleteResult() : 0xFF;
|
|
|
|
|
uiManager->getCharacterScreen().setStatus(
|
2026-02-17 13:59:29 -08:00
|
|
|
"Delete failed (code " + std::to_string(static_cast<int>(code)) + ").", true);
|
2026-02-06 03:24:46 -08:00
|
|
|
}
|
|
|
|
|
});
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Application::spawnPlayerCharacter() {
|
|
|
|
|
if (playerCharacterSpawned) return;
|
|
|
|
|
if (!renderer || !renderer->getCharacterRenderer() || !renderer->getCamera()) return;
|
|
|
|
|
|
|
|
|
|
auto* charRenderer = renderer->getCharacterRenderer();
|
|
|
|
|
auto* camera = renderer->getCamera();
|
|
|
|
|
bool loaded = false;
|
2026-04-01 13:31:48 +03:00
|
|
|
std::string m2Path = appearanceComposer_->getPlayerModelPath(playerRace_, playerGender_);
|
2026-02-05 14:35:12 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-05 14:35:12 -08:00
|
|
|
// Try loading selected character model from MPQ
|
2026-02-02 12:24:50 -08:00
|
|
|
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
|
2026-02-05 14:35:12 -08:00
|
|
|
std::string skinPath = modelDir + baseName + "00.skin";
|
2026-02-02 12:24:50 -08:00
|
|
|
auto skinData = assetManager->readFile(skinPath);
|
2026-02-14 13:57:54 -08:00
|
|
|
if (!skinData.empty() && model.version >= 264) {
|
2026-02-02 12:24:50 -08:00
|
|
|
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, "'");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:31:48 +03:00
|
|
|
// Resolve textures from CharSections.dbc via AppearanceComposer
|
|
|
|
|
PlayerTextureInfo texInfo;
|
2026-02-06 17:27:20 -08:00
|
|
|
bool useCharSections = true;
|
2026-04-01 13:31:48 +03:00
|
|
|
if (appearanceComposer_) {
|
|
|
|
|
uint32_t appearanceBytes = 0;
|
|
|
|
|
if (gameHandler) {
|
|
|
|
|
const game::Character* activeChar = gameHandler->getActiveCharacter();
|
|
|
|
|
if (activeChar) {
|
|
|
|
|
appearanceBytes = activeChar->appearanceBytes;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 13:31:48 +03:00
|
|
|
texInfo = appearanceComposer_->resolvePlayerTextures(model, playerRace_, playerGender_, appearanceBytes);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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),
|
2026-02-05 14:35:12 -08:00
|
|
|
"%s%s%04u-%02u.anim",
|
|
|
|
|
modelDir.c_str(),
|
|
|
|
|
baseName.c_str(),
|
|
|
|
|
model.sequences[si].id,
|
|
|
|
|
model.sequences[si].variationIndex);
|
2026-02-12 02:27:59 -08:00
|
|
|
auto animFileData = assetManager->readFileOptional(animFileName);
|
2026-02-02 12:24:50 -08:00
|
|
|
if (!animFileData.empty()) {
|
|
|
|
|
pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
charRenderer->loadModel(model, 1);
|
|
|
|
|
|
2026-04-01 13:31:48 +03:00
|
|
|
// Apply composited textures via AppearanceComposer (saves skin state for re-compositing)
|
|
|
|
|
if (useCharSections && appearanceComposer_) {
|
|
|
|
|
appearanceComposer_->compositePlayerSkin(1, texInfo);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loaded = true;
|
2026-02-05 14:35:12 -08:00
|
|
|
LOG_INFO("Loaded character model: ", m2Path, " (", model.vertices.size(), " verts, ",
|
2026-02-02 12:24:50 -08:00
|
|
|
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)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 23:30:03 -08:00
|
|
|
// Spawn character at the camera controller's default position (matches hearthstone).
|
|
|
|
|
// Most presets snap to floor; explicit WMO-floor presets keep their authored Z.
|
2026-02-04 13:29:27 -08:00
|
|
|
auto* camCtrl = renderer->getCameraController();
|
|
|
|
|
glm::vec3 spawnPos = camCtrl ? camCtrl->getDefaultPosition()
|
|
|
|
|
: (camera->getPosition() - glm::vec3(0.0f, 0.0f, 5.0f));
|
2026-02-04 23:30:03 -08:00
|
|
|
if (spawnSnapToGround && renderer->getTerrainManager()) {
|
2026-02-04 13:29:27 -08:00
|
|
|
auto terrainH = renderer->getTerrainManager()->getHeightAt(spawnPos.x, spawnPos.y);
|
|
|
|
|
if (terrainH) {
|
|
|
|
|
spawnPos.z = *terrainH + 0.1f;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
uint32_t instanceId = charRenderer->createInstance(1, spawnPos,
|
2026-02-03 14:26:08 -08:00
|
|
|
glm::vec3(0.0f), 1.0f); // Scale 1.0 = normal WoW character size
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
if (instanceId > 0) {
|
2026-02-12 14:55:27 -08:00
|
|
|
// Set up third-person follow
|
|
|
|
|
renderer->getCharacterPosition() = spawnPos;
|
|
|
|
|
renderer->setCharacterFollow(instanceId);
|
|
|
|
|
|
2026-04-01 13:31:48 +03:00
|
|
|
// Build default geosets for the active character via AppearanceComposer
|
2026-02-12 14:55:27 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 13:31:48 +03:00
|
|
|
auto activeGeosets = appearanceComposer_
|
|
|
|
|
? appearanceComposer_->buildDefaultPlayerGeosets(hairStyleId, facialId)
|
|
|
|
|
: std::unordered_set<uint16_t>{};
|
2026-02-12 14:55:27 -08:00
|
|
|
charRenderer->setActiveGeosets(instanceId, activeGeosets);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-02-23 06:22:30 -08:00
|
|
|
// Set voice profile to match character race/gender
|
chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.
AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()
Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
directly, grouped by access pattern:
- UIServices: settings_panel, game_screen, toast_manager, chat_panel,
combat_ui, window_manager
- GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
social_handler, combat_handler
- Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00
|
|
|
if (auto* asm_ = audioCoordinator_ ? audioCoordinator_->getActivitySoundManager() : nullptr) {
|
2026-02-23 06:22:30 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
// Set up camera controller for first-person player hiding
|
|
|
|
|
if (renderer->getCameraController()) {
|
|
|
|
|
renderer->getCameraController()->setCharacterRenderer(charRenderer, instanceId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Load equipped weapons (sword + shield)
|
2026-04-01 13:31:48 +03:00
|
|
|
if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 17:27:20 -08:00
|
|
|
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
|
2026-02-12 22:56:36 -08:00
|
|
|
const auto* facL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr;
|
|
|
|
|
const auto* ftL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("FactionTemplate") : nullptr;
|
2026-02-06 17:27:20 -08:00
|
|
|
std::unordered_set<uint32_t> hostileParentFactions;
|
|
|
|
|
if (fDbc && fDbc->isLoaded()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
const uint32_t facID = facL ? (*facL)["ID"] : 0;
|
|
|
|
|
const uint32_t facRaceMask0 = facL ? (*facL)["ReputationRaceMask0"] : 2;
|
|
|
|
|
const uint32_t facBase0 = facL ? (*facL)["ReputationBase0"] : 10;
|
2026-02-06 17:27:20 -08:00
|
|
|
for (uint32_t i = 0; i < fDbc->getRecordCount(); i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
uint32_t factionId = fDbc->getUInt32(i, facID);
|
2026-02-06 17:27:20 -08:00
|
|
|
for (int slot = 0; slot < 4; slot++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
uint32_t raceMask = fDbc->getUInt32(i, facRaceMask0 + slot);
|
2026-02-06 17:27:20 -08:00
|
|
|
if (raceMask & playerRaceMask) {
|
2026-02-12 22:56:36 -08:00
|
|
|
int32_t baseRep = fDbc->getInt32(i, facBase0 + slot);
|
2026-02-06 17:27:20 -08:00
|
|
|
if (baseRep < 0) {
|
|
|
|
|
hostileParentFactions.insert(factionId);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-25 11:40:49 -07:00
|
|
|
LOG_INFO("Faction.dbc: ", hostileParentFactions.size(), " factions hostile to race ", static_cast<int>(playerRace));
|
2026-02-06 17:27:20 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get player faction template data
|
2026-02-12 22:56:36 -08:00
|
|
|
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;
|
2026-02-06 17:27:20 -08:00
|
|
|
uint32_t playerFriendGroup = 0;
|
|
|
|
|
uint32_t playerEnemyGroup = 0;
|
|
|
|
|
uint32_t playerFactionId = 0;
|
|
|
|
|
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
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);
|
2026-02-06 17:27:20 -08:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build hostility map for each faction template
|
|
|
|
|
std::unordered_map<uint32_t, bool> factionMap;
|
|
|
|
|
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
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);
|
2026-02-06 17:27:20 -08:00
|
|
|
|
|
|
|
|
// 1. Symmetric group check
|
|
|
|
|
bool hostile = (enemyGroup & playerFriendGroup) != 0
|
|
|
|
|
|| (factionGroup & playerEnemyGroup) != 0;
|
|
|
|
|
|
|
|
|
|
// 2. Monster factionGroup bit (8)
|
|
|
|
|
if (!hostile && (factionGroup & 8) != 0) {
|
|
|
|
|
hostile = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
// 3. Individual enemy faction IDs
|
2026-02-06 17:27:20 -08:00
|
|
|
if (!hostile && playerFactionId > 0) {
|
2026-02-12 22:56:36 -08:00
|
|
|
for (uint32_t e = ftEnemy0; e <= ftEnemy0 + 3; e++) {
|
2026-02-06 17:27:20 -08:00
|
|
|
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));
|
2026-03-25 11:40:49 -07:00
|
|
|
LOG_INFO("Faction hostility for race ", static_cast<int>(playerRace), " (FT ", playerFtId, "): ",
|
2026-02-06 17:27:20 -08:00
|
|
|
hostileCount, "/", ftDbc->getRecordCount(),
|
|
|
|
|
" hostile (friendGroup=0x", std::hex, playerFriendGroup, ", enemyGroup=0x", playerEnemyGroup, std::dec, ")");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
// Render bounds/position queries — delegates to EntitySpawner
|
2026-02-06 18:34:45 -08:00
|
|
|
bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const {
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_) return entitySpawner_->getRenderBoundsForGuid(guid, outCenter, outRadius);
|
|
|
|
|
return false;
|
2026-02-06 18:34:45 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:02:34 -08:00
|
|
|
bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const {
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_) return entitySpawner_->getRenderFootZForGuid(guid, outFootZ);
|
|
|
|
|
return false;
|
2026-02-20 16:02:34 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 06:33:44 -07:00
|
|
|
bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const {
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_) return entitySpawner_->getRenderPositionForGuid(guid, outPos);
|
|
|
|
|
return false;
|
2026-02-07 19:44:03 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 23:05:23 -08:00
|
|
|
void Application::loadQuestMarkerModels() {
|
|
|
|
|
if (!assetManager || !renderer) return;
|
|
|
|
|
|
2026-03-09 15:39:16 -07:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 23:05:23 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Application::updateQuestMarkers() {
|
2026-02-09 23:41:38 -08:00
|
|
|
if (!gameHandler || !renderer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto* questMarkerRenderer = renderer->getQuestMarkerRenderer();
|
|
|
|
|
if (!questMarkerRenderer) {
|
2026-02-09 23:08:30 -08:00
|
|
|
static bool logged = false;
|
|
|
|
|
if (!logged) {
|
2026-02-09 23:41:38 -08:00
|
|
|
LOG_WARNING("QuestMarkerRenderer not available!");
|
2026-02-09 23:08:30 -08:00
|
|
|
logged = true;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-09 23:05:23 -08:00
|
|
|
|
|
|
|
|
const auto& questStatuses = gameHandler->getNpcQuestStatuses();
|
|
|
|
|
|
2026-02-09 23:41:38 -08:00
|
|
|
// Clear all markers (we'll re-add active ones)
|
|
|
|
|
questMarkerRenderer->clear();
|
|
|
|
|
|
|
|
|
|
static bool firstRun = true;
|
|
|
|
|
int markersAdded = 0;
|
2026-02-09 23:05:23 -08:00
|
|
|
|
2026-02-09 23:41:38 -08:00
|
|
|
// Add markers for NPCs with quest status
|
2026-02-09 23:05:23 -08:00
|
|
|
for (const auto& [guid, status] : questStatuses) {
|
2026-02-09 23:41:38 -08:00
|
|
|
// Determine marker type
|
|
|
|
|
int markerType = -1; // -1 = no marker
|
2026-02-09 23:05:23 -08:00
|
|
|
|
|
|
|
|
using game::QuestGiverStatus;
|
2026-03-10 22:26:50 -07:00
|
|
|
float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests)
|
2026-02-09 23:05:23 -08:00
|
|
|
switch (status) {
|
|
|
|
|
case QuestGiverStatus::AVAILABLE:
|
2026-03-10 22:26:50 -07:00
|
|
|
markerType = 0; // Yellow !
|
|
|
|
|
break;
|
2026-02-09 23:05:23 -08:00
|
|
|
case QuestGiverStatus::AVAILABLE_LOW:
|
2026-03-10 22:26:50 -07:00
|
|
|
markerType = 0; // Grey ! (same texture, desaturated in shader)
|
|
|
|
|
markerGrayscale = 1.0f;
|
2026-02-09 23:05:23 -08:00
|
|
|
break;
|
|
|
|
|
case QuestGiverStatus::REWARD:
|
2026-02-19 02:04:56 -08:00
|
|
|
case QuestGiverStatus::REWARD_REP:
|
2026-03-10 22:26:50 -07:00
|
|
|
markerType = 1; // Yellow ?
|
2026-02-09 23:05:23 -08:00
|
|
|
break;
|
|
|
|
|
case QuestGiverStatus::INCOMPLETE:
|
2026-03-10 22:26:50 -07:00
|
|
|
markerType = 2; // Grey ?
|
2026-02-09 23:05:23 -08:00
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 23:41:38 -08:00
|
|
|
if (markerType < 0) continue;
|
|
|
|
|
|
2026-02-09 23:05:23 -08:00
|
|
|
// Get NPC entity position
|
|
|
|
|
auto entity = gameHandler->getEntityManager().getEntity(guid);
|
|
|
|
|
if (!entity) continue;
|
2026-02-19 03:31:49 -08:00
|
|
|
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.
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 23:05:23 -08:00
|
|
|
|
|
|
|
|
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
|
|
|
|
|
glm::vec3 renderPos = coords::canonicalToRender(canonical);
|
|
|
|
|
|
2026-02-09 23:41:38 -08:00
|
|
|
// Get NPC bounding height for proper marker positioning
|
2026-02-09 23:05:23 -08:00
|
|
|
glm::vec3 boundsCenter;
|
|
|
|
|
float boundsRadius = 0.0f;
|
2026-02-09 23:41:38 -08:00
|
|
|
float boundingHeight = 2.0f; // Default
|
2026-02-09 23:05:23 -08:00
|
|
|
if (getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
|
2026-02-09 23:41:38 -08:00
|
|
|
boundingHeight = boundsRadius * 2.0f;
|
2026-02-09 23:05:23 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 23:41:38 -08:00
|
|
|
// Set the marker (renderer will handle positioning, bob, glow, etc.)
|
2026-03-10 22:26:50 -07:00
|
|
|
questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale);
|
2026-02-09 23:41:38 -08:00
|
|
|
markersAdded++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (firstRun && markersAdded > 0) {
|
2026-02-11 21:14:35 -08:00
|
|
|
LOG_DEBUG("Quest markers: Added ", markersAdded, " markers on first run");
|
2026-02-09 23:41:38 -08:00
|
|
|
firstRun = false;
|
2026-02-09 23:05:23 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
void Application::setupTestTransport() {
|
2026-03-31 22:10:20 -07:00
|
|
|
if (!entitySpawner_) return;
|
2026-03-31 22:01:55 +03:00
|
|
|
if (entitySpawner_->isTestTransportSetup()) return;
|
2026-02-10 21:29:10 -08:00
|
|
|
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);
|
|
|
|
|
|
2026-02-11 02:23:37 -08:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
// 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);
|
|
|
|
|
|
2026-02-18 22:36:34 -08:00
|
|
|
// 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",
|
|
|
|
|
};
|
2026-02-10 21:29:10 -08:00
|
|
|
|
2026-02-18 22:36:34 -08:00
|
|
|
std::string transportWmoPath;
|
|
|
|
|
std::vector<uint8_t> wmoData;
|
|
|
|
|
for (const auto& candidate : transportCandidates) {
|
|
|
|
|
wmoData = assetManager->readFile(candidate);
|
|
|
|
|
if (!wmoData.empty()) {
|
|
|
|
|
transportWmoPath = candidate;
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-02-10 21:29:10 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (wmoData.empty()) {
|
|
|
|
|
LOG_WARNING("No transport WMO found - test transport disabled");
|
2026-02-18 22:36:34 -08:00
|
|
|
LOG_INFO("Expected under World\\wmo\\transports\\...");
|
2026-02-10 21:29:10 -08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 22:36:34 -08:00
|
|
|
LOG_INFO("Using transport WMO: ", transportWmoPath);
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
// 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
|
2026-02-11 00:54:38 -08:00
|
|
|
transportManager->registerTransport(transportGuid, wmoInstanceId, pathId, startCanonical);
|
2026-02-10 21:29:10 -08:00
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
|
2026-03-31 22:01:55 +03:00
|
|
|
entitySpawner_->setTestTransportSetup(true);
|
2026-02-10 21:29:10 -08:00
|
|
|
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("========================================");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
} // namespace core
|
|
|
|
|
} // namespace wowee
|