From 67a3da3baee802b9b691f511988f783522728cb8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Feb 2026 11:59:51 -0800 Subject: [PATCH] Add quest details dialog, fix vendor UI, suppress both-button clicks Parse SMSG_QUESTGIVER_QUEST_DETAILS and show quest text with Accept/ Decline buttons. Vendor window now shows item names with quality colors, stat tooltips on hover, player money, and closes properly via X button. Suppress left/right-click targeting and interaction when the other mouse button is held (both-button run forward). --- include/game/game_handler.hpp | 14 ++++ include/game/world_packets.hpp | 18 +++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 37 ++++++++++ src/game/world_packets.cpp | 38 ++++++++++ src/ui/game_screen.cpp | 124 +++++++++++++++++++++++++++++++-- 6 files changed, 227 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a1338b22..30412225 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -322,16 +322,25 @@ public: void interactWithNpc(uint64_t guid); void selectGossipOption(uint32_t optionId); void selectGossipQuest(uint32_t questId); + void acceptQuest(); + void declineQuest(); void closeGossip(); bool isGossipWindowOpen() const { return gossipWindowOpen; } const GossipMessageData& getCurrentGossip() const { return currentGossip; } + bool isQuestDetailsOpen() const { return questDetailsOpen; } + const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; } // Vendor void openVendor(uint64_t npcGuid); + void closeVendor(); void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count); void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count); bool isVendorWindowOpen() const { return vendorWindowOpen; } const ListInventoryData& getVendorItems() const { return currentVendorItems; } + const ItemQueryResponseData* getItemInfo(uint32_t itemId) const { + auto it = itemInfoCache_.find(itemId); + return (it != itemInfoCache_.end()) ? &it->second : nullptr; + } /** * Set callbacks @@ -461,6 +470,7 @@ private: void handleLootRemoved(network::Packet& packet); void handleGossipMessage(network::Packet& packet); void handleGossipComplete(network::Packet& packet); + void handleQuestDetails(network::Packet& packet); void handleListInventory(network::Packet& packet); LootResponseData generateLocalLoot(uint64_t guid); void simulateLootResponse(const LootResponseData& data); @@ -602,6 +612,10 @@ private: bool gossipWindowOpen = false; GossipMessageData currentGossip; + // Quest details + bool questDetailsOpen = false; + QuestDetailsData currentQuestDetails; + // Vendor bool vendorWindowOpen = false; ListInventoryData currentVendorItems; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 2460c920..718b1911 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1182,6 +1182,24 @@ public: static network::Packet build(uint64_t npcGuid, uint32_t questId); }; +/** SMSG_QUESTGIVER_QUEST_DETAILS data (simplified) */ +struct QuestDetailsData { + uint64_t npcGuid = 0; + uint32_t questId = 0; + std::string title; + std::string details; // Quest description text + std::string objectives; // Objectives text + uint32_t suggestedPlayers = 0; + uint32_t rewardMoney = 0; + uint32_t rewardXp = 0; +}; + +/** SMSG_QUESTGIVER_QUEST_DETAILS parser */ +class QuestDetailsParser { +public: + static bool parse(network::Packet& packet, QuestDetailsData& data); +}; + // ============================================================ // Phase 5: Vendor // ============================================================ diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 237cb7a9..f496f8d1 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -131,6 +131,7 @@ private: void renderBuffBar(game::GameHandler& gameHandler); void renderLootWindow(game::GameHandler& gameHandler); void renderGossipWindow(game::GameHandler& gameHandler); + void renderQuestDetailsWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); void renderTeleporterPanel(); void renderEscapeMenu(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5b02ed22..cefa6ece 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1092,6 +1092,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_RAID_TARGET_UPDATE: case Opcode::SMSG_QUESTGIVER_STATUS: case Opcode::SMSG_QUESTGIVER_QUEST_DETAILS: + handleQuestDetails(packet); + break; case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS: case Opcode::SMSG_QUESTGIVER_OFFER_REWARD: case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE: @@ -3538,6 +3540,31 @@ void GameHandler::selectGossipQuest(uint32_t questId) { gossipWindowOpen = false; } +void GameHandler::handleQuestDetails(network::Packet& packet) { + QuestDetailsData data; + if (!QuestDetailsParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_QUESTGIVER_QUEST_DETAILS"); + return; + } + currentQuestDetails = data; + questDetailsOpen = true; + gossipWindowOpen = false; +} + +void GameHandler::acceptQuest() { + if (!questDetailsOpen || state != WorldState::IN_WORLD || !socket) return; + auto packet = QuestgiverAcceptQuestPacket::build( + currentQuestDetails.npcGuid, currentQuestDetails.questId); + socket->send(packet); + questDetailsOpen = false; + currentQuestDetails = QuestDetailsData{}; +} + +void GameHandler::declineQuest() { + questDetailsOpen = false; + currentQuestDetails = QuestDetailsData{}; +} + void GameHandler::closeGossip() { gossipWindowOpen = false; currentGossip = GossipMessageData{}; @@ -3549,6 +3576,11 @@ void GameHandler::openVendor(uint64_t npcGuid) { socket->send(packet); } +void GameHandler::closeVendor() { + vendorWindowOpen = false; + currentVendorItems = ListInventoryData{}; +} + void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count); @@ -3605,6 +3637,11 @@ void GameHandler::handleListInventory(network::Packet& packet) { if (!ListInventoryParser::parse(packet, currentVendorItems)) return; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens + + // Query item info for all vendor items so we can show names + for (const auto& item : currentVendorItems.items) { + queryItemInfo(item.itemId, 0); + } } // ============================================================ diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 67b2d647..8668bc65 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1835,6 +1835,44 @@ network::Packet QuestgiverAcceptQuestPacket::build(uint64_t npcGuid, uint32_t qu return packet; } +bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) { + if (packet.getSize() < 20) return false; + data.npcGuid = packet.readUInt64(); + /*informUnit*/ packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = packet.readString(); + data.details = packet.readString(); + data.objectives = packet.readString(); + /*activateAccept*/ packet.readUInt8(); + /*flags*/ packet.readUInt32(); + data.suggestedPlayers = packet.readUInt32(); + /*isFinished*/ packet.readUInt8(); + + // Reward choice items + uint32_t choiceCount = packet.readUInt32(); + for (uint32_t i = 0; i < choiceCount && i < 6; i++) { + packet.readUInt32(); // itemId + packet.readUInt32(); // count + packet.readUInt32(); // displayInfo + } + + // Reward items + uint32_t rewardCount = packet.readUInt32(); + for (uint32_t i = 0; i < rewardCount && i < 4; i++) { + packet.readUInt32(); // itemId + packet.readUInt32(); // count + packet.readUInt32(); // displayInfo + } + + data.rewardMoney = packet.readUInt32(); + if (packet.getReadPos() < packet.getSize()) { + data.rewardXp = packet.readUInt32(); + } + + LOG_INFO("Quest details: id=", data.questId, " title='", data.title, "'"); + return true; +} + bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data) { data.npcGuid = packet.readUInt64(); data.menuId = packet.readUInt32(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3f85ed0f..86989402 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -84,6 +84,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBuffBar(gameHandler); renderLootWindow(gameHandler); renderGossipWindow(gameHandler); + renderQuestDetailsWindow(gameHandler); renderVendorWindow(gameHandler); renderEscapeMenu(); renderSettingsWindow(); @@ -405,7 +406,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } // Left-click targeting (when mouse not captured by UI) - if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_LEFT)) { + // Suppress when right button is held (both-button run) + if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_LEFT) && !input.isMouseButtonPressed(SDL_BUTTON_RIGHT)) { auto* renderer = core::Application::getInstance().getRenderer(); auto* camera = renderer ? renderer->getCamera() : nullptr; auto* window = core::Application::getInstance().getWindow(); @@ -445,7 +447,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } // Right-click on target for NPC interaction / loot / auto-attack - if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_RIGHT)) { + // Suppress when left button is held (both-button run) + if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_RIGHT) && !input.isMouseButtonPressed(SDL_BUTTON_LEFT)) { if (gameHandler.hasTarget()) { auto target = gameHandler.getTarget(); if (target) { @@ -1632,6 +1635,79 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// Quest Details Window +// ============================================================ + +void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isQuestDetailsOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); + + bool open = true; + const auto& quest = gameHandler.getQuestDetails(); + if (ImGui::Begin(quest.title.c_str(), &open)) { + // Quest description + if (!quest.details.empty()) { + ImGui::TextWrapped("%s", quest.details.c_str()); + } + + // Objectives + if (!quest.objectives.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Objectives:"); + ImGui::TextWrapped("%s", quest.objectives.c_str()); + } + + // Rewards + if (quest.rewardXp > 0 || quest.rewardMoney > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Rewards:"); + if (quest.rewardXp > 0) { + ImGui::Text(" %u experience", quest.rewardXp); + } + if (quest.rewardMoney > 0) { + uint32_t gold = quest.rewardMoney / 10000; + uint32_t silver = (quest.rewardMoney % 10000) / 100; + uint32_t copper = quest.rewardMoney % 100; + if (gold > 0) ImGui::Text(" %ug %us %uc", gold, silver, copper); + else if (silver > 0) ImGui::Text(" %us %uc", silver, copper); + else ImGui::Text(" %uc", copper); + } + } + + if (quest.suggestedPlayers > 1) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), + "Suggested players: %u", quest.suggestedPlayers); + } + + // Accept / Decline buttons + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (ImGui::Button("Accept", ImVec2(buttonW, 0))) { + gameHandler.acceptQuest(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(buttonW, 0))) { + gameHandler.declineQuest(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.declineQuest(); + } +} + // ============================================================ // Vendor Window (Phase 5) // ============================================================ @@ -1649,6 +1725,14 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (ImGui::Begin("Vendor", &open)) { const auto& vendor = gameHandler.getVendorItems(); + // Show player money + uint64_t money = gameHandler.getMoneyCopper(); + uint32_t mg = static_cast(money / 10000); + uint32_t ms = static_cast((money / 100) % 100); + uint32_t mc = static_cast(money % 100); + ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::Separator(); + if (vendor.items.empty()) { ImGui::TextDisabled("This vendor has nothing for sale."); } else { @@ -1659,18 +1743,49 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f); ImGui::TableHeadersRow(); + // Quality colors (matching WoW) + static const ImVec4 qualityColors[] = { + ImVec4(0.6f, 0.6f, 0.6f, 1.0f), // 0 Poor (gray) + ImVec4(1.0f, 1.0f, 1.0f, 1.0f), // 1 Common (white) + ImVec4(0.12f, 1.0f, 0.0f, 1.0f), // 2 Uncommon (green) + ImVec4(0.0f, 0.44f, 0.87f, 1.0f), // 3 Rare (blue) + ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4 Epic (purple) + ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5 Legendary (orange) + }; + for (const auto& item : vendor.items) { ImGui::TableNextRow(); ImGui::PushID(static_cast(item.slot)); ImGui::TableSetColumnIndex(0); - ImGui::Text("Item %u", item.itemId); + auto* info = gameHandler.getItemInfo(item.itemId); + if (info && info->valid) { + uint32_t q = info->quality < 6 ? info->quality : 1; + ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); + // Tooltip with stats on hover + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); + if (info->armor > 0) ImGui::Text("Armor: %d", info->armor); + if (info->stamina > 0) ImGui::Text("+%d Stamina", info->stamina); + if (info->strength > 0) ImGui::Text("+%d Strength", info->strength); + if (info->agility > 0) ImGui::Text("+%d Agility", info->agility); + if (info->intellect > 0) ImGui::Text("+%d Intellect", info->intellect); + if (info->spirit > 0) ImGui::Text("+%d Spirit", info->spirit); + ImGui::EndTooltip(); + } + } else { + ImGui::Text("Item %u", item.itemId); + } ImGui::TableSetColumnIndex(1); uint32_t g = item.buyPrice / 10000; uint32_t s = (item.buyPrice / 100) % 100; uint32_t c = item.buyPrice % 100; + bool canAfford = money >= item.buyPrice; + if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); ImGui::Text("%ug %us %uc", g, s, c); + if (!canAfford) ImGui::PopStyleColor(); ImGui::TableSetColumnIndex(2); if (item.maxCount < 0) { @@ -1694,8 +1809,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::End(); if (!open) { - // Close vendor - just hide UI, no server packet needed - // The vendor window state will be reset on next interaction + gameHandler.closeVendor(); } }