diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 4845e880..2166bfa8 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -1,7 +1,10 @@ #pragma once #include +#include #include +#include +#include #include #include #include @@ -76,14 +79,70 @@ public: z = pz; orientation = o; isMoving_ = false; // Instant position set cancels interpolation + usePathMode_ = false; + } + + // Multi-segment path movement + void startMoveAlongPath(const std::vector>& path, float destO, float totalDuration) { + if (path.empty()) return; + if (path.size() == 1 || totalDuration <= 0.0f) { + startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); + return; + } + // Compute cumulative distances for proportional segment timing + pathPoints_ = path; + pathSegDists_.resize(path.size()); + pathSegDists_[0] = 0.0f; + float totalDist = 0.0f; + for (size_t i = 1; i < path.size(); i++) { + float dx = path[i][0] - path[i - 1][0]; + float dy = path[i][1] - path[i - 1][1]; + float dz = path[i][2] - path[i - 1][2]; + totalDist += std::sqrt(dx * dx + dy * dy + dz * dz); + pathSegDists_[i] = totalDist; + } + if (totalDist < 0.001f) { + startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); + return; + } + // Snap position if in overrun phase + if (isMoving_ && moveElapsed_ >= moveDuration_) { + x = moveEndX_; y = moveEndY_; z = moveEndZ_; + } + moveEndX_ = path.back()[0]; moveEndY_ = path.back()[1]; moveEndZ_ = path.back()[2]; + moveDuration_ = totalDuration; + moveElapsed_ = 0.0f; + orientation = destO; + isMoving_ = true; + usePathMode_ = true; + // Velocity for dead-reckoning after path completes + float fromX = isMoving_ ? moveEndX_ : x; + float fromY = isMoving_ ? moveEndY_ : y; + float impliedVX = (path.back()[0] - fromX) / totalDuration; + float impliedVY = (path.back()[1] - fromY) / totalDuration; + float impliedVZ = (path.back()[2] - path[0][2]) / totalDuration; + const float alpha = 0.65f; + velX_ = alpha * impliedVX + (1.0f - alpha) * velX_; + velY_ = alpha * impliedVY + (1.0f - alpha) * velY_; + velZ_ = alpha * impliedVZ + (1.0f - alpha) * velZ_; } // Movement interpolation (syncs entity position with renderer during movement) void startMoveTo(float destX, float destY, float destZ, float destO, float durationSec) { + usePathMode_ = false; if (durationSec <= 0.0f) { setPosition(destX, destY, destZ, destO); return; } + // If we're in the dead-reckoning overrun phase, snap x/y/z back to the + // destination before using them as the new start. The renderer was showing + // the entity at moveEnd (via getLatest) during overrun, so the new + // interpolation must start there to avoid a visible teleport. + if (isMoving_ && moveElapsed_ >= moveDuration_) { + x = moveEndX_; + y = moveEndY_; + z = moveEndZ_; + } // Derive velocity from the displacement this packet implies. // Use the previous destination (not current lerped pos) as the "from" so // variable network timing doesn't inflate/shrink the implied speed. @@ -113,11 +172,31 @@ public: if (!isMoving_) return; moveElapsed_ += deltaTime; if (moveElapsed_ < moveDuration_) { - // Linear interpolation within the packet window - float t = moveElapsed_ / moveDuration_; - x = moveStartX_ + (moveEndX_ - moveStartX_) * t; - y = moveStartY_ + (moveEndY_ - moveStartY_) * t; - z = moveStartZ_ + (moveEndZ_ - moveStartZ_) * t; + if (usePathMode_ && pathPoints_.size() > 1) { + // Multi-segment path interpolation + float totalDist = pathSegDists_.back(); + float t = moveElapsed_ / moveDuration_; + float targetDist = t * totalDist; + // Find the segment containing targetDist + size_t seg = 1; + while (seg < pathSegDists_.size() - 1 && pathSegDists_[seg] < targetDist) + seg++; + float segStart = pathSegDists_[seg - 1]; + float segEnd = pathSegDists_[seg]; + float segLen = segEnd - segStart; + float segT = (segLen > 0.001f) ? (targetDist - segStart) / segLen : 0.0f; + const auto& p0 = pathPoints_[seg - 1]; + const auto& p1 = pathPoints_[seg]; + x = p0[0] + (p1[0] - p0[0]) * segT; + y = p0[1] + (p1[1] - p0[1]) * segT; + z = p0[2] + (p1[2] - p0[2]) * segT; + } else { + // Single-segment linear interpolation + float t = moveElapsed_ / moveDuration_; + x = moveStartX_ + (moveEndX_ - moveStartX_) * t; + y = moveStartY_ + (moveEndY_ - moveStartY_) * t; + z = moveStartZ_ + (moveEndZ_ - moveStartZ_) * t; + } } else { // Past the interpolation window: dead-reckon at the smoothed velocity // rather than freezing in place. Cap to one extra interval so we don't @@ -192,11 +271,15 @@ protected: // Movement interpolation state bool isMoving_ = false; + bool usePathMode_ = false; float moveStartX_ = 0, moveStartY_ = 0, moveStartZ_ = 0; float moveEndX_ = 0, moveEndY_ = 0, moveEndZ_ = 0; float moveDuration_ = 0; float moveElapsed_ = 0; float velX_ = 0, velY_ = 0, velZ_ = 0; // Smoothed velocity for dead reckoning + // Multi-segment path data + std::vector> pathPoints_; + std::vector pathSegDists_; // Cumulative distances for each waypoint }; /** diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index cac41b7c..7cff33f0 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1699,10 +1699,7 @@ public: bool isServerMovementAllowed() const; // Quest giver status (! and ? markers) - QuestGiverStatus getQuestGiverStatus(uint64_t guid) const { - auto it = npcQuestStatus_.find(guid); - return (it != npcQuestStatus_.end()) ? it->second : QuestGiverStatus::NONE; - } + QuestGiverStatus getQuestGiverStatus(uint64_t guid) const; const std::unordered_map& getNpcQuestStatuses() const; // Charge callback — fires when player casts a charge spell toward target diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 19cbf3ba..8734a815 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1678,6 +1678,9 @@ struct MonsterMoveData { // Destination (final point of the spline, server coords) float destX = 0, destY = 0, destZ = 0; bool hasDest = false; + // Intermediate waypoints along the path (server coords, excludes start & final dest) + struct Point { float x, y, z; }; + std::vector waypoints; }; class MonsterMoveParser { diff --git a/include/ui/realm_screen.hpp b/include/ui/realm_screen.hpp index ed6504c1..5963328d 100644 --- a/include/ui/realm_screen.hpp +++ b/include/ui/realm_screen.hpp @@ -44,6 +44,18 @@ public: statusMessage.clear(); } + /** + * Reset for back-navigation from character screen. + * Preserves autoSelectAttempted so single-realm auto-connect doesn't re-fire. + */ + void resetForBack() { + selectedRealmIndex = -1; + realmSelected = false; + selectedRealmName.clear(); + selectedRealmAddress.clear(); + statusMessage.clear(); + } + /** * Check if a realm has been selected */ diff --git a/src/core/application.cpp b/src/core/application.cpp index f62aabc9..966bbba5 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -720,8 +720,12 @@ void Application::run() { int newWidth = event.window.data1; int newHeight = event.window.data2; window->setSize(newWidth, newHeight); + // Mark swapchain dirty so it gets recreated at the correct size + if (window->getVkContext()) { + window->getVkContext()->markSwapchainDirty(); + } // Vulkan viewport set in command buffer, not globally - if (renderer && renderer->getCamera()) { + if (renderer && renderer->getCamera() && newHeight > 0) { renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); } // Notify addons so UI layouts can adapt to the new size @@ -1740,9 +1744,29 @@ void Application::update(float deltaTime) { if (canonDistSq > syncRadiusSq) continue; } - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + // Use the destination position once the entity has reached its + // target. During the dead-reckoning overrun window getX/Y/Z + // drifts past the destination at the last known velocity; + // using getLatest (== moveEnd while isMoving_) avoids the + // visible forward-drift followed by a backward snap. + const bool inOverrun = entity->isEntityMoving() && !entity->isActivelyMoving(); + glm::vec3 canonical( + inOverrun ? entity->getLatestX() : entity->getX(), + inOverrun ? entity->getLatestY() : entity->getY(), + inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + // Clamp creature Z to terrain surface during movement interpolation. + // The server sends single-segment moves and expects the client to place + // creatures on the ground. Only clamp while actively moving — idle + // creatures keep their server-authoritative Z (flight masters, etc.). + if (entity->isActivelyMoving() && renderer->getTerrainManager()) { + auto terrainZ = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); + if (terrainZ.has_value()) { + renderPos.z = terrainZ.value(); + } + } + // Visual collision guard: keep hostile melee units from rendering inside the // player's model while attacking. This is client-side only (no server position change). // Only check for creatures within 8 units (melee range) — saves expensive @@ -1822,17 +1846,18 @@ void Application::update(float deltaTime) { auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f); - // isEntityMoving() reflects server-authoritative move state set by - // startMoveTo() in handleMonsterMove, regardless of distance-cull. - // This correctly detects movement for distant creatures (> 150u) - // where updateMovement() is not called and getX/Y/Z() stays stale. - // Use isActivelyMoving() (not isEntityMoving()) so the - // Run/Walk animation stops when the creature reaches its - // destination, rather than persisting through the dead- - // reckoning overrun window. + // Use isActivelyMoving() so Run/Walk animation stops when the + // creature reaches its destination. Don't use position-change + // (planarDistSq) as a movement indicator when the entity is in + // the dead-reckoning overrun window — the residual velocity + // drift would keep the walk/run animation playing long after + // the creature has actually arrived. Only fall back to position- + // change detection for entities with no active movement tracking + // (e.g. teleports or position-only updates from the server). const bool entityIsMoving = entity->isActivelyMoving(); constexpr float kMoveThreshSq = 0.03f * 0.03f; - const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq || dz > 0.08f); + const bool posChanging = planarDistSq > kMoveThreshSq || dz > 0.08f; + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || (posChanging && !entity->isEntityMoving())); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); } else if (planarDistSq > kMoveThreshSq || dz > 0.08f) { @@ -1936,10 +1961,23 @@ void Application::update(float deltaTime) { if (glm::dot(d, d) > pSyncRadiusSq) continue; } - // Position sync - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + // Position sync — clamp to destination during dead-reckoning + // overrun to avoid drift + backward snap (same as creature loop). + const bool inOverrun = entity->isEntityMoving() && !entity->isActivelyMoving(); + glm::vec3 canonical( + inOverrun ? entity->getLatestX() : entity->getX(), + inOverrun ? entity->getLatestY() : entity->getY(), + inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + // Clamp other players' Z to terrain surface during movement + if (entity->isActivelyMoving() && renderer->getTerrainManager()) { + auto terrainZ = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); + if (terrainZ.has_value()) { + renderPos.z = terrainZ.value(); + } + } + auto posIt = _pCreatureRenderPosCache.find(guid); if (posIt == _pCreatureRenderPosCache.end()) { charRenderer->setInstancePosition(instanceId, renderPos); @@ -1956,7 +1994,8 @@ void Application::update(float deltaTime) { const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f); const bool entityIsMoving = entity->isActivelyMoving(); constexpr float kMoveThreshSq2 = 0.03f * 0.03f; - const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq2 || dz > 0.08f); + const bool posChanging2 = planarDistSq > kMoveThreshSq2 || dz > 0.08f; + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || (posChanging2 && !entity->isEntityMoving())); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); diff --git a/src/core/ui_screen_callback_handler.cpp b/src/core/ui_screen_callback_handler.cpp index 1d6acf87..f2016991 100644 --- a/src/core/ui_screen_callback_handler.cpp +++ b/src/core/ui_screen_callback_handler.cpp @@ -155,7 +155,7 @@ void UIScreenCallbackHandler::setupCallbacks() { uiManager_.getCharacterScreen().setOnBack([this]() { // Disconnect from world server and reset UI state for fresh realm selection gameHandler_.disconnect(); - uiManager_.getRealmScreen().reset(); + uiManager_.getRealmScreen().resetForBack(); uiManager_.getCharacterScreen().reset(); setState_(AppState::REALM_SELECTION); }); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3d38b20b..9eeb836b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2261,6 +2261,10 @@ const std::unordered_map& GameHandler::getNpcQuestSt static const std::unordered_map empty; return empty; } +QuestGiverStatus GameHandler::getQuestGiverStatus(uint64_t guid) const { + if (questHandler_) return questHandler_->getQuestGiverStatus(guid); + return QuestGiverStatus::NONE; +} const std::vector& GameHandler::getQuestLog() const { if (questHandler_) return questHandler_->getQuestLog(); static const std::vector empty; diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index d190389e..6c814235 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -1454,8 +1454,23 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { } } - entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z, - orientation, data.duration / 1000.0f); + // Build full path: start → waypoints → destination (all in canonical coords) + if (!data.waypoints.empty()) { + glm::vec3 startCanonical = core::coords::serverToCanonical( + glm::vec3(data.x, data.y, data.z)); + std::vector> path; + path.push_back({startCanonical.x, startCanonical.y, startCanonical.z}); + for (const auto& wp : data.waypoints) { + glm::vec3 wpCanonical = core::coords::serverToCanonical( + glm::vec3(wp.x, wp.y, wp.z)); + path.push_back({wpCanonical.x, wpCanonical.y, wpCanonical.z}); + } + path.push_back({destCanonical.x, destCanonical.y, destCanonical.z}); + entity->startMoveAlongPath(path, orientation, data.duration / 1000.0f); + } else { + entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z, + orientation, data.duration / 1000.0f); + } if (owner_.creatureMoveCallbackRef()) { owner_.creatureMoveCallbackRef()(data.guid, diff --git a/src/game/quest_handler.cpp b/src/game/quest_handler.cpp index 6b5b12c4..3f4ee6ab 100644 --- a/src/game/quest_handler.cpp +++ b/src/game/quest_handler.cpp @@ -339,6 +339,8 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { if (packet.hasRemaining(9)) { uint64_t npcGuid = packet.readUInt64(); uint8_t status = owner_.getPacketParsers()->readQuestGiverStatus(packet); + LOG_INFO("SMSG_QUESTGIVER_STATUS: npcGuid=0x", std::hex, npcGuid, std::dec, + " status=", static_cast(status)); npcQuestStatus_[npcGuid] = static_cast(status); } }; @@ -1075,8 +1077,17 @@ void QuestHandler::acceptQuest() { const bool inLocalLog = hasQuestInLog(questId); const int serverSlot = findQuestLogSlotIndexFromServer(questId); if (serverSlot >= 0) { - LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId, - " slot=", serverSlot); + LOG_INFO("Quest already in server quest log: questId=", questId, + " slot=", serverSlot, " inLocalLog=", inLocalLog); + // Ensure it's in our local log even if server already has it + addQuestToLocalLogIfMissing(questId, currentQuestDetails_.title, currentQuestDetails_.objectives); + requestQuestQuery(questId, false); + // Re-query NPC status from server + if (npcGuid && owner_.getSocket()) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(npcGuid); + owner_.getSocket()->send(qsPkt); + } questDetailsOpen_ = false; questDetailsOpenTime_ = std::chrono::steady_clock::time_point{}; currentQuestDetails_ = QuestDetailsData{}; @@ -1094,6 +1105,9 @@ void QuestHandler::acceptQuest() { pendingQuestAcceptTimeouts_[questId] = 5.0f; pendingQuestAcceptNpcGuids_[questId] = npcGuid; + // Immediately add to local quest log using available details + addQuestToLocalLogIfMissing(questId, currentQuestDetails_.title, currentQuestDetails_.objectives); + // Play quest-accept sound if (auto* ac = owner_.services().audioCoordinator) { if (auto* sfx = ac->getUiSoundManager()) @@ -1223,6 +1237,19 @@ void QuestHandler::abandonQuest(uint32_t questId) { } } + // Re-query nearby quest giver NPCs so markers refresh (e.g. "?" → "!") + if (owner_.getSocket()) { + for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { + if (entity->getType() != ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (unit->getNpcFlags() & 0x02) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(guid); + owner_.getSocket()->send(qsPkt); + } + } + } + // Remove any quest POI minimap markers for this quest. gossipPois_.erase( std::remove_if(gossipPois_.begin(), gossipPois_.end(), @@ -1376,7 +1403,7 @@ bool QuestHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { void QuestHandler::applyQuestStateFromFields(const std::map& fields) { const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); - if (ufQuestStart == 0xFFFF || questLog_.empty()) return; + if (ufQuestStart == 0xFFFF) return; const uint8_t qStride = owner_.getPacketParsers() ? owner_.getPacketParsers()->questLogStride() : 5; if (qStride < 2) return; @@ -1391,6 +1418,20 @@ void QuestHandler::applyQuestStateFromFields(const std::map& uint32_t questId = idIt->second; if (questId == 0) continue; + // Add quest to local log only if we have a pending accept for it + if (!hasQuestInLog(questId) && pendingQuestAcceptTimeouts_.count(questId) != 0) { + addQuestToLocalLogIfMissing(questId, "Quest #" + std::to_string(questId), ""); + requestQuestQuery(questId, false); + // Re-query quest giver status for the NPC that gave us this quest + auto pendingIt = pendingQuestAcceptNpcGuids_.find(questId); + if (pendingIt != pendingQuestAcceptNpcGuids_.end() && pendingIt->second != 0 && owner_.getSocket()) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(pendingIt->second); + owner_.getSocket()->send(qsPkt); + } + clearPendingQuestAccept(questId); + } + auto stateIt = fields.find(stateField); if (stateIt == fields.end()) continue; bool serverComplete = ((stateIt->second & 0xFF) == kQuestStatusComplete); diff --git a/src/game/world_packets_entity.cpp b/src/game/world_packets_entity.cpp index ce281232..f529da70 100644 --- a/src/game/world_packets_entity.cpp +++ b/src/game/world_packets_entity.cpp @@ -637,13 +637,15 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; if (uncompressed) { - // Read last point as destination - // Skip to last point: each point is 12 bytes - if (pointCount > 1) { - for (uint32_t i = 0; i < pointCount - 1; i++) { - if (!packet.hasRemaining(12)) return true; - packet.readFloat(); packet.readFloat(); packet.readFloat(); - } + // All waypoints stored as absolute float3 (Catmullrom/Flying paths) + // Read all intermediate points, then the final destination + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (!packet.hasRemaining(12)) return true; + MonsterMoveData::Point wp; + wp.x = packet.readFloat(); + wp.y = packet.readFloat(); + wp.z = packet.readFloat(); + data.waypoints.push_back(wp); } if (!packet.hasRemaining(12)) return true; data.destX = packet.readFloat(); @@ -657,6 +659,33 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { data.destY = packet.readFloat(); data.destZ = packet.readFloat(); data.hasDest = true; + + // Remaining waypoints are packed as uint32 deltas from the midpoint + // between the creature's start position and the destination. + // Encoding matches TrinityCore MoveSpline::PackXYZ: + // x = 11-bit signed (bits 0-10), y = 11-bit signed (bits 11-21), + // z = 10-bit signed (bits 22-31), each scaled by 0.25 units. + if (pointCount > 1) { + float midX = (data.x + data.destX) * 0.5f; + float midY = (data.y + data.destY) * 0.5f; + float midZ = (data.z + data.destZ) * 0.5f; + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (!packet.hasRemaining(4)) break; + uint32_t packed = packet.readUInt32(); + // Sign-extend 11-bit x and y, 10-bit z (2's complement) + int32_t sx = static_cast(packed & 0x7FF); + if (sx & 0x400) sx |= static_cast(0xFFFFF800); + int32_t sy = static_cast((packed >> 11) & 0x7FF); + if (sy & 0x400) sy |= static_cast(0xFFFFF800); + int32_t sz = static_cast((packed >> 22) & 0x3FF); + if (sz & 0x200) sz |= static_cast(0xFFFFFC00); + MonsterMoveData::Point wp; + wp.x = midX - static_cast(sx) * 0.25f; + wp.y = midY - static_cast(sy) * 0.25f; + wp.z = midZ - static_cast(sz) * 0.25f; + data.waypoints.push_back(wp); + } + } } LOG_DEBUG("MonsterMove: guid=0x", std::hex, data.guid, std::dec, @@ -754,12 +783,26 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d data.destZ = packet.readFloat(); data.hasDest = true; - // Remaining waypoints are packed as uint32 deltas. + // Remaining waypoints are packed as uint32 deltas from midpoint. if (pointCount > 1) { - size_t skipBytes = static_cast(pointCount - 1) * 4; - size_t newPos = packet.getReadPos() + skipBytes; - if (newPos > packet.getSize()) return false; - packet.setReadPos(newPos); + float midX = (data.x + data.destX) * 0.5f; + float midY = (data.y + data.destY) * 0.5f; + float midZ = (data.z + data.destZ) * 0.5f; + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (!packet.hasRemaining(4)) break; + uint32_t packed = packet.readUInt32(); + int32_t sx = static_cast(packed & 0x7FF); + if (sx & 0x400) sx |= static_cast(0xFFFFF800); + int32_t sy = static_cast((packed >> 11) & 0x7FF); + if (sy & 0x400) sy |= static_cast(0xFFFFF800); + int32_t sz = static_cast((packed >> 22) & 0x3FF); + if (sz & 0x200) sz |= static_cast(0xFFFFFC00); + MonsterMoveData::Point wp; + wp.x = midX - static_cast(sx) * 0.25f; + wp.y = midY - static_cast(sy) * 0.25f; + wp.z = midZ - static_cast(sz) * 0.25f; + data.waypoints.push_back(wp); + } } LOG_DEBUG("MonsterMove(turtle): guid=0x", std::hex, data.guid, std::dec, diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 8749a94e..3864ae1e 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -859,6 +859,8 @@ void Renderer::beginFrame() { // Handle swapchain recreation if needed if (vkCtx->isSwapchainDirty()) { + // Skip recreation while window is minimized (0×0 extent is a Vulkan spec violation) + if (window->getWidth() == 0 || window->getHeight() == 0) return; (void)vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); // Rebuild water resources that reference swapchain extent/views if (waterRenderer) { diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 8024ee5e..67c758b7 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -6,6 +6,7 @@ #include "core/application.hpp" #include "core/logger.hpp" #include +#include #include #include #include @@ -241,6 +242,43 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::EndTable(); } + + // Keyboard navigation: Up/Down to change selection, Enter to enter world + // Claim ownership of arrow keys so ImGui nav doesn't move focus between buttons + ImGuiID charListOwner = ImGui::GetID("CharListNav"); + ImGui::SetKeyOwner(ImGuiKey_UpArrow, charListOwner); + ImGui::SetKeyOwner(ImGuiKey_DownArrow, charListOwner); + + if (!characters.empty() && deleteConfirmStage == 0) { + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { + if (selectedCharacterIndex > 0) { + selectedCharacterIndex--; + selectedCharacterGuid = characters[selectedCharacterIndex].guid; + saveLastCharacter(selectedCharacterGuid); + } + } + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { + if (selectedCharacterIndex < static_cast(characters.size()) - 1) { + selectedCharacterIndex++; + selectedCharacterGuid = characters[selectedCharacterIndex].guid; + saveLastCharacter(selectedCharacterGuid); + } + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) { + if (selectedCharacterIndex >= 0 && + selectedCharacterIndex < static_cast(characters.size())) { + const auto& ch = characters[selectedCharacterIndex]; + characterSelected = true; + saveLastCharacter(ch.guid); + // Claim Enter so the game screen doesn't activate chat on the same press + ImGui::SetKeyOwner(ImGuiKey_Enter, charListOwner, ImGuiInputFlags_LockUntilRelease); + ImGui::SetKeyOwner(ImGuiKey_KeypadEnter, charListOwner, ImGuiInputFlags_LockUntilRelease); + gameHandler.selectCharacter(ch.guid); + if (onCharacterSelected) onCharacterSelected(ch.guid); + } + } + } + ImGui::EndChild(); // ── Right: Details panel ──