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).
This commit is contained in:
Kelsi 2026-02-06 11:59:51 -08:00
parent 60be428250
commit 67a3da3bae
6 changed files with 227 additions and 5 deletions

View file

@ -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;

View file

@ -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
// ============================================================

View file

@ -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();

View file

@ -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);
}
}
// ============================================================

View file

@ -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();

View file

@ -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<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(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<uint32_t>(money / 10000);
uint32_t ms = static_cast<uint32_t>((money / 100) % 100);
uint32_t mc = static_cast<uint32_t>(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<int>(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();
}
}