From 1fe5fffc338e15499275f26440fff4655e473584 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Feb 2026 20:10:10 -0800 Subject: [PATCH] Add quest markers (! and ?) above NPCs and on minimap Parse SMSG_QUESTGIVER_STATUS and SMSG_QUESTGIVER_STATUS_MULTIPLE packets to track per-NPC quest status, render yellow/gray ! and ? markers in 3D world space above NPC heads with distance-based scaling, and show corresponding dots on the minimap. --- include/game/game_handler.hpp | 23 +++++ include/game/opcodes.hpp | 1 + include/ui/game_screen.hpp | 2 + src/game/game_handler.cpp | 29 +++++- src/ui/game_screen.cpp | 171 ++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 99de1ca5..d42a47d9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -20,6 +20,19 @@ namespace network { class WorldSocket; class Packet; } namespace game { +/** + * Quest giver status values (WoW 3.3.5a) + */ +enum class QuestGiverStatus : uint8_t { + NONE = 0, + UNAVAILABLE = 1, + INCOMPLETE = 5, // ? (gray) + REWARD_REP = 6, + AVAILABLE_LOW = 7, // ! (gray, low-level) + AVAILABLE = 8, // ! (yellow) + REWARD = 10 // ? (yellow) +}; + /** * World connection state */ @@ -360,6 +373,13 @@ public: const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); + // Quest giver status (! and ? markers) + QuestGiverStatus getQuestGiverStatus(uint64_t guid) const { + auto it = npcQuestStatus_.find(guid); + return (it != npcQuestStatus_.end()) ? it->second : QuestGiverStatus::NONE; + } + const std::unordered_map& getNpcQuestStatuses() const { return npcQuestStatus_; } + // Vendor void openVendor(uint64_t npcGuid); void closeVendor(); @@ -672,6 +692,9 @@ private: // Quest log std::vector questLog_; + // Quest giver status per NPC + std::unordered_map npcQuestStatus_; + // Faction hostility lookup (populated from FactionTemplate.dbc) std::unordered_map factionHostileMap_; bool isHostileFaction(uint32_t factionTemplateId) const { diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 3b7a6247..cd5cced3 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -139,6 +139,7 @@ enum class Opcode : uint16_t { // ---- Phase 5: Quests ---- CMSG_QUESTGIVER_STATUS_QUERY = 0x182, SMSG_QUESTGIVER_STATUS = 0x183, + SMSG_QUESTGIVER_STATUS_MULTIPLE = 0x198, CMSG_QUESTGIVER_HELLO = 0x184, CMSG_QUESTGIVER_QUERY_QUEST = 0x186, SMSG_QUESTGIVER_QUEST_DETAILS = 0x188, diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e4394237..ceaf3af4 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -147,6 +147,8 @@ private: void renderDeathScreen(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); + void renderQuestMarkers(game::GameHandler& gameHandler); + void renderMinimapMarkers(game::GameHandler& gameHandler); /** * Inventory screen diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6d21dc8c..1c4e7487 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1228,9 +1228,31 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_BUY_FAILED: case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE: case Opcode::MSG_RAID_TARGET_UPDATE: - case Opcode::SMSG_QUESTGIVER_STATUS: - LOG_DEBUG("Ignoring SMSG_QUESTGIVER_STATUS"); break; + case Opcode::SMSG_QUESTGIVER_STATUS: { + // uint64 npcGuid + uint8 status + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t npcGuid = packet.readUInt64(); + uint8_t status = packet.readUInt8(); + npcQuestStatus_[npcGuid] = static_cast(status); + LOG_DEBUG("SMSG_QUESTGIVER_STATUS: guid=0x", std::hex, npcGuid, std::dec, " status=", (int)status); + } + break; + } + case Opcode::SMSG_QUESTGIVER_STATUS_MULTIPLE: { + // uint32 count, then count * (uint64 guid + uint8 status) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t count = packet.readUInt32(); + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < 9) break; + uint64_t npcGuid = packet.readUInt64(); + uint8_t status = packet.readUInt8(); + npcQuestStatus_[npcGuid] = static_cast(status); + } + LOG_DEBUG("SMSG_QUESTGIVER_STATUS_MULTIPLE: ", count, " entries"); + } + break; + } case Opcode::SMSG_QUESTGIVER_QUEST_DETAILS: handleQuestDetails(packet); break; @@ -2897,6 +2919,9 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { rebuildOnlineInventory(); } + // Clean up quest giver status + npcQuestStatus_.erase(data.guid); + tabCycleStale = true; LOG_INFO("Entity count: ", entityManager.getEntityCount()); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5fe17137..cd4e3050 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -90,6 +90,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGossipWindow(gameHandler); renderQuestDetailsWindow(gameHandler); renderVendorWindow(gameHandler); + renderQuestMarkers(gameHandler); + renderMinimapMarkers(gameHandler); renderDeathScreen(gameHandler); renderEscapeMenu(); renderSettingsWindow(); @@ -2723,4 +2725,173 @@ void GameScreen::renderSettingsWindow() { ImGui::End(); } +void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) { + const auto& statuses = gameHandler.getNpcQuestStatuses(); + if (statuses.empty()) return; + + auto* renderer = core::Application::getInstance().getRenderer(); + auto* camera = renderer ? renderer->getCamera() : nullptr; + auto* window = core::Application::getInstance().getWindow(); + if (!camera || !window) return; + + float screenW = static_cast(window->getWidth()); + float screenH = static_cast(window->getHeight()); + glm::mat4 viewProj = camera->getViewProjectionMatrix(); + auto* drawList = ImGui::GetForegroundDrawList(); + + for (const auto& [guid, status] : statuses) { + // Only show markers for available (!) and reward/completable (?) + const char* marker = nullptr; + ImU32 color = IM_COL32(255, 210, 0, 255); // yellow + if (status == game::QuestGiverStatus::AVAILABLE) { + marker = "!"; + } else if (status == game::QuestGiverStatus::AVAILABLE_LOW) { + marker = "!"; + color = IM_COL32(160, 160, 160, 255); // gray + } else if (status == game::QuestGiverStatus::REWARD) { + marker = "?"; + } else if (status == game::QuestGiverStatus::INCOMPLETE) { + marker = "?"; + color = IM_COL32(160, 160, 160, 255); // gray + } else { + continue; + } + + // Get entity position (canonical coords) + auto entity = gameHandler.getEntityManager().getEntity(guid); + if (!entity) continue; + + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + // Get model height for offset + float heightOffset = 3.0f; + glm::vec3 boundsCenter; + float boundsRadius = 0.0f; + if (core::Application::getInstance().getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) { + heightOffset = boundsRadius * 2.0f + 1.0f; + } + renderPos.z += heightOffset; + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.0f) continue; + + glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); + float sx = (ndc.x + 1.0f) * 0.5f * screenW; + float sy = (1.0f - ndc.y) * 0.5f * screenH; + + // Skip if off-screen + if (sx < -50 || sx > screenW + 50 || sy < -50 || sy > screenH + 50) continue; + + // Scale text size based on distance + float dist = clipPos.w; + float fontSize = std::clamp(800.0f / dist, 14.0f, 48.0f); + + // Draw outlined text: 4 shadow copies then main text + ImFont* font = ImGui::GetFont(); + ImU32 outlineColor = IM_COL32(0, 0, 0, 220); + float off = std::max(1.0f, fontSize * 0.06f); + ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, marker); + float tx = sx - textSize.x * 0.5f; + float ty = sy - textSize.y * 0.5f; + + drawList->AddText(font, fontSize, ImVec2(tx - off, ty), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx + off, ty), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx, ty - off), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx, ty + off), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx, ty), color, marker); + } +} + +void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { + const auto& statuses = gameHandler.getNpcQuestStatuses(); + if (statuses.empty()) return; + + auto* renderer = core::Application::getInstance().getRenderer(); + auto* camera = renderer ? renderer->getCamera() : nullptr; + auto* minimap = renderer ? renderer->getMinimap() : nullptr; + auto* window = core::Application::getInstance().getWindow(); + if (!camera || !minimap || !window) return; + + float screenW = static_cast(window->getWidth()); + + // Minimap parameters (matching minimap.cpp) + float mapSize = 200.0f; + float margin = 10.0f; + float mapRadius = mapSize * 0.5f; + float centerX = screenW - margin - mapRadius; + float centerY = margin + mapRadius; + float viewRadius = 400.0f; + + // Player position in render coords + auto& mi = gameHandler.getMovementInfo(); + glm::vec3 playerRender = core::coords::canonicalToRender(glm::vec3(mi.x, mi.y, mi.z)); + + // Camera bearing for minimap rotation + glm::vec3 fwd = camera->getForward(); + float bearing = std::atan2(-fwd.x, fwd.y); + float cosB = std::cos(bearing); + float sinB = std::sin(bearing); + + auto* drawList = ImGui::GetForegroundDrawList(); + + for (const auto& [guid, status] : statuses) { + ImU32 dotColor; + const char* marker = nullptr; + if (status == game::QuestGiverStatus::AVAILABLE) { + dotColor = IM_COL32(255, 210, 0, 255); + marker = "!"; + } else if (status == game::QuestGiverStatus::AVAILABLE_LOW) { + dotColor = IM_COL32(160, 160, 160, 255); + marker = "!"; + } else if (status == game::QuestGiverStatus::REWARD) { + dotColor = IM_COL32(255, 210, 0, 255); + marker = "?"; + } else if (status == game::QuestGiverStatus::INCOMPLETE) { + dotColor = IM_COL32(160, 160, 160, 255); + marker = "?"; + } else { + continue; + } + + auto entity = gameHandler.getEntityManager().getEntity(guid); + if (!entity) continue; + + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 npcRender = core::coords::canonicalToRender(canonical); + + // Offset from player in render coords + float dx = npcRender.x - playerRender.x; + float dy = npcRender.y - playerRender.y; + + // Rotate by camera bearing (minimap north-up rotation) + float rx = dx * cosB - dy * sinB; + float ry = dx * sinB + dy * cosB; + + // Scale to minimap pixels + float px = rx / viewRadius * mapRadius; + float py = -ry / viewRadius * mapRadius; // screen Y is inverted + + // Clamp to circle + float distFromCenter = std::sqrt(px * px + py * py); + if (distFromCenter > mapRadius - 4.0f) { + float scale = (mapRadius - 4.0f) / distFromCenter; + px *= scale; + py *= scale; + } + + float sx = centerX + px; + float sy = centerY + py; + + // Draw dot with marker text + drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, dotColor); + ImFont* font = ImGui::GetFont(); + ImVec2 textSize = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, marker); + drawList->AddText(font, 11.0f, + ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f), + IM_COL32(0, 0, 0, 255), marker); + } +} + }} // namespace wowee::ui