From 3c2a728ec4e0b6c952f8c4c52d722be78a61e3a0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 7 Feb 2026 16:59:20 -0800 Subject: [PATCH] Add taxi system, fix WMO interior lighting, ramp collision, and /unstuck - Implement flight path system: SMSG_SHOWTAXINODES parser, CMSG_ACTIVATETAXIEXPRESS builder, BFS multi-hop pathfinding through TaxiNodes/TaxiPath DBC, taxi destination UI, movement blocking during flight - Fix WMO interiors too dark by boosting vertex color lighting multiplier - Dim M2 objects inside WMO interiors (rugs, furniture) via per-instance interior detection - Fix ramp/stair clipping by lowering wall collision normal threshold from 0.85 to 0.55 - Restore 5-sample cardinal footprint for ground detection to fix rug slipping - Fix /unstuck command to reset player Z to WMO/terrain floor height - Handle MSG_MOVE_TELEPORT_ACK and SMSG_TRANSFER_PENDING for hearthstone teleports - Fix spawning under Stormwind with online-mode camera controller reset --- include/game/game_handler.hpp | 47 ++++- include/game/opcodes.hpp | 9 + include/game/world_packets.hpp | 42 ++++ include/rendering/camera_controller.hpp | 9 +- include/rendering/m2_renderer.hpp | 4 + include/rendering/wmo_renderer.hpp | 6 + include/ui/game_screen.hpp | 1 + src/core/application.cpp | 53 ++++- src/game/game_handler.cpp | 259 +++++++++++++++++++++++- src/game/world_packets.cpp | 43 ++++ src/rendering/camera_controller.cpp | 41 ++-- src/rendering/m2_renderer.cpp | 75 ++++--- src/rendering/renderer.cpp | 3 + src/rendering/wmo_renderer.cpp | 134 ++++++++---- src/ui/game_screen.cpp | 73 +++++++ 15 files changed, 691 insertions(+), 108 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index fa054628..83ce8370 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -364,6 +364,11 @@ public: using WorldEntryCallback = std::function; void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); } + // Unstuck callback (resets player Z to floor height) + using UnstuckCallback = std::function; + void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } + void unstuck(); + // Creature spawn callback (online mode - triggered when creature enters view) // Parameters: guid, displayId, x, y, z (canonical), orientation using CreatureSpawnCallback = std::function; @@ -450,6 +455,27 @@ public: } const std::unordered_map& getNpcQuestStatuses() const { return npcQuestStatus_; } + // Taxi / Flight Paths + bool isTaxiWindowOpen() const { return taxiWindowOpen_; } + void closeTaxi(); + void activateTaxi(uint32_t destNodeId); + bool isOnTaxiFlight() const { return onTaxiFlight_; } + const ShowTaxiNodesData& getTaxiData() const { return currentTaxiData_; } + uint32_t getTaxiCurrentNode() const { return currentTaxiData_.nearestNode; } + + struct TaxiNode { + uint32_t id = 0; + uint32_t mapId = 0; + float x = 0, y = 0, z = 0; + std::string name; + }; + struct TaxiPathEdge { + uint32_t pathId = 0; + uint32_t fromNode = 0, toNode = 0; + uint32_t cost = 0; + }; + const std::unordered_map& getTaxiNodes() const { return taxiNodes_; } + // Vendor void openVendor(uint64_t npcGuid); void closeVendor(); @@ -601,6 +627,14 @@ private: void handleListInventory(network::Packet& packet); void addMoneyCopper(uint32_t amount); + // ---- Teleport handler ---- + void handleTeleportAck(network::Packet& packet); + + // ---- Taxi handlers ---- + void handleShowTaxiNodes(network::Packet& packet); + void handleActivateTaxiReply(network::Packet& packet); + void loadTaxiDbc(); + // ---- Server info handlers ---- void handleQueryTimeResponse(network::Packet& packet); void handlePlayedTime(network::Packet& packet); @@ -686,8 +720,9 @@ private: float pingInterval = 30.0f; // Ping interval (30 seconds) uint32_t lastLatency = 0; // Last measured latency (milliseconds) - // Player GUID + // Player GUID and map uint64_t playerGuid = 0; + uint32_t currentMapId_ = 0; // ---- Phase 1: Name caches ---- std::unordered_map playerNameCache; @@ -742,6 +777,7 @@ private: // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; + UnstuckCallback unstuckCallback_; CreatureSpawnCallback creatureSpawnCallback_; CreatureDespawnCallback creatureDespawnCallback_; CreatureMoveCallback creatureMoveCallback_; @@ -800,6 +836,15 @@ private: return it != factionHostileMap_.end() ? it->second : true; // default hostile if unknown } + // Taxi / Flight Paths + std::unordered_map taxiNodes_; + std::vector taxiPathEdges_; + bool taxiDbcLoaded_ = false; + bool taxiWindowOpen_ = false; + ShowTaxiNodesData currentTaxiData_; + uint64_t taxiNpcGuid_ = 0; + bool onTaxiFlight_ = false; + // Vendor bool vendorWindowOpen = false; ListInventoryData currentVendorItems; diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 4dbc0559..c78c61d0 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -236,6 +236,15 @@ enum class Opcode : uint16_t { // ---- Death/Respawn ---- CMSG_REPOP_REQUEST = 0x015A, CMSG_SPIRIT_HEALER_ACTIVATE = 0x0176, + + // ---- Teleport / Transfer ---- + MSG_MOVE_TELEPORT_ACK = 0x0C7, + SMSG_TRANSFER_PENDING = 0x003F, + + // ---- Taxi / Flight Paths ---- + SMSG_SHOWTAXINODES = 0x01A9, + SMSG_ACTIVATETAXIREPLY = 0x01AE, + CMSG_ACTIVATETAXIEXPRESS = 0x0312, }; } // namespace game diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 01346c73..750e7ce9 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1652,6 +1652,48 @@ public: static bool parse(network::Packet& packet, ListInventoryData& data); }; +// ============================================================ +// Taxi / Flight Paths +// ============================================================ + +static constexpr uint32_t TLK_TAXI_MASK_SIZE = 12; + +/** SMSG_SHOWTAXINODES data */ +struct ShowTaxiNodesData { + uint32_t windowInfo = 0; // 1 = show window + uint64_t npcGuid = 0; + uint32_t nearestNode = 0; // Taxi node player is at + uint32_t nodeMask[TLK_TAXI_MASK_SIZE] = {}; + bool isNodeKnown(uint32_t nodeId) const { + uint32_t idx = nodeId / 32; + uint32_t bit = nodeId % 32; + return idx < TLK_TAXI_MASK_SIZE && (nodeMask[idx] & (1u << bit)); + } +}; + +/** SMSG_SHOWTAXINODES parser */ +class ShowTaxiNodesParser { +public: + static bool parse(network::Packet& packet, ShowTaxiNodesData& data); +}; + +/** SMSG_ACTIVATETAXIREPLY data */ +struct ActivateTaxiReplyData { + uint32_t result = 0; // 0 = OK +}; + +/** SMSG_ACTIVATETAXIREPLY parser */ +class ActivateTaxiReplyParser { +public: + static bool parse(network::Packet& packet, ActivateTaxiReplyData& data); +}; + +/** CMSG_ACTIVATETAXIEXPRESS packet builder */ +class ActivateTaxiExpressPacket { +public: + static network::Packet build(uint64_t npcGuid, const std::vector& pathNodes); +}; + /** CMSG_REPOP_REQUEST packet builder */ class RepopRequestPacket { public: diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 4f23056e..329a018d 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -42,6 +42,7 @@ public: } void reset(); + void setOnlineMode(bool online) { onlineMode = online; } void startIntroPan(float durationSec = 2.8f, float orbitDegrees = 140.0f); bool isIntroActive() const { return introActive; } @@ -63,6 +64,7 @@ public: bool isSitting() const { return sitting; } bool isSwimming() const { return swimming; } const glm::vec3* getFollowTarget() const { return followTarget; } + glm::vec3* getFollowTargetMutable() { return followTarget; } // Movement callback for sending opcodes to server using MovementCallback = std::function; @@ -134,10 +136,6 @@ private: static constexpr float JUMP_BUFFER_TIME = 0.15f; // 150ms input buffer static constexpr float COYOTE_TIME = 0.10f; // 100ms grace after leaving ground - // Cached floor height between frames (skip expensive probes when barely moving) - std::optional cachedFloorHeight; - glm::vec2 cachedFloorPos = glm::vec2(0.0f); - // Cached isInsideWMO result (throttled to avoid per-frame cost) bool cachedInsideWMO = false; int insideWMOCheckCounter = 0; @@ -188,6 +186,9 @@ private: static constexpr float WOW_GRAVITY = -19.29f; static constexpr float WOW_JUMP_VELOCITY = 7.96f; + // Online mode: trust server position, don't prefer outdoors over WMO floors + bool onlineMode = false; + // Default spawn position (Goldshire Inn) glm::vec3 defaultPosition = glm::vec3(-9464.0f, 62.0f, 200.0f); float defaultYaw = 0.0f; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index c122fd2e..86ac8f22 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -22,6 +22,7 @@ namespace rendering { class Shader; class Camera; +class WMORenderer; /** * GPU representation of an M2 model @@ -276,7 +277,10 @@ public: } void clearShadowMap() { shadowEnabled = false; } + void setWMORenderer(WMORenderer* wmo) { wmoRenderer = wmo; } + private: + WMORenderer* wmoRenderer = nullptr; pipeline::AssetManager* assetManager = nullptr; std::unique_ptr shader; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 9029c5aa..f0903f2f 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -203,6 +203,12 @@ public: */ bool isInsideWMO(float glX, float glY, float glZ, uint32_t* outModelId = nullptr) const; + /** + * Check if a position is inside an interior WMO group (flag 0x2000). + * Used to dim M2 lighting for doodads placed indoors. + */ + bool isInsideInteriorWMO(float glX, float glY, float glZ) const; + /** * Raycast against WMO bounding boxes for camera collision * @param origin Ray origin (e.g., character head position) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index ebd7407f..c4cfe421 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -143,6 +143,7 @@ private: void renderQuestRequestItemsWindow(game::GameHandler& gameHandler); void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); + void renderTaxiWindow(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); diff --git a/src/core/application.cpp b/src/core/application.cpp index e8404a8e..cc884bf4 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -530,6 +530,38 @@ void Application::setupUICallbacks() { loadOnlineWorldTerrain(mapId, x, y, z); }); + // Unstuck callback — snap player Z to WMO/terrain floor height + gameHandler->setUnstuckCallback([this]() { + if (!renderer || !renderer->getCameraController()) return; + auto* cc = renderer->getCameraController(); + auto* ft = cc->getFollowTargetMutable(); + if (!ft) return; + // Probe floor at current XY from high up to find WMO floors above + auto* wmo = renderer->getWMORenderer(); + auto* terrain = renderer->getTerrainManager(); + float bestZ = ft->z; + bool found = false; + // Probe from well above to catch WMO floors like Stormwind + float probeZ = ft->z + 200.0f; + if (wmo) { + auto wmoH = wmo->getFloorHeight(ft->x, ft->y, probeZ); + if (wmoH) { + bestZ = *wmoH; + found = true; + } + } + if (terrain) { + auto terrH = terrain->getHeightAt(ft->x, ft->y); + if (terrH && (!found || *terrH > bestZ)) { + bestZ = *terrH; + found = true; + } + } + if (found) { + ft->z = bestZ; + } + }); + // Faction hostility map is built in buildFactionHostilityMap() when character enters world // Creature spawn callback (online mode) - spawn creature models @@ -1336,6 +1368,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // Set camera position if (renderer->getCameraController()) { + renderer->getCameraController()->setOnlineMode(true); renderer->getCameraController()->setDefaultSpawn(spawnRender, 0.0f, 15.0f); renderer->getCameraController()->reset(); renderer->getCameraController()->startIntroPan(2.8f, 140.0f); @@ -1361,21 +1394,19 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float } } - // Spawn player model for online mode + // Spawn player model for online mode (skip if already spawned, e.g. teleport) if (gameHandler) { const game::Character* activeChar = gameHandler->getActiveCharacter(); if (activeChar) { - playerRace_ = activeChar->race; - playerGender_ = activeChar->gender; - playerClass_ = activeChar->characterClass; - spawnSnapToGround = false; - playerCharacterSpawned = false; - spawnPlayerCharacter(); + if (!playerCharacterSpawned) { + playerRace_ = activeChar->race; + playerGender_ = activeChar->gender; + playerClass_ = activeChar->characterClass; + spawnSnapToGround = false; + spawnPlayerCharacter(); + } renderer->getCharacterPosition() = spawnRender; - LOG_INFO("Spawned online player model: ", activeChar->name, - " (race=", static_cast(playerRace_), - ", gender=", static_cast(playerGender_), - ") at render pos (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")"); + LOG_INFO("Online player at render pos (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")"); } else { LOG_WARNING("No active character found for player model spawning"); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7bdc519a..acd9a081 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -177,6 +177,18 @@ void GameHandler::update(float deltaTime) { // Update combat text (Phase 2) updateCombatText(deltaTime); + // Detect taxi flight landing: UNIT_FLAG_TAXI_FLIGHT (0x00000100) cleared + if (onTaxiFlight_) { + auto playerEntity = entityManager.getEntity(playerGuid); + if (playerEntity && playerEntity->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(playerEntity); + if ((unit->getUnitFlags() & 0x00000100) == 0) { + onTaxiFlight_ = false; + LOG_INFO("Taxi flight landed"); + } + } + } + // Update entity movement interpolation (keeps targeting in sync with visuals) for (auto& [guid, entity] : entityManager.getEntities()) { entity->updateMovement(deltaTime); @@ -238,7 +250,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_LOGIN_VERIFY_WORLD: - if (state == WorldState::ENTERING_WORLD) { + if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD) { handleLoginVerifyWorld(packet); } else { LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", (int)state); @@ -621,6 +633,22 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec); break; + // ---- Teleport / Transfer ---- + case Opcode::MSG_MOVE_TELEPORT_ACK: + handleTeleportAck(packet); + break; + case Opcode::SMSG_TRANSFER_PENDING: + LOG_INFO("SMSG_TRANSFER_PENDING received - map transfer incoming"); + break; + + // ---- Taxi / Flight Paths ---- + case Opcode::SMSG_SHOWTAXINODES: + handleShowTaxiNodes(packet); + break; + case Opcode::SMSG_ACTIVATETAXIREPLY: + handleActivateTaxiReply(packet); + break; + default: LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec); break; @@ -964,7 +992,8 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { return; } - // Successfully entered the world! + // Successfully entered the world (or teleported) + currentMapId_ = data.mapId; setState(WorldState::IN_WORLD); LOG_INFO("========================================"); @@ -974,7 +1003,6 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { LOG_INFO("Position: (", data.x, ", ", data.y, ", ", data.z, ")"); LOG_INFO("Orientation: ", data.orientation, " radians"); LOG_INFO("Player is now in the game world"); - addSystemChatMessage("You have entered the world."); // Initialize movement info with world entry position (server → canonical) glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); @@ -1073,6 +1101,9 @@ void GameHandler::sendMovement(Opcode opcode) { return; } + // Block movement during taxi flight + if (onTaxiFlight_) return; + // Use real millisecond timestamp (server validates for anti-cheat) static auto startTime = std::chrono::steady_clock::now(); auto now = std::chrono::steady_clock::now(); @@ -3617,6 +3648,13 @@ void GameHandler::useItemById(uint32_t itemId) { } } +void GameHandler::unstuck() { + if (unstuckCallback_) { + unstuckCallback_(); + addSystemChatMessage("Unstuck: position reset to floor."); + } +} + void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot)) return; lootWindowOpen = true; @@ -3774,6 +3812,221 @@ void GameHandler::addSystemChatMessage(const std::string& message) { addLocalChatMessage(msg); } +// ============================================================ +// Teleport Handler +// ============================================================ + +void GameHandler::handleTeleportAck(network::Packet& packet) { + // MSG_MOVE_TELEPORT_ACK (server→client): packedGuid + u32 counter + u32 time + // followed by movement info with the new position + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_WARNING("MSG_MOVE_TELEPORT_ACK too short"); + return; + } + + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t counter = packet.readUInt32(); + + // Read the movement info embedded in the teleport + // Format: u32 flags, u16 flags2, u32 time, float x, float y, float z, float o + if (packet.getSize() - packet.getReadPos() < 4 + 2 + 4 + 4 * 4) { + LOG_WARNING("MSG_MOVE_TELEPORT_ACK: not enough data for movement info"); + return; + } + + packet.readUInt32(); // moveFlags + packet.readUInt16(); // moveFlags2 + uint32_t moveTime = packet.readUInt32(); + float serverX = packet.readFloat(); + float serverY = packet.readFloat(); + float serverZ = packet.readFloat(); + float orientation = packet.readFloat(); + + LOG_INFO("MSG_MOVE_TELEPORT_ACK: guid=0x", std::hex, guid, std::dec, + " counter=", counter, + " pos=(", serverX, ", ", serverY, ", ", serverZ, ")"); + + // Update our position + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ)); + movementInfo.x = canonical.x; + movementInfo.y = canonical.y; + movementInfo.z = canonical.z; + movementInfo.orientation = orientation; + movementInfo.flags = 0; + + // Send the ack back to the server + // Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time + if (socket) { + network::Packet ack(static_cast(Opcode::MSG_MOVE_TELEPORT_ACK)); + // Write packed guid + uint8_t mask = 0; + uint8_t bytes[8]; + int byteCount = 0; + uint64_t g = playerGuid; + for (int i = 0; i < 8; i++) { + uint8_t b = static_cast(g & 0xFF); + g >>= 8; + if (b != 0) { + mask |= (1 << i); + bytes[byteCount++] = b; + } + } + ack.writeUInt8(mask); + for (int i = 0; i < byteCount; i++) { + ack.writeUInt8(bytes[i]); + } + ack.writeUInt32(counter); + ack.writeUInt32(moveTime); + socket->send(ack); + LOG_INFO("Sent MSG_MOVE_TELEPORT_ACK response"); + } + + // Notify application to reload terrain at new position + if (worldEntryCallback_) { + worldEntryCallback_(currentMapId_, serverX, serverY, serverZ); + } +} + +// ============================================================ +// Taxi / Flight Path Handlers +// ============================================================ + +void GameHandler::loadTaxiDbc() { + if (taxiDbcLoaded_) return; + taxiDbcLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + // Load TaxiNodes.dbc: 0=ID, 1=mapId, 2=x, 3=y, 4=z, 6=name + auto nodesDbc = am->loadDBC("TaxiNodes.dbc"); + if (nodesDbc && nodesDbc->isLoaded()) { + for (uint32_t i = 0; i < nodesDbc->getRecordCount(); i++) { + TaxiNode node; + node.id = nodesDbc->getUInt32(i, 0); + node.mapId = nodesDbc->getUInt32(i, 1); + node.x = nodesDbc->getFloat(i, 2); + node.y = nodesDbc->getFloat(i, 3); + node.z = nodesDbc->getFloat(i, 4); + node.name = nodesDbc->getString(i, 6); + if (node.id > 0) { + taxiNodes_[node.id] = std::move(node); + } + } + LOG_INFO("Loaded ", taxiNodes_.size(), " taxi nodes from TaxiNodes.dbc"); + } else { + LOG_WARNING("Could not load TaxiNodes.dbc"); + } + + // Load TaxiPath.dbc: 0=pathId, 1=fromNode, 2=toNode, 3=cost + auto pathDbc = am->loadDBC("TaxiPath.dbc"); + if (pathDbc && pathDbc->isLoaded()) { + for (uint32_t i = 0; i < pathDbc->getRecordCount(); i++) { + TaxiPathEdge edge; + edge.pathId = pathDbc->getUInt32(i, 0); + edge.fromNode = pathDbc->getUInt32(i, 1); + edge.toNode = pathDbc->getUInt32(i, 2); + edge.cost = pathDbc->getUInt32(i, 3); + taxiPathEdges_.push_back(edge); + } + LOG_INFO("Loaded ", taxiPathEdges_.size(), " taxi path edges from TaxiPath.dbc"); + } else { + LOG_WARNING("Could not load TaxiPath.dbc"); + } +} + +void GameHandler::handleShowTaxiNodes(network::Packet& packet) { + ShowTaxiNodesData data; + if (!ShowTaxiNodesParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_SHOWTAXINODES"); + return; + } + + loadTaxiDbc(); + + currentTaxiData_ = data; + taxiNpcGuid_ = data.npcGuid; + taxiWindowOpen_ = true; + gossipWindowOpen = false; + LOG_INFO("Taxi window opened, nearest node=", data.nearestNode); +} + +void GameHandler::handleActivateTaxiReply(network::Packet& packet) { + ActivateTaxiReplyData data; + if (!ActivateTaxiReplyParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_ACTIVATETAXIREPLY"); + return; + } + + if (data.result == 0) { + onTaxiFlight_ = true; + taxiWindowOpen_ = false; + LOG_INFO("Taxi flight started!"); + } else { + LOG_WARNING("Taxi activation failed, result=", data.result); + addSystemChatMessage("Cannot take that flight path."); + } +} + +void GameHandler::closeTaxi() { + taxiWindowOpen_ = false; +} + +void GameHandler::activateTaxi(uint32_t destNodeId) { + if (!socket || state != WorldState::IN_WORLD) return; + + uint32_t startNode = currentTaxiData_.nearestNode; + if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return; + + // BFS to find path from startNode to destNodeId through known nodes + // Build adjacency list from edges where both nodes are known + std::unordered_map> adj; + for (const auto& edge : taxiPathEdges_) { + if (currentTaxiData_.isNodeKnown(edge.fromNode) && currentTaxiData_.isNodeKnown(edge.toNode)) { + adj[edge.fromNode].push_back(edge.toNode); + } + } + + // BFS + std::unordered_map parent; + std::deque queue; + queue.push_back(startNode); + parent[startNode] = startNode; + + bool found = false; + while (!queue.empty()) { + uint32_t cur = queue.front(); + queue.pop_front(); + if (cur == destNodeId) { found = true; break; } + for (uint32_t next : adj[cur]) { + if (parent.find(next) == parent.end()) { + parent[next] = cur; + queue.push_back(next); + } + } + } + + if (!found) { + LOG_WARNING("No taxi path found from node ", startNode, " to ", destNodeId); + addSystemChatMessage("No flight path available to that destination."); + return; + } + + // Reconstruct path + std::vector path; + for (uint32_t n = destNodeId; n != startNode; n = parent[n]) { + path.push_back(n); + } + path.push_back(startNode); + std::reverse(path.begin(), path.end()); + + LOG_INFO("Taxi path: ", path.size(), " nodes, from ", startNode, " to ", destNodeId); + + auto pkt = ActivateTaxiExpressPacket::build(taxiNpcGuid_, path); + socket->send(pkt); +} + // ============================================================ // Server Info Command Handlers // ============================================================ diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 8a81329b..4c0b95c2 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2591,5 +2591,48 @@ network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) { return packet; } +// ============================================================ +// Taxi / Flight Paths +// ============================================================ + +bool ShowTaxiNodesParser::parse(network::Packet& packet, ShowTaxiNodesData& data) { + if (packet.getSize() - packet.getReadPos() < 4 + 8 + 4 + TLK_TAXI_MASK_SIZE * 4) { + LOG_ERROR("ShowTaxiNodesParser: packet too short"); + return false; + } + data.windowInfo = packet.readUInt32(); + data.npcGuid = packet.readUInt64(); + data.nearestNode = packet.readUInt32(); + for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) { + data.nodeMask[i] = packet.readUInt32(); + } + LOG_INFO("ShowTaxiNodes: window=", data.windowInfo, " npc=0x", std::hex, data.npcGuid, std::dec, + " nearest=", data.nearestNode); + return true; +} + +bool ActivateTaxiReplyParser::parse(network::Packet& packet, ActivateTaxiReplyData& data) { + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_ERROR("ActivateTaxiReplyParser: packet too short"); + return false; + } + data.result = packet.readUInt32(); + LOG_INFO("ActivateTaxiReply: result=", data.result); + return true; +} + +network::Packet ActivateTaxiExpressPacket::build(uint64_t npcGuid, const std::vector& pathNodes) { + network::Packet packet(static_cast(Opcode::CMSG_ACTIVATETAXIEXPRESS)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(0); // totalCost (server recalculates) + packet.writeUInt32(static_cast(pathNodes.size())); + for (uint32_t nodeId : pathNodes) { + packet.writeUInt32(nodeId); + } + LOG_INFO("ActivateTaxiExpress: npc=0x", std::hex, npcGuid, std::dec, + " nodes=", pathNodes.size()); + return packet; +} + } // namespace game } // namespace wowee diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index bd1ee0c9..bb662e1d 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -588,15 +588,22 @@ void CameraController::update(float deltaTime) { return base; }; - // Use cached floor height if player hasn't moved much horizontally. - float floorPosDist = glm::length(glm::vec2(targetPos.x, targetPos.y) - cachedFloorPos); + // Sample center + 4 cardinal offsets so narrow M2 objects (rugs, + // planks) are reliably detected. Take the highest result. std::optional groundH; - if (cachedFloorHeight && floorPosDist < 0.5f) { - groundH = cachedFloorHeight; - } else { - groundH = sampleGround(targetPos.x, targetPos.y); - cachedFloorHeight = groundH; - cachedFloorPos = glm::vec2(targetPos.x, targetPos.y); + { + constexpr float FOOTPRINT = 0.4f; + const glm::vec2 offsets[] = { + {0.0f, 0.0f}, + {FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f}, + {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT} + }; + for (const auto& o : offsets) { + auto h = sampleGround(targetPos.x + o.x, targetPos.y + o.y); + if (h && (!groundH || *h > *groundH)) { + groundH = h; + } + } } if (groundH) { @@ -1081,13 +1088,18 @@ void CameraController::reset() { }; // Search nearby for a stable, non-steep spawn floor to avoid waterfall/ledge spawns. + // In online mode, use a tight search radius since the server dictates position. float bestScore = std::numeric_limits::max(); glm::vec3 bestPos = spawnPos; bool foundBest = false; - constexpr float radii[] = {0.0f, 6.0f, 12.0f, 18.0f, 24.0f, 32.0f}; + constexpr float radiiOffline[] = {0.0f, 6.0f, 12.0f, 18.0f, 24.0f, 32.0f}; + constexpr float radiiOnline[] = {0.0f, 2.0f}; + const float* radii = onlineMode ? radiiOnline : radiiOffline; + const int radiiCount = onlineMode ? 2 : 6; constexpr int ANGLES = 16; constexpr float PI = 3.14159265f; - for (float r : radii) { + for (int ri = 0; ri < radiiCount; ri++) { + float r = radii[ri]; int steps = (r <= 0.01f) ? 1 : ANGLES; for (int i = 0; i < steps; i++) { float a = (2.0f * PI * static_cast(i)) / static_cast(steps); @@ -1128,8 +1140,9 @@ void CameraController::reset() { const glm::vec3 from(x, y, *h + 0.20f); const bool insideWMO = wmoRenderer->isInsideWMO(x, y, *h + 1.5f, nullptr); - // Prefer outdoors for default hearth-like spawn points. - if (insideWMO) { + // Prefer outdoors for default hearth-like spawn points (offline only). + // In online mode, trust the server position even if inside a WMO. + if (insideWMO && !onlineMode) { score += 120.0f; } @@ -1192,10 +1205,6 @@ void CameraController::reset() { lastGroundZ = spawnPos.z - 0.05f; } - // Invalidate inter-frame floor cache so the first frame probes fresh. - cachedFloorHeight.reset(); - cachedFloorPos = glm::vec2(0.0f); - camera->setRotation(yaw, pitch); glm::vec3 forward3D = camera->getForward(); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index dd2deba7..6eecf3b2 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1,4 +1,5 @@ #include "rendering/m2_renderer.hpp" +#include "rendering/wmo_renderer.hpp" #include "rendering/texture.hpp" #include "rendering/shader.hpp" #include "rendering/camera.hpp" @@ -275,6 +276,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { uniform mat4 uLightSpaceMatrix; uniform bool uShadowEnabled; uniform float uShadowStrength; + uniform bool uInteriorDarken; out vec4 FragColor; @@ -306,41 +308,48 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { vec3 normal = normalize(Normal); vec3 lightDir = normalize(uLightDir); - // Two-sided lighting for foliage - float diff = max(abs(dot(normal, lightDir)), 0.3); + vec3 result; + if (uInteriorDarken) { + // Interior: dim ambient, minimal directional light + float diff = max(abs(dot(normal, lightDir)), 0.0) * 0.15; + result = texColor.rgb * (0.55 + diff); + } else { + // Two-sided lighting for foliage + float diff = max(abs(dot(normal, lightDir)), 0.3); - // Blinn-Phong specular - vec3 viewDir = normalize(uViewPos - FragPos); - vec3 halfDir = normalize(lightDir + viewDir); - float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); - vec3 specular = spec * uLightColor * uSpecularIntensity; + // Blinn-Phong specular + vec3 viewDir = normalize(uViewPos - FragPos); + vec3 halfDir = normalize(lightDir + viewDir); + float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); + vec3 specular = spec * uLightColor * uSpecularIntensity; - // Shadow mapping - float shadow = 1.0; - if (uShadowEnabled) { - vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); - vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; - if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { - float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); - float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); - float bias = max(0.005 * (1.0 - abs(dot(normal, lightDir))), 0.001); - shadow = 0.0; - vec2 texelSize = vec2(1.0 / 2048.0); - for (int sx = -1; sx <= 1; sx++) { - for (int sy = -1; sy <= 1; sy++) { - shadow += texture(uShadowMap, vec3(proj.xy + vec2(sx, sy) * texelSize, proj.z - bias)); + // Shadow mapping + float shadow = 1.0; + if (uShadowEnabled) { + vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); + vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; + if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { + float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); + float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); + float bias = max(0.005 * (1.0 - abs(dot(normal, lightDir))), 0.001); + shadow = 0.0; + vec2 texelSize = vec2(1.0 / 2048.0); + for (int sx = -1; sx <= 1; sx++) { + for (int sy = -1; sy <= 1; sy++) { + shadow += texture(uShadowMap, vec3(proj.xy + vec2(sx, sy) * texelSize, proj.z - bias)); + } } + shadow /= 9.0; + shadow = mix(1.0, shadow, coverageFade); } - shadow /= 9.0; - shadow = mix(1.0, shadow, coverageFade); } + shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); + + vec3 ambient = uAmbientColor * texColor.rgb; + vec3 diffuse = diff * texColor.rgb; + + result = ambient + (diffuse + specular) * shadow; } - shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); - - vec3 ambient = uAmbientColor * texColor.rgb; - vec3 diffuse = diff * texColor.rgb; - - vec3 result = ambient + (diffuse + specular) * shadow; // Fog float fogDist = length(uViewPos - FragPos); @@ -1487,6 +1496,14 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: shader->setUniform("uModel", instance.modelMatrix); shader->setUniform("uFadeAlpha", fadeAlpha); + // Dim M2 objects inside WMO interiors + bool interior = false; + if (wmoRenderer && entry.distSq < 200.0f * 200.0f) { + interior = wmoRenderer->isInsideInteriorWMO( + instance.position.x, instance.position.y, instance.position.z); + } + shader->setUniform("uInteriorDarken", interior); + // Upload bone matrices if model has skeletal animation bool useBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); shader->setUniform("uUseBones", useBones); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 14f9b79e..1d5cedb8 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -185,6 +185,9 @@ bool Renderer::initialize(core::Window* win) { // Create M2 renderer (for doodads) m2Renderer = std::make_unique(); + if (wmoRenderer) { + m2Renderer->setWMORenderer(wmoRenderer.get()); + } // Note: M2 renderer needs asset manager, will be initialized when terrain loads // Create zone manager diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index cade789a..afb7418c 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -109,9 +109,12 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) { texColor = texture(uTexture, TexCoord); // Alpha test only for cutout materials (lattice, grating, etc.) if (uAlphaTest && texColor.a < 0.5) discard; - // Multiply vertex color (MOCV baked lighting/AO) into texture - texColor.rgb *= VertexColor.rgb; alpha = texColor.a; + // Exterior: multiply vertex color (MOCV baked AO) into texture + // Interior: keep texture clean — vertex color is used as light below + if (!uIsInterior) { + texColor.rgb *= VertexColor.rgb; + } } else { // MOCV vertex color alpha is a lighting blend factor, not transparency texColor = vec4(VertexColor.rgb, 1.0); @@ -130,56 +133,56 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) { vec3 normal = normalize(Normal); vec3 lightDir = normalize(uLightDir); - // Interior vs exterior lighting - vec3 ambient; - float dirScale; + vec3 litColor; if (uIsInterior) { - ambient = vec3(0.7, 0.7, 0.7); - dirScale = 0.3; + // Interior: MOCV vertex colors are baked lighting. + // Use them directly as the light multiplier on the texture. + vec3 vertLight = VertexColor.rgb * 2.2 + 0.3; + // Subtle directional fill so geometry reads + float diff = max(dot(normal, lightDir), 0.0); + vertLight += diff * 0.10; + litColor = texColor.rgb * vertLight; } else { - ambient = uAmbientColor; - dirScale = 1.0; - } + // Exterior: standard diffuse + specular lighting + vec3 ambient = uAmbientColor; - // Diffuse lighting - float diff = max(dot(normal, lightDir), 0.0); - vec3 diffuse = diff * vec3(1.0) * dirScale; + float diff = max(dot(normal, lightDir), 0.0); + vec3 diffuse = diff * vec3(1.0); - // Blinn-Phong specular - vec3 viewDir = normalize(uViewPos - FragPos); - vec3 halfDir = normalize(lightDir + viewDir); - float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); - vec3 specular = spec * uLightColor * uSpecularIntensity * dirScale; + vec3 viewDir = normalize(uViewPos - FragPos); + vec3 halfDir = normalize(lightDir + viewDir); + float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); + vec3 specular = spec * uLightColor * uSpecularIntensity; - // Shadow mapping - float shadow = 1.0; - if (uShadowEnabled) { - vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); - vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; - if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { - float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); - float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); - float bias = max(0.005 * (1.0 - dot(normal, lightDir)), 0.001); - shadow = 0.0; - vec2 texelSize = vec2(1.0 / 2048.0); - for (int sx = -1; sx <= 1; sx++) { - for (int sy = -1; sy <= 1; sy++) { - shadow += texture(uShadowMap, vec3(proj.xy + vec2(sx, sy) * texelSize, proj.z - bias)); + // Shadow mapping + float shadow = 1.0; + if (uShadowEnabled) { + vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); + vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; + if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { + float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); + float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); + float bias = max(0.005 * (1.0 - dot(normal, lightDir)), 0.001); + shadow = 0.0; + vec2 texelSize = vec2(1.0 / 2048.0); + for (int sx = -1; sx <= 1; sx++) { + for (int sy = -1; sy <= 1; sy++) { + shadow += texture(uShadowMap, vec3(proj.xy + vec2(sx, sy) * texelSize, proj.z - bias)); + } } + shadow /= 9.0; + shadow = mix(1.0, shadow, coverageFade); } - shadow /= 9.0; - shadow = mix(1.0, shadow, coverageFade); } - } - shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); + shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); - // Combine lighting with texture - vec3 result = (ambient + (diffuse + specular) * shadow) * texColor.rgb; + litColor = (ambient + (diffuse + specular) * shadow) * texColor.rgb; + } // Fog float fogDist = length(uViewPos - FragPos); float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); - result = mix(uFogColor, result, fogFactor); + vec3 result = mix(uFogColor, litColor, fogFactor); FragColor = vec4(result, alpha); } @@ -1833,8 +1836,9 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, if (normalLen < 0.001f) continue; normal /= normalLen; - // Skip near-horizontal triangles (floors/ceilings). - if (std::abs(normal.z) > 0.85f) continue; + // Skip near-horizontal triangles (floors/ceilings/ramps). + // Anything more horizontal than ~55° from vertical is walkable. + if (std::abs(normal.z) > 0.55f) continue; // Get triangle Z range float triMinZ = std::min({v0.z, v1.z, v2.z}); @@ -1852,9 +1856,6 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, // Skip low geometry that can be stepped over if (triMaxZ <= localFeetZ + MAX_STEP_HEIGHT) continue; - // Skip ramp surfaces (facing mostly upward) that are very low - if (normal.z > 0.60f && triMaxZ <= localFeetZ + 0.8f) continue; - // Skip very short vertical surfaces (stair risers) if (triHeight < 0.6f && triMaxZ <= localFeetZ + 0.8f) continue; @@ -1960,6 +1961,51 @@ bool WMORenderer::isInsideWMO(float glX, float glY, float glZ, uint32_t* outMode return false; } +bool WMORenderer::isInsideInteriorWMO(float glX, float glY, float glZ) const { + glm::vec3 queryMin(glX - 0.5f, glY - 0.5f, glZ - 0.5f); + glm::vec3 queryMax(glX + 0.5f, glY + 0.5f, glZ + 0.5f); + gatherCandidates(queryMin, queryMax, candidateScratch); + + for (size_t idx : candidateScratch) { + const auto& instance = instances[idx]; + if (collisionFocusEnabled && + pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { + continue; + } + if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x || + glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || + glZ < instance.worldBoundsMin.z || glZ > instance.worldBoundsMax.z) { + continue; + } + auto it = loadedModels.find(instance.modelId); + if (it == loadedModels.end()) continue; + const ModelData& model = it->second; + + bool anyGroupContains = false; + for (size_t gi = 0; gi < model.groups.size() && gi < instance.worldGroupBounds.size(); ++gi) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (glX >= gMin.x && glX <= gMax.x && + glY >= gMin.y && glY <= gMax.y && + glZ >= gMin.z && glZ <= gMax.z) { + anyGroupContains = true; + break; + } + } + if (!anyGroupContains) continue; + + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); + for (const auto& group : model.groups) { + if (!(group.groupFlags & 0x2000)) continue; // Skip exterior groups + if (localPos.x >= group.boundingBoxMin.x && localPos.x <= group.boundingBoxMax.x && + localPos.y >= group.boundingBoxMin.y && localPos.y <= group.boundingBoxMax.y && + localPos.z >= group.boundingBoxMin.z && localPos.z <= group.boundingBoxMax.z) { + return true; + } + } + } + return false; +} + float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& direction, float maxDistance) const { QueryTimer timer(&queryTimeMs, &queryCallCount); float closestHit = maxDistance; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a39e2c56..1a1c7883 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -100,6 +100,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestRequestItemsWindow(gameHandler); renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); + renderTaxiWindow(gameHandler); renderQuestMarkers(gameHandler); renderMinimapMarkers(gameHandler); renderDeathScreen(gameHandler); @@ -1539,6 +1540,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /unstuck command — resets player position to floor height + if (cmdLower == "unstuck") { + gameHandler.unstuck(); + chatInputBuffer[0] = '\0'; + return; + } + // Chat channel slash commands bool isChannelCommand = false; if (cmdLower == "s" || cmdLower == "say") { @@ -3342,6 +3350,71 @@ void GameScreen::renderEscapeMenu() { ImGui::End(); } +// ============================================================ +// Taxi Window +// ============================================================ + +void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isTaxiWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always); + + bool open = true; + if (ImGui::Begin("Flight Master", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& taxiData = gameHandler.getTaxiData(); + const auto& nodes = gameHandler.getTaxiNodes(); + uint32_t currentNode = gameHandler.getTaxiCurrentNode(); + + // Get current node's map to filter destinations + uint32_t currentMapId = 0; + auto curIt = nodes.find(currentNode); + if (curIt != nodes.end()) { + currentMapId = curIt->second.mapId; + ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), "Current: %s", curIt->second.name.c_str()); + ImGui::Separator(); + } + + ImGui::Text("Select a destination:"); + ImGui::Spacing(); + + // List known destinations on same map, excluding current node + int destCount = 0; + for (const auto& [nodeId, node] : nodes) { + if (nodeId == currentNode) continue; + if (node.mapId != currentMapId) continue; + if (!taxiData.isNodeKnown(nodeId)) continue; + + ImGui::PushID(static_cast(nodeId)); + ImGui::Text("%s", node.name.c_str()); + ImGui::SameLine(ImGui::GetWindowWidth() - 60); + if (ImGui::SmallButton("Fly")) { + gameHandler.activateTaxi(nodeId); + } + ImGui::PopID(); + destCount++; + } + + if (destCount == 0) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "No destinations available."); + } + + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::Button("Close", ImVec2(-1, 0))) { + gameHandler.closeTaxi(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeTaxi(); + } +} + // ============================================================ // Death Screen // ============================================================