diff --git a/CMakeLists.txt b/CMakeLists.txt index 752a42db..aad2ea9c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,7 @@ set(WOWEE_SOURCES # Game src/game/game_handler.cpp + src/game/transport_manager.cpp src/game/world.cpp src/game/player.cpp src/game/entity.cpp @@ -95,7 +96,6 @@ set(WOWEE_SOURCES src/game/world_packets.cpp src/game/character.cpp src/game/zone_manager.cpp - src/game/npc_manager.cpp src/game/inventory.cpp # Audio @@ -199,7 +199,6 @@ set(WOWEE_HEADERS include/game/entity.hpp include/game/opcodes.hpp include/game/zone_manager.hpp - include/game/npc_manager.hpp include/game/inventory.hpp include/game/spell_defines.hpp include/game/group_defines.hpp diff --git a/include/core/application.hpp b/include/core/application.hpp index e6e67241..f023c53f 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -14,7 +14,7 @@ namespace wowee { namespace rendering { class Renderer; } namespace ui { class UIManager; } namespace auth { class AuthHandler; } -namespace game { class GameHandler; class World; class NpcManager; } +namespace game { class GameHandler; class World; } namespace pipeline { class AssetManager; } namespace audio { enum class VoiceType; } @@ -79,7 +79,6 @@ private: void render(); void setupUICallbacks(); void spawnPlayerCharacter(); - void spawnNpcs(); std::string getPlayerModelPath() const; static const char* mapIdToName(uint32_t mapId); void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z); @@ -93,6 +92,7 @@ private: void buildGameObjectDisplayLookups(); std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const; audio::VoiceType detectVoiceTypeFromDisplayId(uint32_t displayId) const; + void setupTestTransport(); // Test transport boat for development static Application* instance; @@ -102,7 +102,6 @@ private: std::unique_ptr authHandler; std::unique_ptr gameHandler; std::unique_ptr world; - std::unique_ptr npcManager; std::unique_ptr assetManager; AppState state = AppState::AUTHENTICATION; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2c3d2688..04ddb7de 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -17,6 +17,10 @@ #include #include +namespace wowee::game { + class TransportManager; +} + namespace wowee { namespace network { class WorldSocket; class Packet; } @@ -483,6 +487,16 @@ public: bool isOnTransport() const { return playerTransportGuid_ != 0; } uint64_t getPlayerTransportGuid() const { return playerTransportGuid_; } glm::vec3 getPlayerTransportOffset() const { return playerTransportOffset_; } + glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset + TransportManager* getTransportManager() { return transportManager_.get(); } + void setPlayerOnTransport(uint64_t transportGuid, const glm::vec3& localOffset) { + playerTransportGuid_ = transportGuid; + playerTransportOffset_ = localOffset; + } + void clearPlayerTransport() { + playerTransportGuid_ = 0; + playerTransportOffset_ = glm::vec3(0.0f); + } // Cooldowns float getSpellCooldown(uint32_t spellId) const; @@ -972,6 +986,7 @@ private: std::unordered_set transportGuids_; // GUIDs of known transport GameObjects uint64_t playerTransportGuid_ = 0; // Transport the player is riding (0 = none) glm::vec3 playerTransportOffset_ = glm::vec3(0.0f); // Player offset on transport + std::unique_ptr transportManager_; // Transport movement manager std::vector knownSpells; std::unordered_map spellCooldowns; // spellId -> remaining seconds uint8_t castCount = 0; diff --git a/include/game/npc_manager.hpp b/include/game/npc_manager.hpp deleted file mode 100644 index 96d1987a..00000000 --- a/include/game/npc_manager.hpp +++ /dev/null @@ -1,75 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -namespace wowee { -namespace pipeline { class AssetManager; } -namespace rendering { class CharacterRenderer; } -namespace rendering { class TerrainManager; } -namespace game { - -class EntityManager; - -struct NpcSpawnDef { - std::string mapName; - uint32_t entry = 0; - std::string name; - std::string m2Path; - uint32_t level; - uint32_t health; - glm::vec3 canonicalPosition; // WoW canonical coords (+X north, +Y west, +Z up) - bool inputIsServerCoords = false; // if true, input XYZ are server/wire order - float rotation; // radians around Z - float scale; - bool isCritter; // critters don't do humanoid emotes - uint32_t faction = 0; // faction template ID from creature_template - uint32_t npcFlags = 0; // NPC interaction flags from creature_template -}; - -struct NpcInstance { - uint64_t guid; - uint32_t renderInstanceId; - float emoteTimer; // countdown to next random emote - float emoteEndTimer; // countdown until emote animation finishes - bool isEmoting; - bool isCritter; -}; - -class NpcManager { -public: - void clear(rendering::CharacterRenderer* cr, EntityManager* em); - void initialize(pipeline::AssetManager* am, - rendering::CharacterRenderer* cr, - EntityManager& em, - const std::string& mapName, - const glm::vec3& playerCanonical, - const rendering::TerrainManager* terrainManager); - void update(float deltaTime, rendering::CharacterRenderer* cr); - - uint32_t findRenderInstanceId(uint64_t guid) const; - -private: - std::vector loadSpawnDefsFromFile(const std::string& path) const; - std::vector loadSpawnDefsFromAzerothCoreDb( - const std::string& basePath, - const std::string& mapName, - const glm::vec3& playerCanonical, - pipeline::AssetManager* am) const; - - void loadCreatureModel(pipeline::AssetManager* am, - rendering::CharacterRenderer* cr, - const std::string& m2Path, - uint32_t modelId); - - std::vector npcs; - std::unordered_map loadedModels; // path -> modelId - uint64_t nextGuid = 0xF1300000DEAD0001ULL; - uint32_t nextModelId = 100; -}; - -} // namespace game -} // namespace wowee diff --git a/include/game/transport_manager.hpp b/include/game/transport_manager.hpp new file mode 100644 index 00000000..b928866f --- /dev/null +++ b/include/game/transport_manager.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace wowee::rendering { + class WMORenderer; +} + +namespace wowee::game { + +struct TransportPath { + uint32_t pathId; + std::vector waypoints; // Position keyframes + std::vector rotations; // Optional rotation keyframes + bool looping; + float speed; // units/sec (default 18.0f like taxi) +}; + +struct ActiveTransport { + uint64_t guid; // Entity GUID + uint32_t wmoInstanceId; // WMO renderer instance ID + uint32_t pathId; // Current path + size_t currentSegment; // Current waypoint index + float segmentProgress; // Distance along segment + glm::vec3 position; // Current world position + glm::quat rotation; // Current world rotation + glm::mat4 transform; // Cached world transform + glm::mat4 invTransform; // Cached inverse for collision + + // Player attachment (single-player for now) + bool playerOnBoard; + glm::vec3 playerLocalOffset; + + // Optional deck boundaries + glm::vec3 deckMin; + glm::vec3 deckMax; + bool hasDeckBounds; +}; + +class TransportManager { +public: + TransportManager(); + ~TransportManager(); + + void setWMORenderer(rendering::WMORenderer* renderer) { wmoRenderer_ = renderer; } + + void update(float deltaTime); + void registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId); + void unregisterTransport(uint64_t guid); + + ActiveTransport* getTransport(uint64_t guid); + glm::vec3 getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset); + glm::mat4 getTransportInvTransform(uint64_t transportGuid); + + void loadPathFromNodes(uint32_t pathId, const std::vector& waypoints, bool looping = true, float speed = 18.0f); + void setDeckBounds(uint64_t guid, const glm::vec3& min, const glm::vec3& max); + +private: + void updateTransportMovement(ActiveTransport& transport, float deltaTime); + glm::vec3 interpolatePath(const TransportPath& path, size_t segmentIdx, float t); + glm::quat calculateOrientation(const TransportPath& path, size_t segmentIdx, float t); + void updateTransformMatrices(ActiveTransport& transport); + + std::unordered_map transports_; + std::unordered_map paths_; + rendering::WMORenderer* wmoRenderer_ = nullptr; +}; + +} // namespace wowee::game diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 03f0cf64..8859655f 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -82,6 +82,13 @@ public: */ void setInstancePosition(uint32_t instanceId, const glm::vec3& position); + /** + * Update the full transform of an existing instance (for moving transports) + * @param instanceId Instance to update + * @param transform World transform matrix + */ + void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform); + /** * Remove WMO instance * @param instanceId Instance to remove diff --git a/src/core/application.cpp b/src/core/application.cpp index e61bd7c8..28fb777a 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -35,8 +35,8 @@ #include "ui/ui_manager.hpp" #include "auth/auth_handler.hpp" #include "game/game_handler.hpp" +#include "game/transport_manager.hpp" #include "game/world.hpp" -#include "game/npc_manager.hpp" #include "pipeline/asset_manager.hpp" #include #include @@ -409,12 +409,6 @@ void Application::update(float deltaTime) { auto w2 = std::chrono::high_resolution_clock::now(); worldTime += std::chrono::duration(w2 - w1).count(); - auto s1 = std::chrono::high_resolution_clock::now(); - // Spawn NPCs once when entering world - spawnNpcs(); - auto s2 = std::chrono::high_resolution_clock::now(); - spawnTime += std::chrono::duration(s2 - s1).count(); - auto cq1 = std::chrono::high_resolution_clock::now(); // Process deferred online creature spawns (throttled) processCreatureSpawnQueue(); @@ -432,9 +426,6 @@ void Application::update(float deltaTime) { mountTime += std::chrono::duration(m2 - m1).count(); auto nm1 = std::chrono::high_resolution_clock::now(); - if (npcManager && renderer && renderer->getCharacterRenderer()) { - npcManager->update(deltaTime, renderer->getCharacterRenderer()); - } auto nm2 = std::chrono::high_resolution_clock::now(); npcMgrTime += std::chrono::duration(nm2 - nm1).count(); @@ -493,6 +484,8 @@ void Application::update(float deltaTime) { // Sync character render position ↔ canonical WoW coords each frame if (renderer && gameHandler) { + bool onTransport = gameHandler->isOnTransport(); + if (onTaxi) { auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid()); if (playerEntity) { @@ -503,6 +496,18 @@ void Application::update(float deltaTime) { float yawDeg = glm::degrees(playerEntity->getOrientation()) + 90.0f; renderer->setCharacterYaw(yawDeg); } + } else if (onTransport) { + // Transport mode: compose world position from transport transform + local offset + glm::vec3 canonical = gameHandler->getComposedWorldPosition(); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + renderer->getCharacterPosition() = renderPos; + // Update camera follow target + if (renderer->getCameraController()) { + glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); + if (followTarget) { + *followTarget = renderPos; + } + } } else { glm::vec3 renderPos = renderer->getCharacterPosition(); glm::vec3 canonical = core::coords::renderToCanonical(renderPos); @@ -1487,93 +1492,6 @@ void Application::loadEquippedWeapons() { } } -void Application::spawnNpcs() { - if (npcsSpawned) return; - LOG_INFO("spawnNpcs: checking preconditions..."); - if (!assetManager || !assetManager->isInitialized()) { - LOG_INFO("spawnNpcs: assetManager not ready"); - return; - } - if (!renderer || !renderer->getCharacterRenderer() || !renderer->getCamera()) { - LOG_INFO("spawnNpcs: renderer not ready"); - return; - } - if (!gameHandler) { - LOG_INFO("spawnNpcs: gameHandler not ready"); - return; - } - - LOG_INFO("spawnNpcs: spawning NPCs..."); - if (npcManager) { - npcManager->clear(renderer->getCharacterRenderer(), &gameHandler->getEntityManager()); - } - npcManager = std::make_unique(); - glm::vec3 playerSpawnGL = renderer->getCharacterPosition(); - glm::vec3 playerCanonical = core::coords::renderToCanonical(playerSpawnGL); - LOG_INFO("spawnNpcs: player position GL=(", playerSpawnGL.x, ",", playerSpawnGL.y, ",", playerSpawnGL.z, - ") canonical=(", playerCanonical.x, ",", playerCanonical.y, ",", playerCanonical.z, ")"); - std::string mapName = "Azeroth"; - if (auto* minimap = renderer->getMinimap()) { - mapName = minimap->getMapName(); - } - - npcManager->initialize(assetManager.get(), - renderer->getCharacterRenderer(), - gameHandler->getEntityManager(), - mapName, - playerCanonical, - renderer->getTerrainManager()); - - // If the player WoW position hasn't been set by the server yet (offline mode), - // derive it from the camera so targeting distance calculations work. - const auto& movement = gameHandler->getMovementInfo(); - if (movement.x == 0.0f && movement.y == 0.0f && movement.z == 0.0f) { - glm::vec3 canonical = playerCanonical; - gameHandler->setPosition(canonical.x, canonical.y, canonical.z); - } - - // Set NPC animation callbacks (works for both single-player and online creatures) - if (gameHandler && npcManager) { - auto* npcMgr = npcManager.get(); - auto* cr = renderer->getCharacterRenderer(); - auto* app = this; - gameHandler->setNpcDeathCallback([npcMgr, cr, app](uint64_t guid) { - uint32_t instanceId = npcMgr->findRenderInstanceId(guid); - if (instanceId == 0) { - auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end()) instanceId = it->second; - } - if (instanceId != 0 && cr) { - cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death - } - }); - gameHandler->setNpcRespawnCallback([npcMgr, cr, app](uint64_t guid) { - uint32_t instanceId = npcMgr->findRenderInstanceId(guid); - if (instanceId == 0) { - auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end()) instanceId = it->second; - } - if (instanceId != 0 && cr) { - cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle - } - }); - gameHandler->setNpcSwingCallback([npcMgr, cr, app](uint64_t guid) { - uint32_t instanceId = npcMgr->findRenderInstanceId(guid); - if (instanceId == 0) { - auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end()) instanceId = it->second; - } - if (instanceId != 0 && cr) { - cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1 - } - }); - } - - npcsSpawned = true; - LOG_INFO("NPCs spawned for in-game session"); -} - - void Application::buildFactionHostilityMap(uint8_t playerRace) { if (!assetManager || !assetManager->isInitialized() || !gameHandler) return; @@ -1901,6 +1819,36 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float loadingScreen.shutdown(); } + // Set up test transport (development feature) + setupTestTransport(); + + // Set up NPC animation callbacks (for online creatures) + if (gameHandler && renderer && renderer->getCharacterRenderer()) { + auto* cr = renderer->getCharacterRenderer(); + auto* app = this; + + gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) { + auto it = app->creatureInstances_.find(guid); + if (it != app->creatureInstances_.end() && cr) { + cr->playAnimation(it->second, 1, false); // animation ID 1 = Death + } + }); + + gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) { + auto it = app->creatureInstances_.find(guid); + if (it != app->creatureInstances_.end() && cr) { + cr->playAnimation(it->second, 0, true); // animation ID 0 = Idle + } + }); + + gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) { + auto it = app->creatureInstances_.find(guid); + if (it != app->creatureInstances_.end() && cr) { + cr->playAnimation(it->second, 16, false); // animation ID 16 = Attack1 + } + }); + } + // Set game state setState(AppState::IN_GAME); } @@ -2167,9 +2115,6 @@ bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, fl auto it = creatureInstances_.find(guid); if (it != creatureInstances_.end()) instanceId = it->second; } - if (instanceId == 0 && npcManager) { - instanceId = npcManager->findRenderInstanceId(guid); - } if (instanceId == 0) return false; return renderer->getCharacterRenderer()->getInstanceBounds(instanceId, outCenter, outRadius); @@ -2380,6 +2325,25 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Convert canonical → render coordinates glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + // Smart filtering for bad spawn data: + // - If over ocean AND at continental height (Z > 50): bad data, skip + // - If over ocean AND near sea level (Z <= 50): water creature, allow + // - If over land: snap to terrain height + if (renderer->getTerrainManager()) { + auto terrainH = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); + if (!terrainH) { + // No terrain at this X,Y position (ocean/void) + if (z > 50.0f) { + // High altitude over ocean = bad spawn data (e.g., bears at Z=94 over water) + return; + } + // Low altitude = probably legitimate water creature, allow spawn at original Z + } else { + // Valid terrain found - snap to terrain height + renderPos.z = *terrainH + 0.1f; + } + } + // Convert canonical WoW orientation (0=north) -> render yaw (0=west) float renderYaw = orientation + glm::radians(90.0f); @@ -3147,5 +3111,130 @@ void Application::updateQuestMarkers() { } } +void Application::setupTestTransport() { + 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); + + // Define a simple circular path around Stormwind harbor (canonical coordinates) + // These coordinates are approximate - adjust based on actual harbor layout + std::vector harborPath = { + {-8833.0f, 628.0f, 94.0f}, // Start point (Stormwind harbor) + {-8900.0f, 650.0f, 94.0f}, // Move west + {-8950.0f, 700.0f, 94.0f}, // Northwest + {-8950.0f, 780.0f, 94.0f}, // North + {-8900.0f, 830.0f, 94.0f}, // Northeast + {-8833.0f, 850.0f, 94.0f}, // East + {-8766.0f, 830.0f, 94.0f}, // Southeast + {-8716.0f, 780.0f, 94.0f}, // South + {-8716.0f, 700.0f, 94.0f}, // Southwest + {-8766.0f, 650.0f, 94.0f}, // Back to start direction + }; + + // Register the path with transport manager + uint32_t pathId = 1; + float speed = 12.0f; // 12 units/sec (slower than taxi for a leisurely boat ride) + transportManager->loadPathFromNodes(pathId, harborPath, true, speed); + LOG_INFO("Registered transport path ", pathId, " with ", harborPath.size(), " waypoints, speed=", speed); + + // Try to load a transport WMO model + // Common transport WMOs: Transportship.wmo (generic ship) + std::string transportWmoPath = "Transports\\Transportship\\Transportship.wmo"; + + auto wmoData = assetManager->readFile(transportWmoPath); + if (wmoData.empty()) { + LOG_WARNING("Could not load transport WMO: ", transportWmoPath); + LOG_INFO("Trying alternative: Boat transport"); + transportWmoPath = "Transports\\Boat\\Boat.wmo"; + wmoData = assetManager->readFile(transportWmoPath); + } + + if (wmoData.empty()) { + LOG_WARNING("No transport WMO found - test transport disabled"); + LOG_INFO("Available transport WMOs are typically in Transports\\ directory"); + return; + } + + // 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 groupData = assetManager->readFile(groupPath); + + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); + loadedGroups++; + } else { + LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath); + } + } + } + + if (loadedGroups == 0 && wmoModel.nGroups > 0) { + LOG_WARNING("Failed to load any WMO groups for transport"); + return; + } + + // Load WMO into renderer + uint32_t wmoModelId = 99999; // Use high ID to avoid conflicts + if (!wmoRenderer->loadModel(wmoModel, wmoModelId)) { + LOG_WARNING("Failed to load transport WMO model into renderer"); + return; + } + + // Create WMO instance at first waypoint (convert canonical to render coords) + glm::vec3 startCanonical = harborPath[0]; + glm::vec3 startRender = core::coords::canonicalToRender(startCanonical); + + uint32_t wmoInstanceId = wmoRenderer->createInstance(wmoModelId, startRender, + glm::vec3(0.0f, 0.0f, 0.0f), 1.0f); + + if (wmoInstanceId == 0) { + LOG_WARNING("Failed to create transport WMO instance"); + return; + } + + // Register transport with transport manager + uint64_t transportGuid = 0x1000000000000001ULL; // Fake GUID for test + transportManager->registerTransport(transportGuid, wmoInstanceId, pathId); + + // 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)); + + LOG_INFO("========================================"); + LOG_INFO("Test transport registered:"); + LOG_INFO(" GUID: 0x", std::hex, transportGuid, std::dec); + LOG_INFO(" WMO Instance: ", wmoInstanceId); + LOG_INFO(" Path: ", pathId, " (", harborPath.size(), " waypoints)"); + LOG_INFO(" Speed: ", speed, " units/sec"); + LOG_INFO("========================================"); + LOG_INFO(""); + LOG_INFO("To board the transport, use console command:"); + LOG_INFO(" /transport board"); + LOG_INFO("To disembark:"); + LOG_INFO(" /transport leave"); + LOG_INFO("========================================"); +} + } // namespace core } // namespace wowee diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fd541d99..99e05644 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1,4 +1,5 @@ #include "game/game_handler.hpp" +#include "game/transport_manager.hpp" #include "game/opcodes.hpp" #include "network/world_socket.hpp" #include "network/packet.hpp" @@ -28,6 +29,9 @@ namespace game { GameHandler::GameHandler() { LOG_DEBUG("GameHandler created"); + // Initialize transport manager + transportManager_ = std::make_unique(); + // Default spells always available knownSpells.push_back(6603); // Attack knownSpells.push_back(8690); // Hearthstone @@ -305,6 +309,11 @@ void GameHandler::update(float deltaTime) { auto taxiEnd = std::chrono::high_resolution_clock::now(); taxiTime += std::chrono::duration(taxiEnd - taxiStart).count(); + // Update transport manager + if (transportManager_) { + transportManager_->update(deltaTime); + } + // Distance check timing auto distanceStart = std::chrono::high_resolution_clock::now(); @@ -6767,5 +6776,13 @@ void GameHandler::loadCharacterConfig() { } } +glm::vec3 GameHandler::getComposedWorldPosition() { + if (playerTransportGuid_ != 0 && transportManager_) { + return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + } + // Not on transport, return normal movement position + return glm::vec3(movementInfo.x, movementInfo.y, movementInfo.z); +} + } // namespace game } // namespace wowee diff --git a/src/game/npc_manager.cpp b/src/game/npc_manager.cpp deleted file mode 100644 index 2aa2be24..00000000 --- a/src/game/npc_manager.cpp +++ /dev/null @@ -1,889 +0,0 @@ -#include "game/npc_manager.hpp" -#include "game/entity.hpp" -#include -#include "core/coordinates.hpp" -#include "pipeline/asset_manager.hpp" -#include "pipeline/m2_loader.hpp" -#include "pipeline/dbc_loader.hpp" -#include "rendering/character_renderer.hpp" -#include "rendering/terrain_manager.hpp" -#include "core/logger.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace wowee { -namespace game { - -void NpcManager::clear(rendering::CharacterRenderer* cr, EntityManager* em) { - for (const auto& npc : npcs) { - if (cr) { - cr->removeInstance(npc.renderInstanceId); - } - if (em) { - em->removeEntity(npc.guid); - } - } - npcs.clear(); - loadedModels.clear(); -} - -// Random emote animation IDs (humanoid only) -static const uint32_t EMOTE_ANIMS[] = { 60, 66, 67, 70 }; // Talk, Bow, Wave, Laugh -static constexpr int NUM_EMOTE_ANIMS = 4; - -static float randomFloat(float lo, float hi) { - static std::mt19937 rng(std::random_device{}()); - std::uniform_real_distribution dist(lo, hi); - return dist(rng); -} - -static std::string toLowerStr(const std::string& s) { - std::string out = s; - for (char& c : out) c = static_cast(std::tolower(static_cast(c))); - return out; -} - -static std::string trim(const std::string& s) { - size_t b = 0; - while (b < s.size() && std::isspace(static_cast(s[b]))) b++; - size_t e = s.size(); - while (e > b && std::isspace(static_cast(s[e - 1]))) e--; - return s.substr(b, e - b); -} - -static std::string normalizeMapName(const std::string& raw) { - std::string n = toLowerStr(trim(raw)); - n.erase(std::remove_if(n.begin(), n.end(), [](char c) { return c == ' ' || c == '_'; }), n.end()); - return n; -} - -static bool mapNamesEquivalent(const std::string& a, const std::string& b) { - std::string na = normalizeMapName(a); - std::string nb = normalizeMapName(b); - if (na == nb) return true; - // Azeroth world aliases seen across systems/UI. - auto isAzerothAlias = [](const std::string& n) { - return n == "azeroth" || n == "easternkingdoms" || n == "easternkingdom"; - }; - return isAzerothAlias(na) && isAzerothAlias(nb); -} - -static bool parseVec2Csv(const char* raw, float& x, float& y) { - if (!raw || !*raw) return false; - std::string s(raw); - std::replace(s.begin(), s.end(), ';', ','); - std::stringstream ss(s); - std::string a, b; - if (!std::getline(ss, a, ',')) return false; - if (!std::getline(ss, b, ',')) return false; - try { - x = std::stof(trim(a)); - y = std::stof(trim(b)); - return true; - } catch (const std::exception&) { - return false; - } -} - -static bool parseFloatEnv(const char* raw, float& out) { - if (!raw || !*raw) return false; - try { - out = std::stof(trim(raw)); - return true; - } catch (const std::exception&) { - return false; - } -} - -static int mapNameToId(const std::string& mapName) { - std::string n = normalizeMapName(mapName); - if (n == "azeroth" || n == "easternkingdoms" || n == "easternkingdom") return 0; - if (n == "kalimdor") return 1; - if (n == "outland" || n == "expansion01") return 530; - if (n == "northrend") return 571; - return 0; -} - -static bool parseInsertTuples(const std::string& line, std::vector& outTuples) { - outTuples.clear(); - size_t valuesPos = line.find("VALUES"); - if (valuesPos == std::string::npos) valuesPos = line.find("values"); - if (valuesPos == std::string::npos) return false; - - bool inQuote = false; - int depth = 0; - size_t tupleStart = std::string::npos; - for (size_t i = valuesPos; i < line.size(); i++) { - char c = line[i]; - if (c == '\'' && (i == 0 || line[i - 1] != '\\')) inQuote = !inQuote; - if (inQuote) continue; - if (c == '(') { - if (depth == 0) tupleStart = i + 1; - depth++; - } else if (c == ')') { - depth--; - if (depth == 0 && tupleStart != std::string::npos && i > tupleStart) { - outTuples.push_back(line.substr(tupleStart, i - tupleStart)); - tupleStart = std::string::npos; - } - } - } - return !outTuples.empty(); -} - -static std::vector splitCsvTuple(const std::string& tuple) { - std::vector cols; - std::string cur; - bool inQuote = false; - for (size_t i = 0; i < tuple.size(); i++) { - char c = tuple[i]; - if (c == '\'' && (i == 0 || tuple[i - 1] != '\\')) { - inQuote = !inQuote; - cur.push_back(c); - continue; - } - if (c == ',' && !inQuote) { - cols.push_back(trim(cur)); - cur.clear(); - continue; - } - cur.push_back(c); - } - if (!cur.empty()) cols.push_back(trim(cur)); - return cols; -} - -static std::string unquoteSqlString(const std::string& s) { - if (s.size() >= 2 && s.front() == '\'' && s.back() == '\'') { - return s.substr(1, s.size() - 2); - } - return s; -} - -static glm::vec3 toCanonicalSpawn(const NpcSpawnDef& s, bool swapXY, float rotDeg, - float pivotX, float pivotY, float dx, float dy) { - glm::vec3 canonical = s.inputIsServerCoords - ? core::coords::serverToCanonical(s.canonicalPosition) - : s.canonicalPosition; - if (swapXY) std::swap(canonical.x, canonical.y); - - if (std::abs(rotDeg) > 0.001f) { - float rad = rotDeg * (3.1415926535f / 180.0f); - float c = std::cos(rad); - float s = std::sin(rad); - float x = canonical.x - pivotX; - float y = canonical.y - pivotY; - canonical.x = pivotX + x * c - y * s; - canonical.y = pivotY + x * s + y * c; - } - - canonical.x += dx; - canonical.y += dy; - return canonical; -} - -// Look up texture variants for a creature M2 using CreatureDisplayInfo.dbc -// Returns up to 3 texture variant names (for type 1, 2, 3 texture slots) -static std::vector lookupTextureVariants( - pipeline::AssetManager* am, const std::string& m2Path) { - std::vector variants; - - auto modelDataDbc = am->loadDBC("CreatureModelData.dbc"); - auto displayInfoDbc = am->loadDBC("CreatureDisplayInfo.dbc"); - if (!modelDataDbc || !displayInfoDbc) return variants; - - // CreatureModelData stores .mdx paths; convert our .m2 path for matching - std::string mdxPath = m2Path; - if (mdxPath.size() > 3) { - mdxPath = mdxPath.substr(0, mdxPath.size() - 3) + ".mdx"; - } - std::string mdxLower = toLowerStr(mdxPath); - - // Find model ID from CreatureModelData (col 0 = ID, col 2 = modelName) - uint32_t creatureModelId = 0; - for (uint32_t r = 0; r < modelDataDbc->getRecordCount(); r++) { - std::string dbcModel = modelDataDbc->getString(r, 2); - if (toLowerStr(dbcModel) == mdxLower) { - creatureModelId = modelDataDbc->getUInt32(r, 0); - LOG_INFO("NpcManager: DBC match for '", m2Path, - "' -> CreatureModelData ID ", creatureModelId); - break; - } - } - if (creatureModelId == 0) return variants; - - // Find first CreatureDisplayInfo entry for this model - // Col 0=ID, 1=ModelID, 6=TextureVariation_1, 7=TextureVariation_2, 8=TextureVariation_3 - for (uint32_t r = 0; r < displayInfoDbc->getRecordCount(); r++) { - if (displayInfoDbc->getUInt32(r, 1) == creatureModelId) { - std::string v1 = displayInfoDbc->getString(r, 6); - std::string v2 = displayInfoDbc->getString(r, 7); - std::string v3 = displayInfoDbc->getString(r, 8); - if (!v1.empty()) variants.push_back(v1); - if (!v2.empty()) variants.push_back(v2); - if (!v3.empty()) variants.push_back(v3); - LOG_INFO("NpcManager: DisplayInfo textures: '", v1, "', '", v2, "', '", v3, "'"); - break; - } - } - return variants; -} - -void NpcManager::loadCreatureModel(pipeline::AssetManager* am, - rendering::CharacterRenderer* cr, - const std::string& m2Path, - uint32_t modelId) { - auto m2Data = am->readFile(m2Path); - if (m2Data.empty()) { - LOG_WARNING("NpcManager: failed to read M2 file: ", m2Path); - return; - } - - auto model = pipeline::M2Loader::load(m2Data); - - // Derive skin path: replace .m2 with 00.skin - std::string skinPath = m2Path; - if (skinPath.size() > 3) { - skinPath = skinPath.substr(0, skinPath.size() - 3) + "00.skin"; - } - auto skinData = am->readFile(skinPath); - if (!skinData.empty()) { - pipeline::M2Loader::loadSkin(skinData, model); - } - - if (!model.isValid()) { - LOG_WARNING("NpcManager: invalid model: ", m2Path); - return; - } - - // Load external .anim files for sequences without flag 0x20 - std::string basePath = m2Path.substr(0, m2Path.size() - 3); // remove ".m2" - for (uint32_t si = 0; si < model.sequences.size(); si++) { - if (!(model.sequences[si].flags & 0x20)) { - char animFileName[256]; - snprintf(animFileName, sizeof(animFileName), - "%s%04u-%02u.anim", - basePath.c_str(), - model.sequences[si].id, - model.sequences[si].variationIndex); - auto animFileData = am->readFile(animFileName); - if (!animFileData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model); - } - } - } - - // --- Resolve creature skin textures --- - // Extract model directory: "Creature\Wolf\" from "Creature\Wolf\Wolf.m2" - size_t lastSlash = m2Path.find_last_of("\\/"); - std::string modelDir = (lastSlash != std::string::npos) - ? m2Path.substr(0, lastSlash + 1) : ""; - - // Extract model base name: "Wolf" from "Creature\Wolf\Wolf.m2" - std::string modelFileName = (lastSlash != std::string::npos) - ? m2Path.substr(lastSlash + 1) : m2Path; - std::string modelBaseName = modelFileName.substr(0, modelFileName.size() - 3); // remove ".m2" - - // Log existing texture info - for (size_t ti = 0; ti < model.textures.size(); ti++) { - LOG_INFO("NpcManager: ", m2Path, " tex[", ti, "] type=", - model.textures[ti].type, " file='", model.textures[ti].filename, "'"); - } - - // Check if any textures need resolution - // Type 11 = creature skin 1, type 12 = creature skin 2, type 13 = creature skin 3 - // Type 1 = character body skin (also possible on some creature models) - auto needsResolve = [](uint32_t t) { - return t == 11 || t == 12 || t == 13 || t == 1 || t == 2 || t == 3; - }; - - bool needsVariants = false; - for (const auto& tex : model.textures) { - if (needsResolve(tex.type) && tex.filename.empty()) { - needsVariants = true; - break; - } - } - - if (needsVariants) { - // Try DBC-based lookup first - auto variants = lookupTextureVariants(am, m2Path); - - // Fill in unresolved textures from DBC variants - // Creature skin types map: type 11 -> variant[0], type 12 -> variant[1], type 13 -> variant[2] - // Also type 1 -> variant[0] as fallback - for (auto& tex : model.textures) { - if (!needsResolve(tex.type) || !tex.filename.empty()) continue; - - // Determine which variant index this texture type maps to - size_t varIdx = 0; - if (tex.type == 11 || tex.type == 1) varIdx = 0; - else if (tex.type == 12 || tex.type == 2) varIdx = 1; - else if (tex.type == 13 || tex.type == 3) varIdx = 2; - - std::string resolved; - - if (varIdx < variants.size() && !variants[varIdx].empty()) { - // DBC variant: \.blp - resolved = modelDir + variants[varIdx] + ".blp"; - if (!am->fileExists(resolved)) { - LOG_WARNING("NpcManager: DBC texture not found: ", resolved); - resolved.clear(); - } - } - - // Fallback heuristics if DBC didn't provide a texture - if (resolved.empty()) { - // Try \Skin.blp - std::string skinTry = modelDir + modelBaseName + "Skin.blp"; - if (am->fileExists(skinTry)) { - resolved = skinTry; - } else { - // Try \.blp - std::string altTry = modelDir + modelBaseName + ".blp"; - if (am->fileExists(altTry)) { - resolved = altTry; - } - } - } - - if (!resolved.empty()) { - tex.filename = resolved; - LOG_INFO("NpcManager: resolved type-", tex.type, - " texture -> '", resolved, "'"); - } else { - LOG_WARNING("NpcManager: could not resolve type-", tex.type, - " texture for ", m2Path); - } - } - } - - cr->loadModel(model, modelId); - LOG_INFO("NpcManager: loaded model id=", modelId, " path=", m2Path, - " verts=", model.vertices.size(), " bones=", model.bones.size(), - " anims=", model.sequences.size(), " textures=", model.textures.size()); -} - -std::vector NpcManager::loadSpawnDefsFromFile(const std::string& path) const { - std::vector out; - std::string resolvedPath; - const std::string candidates[] = { - path, - "./" + path, - "../" + path, - "../../" + path, - "../../../" + path - }; - for (const auto& c : candidates) { - if (std::filesystem::exists(c)) { - resolvedPath = c; - break; - } - } - if (resolvedPath.empty()) { - // Try relative to executable location. - std::error_code ec; - std::filesystem::path exe = std::filesystem::read_symlink("/proc/self/exe", ec); - if (!ec) { - std::filesystem::path dir = exe.parent_path(); - for (int i = 0; i < 5 && !dir.empty(); i++) { - std::filesystem::path candidate = dir / path; - if (std::filesystem::exists(candidate)) { - resolvedPath = candidate.string(); - break; - } - dir = dir.parent_path(); - } - } - } - - if (resolvedPath.empty()) { - LOG_WARNING("NpcManager: spawn CSV not found at ", path, " (or nearby relative paths)"); - return out; - } - - std::ifstream in(resolvedPath); - if (!in.is_open()) return out; - - std::string line; - int lineNo = 0; - while (std::getline(in, line)) { - lineNo++; - line = trim(line); - if (line.empty() || line[0] == '#') continue; - - std::vector cols; - std::stringstream ss(line); - std::string tok; - while (std::getline(ss, tok, ',')) { - cols.push_back(trim(tok)); - } - - if (cols.size() != 11 && cols.size() != 12) { - LOG_WARNING("NpcManager: bad NPC CSV row at ", resolvedPath, ":", lineNo, - " (expected 11 or 12 columns)"); - continue; - } - - try { - NpcSpawnDef def; - def.mapName = cols[0]; - def.name = cols[1]; - def.m2Path = cols[2]; - def.level = static_cast(std::stoul(cols[3])); - def.health = static_cast(std::stoul(cols[4])); - def.canonicalPosition.x = std::stof(cols[5]); - def.canonicalPosition.y = std::stof(cols[6]); - def.canonicalPosition.z = std::stof(cols[7]); - def.rotation = std::stof(cols[8]); - def.scale = std::stof(cols[9]); - def.isCritter = (cols[10] == "1" || toLowerStr(cols[10]) == "true"); - if (cols.size() == 12) { - std::string space = toLowerStr(cols[11]); - def.inputIsServerCoords = (space == "server" || space == "wire"); - } - - if (def.mapName.empty() || def.name.empty() || def.m2Path.empty()) continue; - out.push_back(std::move(def)); - } catch (const std::exception&) { - LOG_WARNING("NpcManager: failed parsing NPC CSV row at ", resolvedPath, ":", lineNo); - } - } - - LOG_INFO("NpcManager: loaded ", out.size(), " spawn defs from ", resolvedPath); - - return out; -} - -std::vector NpcManager::loadSpawnDefsFromAzerothCoreDb( - const std::string& basePath, - const std::string& mapName, - const glm::vec3& playerCanonical, - pipeline::AssetManager* am) const { - std::vector out; - if (!am) return out; - - std::filesystem::path base(basePath); - std::filesystem::path creaturePath = base / "creature.sql"; - std::filesystem::path tmplPath = base / "creature_template.sql"; - if (!std::filesystem::exists(creaturePath) || !std::filesystem::exists(tmplPath)) { - // Allow passing .../sql or repo root as WOW_DB_BASE_PATH. - std::filesystem::path alt = base / "base"; - if (std::filesystem::exists(alt / "creature.sql") && std::filesystem::exists(alt / "creature_template.sql")) { - base = alt; - creaturePath = base / "creature.sql"; - tmplPath = base / "creature_template.sql"; - } else { - alt = base / "sql" / "base"; - if (std::filesystem::exists(alt / "creature.sql") && std::filesystem::exists(alt / "creature_template.sql")) { - base = alt; - creaturePath = base / "creature.sql"; - tmplPath = base / "creature_template.sql"; - } - } - } - if (!std::filesystem::exists(creaturePath) || !std::filesystem::exists(tmplPath)) { - return out; - } - - struct TemplateRow { - std::string name; - uint32_t level = 1; - uint32_t health = 100; - std::string m2Path; - uint32_t faction = 0; - uint32_t npcFlags = 0; - }; - std::unordered_map templates; - - // Build displayId -> modelId lookup. - std::unordered_map displayToModel; - if (auto cdi = am->loadDBC("CreatureDisplayInfo.dbc"); cdi && cdi->isLoaded()) { - for (uint32_t i = 0; i < cdi->getRecordCount(); i++) { - displayToModel[cdi->getUInt32(i, 0)] = cdi->getUInt32(i, 1); - } - } - std::unordered_map modelToPath; - if (auto cmd = am->loadDBC("CreatureModelData.dbc"); cmd && cmd->isLoaded()) { - for (uint32_t i = 0; i < cmd->getRecordCount(); i++) { - std::string mdx = cmd->getString(i, 2); - if (mdx.empty()) continue; - std::string p = mdx; - if (p.size() >= 4) p = p.substr(0, p.size() - 4) + ".m2"; - modelToPath[cmd->getUInt32(i, 0)] = p; - } - } - - auto processInsertStatements = - [](std::ifstream& in, const std::function&)>& onTuple) { - std::string line; - std::string stmt; - std::vector tuples; - while (std::getline(in, line)) { - if (stmt.empty()) { - // Skip non-INSERT lines early. - if (line.find("INSERT INTO") == std::string::npos && - line.find("insert into") == std::string::npos) { - continue; - } - } - if (!stmt.empty()) stmt.push_back('\n'); - stmt += line; - if (line.find(';') == std::string::npos) continue; - - if (parseInsertTuples(stmt, tuples)) { - for (const auto& t : tuples) { - if (!onTuple(splitCsvTuple(t))) { - return; - } - } - } - stmt.clear(); - } - }; - - // Parse creature_template.sql: entry, modelid1(displayId), name, minlevel, faction, npcflag. - { - std::ifstream in(tmplPath); - processInsertStatements(in, [&](const std::vector& cols) { - if (cols.size() < 19) return true; - try { - uint32_t entry = static_cast(std::stoul(cols[0])); - uint32_t displayId = static_cast(std::stoul(cols[6])); - std::string name = unquoteSqlString(cols[10]); - uint32_t minLevel = static_cast(std::stoul(cols[14])); - uint32_t faction = static_cast(std::stoul(cols[17])); - uint32_t npcflag = static_cast(std::stoul(cols[18])); - TemplateRow tr; - tr.name = name.empty() ? ("Creature " + std::to_string(entry)) : name; - tr.level = std::max(1u, minLevel); - tr.health = 150 + tr.level * 35; - tr.faction = faction; - tr.npcFlags = npcflag; - auto itModel = displayToModel.find(displayId); - if (itModel != displayToModel.end()) { - auto itPath = modelToPath.find(itModel->second); - if (itPath != modelToPath.end()) tr.m2Path = itPath->second; - } - templates[entry] = std::move(tr); - } catch (const std::exception&) { - } - return true; - }); - } - - int targetMap = mapNameToId(mapName); - constexpr float kRadius = 2200.0f; - constexpr size_t kMaxSpawns = 220; - std::ifstream in(creaturePath); - processInsertStatements(in, [&](const std::vector& cols) { - if (cols.size() < 16) return true; - try { - uint32_t entry = static_cast(std::stoul(cols[1])); - int mapId = static_cast(std::stol(cols[2])); - if (mapId != targetMap) return true; - - float sx = std::stof(cols[7]); - float sy = std::stof(cols[8]); - float sz = std::stof(cols[9]); - float o = std::stof(cols[10]); - uint32_t curhealth = static_cast(std::stoul(cols[14])); - - // AzerothCore DB uses client/canonical coordinates. - glm::vec3 canonical = glm::vec3(sx, sy, sz); - float dx = canonical.x - playerCanonical.x; - float dy = canonical.y - playerCanonical.y; - if (dx * dx + dy * dy > kRadius * kRadius) return true; - - NpcSpawnDef def; - def.mapName = mapName; - auto it = templates.find(entry); - if (it != templates.end()) { - def.entry = entry; - def.name = it->second.name; - def.level = it->second.level; - def.health = std::max(it->second.health, curhealth); - def.m2Path = it->second.m2Path; - def.faction = it->second.faction; - def.npcFlags = it->second.npcFlags; - } else { - def.entry = entry; - def.name = "Creature " + std::to_string(entry); - def.level = 1; - def.health = std::max(100u, curhealth); - } - if (def.m2Path.empty()) { - def.m2Path = "Creature\\HumanMalePeasant\\HumanMalePeasant.m2"; - } - def.canonicalPosition = canonical; - def.inputIsServerCoords = false; - def.rotation = o; - def.scale = 1.0f; - def.isCritter = (def.level <= 1 || def.health <= 50); - out.push_back(std::move(def)); - if (out.size() >= kMaxSpawns) return false; - } catch (const std::exception&) { - } - return true; - }); - - LOG_INFO("NpcManager: loaded ", out.size(), " nearby creature spawns from AzerothCore DB at ", basePath); - return out; -} - -void NpcManager::initialize(pipeline::AssetManager* am, - rendering::CharacterRenderer* cr, - EntityManager& em, - const std::string& mapName, - const glm::vec3& playerCanonical, - const rendering::TerrainManager* terrainManager) { - if (!am || !am->isInitialized() || !cr) { - LOG_WARNING("NpcManager: cannot initialize — missing AssetManager or CharacterRenderer"); - return; - } - - float globalDx = 0.0f; - float globalDy = 0.0f; - bool hasGlobalOffset = parseVec2Csv(std::getenv("WOW_NPC_OFFSET"), globalDx, globalDy); - float globalRotDeg = 0.0f; - parseFloatEnv(std::getenv("WOW_NPC_ROT_DEG"), globalRotDeg); - bool swapXY = false; - if (const char* swap = std::getenv("WOW_NPC_SWAP_XY")) { - std::string v = toLowerStr(trim(swap)); - swapXY = (v == "1" || v == "true" || v == "yes"); - } - float pivotX = playerCanonical.x; - float pivotY = playerCanonical.y; - parseVec2Csv(std::getenv("WOW_NPC_PIVOT"), pivotX, pivotY); - - if (hasGlobalOffset || swapXY || std::abs(globalRotDeg) > 0.001f) { - LOG_INFO("NpcManager: transform overrides swapXY=", swapXY, - " rotDeg=", globalRotDeg, - " pivot=(", pivotX, ", ", pivotY, ")", - " offset=(", globalDx, ", ", globalDy, ")"); - } - - std::vector spawnDefs; - std::string dbBasePath; - if (const char* dbBase = std::getenv("WOW_DB_BASE_PATH")) { - dbBasePath = dbBase; - } else if (std::filesystem::exists("assets/sql")) { - dbBasePath = "assets/sql"; - } - if (!dbBasePath.empty()) { - auto dbDefs = loadSpawnDefsFromAzerothCoreDb(dbBasePath, mapName, playerCanonical, am); - if (!dbDefs.empty()) spawnDefs = std::move(dbDefs); - } - if (spawnDefs.empty()) { - LOG_WARNING("NpcManager: no spawn defs found (DB required for single-player)"); - } - - // Spawn only nearby placements on current map. - std::vector active; - active.reserve(spawnDefs.size()); - constexpr float kSpawnRadius = 2200.0f; - int mapSkipped = 0; - for (const auto& s : spawnDefs) { - if (!mapNamesEquivalent(mapName, s.mapName)) { - mapSkipped++; - continue; - } - glm::vec3 c = toCanonicalSpawn(s, swapXY, globalRotDeg, pivotX, pivotY, globalDx, globalDy); - float distX = c.x - playerCanonical.x; - float distY = c.y - playerCanonical.y; - if (distX * distX + distY * distY > kSpawnRadius * kSpawnRadius) continue; - active.push_back(&s); - } - - if (active.empty()) { - LOG_INFO("NpcManager: no static NPC placements near player on map ", mapName, - " (mapSkipped=", mapSkipped, ")"); - return; - } - - // Load each unique M2 model once - for (const auto* s : active) { - const std::string path = s->m2Path; - if (loadedModels.find(path) == loadedModels.end()) { - uint32_t mid = nextModelId++; - loadCreatureModel(am, cr, path, mid); - loadedModels[path] = mid; - } - } - - // Build faction hostility lookup from FactionTemplate.dbc + Faction.dbc - std::unordered_map factionHostile; - { - auto ftDbc = am->loadDBC("FactionTemplate.dbc"); - auto fDbc = am->loadDBC("Faction.dbc"); - if (ftDbc && ftDbc->isLoaded()) { - // Build hostile parent factions from Faction.dbc base reputation - std::unordered_set hostileParentFactions; - if (fDbc && fDbc->isLoaded()) { - for (uint32_t i = 0; i < fDbc->getRecordCount(); i++) { - uint32_t factionId = fDbc->getUInt32(i, 0); - for (int slot = 0; slot < 4; slot++) { - uint32_t raceMask = fDbc->getUInt32(i, 2 + slot); - if (raceMask & 0x1) { // Human race bit - int32_t baseRep = fDbc->getInt32(i, 10 + slot); - if (baseRep < 0) hostileParentFactions.insert(factionId); - break; - } - } - } - } - - uint32_t playerFriendGroup = 0, playerEnemyGroup = 0, playerFactionId = 0; - for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) { - if (ftDbc->getUInt32(i, 0) == 1) { - playerFriendGroup = ftDbc->getUInt32(i, 4) | ftDbc->getUInt32(i, 3); - playerEnemyGroup = ftDbc->getUInt32(i, 5); - playerFactionId = ftDbc->getUInt32(i, 1); - break; - } - } - - for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) { - uint32_t id = ftDbc->getUInt32(i, 0); - uint32_t parentFaction = ftDbc->getUInt32(i, 1); - uint32_t factionGroup = ftDbc->getUInt32(i, 3); - uint32_t friendGroup = ftDbc->getUInt32(i, 4); - uint32_t enemyGroup = ftDbc->getUInt32(i, 5); - - bool hostile = (enemyGroup & playerFriendGroup) != 0 - || (factionGroup & playerEnemyGroup) != 0; - if (!hostile && (factionGroup & 8) != 0) hostile = true; - if (!hostile && playerFactionId > 0) { - for (int e = 6; e <= 9; e++) { - if (ftDbc->getUInt32(i, e) == playerFactionId) { hostile = true; break; } - } - } - if (!hostile && parentFaction > 0 && hostileParentFactions.count(parentFaction)) { - hostile = true; - } - if (hostile && (friendGroup & playerFriendGroup) != 0) { - hostile = false; - } - factionHostile[id] = hostile; - } - LOG_INFO("NpcManager: loaded ", ftDbc->getRecordCount(), " faction templates"); - } else { - LOG_WARNING("NpcManager: FactionTemplate.dbc not available, all NPCs default to hostile"); - } - } - - // Spawn each NPC instance - for (const auto* sPtr : active) { - const auto& s = *sPtr; - const std::string path = s.m2Path; - - auto it = loadedModels.find(path); - if (it == loadedModels.end()) continue; // model failed to load - - uint32_t modelId = it->second; - - glm::vec3 canonical = toCanonicalSpawn(s, swapXY, globalRotDeg, pivotX, pivotY, globalDx, globalDy); - glm::vec3 glPos = core::coords::canonicalToRender(canonical); - // Keep authored indoor Z for named NPCs; terrain snap is mainly for critters/outdoor fauna. - if (terrainManager && s.isCritter) { - if (auto h = terrainManager->getHeightAt(glPos.x, glPos.y)) { - glPos.z = *h + 0.05f; - } - } - - // Create render instance - uint32_t instanceId = cr->createInstance(modelId, glPos, - glm::vec3(0.0f, 0.0f, s.rotation), s.scale); - if (instanceId == 0) { - LOG_WARNING("NpcManager: failed to create instance for ", s.name); - continue; - } - - // Play idle animation (anim ID 0) - cr->playAnimation(instanceId, 0, true); - - // Assign unique GUID - uint64_t guid = nextGuid++; - - // Create entity in EntityManager - auto unit = std::make_shared(guid); - unit->setName(s.name); - unit->setLevel(s.level); - unit->setHealth(s.health); - unit->setMaxHealth(s.health); - if (s.entry != 0) { - unit->setEntry(s.entry); - } - unit->setNpcFlags(s.npcFlags); - unit->setFactionTemplate(s.faction); - - // Determine hostility from faction template - auto fIt = factionHostile.find(s.faction); - unit->setHostile(fIt != factionHostile.end() ? fIt->second : false); - - // Store canonical WoW coordinates for targeting/server compatibility - glm::vec3 spawnCanonical = core::coords::renderToCanonical(glPos); - unit->setPosition(spawnCanonical.x, spawnCanonical.y, spawnCanonical.z, s.rotation); - - em.addEntity(guid, unit); - - // Track NPC instance - NpcInstance npc{}; - npc.guid = guid; - npc.renderInstanceId = instanceId; - npc.emoteTimer = randomFloat(5.0f, 15.0f); - npc.emoteEndTimer = 0.0f; - npc.isEmoting = false; - npc.isCritter = s.isCritter; - npcs.push_back(npc); - } - - LOG_INFO("NpcManager: initialized ", npcs.size(), " NPCs with ", - loadedModels.size(), " unique models"); -} - -uint32_t NpcManager::findRenderInstanceId(uint64_t guid) const { - for (const auto& npc : npcs) { - if (npc.guid == guid) return npc.renderInstanceId; - } - return 0; -} - -void NpcManager::update(float deltaTime, rendering::CharacterRenderer* cr) { - if (!cr) return; - - for (auto& npc : npcs) { - // Critters just idle — no emotes - if (npc.isCritter) continue; - - if (npc.isEmoting) { - npc.emoteEndTimer -= deltaTime; - if (npc.emoteEndTimer <= 0.0f) { - // Return to idle - cr->playAnimation(npc.renderInstanceId, 0, true); - npc.isEmoting = false; - npc.emoteTimer = randomFloat(5.0f, 15.0f); - } - } else { - npc.emoteTimer -= deltaTime; - if (npc.emoteTimer <= 0.0f) { - // Play random emote - int idx = static_cast(randomFloat(0.0f, static_cast(NUM_EMOTE_ANIMS) - 0.01f)); - uint32_t emoteAnim = EMOTE_ANIMS[idx]; - cr->playAnimation(npc.renderInstanceId, emoteAnim, false); - npc.isEmoting = true; - npc.emoteEndTimer = randomFloat(2.0f, 4.0f); - } - } - } -} - -} // namespace game -} // namespace wowee diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp new file mode 100644 index 00000000..2f2d9d2e --- /dev/null +++ b/src/game/transport_manager.cpp @@ -0,0 +1,297 @@ +#include "game/transport_manager.hpp" +#include "rendering/wmo_renderer.hpp" +#include +#include +#include +#include + +namespace wowee::game { + +TransportManager::TransportManager() = default; +TransportManager::~TransportManager() = default; + +void TransportManager::update(float deltaTime) { + for (auto& [guid, transport] : transports_) { + updateTransportMovement(transport, deltaTime); + } +} + +void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId) { + auto pathIt = paths_.find(pathId); + if (pathIt == paths_.end()) { + std::cerr << "TransportManager: Path " << pathId << " not found for transport " << guid << std::endl; + return; + } + + const auto& path = pathIt->second; + if (path.waypoints.empty()) { + std::cerr << "TransportManager: Path " << pathId << " has no waypoints" << std::endl; + return; + } + + ActiveTransport transport; + transport.guid = guid; + transport.wmoInstanceId = wmoInstanceId; + transport.pathId = pathId; + transport.currentSegment = 0; + transport.segmentProgress = 0.0f; + transport.position = path.waypoints[0]; + transport.rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity quaternion + transport.playerOnBoard = false; + transport.playerLocalOffset = glm::vec3(0.0f); + transport.hasDeckBounds = false; + + updateTransformMatrices(transport); + + transports_[guid] = transport; + + std::cout << "TransportManager: Registered transport " << guid + << " at path " << pathId << " with " << path.waypoints.size() << " waypoints" << std::endl; +} + +void TransportManager::unregisterTransport(uint64_t guid) { + transports_.erase(guid); + std::cout << "TransportManager: Unregistered transport " << guid << std::endl; +} + +ActiveTransport* TransportManager::getTransport(uint64_t guid) { + auto it = transports_.find(guid); + if (it != transports_.end()) { + return &it->second; + } + return nullptr; +} + +glm::vec3 TransportManager::getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset) { + auto* transport = getTransport(transportGuid); + if (!transport) { + return localOffset; // Fallback + } + + glm::vec4 localPos(localOffset, 1.0f); + glm::vec4 worldPos = transport->transform * localPos; + return glm::vec3(worldPos); +} + +glm::mat4 TransportManager::getTransportInvTransform(uint64_t transportGuid) { + auto* transport = getTransport(transportGuid); + if (!transport) { + return glm::mat4(1.0f); // Identity fallback + } + return transport->invTransform; +} + +void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector& waypoints, bool looping, float speed) { + if (waypoints.empty()) { + std::cerr << "TransportManager: Cannot load empty path " << pathId << std::endl; + return; + } + + TransportPath path; + path.pathId = pathId; + path.waypoints = waypoints; + path.looping = looping; + path.speed = speed; + + paths_[pathId] = path; + + std::cout << "TransportManager: Loaded path " << pathId + << " with " << waypoints.size() << " waypoints, " + << "looping=" << looping << ", speed=" << speed << std::endl; +} + +void TransportManager::setDeckBounds(uint64_t guid, const glm::vec3& min, const glm::vec3& max) { + auto* transport = getTransport(guid); + if (!transport) { + std::cerr << "TransportManager: Cannot set deck bounds for unknown transport " << guid << std::endl; + return; + } + + transport->deckMin = min; + transport->deckMax = max; + transport->hasDeckBounds = true; +} + +void TransportManager::updateTransportMovement(ActiveTransport& transport, float deltaTime) { + auto pathIt = paths_.find(transport.pathId); + if (pathIt == paths_.end()) { + return; + } + + const auto& path = pathIt->second; + if (path.waypoints.size() < 2) { + return; // Need at least 2 waypoints to move + } + + // Calculate segment length + glm::vec3 p0 = path.waypoints[transport.currentSegment]; + size_t nextIdx = (transport.currentSegment + 1) % path.waypoints.size(); + glm::vec3 p1 = path.waypoints[nextIdx]; + float segmentLength = glm::distance(p0, p1); + + if (segmentLength < 0.001f) { + // Zero-length segment, skip to next + transport.currentSegment = nextIdx; + transport.segmentProgress = 0.0f; + return; + } + + // Update progress + float distanceThisFrame = path.speed * deltaTime; + transport.segmentProgress += distanceThisFrame; + + // Check if we've completed this segment + while (transport.segmentProgress >= segmentLength) { + transport.segmentProgress -= segmentLength; + transport.currentSegment = nextIdx; + + // Check for path completion + if (!path.looping && transport.currentSegment >= path.waypoints.size() - 1) { + // Reached end of non-looping path + transport.currentSegment = path.waypoints.size() - 1; + transport.segmentProgress = 0.0f; + transport.position = path.waypoints[transport.currentSegment]; + updateTransformMatrices(transport); + return; + } + + // Update for next segment + p0 = path.waypoints[transport.currentSegment]; + nextIdx = (transport.currentSegment + 1) % path.waypoints.size(); + p1 = path.waypoints[nextIdx]; + segmentLength = glm::distance(p0, p1); + + if (segmentLength < 0.001f) { + transport.segmentProgress = 0.0f; + continue; + } + } + + // Interpolate position + float t = transport.segmentProgress / segmentLength; + transport.position = interpolatePath(path, transport.currentSegment, t); + + // Calculate orientation from path tangent + transport.rotation = calculateOrientation(path, transport.currentSegment, t); + + // Update transform matrices + updateTransformMatrices(transport); + + // Update WMO instance position + if (wmoRenderer_) { + wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } +} + +glm::vec3 TransportManager::interpolatePath(const TransportPath& path, size_t segmentIdx, float t) { + // Catmull-Rom spline interpolation (same as taxi flights) + size_t numPoints = path.waypoints.size(); + + // Get 4 control points for Catmull-Rom + size_t p0Idx = (segmentIdx == 0) ? (path.looping ? numPoints - 1 : 0) : segmentIdx - 1; + size_t p1Idx = segmentIdx; + size_t p2Idx = (segmentIdx + 1) % numPoints; + size_t p3Idx = (segmentIdx + 2) % numPoints; + + // If non-looping and at boundaries, clamp indices + if (!path.looping) { + if (segmentIdx == 0) p0Idx = 0; + if (segmentIdx >= numPoints - 2) p3Idx = numPoints - 1; + if (segmentIdx >= numPoints - 1) p2Idx = numPoints - 1; + } + + glm::vec3 p0 = path.waypoints[p0Idx]; + glm::vec3 p1 = path.waypoints[p1Idx]; + glm::vec3 p2 = path.waypoints[p2Idx]; + glm::vec3 p3 = path.waypoints[p3Idx]; + + // Catmull-Rom spline formula + float t2 = t * t; + float t3 = t2 * t; + + glm::vec3 result = 0.5f * ( + (2.0f * p1) + + (-p0 + p2) * t + + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3 + ); + + return result; +} + +glm::quat TransportManager::calculateOrientation(const TransportPath& path, size_t segmentIdx, float t) { + // Calculate tangent vector for orientation + size_t numPoints = path.waypoints.size(); + + // Get 4 control points + size_t p0Idx = (segmentIdx == 0) ? (path.looping ? numPoints - 1 : 0) : segmentIdx - 1; + size_t p1Idx = segmentIdx; + size_t p2Idx = (segmentIdx + 1) % numPoints; + size_t p3Idx = (segmentIdx + 2) % numPoints; + + if (!path.looping) { + if (segmentIdx == 0) p0Idx = 0; + if (segmentIdx >= numPoints - 2) p3Idx = numPoints - 1; + if (segmentIdx >= numPoints - 1) p2Idx = numPoints - 1; + } + + glm::vec3 p0 = path.waypoints[p0Idx]; + glm::vec3 p1 = path.waypoints[p1Idx]; + glm::vec3 p2 = path.waypoints[p2Idx]; + glm::vec3 p3 = path.waypoints[p3Idx]; + + // Tangent of Catmull-Rom spline (derivative) + float t2 = t * t; + glm::vec3 tangent = 0.5f * ( + (-p0 + p2) + + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * 2.0f * t + + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * 3.0f * t2 + ); + + // Normalize tangent + float tangentLength = glm::length(tangent); + if (tangentLength < 0.001f) { + // Fallback to simple direction + tangent = p2 - p1; + tangentLength = glm::length(tangent); + } + + if (tangentLength < 0.001f) { + return glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity + } + + tangent /= tangentLength; + + // Calculate rotation from forward direction + // WoW forward is typically +Y, but we'll use the tangent as forward + glm::vec3 forward = tangent; + glm::vec3 up(0.0f, 0.0f, 1.0f); // WoW Z is up + + // If forward is nearly vertical, use different up vector + if (std::abs(forward.z) > 0.99f) { + up = glm::vec3(0.0f, 1.0f, 0.0f); + } + + glm::vec3 right = glm::normalize(glm::cross(up, forward)); + up = glm::cross(forward, right); + + // Build rotation matrix and convert to quaternion + glm::mat3 rotMat; + rotMat[0] = right; + rotMat[1] = forward; + rotMat[2] = up; + + return glm::quat_cast(rotMat); +} + +void TransportManager::updateTransformMatrices(ActiveTransport& transport) { + // Build transform matrix: translate * rotate * scale + glm::mat4 translation = glm::translate(glm::mat4(1.0f), transport.position); + glm::mat4 rotation = glm::mat4_cast(transport.rotation); + glm::mat4 scale = glm::scale(glm::mat4(1.0f), glm::vec3(1.0f)); // No scaling for transports + + transport.transform = translation * rotation * scale; + transport.invTransform = glm::inverse(transport.transform); +} + +} // namespace wowee::game diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index efe0ab21..0b65102c 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -39,6 +39,7 @@ bool WaterRenderer::initialize() { uniform float waveAmp; uniform float waveFreq; uniform float waveSpeed; + uniform vec3 viewPos; out vec3 FragPos; out vec3 Normal; @@ -48,19 +49,30 @@ bool WaterRenderer::initialize() { void main() { vec3 pos = aPos; - // Pseudo-random phase offsets to break up regular pattern + // Distance from camera for LOD blending + float dist = length(viewPos - aPos); + float gridBlend = smoothstep(150.0, 400.0, dist); // 0=close (seamless), 1=far (grid effect) + + // Seamless waves (continuous across tiles) + float w1_seamless = sin((aPos.x + time * waveSpeed) * waveFreq) * waveAmp; + float w2_seamless = cos((aPos.y - time * (waveSpeed * 0.78)) * (waveFreq * 0.82)) * (waveAmp * 0.72); + float w3_seamless = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + aPos.y * 0.3) * waveFreq * 2.1) * (waveAmp * 0.35); + float w4_seamless = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + aPos.x * 0.2) * waveFreq * 1.8) * (waveAmp * 0.28); + + // Grid effect waves (per-vertex randomization for distance view) float hash1 = fract(sin(dot(aPos.xy, vec2(12.9898, 78.233))) * 43758.5453); float hash2 = fract(sin(dot(aPos.xy, vec2(93.9898, 67.345))) * 27153.5328); + float w1_grid = sin((aPos.x + time * waveSpeed + hash1 * 6.28) * waveFreq) * waveAmp; + float w2_grid = cos((aPos.y - time * (waveSpeed * 0.78) + hash2 * 6.28) * (waveFreq * 0.82)) * (waveAmp * 0.72); + float w3_grid = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + hash1 * 3.14) * waveFreq * 2.1) * (waveAmp * 0.35); + float w4_grid = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + hash2 * 3.14) * waveFreq * 1.8) * (waveAmp * 0.28); - // Multiple wave octaves with randomized phases for natural variation - float w1 = sin((aPos.x + time * waveSpeed + hash1 * 6.28) * waveFreq) * waveAmp; - float w2 = cos((aPos.y - time * (waveSpeed * 0.78) + hash2 * 6.28) * (waveFreq * 0.82)) * (waveAmp * 0.72); - - // Add higher frequency detail waves (smaller amplitude) - float w3 = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + hash1 * 3.14) * waveFreq * 2.1) * (waveAmp * 0.35); - float w4 = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + hash2 * 3.14) * waveFreq * 1.8) * (waveAmp * 0.28); - - float wave = w1 + w2 + w3 + w4; + // Blend between seamless (close) and grid (far) + float wave = mix( + w1_seamless + w2_seamless + w3_seamless + w4_seamless, + w1_grid + w2_grid + w3_grid + w4_grid, + gridBlend + ); pos.z += wave; FragPos = vec3(model * vec4(pos, 1.0)); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index c09b126d..be7de102 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -554,6 +554,49 @@ void WMORenderer::setInstancePosition(uint32_t instanceId, const glm::vec3& posi rebuildSpatialIndex(); } +void WMORenderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + auto& inst = instances[idxIt->second]; + + // Decompose transform to position/rotation/scale + inst.position = glm::vec3(transform[3]); + + // Extract rotation (assuming uniform scale) + glm::mat3 rotationMatrix(transform); + float scaleX = glm::length(glm::vec3(transform[0])); + float scaleY = glm::length(glm::vec3(transform[1])); + float scaleZ = glm::length(glm::vec3(transform[2])); + inst.scale = scaleX; // Assume uniform scale + + if (scaleX > 0.0001f) rotationMatrix[0] /= scaleX; + if (scaleY > 0.0001f) rotationMatrix[1] /= scaleY; + if (scaleZ > 0.0001f) rotationMatrix[2] /= scaleZ; + + inst.rotation = glm::vec3(0.0f); // Euler angles not directly used, so zero them + + // Update model matrix and bounds + inst.modelMatrix = transform; + inst.invModelMatrix = glm::inverse(transform); + + auto modelIt = loadedModels.find(inst.modelId); + if (modelIt != loadedModels.end()) { + const ModelData& model = modelIt->second; + transformAABB(inst.modelMatrix, model.boundingBoxMin, model.boundingBoxMax, + inst.worldBoundsMin, inst.worldBoundsMax); + inst.worldGroupBounds.clear(); + inst.worldGroupBounds.reserve(model.groups.size()); + for (const auto& group : model.groups) { + glm::vec3 gMin, gMax; + transformAABB(inst.modelMatrix, group.boundingBoxMin, group.boundingBoxMax, gMin, gMax); + gMin -= glm::vec3(0.5f); + gMax += glm::vec3(0.5f); + inst.worldGroupBounds.emplace_back(gMin, gMax); + } + } + rebuildSpatialIndex(); +} + void WMORenderer::removeInstance(uint32_t instanceId) { auto it = std::find_if(instances.begin(), instances.end(), [instanceId](const WMOInstance& inst) { return inst.id == instanceId; }); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ce7cb8a2..e9a02d65 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1758,6 +1758,51 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /transport board — board test transport + if (cmdLower == "transport board") { + auto* tm = gameHandler.getTransportManager(); + if (tm) { + // Test transport GUID + uint64_t testTransportGuid = 0x1000000000000001ULL; + // Place player at center of deck (rough estimate) + glm::vec3 deckCenter(0.0f, 0.0f, 5.0f); + gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Boarded test transport. Use '/transport leave' to disembark."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Transport system not available."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /transport leave — disembark from transport + if (cmdLower == "transport leave") { + if (gameHandler.isOnTransport()) { + gameHandler.clearPlayerTransport(); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Disembarked from transport."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You are not on a transport."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer[0] = '\0'; + return; + } + // Chat channel slash commands // If used without a message (e.g. just "/s"), switch the chat type dropdown bool isChannelCommand = false;