From 8c2f69ca0ea1eca1b61e2b3cfca0a8ae4b963fc9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:17:41 -0700 Subject: [PATCH 001/111] Rate-limit icon GPU uploads in spellbook, action bar, and inventory screens Opening the spellbook on a new tab, logging in with many auras/action slots, or opening a full bag all triggered synchronous BLP-decode + GPU uploads for every uncached icon in one frame, causing a visible stall. Apply the same 4-per-frame upload cap that was added to talent_screen, so icons load progressively. --- src/ui/game_screen.cpp | 9 +++++++++ src/ui/inventory_screen.cpp | 9 +++++++++ src/ui/spellbook_screen.cpp | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index acb6cf22..f1bcdf66 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4108,6 +4108,14 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage } } + // Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached + // (e.g., first login, after loading screen, or many new auras appearing at once). + static int gsLoadsThisFrame = 0; + static int gsLastImGuiFrame = -1; + int gsCurFrame = ImGui::GetFrameCount(); + if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; } + if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + // Look up spellId -> SpellIconID -> icon path auto iit = spellIconIds_.find(spellId); if (iit == spellIconIds_.end()) { @@ -4143,6 +4151,7 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage return VK_NULL_HANDLE; } + ++gsLoadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); spellIconCache_[spellId] = ds; return ds; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 2f63c34a..7899d654 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -101,6 +101,14 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) { auto it = iconCache_.find(displayInfoId); if (it != iconCache_.end()) return it->second; + // Rate-limit GPU uploads per frame to avoid stalling when many items appear at once + // (e.g., opening a full bag, vendor window, or loot from a boss with many drops). + static int iiLoadsThisFrame = 0; + static int iiLastImGuiFrame = -1; + int iiCurFrame = ImGui::GetFrameCount(); + if (iiCurFrame != iiLastImGuiFrame) { iiLoadsThisFrame = 0; iiLastImGuiFrame = iiCurFrame; } + if (iiLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + // Load ItemDisplayInfo.dbc auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc) { @@ -143,6 +151,7 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) { return VK_NULL_HANDLE; } + ++iiLoadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); iconCache_[displayInfoId] = ds; return ds; diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index ef8815f5..8f3edb0f 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -411,6 +411,14 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; + // Rate-limit GPU uploads to avoid a multi-frame stall when switching tabs. + // Icons not loaded this frame will be retried next frame (progressive load). + static int loadsThisFrame = 0; + static int lastImGuiFrame = -1; + int curFrame = ImGui::GetFrameCount(); + if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; } + if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { spellIconCache[iconId] = VK_NULL_HANDLE; @@ -437,6 +445,7 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa return VK_NULL_HANDLE; } + ++loadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); spellIconCache[iconId] = ds; return ds; From 2e92ec903c0af13f26db59224c6ab8c3b1e2dfcc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:21:37 -0700 Subject: [PATCH 002/111] Fix SMSG_ITEM_COOLDOWN missing cooldownTotal for sweep animation SMSG_ITEM_COOLDOWN (on-use trinket/item cooldowns) was only setting cooldownRemaining, leaving cooldownTotal=0. The action bar clock-sweep overlay requires both fields; without cooldownTotal the fan shrinks instantly rather than showing the correct elapsed arc. --- src/game/game_handler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2b29ef80..b84edf19 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2870,6 +2870,7 @@ void GameHandler::handlePacket(network::Packet& packet) { spellCooldowns[spellId] = cdSec; for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot.cooldownTotal = cdSec; slot.cooldownRemaining = cdSec; } } From 26fab2d5d076bc892487df2997bd020ec5af6e43 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:33:46 -0700 Subject: [PATCH 003/111] Show item icons in guild bank window Replace text-only buttons with icon+draw-list rendering that matches the style of the regular bank, loot, and vendor windows. Item icons are looked up via inventoryScreen.getItemIcon(info->displayInfoId); falls back to a coloured bordered square with two-letter abbreviation when the texture is not yet cached. Stack count is overlaid in the bottom-right corner. Withdraw still fires on left-click. --- src/ui/game_screen.cpp | 52 +++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f1bcdf66..1ef06edc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11096,30 +11096,66 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { ImGui::Separator(); // Tab items (98 slots = 14 columns × 7 rows) + constexpr float GB_SLOT = 34.0f; + ImDrawList* gbDraw = ImGui::GetWindowDrawList(); for (size_t i = 0; i < data.tabItems.size(); i++) { - if (i % 14 != 0) ImGui::SameLine(); + if (i % 14 != 0) ImGui::SameLine(0.0f, 2.0f); const auto& item = data.tabItems[i]; ImGui::PushID(static_cast(i) + 5000); + ImVec2 pos = ImGui::GetCursorScreenPos(); + if (item.itemEntry == 0) { - ImGui::Button("##gb", ImVec2(34, 34)); + gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(30, 30, 30, 200)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(60, 60, 60, 180)); + ImGui::InvisibleButton("##gbempty", ImVec2(GB_SLOT, GB_SLOT)); } else { auto* info = gameHandler.getItemInfo(item.itemEntry); game::ItemQuality quality = game::ItemQuality::COMMON; std::string name = "Item " + std::to_string(item.itemEntry); + uint32_t displayInfoId = 0; if (info) { quality = static_cast(info->quality); name = info->name; + displayInfoId = info->displayInfoId; } ImVec4 qc = InventoryScreen::getQualityColor(quality); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qc.x * 0.3f, qc.y * 0.3f, qc.z * 0.3f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qc.x * 0.5f, qc.y * 0.5f, qc.z * 0.5f, 0.9f)); - std::string lbl = item.stackCount > 1 ? std::to_string(item.stackCount) : ("##gi" + std::to_string(i)); - if (ImGui::Button(lbl.c_str(), ImVec2(34, 34))) { - // Withdraw: auto-store to first free bag slot + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + + VkDescriptorSet iconTex = displayInfoId ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; + if (iconTex) { + gbDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + borderCol, 0.0f, 0, 1.5f); + } else { + gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(40, 35, 30, 220)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + borderCol, 0.0f, 0, 1.5f); + if (!name.empty() && name[0] != 'I') { + char abbr[3] = { name[0], name.size() > 1 ? name[1] : '\0', '\0' }; + float tw = ImGui::CalcTextSize(abbr).x; + gbDraw->AddText(ImVec2(pos.x + (GB_SLOT - tw) * 0.5f, pos.y + 2.0f), + borderCol, abbr); + } + } + + if (item.stackCount > 1) { + char cnt[16]; + snprintf(cnt, sizeof(cnt), "%u", item.stackCount); + float cw = ImGui::CalcTextSize(cnt).x; + gbDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), IM_COL32(0, 0, 0, 200), cnt); + gbDraw->AddText(ImVec2(pos.x + GB_SLOT - cw - 2.0f, pos.y + GB_SLOT - 14.0f), + IM_COL32(255, 255, 255, 220), cnt); + } + + ImGui::InvisibleButton("##gbslot", ImVec2(GB_SLOT, GB_SLOT)); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { gameHandler.guildBankWithdrawItem(activeTab, item.slotId, 0xFF, 0); } - ImGui::PopStyleColor(2); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::TextColored(qc, "%s", name.c_str()); From 1e8c85d850b49b74a0ea253c26b9ce70a8a22d54 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:39:15 -0700 Subject: [PATCH 004/111] Show item icons in mail read-view attachment list --- src/ui/game_screen.cpp | 49 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1ef06edc..a85f2d19 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10683,17 +10683,62 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { // Attachments if (!mail.attachments.empty()) { ImGui::Text("Attachments: %zu", mail.attachments.size()); + ImDrawList* mailDraw = ImGui::GetWindowDrawList(); + constexpr float MAIL_SLOT = 34.0f; for (size_t j = 0; j < mail.attachments.size(); ++j) { const auto& att = mail.attachments[j]; ImGui::PushID(static_cast(j)); auto* info = gameHandler.getItemInfo(att.itemId); + game::ItemQuality quality = game::ItemQuality::COMMON; + std::string name = "Item " + std::to_string(att.itemId); + uint32_t displayInfoId = 0; if (info && info->valid) { - ImGui::BulletText("%s x%u", info->name.c_str(), att.stackCount); + quality = static_cast(info->quality); + name = info->name; + displayInfoId = info->displayInfoId; } else { - ImGui::BulletText("Item %u x%u", att.itemId, att.stackCount); gameHandler.ensureItemInfo(att.itemId); } + ImVec4 qc = InventoryScreen::getQualityColor(quality); + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + + ImVec2 pos = ImGui::GetCursorScreenPos(); + VkDescriptorSet iconTex = displayInfoId + ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; + if (iconTex) { + mailDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT)); + mailDraw->AddRect(pos, ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + borderCol, 0.0f, 0, 1.5f); + } else { + mailDraw->AddRectFilled(pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + IM_COL32(40, 35, 30, 220)); + mailDraw->AddRect(pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + borderCol, 0.0f, 0, 1.5f); + } + if (att.stackCount > 1) { + char cnt[16]; + snprintf(cnt, sizeof(cnt), "%u", att.stackCount); + float cw = ImGui::CalcTextSize(cnt).x; + mailDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), + IM_COL32(0, 0, 0, 200), cnt); + mailDraw->AddText( + ImVec2(pos.x + MAIL_SLOT - cw - 2.0f, pos.y + MAIL_SLOT - 14.0f), + IM_COL32(255, 255, 255, 220), cnt); + } + + ImGui::InvisibleButton("##mailatt", ImVec2(MAIL_SLOT, MAIL_SLOT)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextColored(qc, "%s", name.c_str()); + if (att.stackCount > 1) ImGui::Text("Count: %u", att.stackCount); + ImGui::EndTooltip(); + } + ImGui::SameLine(); + ImGui::TextColored(qc, "%s", name.c_str()); ImGui::SameLine(); if (ImGui::SmallButton("Take")) { gameHandler.mailTakeItem(mail.messageId, att.slot); From 4ceb313fb2c6b1b9b5949b7032c3aa017acc35d4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:41:02 -0700 Subject: [PATCH 005/111] Show item icons in quest turn-in required items list --- src/ui/game_screen.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a85f2d19..62d27056 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7500,14 +7500,24 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { for (const auto& item : quest.requiredItems) { uint32_t have = countItemInInventory(item.itemId); bool enough = have >= item.count; + ImVec4 textCol = enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f); auto* info = gameHandler.getItemInfo(item.itemId); const char* name = (info && info->valid) ? info->name.c_str() : nullptr; + + // Show icon if display info is available + uint32_t dispId = item.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + ImGui::SameLine(); + } + } if (name && *name) { - ImGui::TextColored(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f), - " %s %u/%u", name, have, item.count); + ImGui::TextColored(textCol, "%s %u/%u", name, have, item.count); } else { - ImGui::TextColored(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f), - " Item %u %u/%u", item.itemId, have, item.count); + ImGui::TextColored(textCol, "Item %u %u/%u", item.itemId, have, item.count); } } } From d9a58115f9601cf3defe5018096f2c022df26059 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:42:26 -0700 Subject: [PATCH 006/111] Show item icons and rich tooltips in trade window slots --- src/ui/game_screen.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 62d27056..2656ae4b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6093,13 +6093,23 @@ void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { : ("Item " + std::to_string(slot.itemId)); if (slot.stackCount > 1) name += " x" + std::to_string(slot.stackCount); - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), " %d. %s", i + 1, name.c_str()); - + ImVec4 qc = (info && info->valid) + ? InventoryScreen::getQualityColor(static_cast(info->quality)) + : ImVec4(1.0f, 0.9f, 0.5f, 1.0f); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(qc, "%d. %s", i + 1, name.c_str()); if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { gameHandler.clearTradeItem(static_cast(i)); } if (isMine && ImGui::IsItemHovered()) { - ImGui::SetTooltip("Double-click to remove"); + if (info && info->valid) inventoryScreen.renderItemTooltip(*info); + else ImGui::SetTooltip("Double-click to remove"); } } else { ImGui::TextDisabled(" %d. (empty)", i + 1); From 764cf86e38c07598ae4a7017e6551eedfb9cb12b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:48:03 -0700 Subject: [PATCH 007/111] Show spell icons in trainer window with dimming for unavailable spells --- src/ui/game_screen.cpp | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2656ae4b..85ea11f6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7916,6 +7916,7 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, 100), ImGuiCond_Appearing); ImGui::SetNextWindowSize(ImVec2(500, 450), ImGuiCond_Appearing); @@ -8015,8 +8016,23 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { statusLabel = "Unavailable"; } - // Spell name + // Icon column ImGui::TableSetColumnIndex(0); + { + VkDescriptorSet spellIcon = getSpellIcon(spell->spellId, assetMgr); + if (spellIcon) { + if (effectiveState == 1 && !alreadyKnown) { + ImGui::ImageWithBg((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18), + ImVec2(0, 0), ImVec2(1, 1), + ImVec4(0, 0, 0, 0), ImVec4(0.5f, 0.5f, 0.5f, 0.6f)); + } else { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18)); + } + } + } + + // Spell name + ImGui::TableSetColumnIndex(1); const std::string& name = gameHandler.getSpellName(spell->spellId); const std::string& rank = gameHandler.getSpellRank(spell->spellId); if (!name.empty()) { @@ -8057,11 +8073,11 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } // Level - ImGui::TableSetColumnIndex(1); + ImGui::TableSetColumnIndex(2); ImGui::TextColored(color, "%u", spell->reqLevel); // Cost - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(3); if (spell->spellCost > 0) { uint32_t g = spell->spellCost / 10000; uint32_t s = (spell->spellCost / 100) % 100; @@ -8074,7 +8090,7 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } // Train button - only enabled if available, affordable, prereqs met - ImGui::TableSetColumnIndex(3); + ImGui::TableSetColumnIndex(4); // Use effectiveState so newly available spells (after learning prereqs) can be trained bool canTrain = !alreadyKnown && effectiveState == 0 && prereqsMet && levelMet @@ -8110,8 +8126,9 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { }; auto renderSpellTable = [&](const char* tableId, const std::vector& spells) { - if (ImGui::BeginTable(tableId, 4, + if (ImGui::BeginTable(tableId, 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); From 647967cccb55f6c0848a62285eeeecc30d52becc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:49:25 -0700 Subject: [PATCH 008/111] Show item icons in vendor window item list --- src/ui/game_screen.cpp | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 85ea11f6..664ff541 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7828,23 +7828,14 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (vendor.items.empty()) { ImGui::TextDisabled("This vendor has nothing for sale."); } else { - if (ImGui::BeginTable("VendorTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + if (ImGui::BeginTable("VendorTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 120.0f); ImGui::TableSetupColumn("Stock", ImGuiTableColumnFlags_WidthFixed, 60.0f); 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 (int vi = 0; vi < static_cast(vendor.items.size()); ++vi) { const auto& item = vendor.items[vi]; ImGui::TableNextRow(); @@ -7852,13 +7843,24 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { // Proactively ensure vendor item info is loaded gameHandler.ensureItemInfo(item.itemId); - - ImGui::TableSetColumnIndex(0); auto* info = gameHandler.getItemInfo(item.itemId); + + // Icon column + ImGui::TableSetColumnIndex(0); + { + uint32_t dispId = item.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + } + } + + // Name column + ImGui::TableSetColumnIndex(1); 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 + ImVec4 qc = InventoryScreen::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qc, "%s", info->name.c_str()); if (ImGui::IsItemHovered()) { inventoryScreen.renderItemTooltip(*info); } @@ -7866,7 +7868,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::Text("Item %u", item.itemId); } - ImGui::TableSetColumnIndex(1); + ImGui::TableSetColumnIndex(2); if (item.buyPrice == 0 && item.extendedCost != 0) { // Token-only item (no gold cost) ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "[Tokens]"); @@ -7880,14 +7882,14 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (!canAfford) ImGui::PopStyleColor(); } - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(3); if (item.maxCount < 0) { ImGui::Text("Inf"); } else { ImGui::Text("%d", item.maxCount); } - ImGui::TableSetColumnIndex(3); + ImGui::TableSetColumnIndex(4); std::string buyBtnId = "Buy##vendor_" + std::to_string(vi); if (ImGui::SmallButton(buyBtnId.c_str())) { gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, 1); From 3c0e58bff490d3c4beb1cc94dc48b493b05d110e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:52:42 -0700 Subject: [PATCH 009/111] Show item icon and quality color in buyback table --- src/ui/game_screen.cpp | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 664ff541..4c454a9a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7777,7 +7777,8 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { const auto& buyback = gameHandler.getBuybackItems(); if (!buyback.empty()) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Buy Back"); - if (ImGui::BeginTable("BuybackTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + if (ImGui::BeginTable("BuybackTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 110.0f); ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 62.0f); @@ -7787,11 +7788,10 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { const auto& entry = buyback[0]; // Proactively ensure buyback item info is loaded gameHandler.ensureItemInfo(entry.item.itemId); + auto* bbInfo = gameHandler.getItemInfo(entry.item.itemId); uint32_t sellPrice = entry.item.sellPrice; if (sellPrice == 0) { - if (auto* info = gameHandler.getItemInfo(entry.item.itemId); info && info->valid) { - sellPrice = info->sellPrice; - } + if (bbInfo && bbInfo->valid) sellPrice = bbInfo->sellPrice; } uint64_t price = static_cast(sellPrice) * static_cast(entry.count > 0 ? entry.count : 1); @@ -7803,17 +7803,29 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TableNextRow(); ImGui::PushID(8000 + i); ImGui::TableSetColumnIndex(0); - const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); - if (entry.count > 1) { - ImGui::Text("%s x%u", name, entry.count); - } else { - ImGui::Text("%s", name); + { + uint32_t dispId = entry.item.displayInfoId; + if (bbInfo && bbInfo->valid && bbInfo->displayInfoId != 0) dispId = bbInfo->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + } } ImGui::TableSetColumnIndex(1); + game::ItemQuality bbQuality = entry.item.quality; + if (bbInfo && bbInfo->valid) bbQuality = static_cast(bbInfo->quality); + ImVec4 bbQc = InventoryScreen::getQualityColor(bbQuality); + const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); + if (entry.count > 1) { + ImGui::TextColored(bbQc, "%s x%u", name, entry.count); + } else { + ImGui::TextColored(bbQc, "%s", name); + } + ImGui::TableSetColumnIndex(2); 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); + ImGui::TableSetColumnIndex(3); if (!canAfford) ImGui::BeginDisabled(); if (ImGui::SmallButton("Buy Back##buyback_0")) { gameHandler.buyBackItem(0); From 458c9ebe8c18190a36bf5f3e270b4ef48ff8be7b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 20:57:39 -0700 Subject: [PATCH 010/111] Show item icons for item objectives in quest log --- include/ui/quest_log_screen.hpp | 4 +++- src/ui/game_screen.cpp | 2 +- src/ui/quest_log_screen.cpp | 16 ++++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp index d86abedc..eef78289 100644 --- a/include/ui/quest_log_screen.hpp +++ b/include/ui/quest_log_screen.hpp @@ -7,9 +7,11 @@ namespace wowee { namespace ui { +class InventoryScreen; + class QuestLogScreen { public: - void render(game::GameHandler& gameHandler); + void render(game::GameHandler& gameHandler, InventoryScreen& invScreen); bool isOpen() const { return open; } void toggle() { open = !open; } void setOpen(bool o) { open = o; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4c454a9a..9f8b8fa8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -460,7 +460,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderWorldMap(gameHandler); // Quest Log (L key toggle handled inside) - questLogScreen.render(gameHandler); + questLogScreen.render(gameHandler, inventoryScreen); // Spellbook (P key toggle handled inside) spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager()); diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index a5dc4945..ef54d6fc 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -1,4 +1,5 @@ #include "ui/quest_log_screen.hpp" +#include "ui/inventory_screen.hpp" #include "ui/keybinding_manager.hpp" #include "core/application.hpp" #include "core/input.hpp" @@ -206,7 +207,7 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { } } // anonymous namespace -void QuestLogScreen::render(game::GameHandler& gameHandler) { +void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) { // Quests toggle via keybinding (edge-triggered) // Customizable key (default: L) from KeybindingManager bool questsDown = KeybindingManager::getInstance().isActionPressed( @@ -392,13 +393,24 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { } for (const auto& [itemId, count] : sel.itemCounts) { std::string itemLabel = "Item " + std::to_string(itemId); + uint32_t dispId = 0; if (const auto* info = gameHandler.getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; + dispId = info->displayInfoId; + } else { + gameHandler.ensureItemInfo(itemId); } uint32_t required = 1; auto reqIt = sel.requiredItemCounts.find(itemId); if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second; - ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); + VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14)); + ImGui::SameLine(); + ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required); + } else { + ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); + } } } From 5bafacc37249072eabe5c92be6933c68135529d0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:02:02 -0700 Subject: [PATCH 011/111] Use full item tooltips in all auction house tabs --- src/ui/game_screen.cpp | 80 +++--------------------------------------- 1 file changed, 5 insertions(+), 75 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9f8b8fa8..74c9e6b4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11512,35 +11512,7 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TextColored(qc, "%s", name.c_str()); // Item tooltip on hover if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", info->name.c_str()); - if (info->inventoryType > 0) { - if (!info->subclassName.empty()) - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1), "%s", info->subclassName.c_str()); - } - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); - } - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - std::string bonusLine; - auto appendStat = [](std::string& out, int32_t val, const char* n) { - if (val <= 0) return; - if (!out.empty()) out += " "; - out += "+" + std::to_string(val) + " " + n; - }; - appendStat(bonusLine, info->strength, "Str"); - appendStat(bonusLine, info->agility, "Agi"); - appendStat(bonusLine, info->stamina, "Sta"); - appendStat(bonusLine, info->intellect, "Int"); - appendStat(bonusLine, info->spirit, "Spi"); - if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); - if (info->sellPrice > 0) { - ImGui::TextColored(ImVec4(1, 0.84f, 0, 1), "Sell: %ug %us %uc", - info->sellPrice / 10000, (info->sellPrice / 100) % 100, info->sellPrice % 100); - } - ImGui::EndTooltip(); + inventoryScreen.renderItemTooltip(*info); } ImGui::TableSetColumnIndex(1); @@ -11721,29 +11693,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } ImGui::TextColored(bqc, "%s", name.c_str()); // Tooltip - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(bqc, "%s", info->name.c_str()); - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); - } - std::string bl; - auto appS = [](std::string& o, int32_t v, const char* n) { - if (v <= 0) return; - if (!o.empty()) o += " "; - o += "+" + std::to_string(v) + " " + n; - }; - appS(bl, info->strength, "Str"); appS(bl, info->agility, "Agi"); - appS(bl, info->stamina, "Sta"); appS(bl, info->intellect, "Int"); - appS(bl, info->spirit, "Spi"); - if (!bl.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", bl.c_str()); - if (info->sellPrice > 0) - ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", - info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); - ImGui::EndTooltip(); - } + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); @@ -11806,29 +11757,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } ImGui::TextColored(oqc, "%s", name.c_str()); - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(oqc, "%s", info->name.c_str()); - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); - } - std::string ol; - auto appO = [](std::string& o, int32_t v, const char* n) { - if (v <= 0) return; - if (!o.empty()) o += " "; - o += "+" + std::to_string(v) + " " + n; - }; - appO(ol, info->strength, "Str"); appO(ol, info->agility, "Agi"); - appO(ol, info->stamina, "Sta"); appO(ol, info->intellect, "Int"); - appO(ol, info->spirit, "Spi"); - if (!ol.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", ol.c_str()); - if (info->sellPrice > 0) - ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", - info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); - ImGui::EndTooltip(); - } + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); From e415451f89b5c1b8e2a336a78081de23fd31c43a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:03:51 -0700 Subject: [PATCH 012/111] Show item icons inline in chat item links --- src/ui/game_screen.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 74c9e6b4..bd22b369 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1090,6 +1090,22 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { gameHandler.ensureItemInfo(itemEntry); } + // Show small icon before item link if available + if (itemEntry > 0) { + const auto* chatInfo = gameHandler.getItemInfo(itemEntry); + if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) { + VkDescriptorSet chatIcon = inventoryScreen.getItemIcon(chatInfo->displayInfoId); + if (chatIcon) { + ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + renderItemLinkTooltip(itemEntry); + } + ImGui::SameLine(0, 2); + } + } + } + // Render bracketed item name in quality color std::string display = "[" + itemName + "]"; ImGui::PushStyleColor(ImGuiCol_Text, linkColor); From 43c239ee2f1d8fa2a2f358307c13321f270f9f2b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:09:42 -0700 Subject: [PATCH 013/111] Shift-click bag items to insert item links into chat input --- include/ui/inventory_screen.hpp | 10 ++++++++++ src/ui/game_screen.cpp | 13 +++++++++++++ src/ui/inventory_screen.cpp | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index bfca779f..9d4f18ef 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -176,6 +176,9 @@ private: int dropBackpackIndex_ = -1; std::string dropItemName_; + // Pending chat item link from shift-click + std::string pendingChatItemLink_; + public: static ImVec4 getQualityColor(game::ItemQuality quality); @@ -190,6 +193,13 @@ public: /// Drop the currently held item into a specific equipment slot. /// Returns true if the drop was accepted and consumed. bool dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipSlot slot); + /// Returns a WoW item link string if the user shift-clicked a bag item, then clears it. + std::string getAndClearPendingChatLink() { + std::string out = std::move(pendingChatItemLink_); + pendingChatItemLink_.clear(); + return out; + } + /// Drop the currently held item into a bank slot via CMSG_SWAP_ITEM. void dropIntoBankSlot(game::GameHandler& gh, uint8_t dstBag, uint8_t dstSlot); /// Pick up an item from main bank slot (click-and-hold from bank window). diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bd22b369..4a14980b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -511,6 +511,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { inventoryScreen.setGameHandler(&gameHandler); inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper()); + // Insert item link into chat if player shift-clicked a bag item + { + std::string pendingLink = inventoryScreen.getAndClearPendingChatLink(); + if (!pendingLink.empty()) { + size_t curLen = strlen(chatInputBuffer); + if (curLen + pendingLink.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, pendingLink.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + } + // Character screen (C key toggle handled inside render()) inventoryScreen.renderCharacterScreen(gameHandler); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 7899d654..12c8ff9f 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1737,6 +1737,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } + // Shift+left-click: insert item link into chat input + if (ImGui::IsItemHovered() && !holdingItem && + ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && + item.itemId != 0 && !item.name.empty()) { + // Build WoW item link: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r + const char* qualHex = "9d9d9d"; + switch (item.quality) { + case game::ItemQuality::COMMON: qualHex = "ffffff"; break; + case game::ItemQuality::UNCOMMON: qualHex = "1eff00"; break; + case game::ItemQuality::RARE: qualHex = "0070dd"; break; + case game::ItemQuality::EPIC: qualHex = "a335ee"; break; + case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break; + default: break; + } + char linkBuf[512]; + snprintf(linkBuf, sizeof(linkBuf), + "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + qualHex, item.itemId, item.name.c_str()); + pendingChatItemLink_ = linkBuf; + } + if (ImGui::IsItemHovered() && !holdingItem) { // Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory; From 4394f93a1782afe2a904ec5bc94adeeb7e29d18b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:11:58 -0700 Subject: [PATCH 014/111] Use rich item tooltips in quest details window; fix shift-click chat link ordering --- src/ui/game_screen.cpp | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4a14980b..983f2cf9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -511,7 +511,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { inventoryScreen.setGameHandler(&gameHandler); inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper()); - // Insert item link into chat if player shift-clicked a bag item + // Character screen (C key toggle handled inside render()) + inventoryScreen.renderCharacterScreen(gameHandler); + + // Insert item link into chat if player shift-clicked any inventory/equipment slot { std::string pendingLink = inventoryScreen.getAndClearPendingChatLink(); if (!pendingLink.empty()) { @@ -524,9 +527,6 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } - // Character screen (C key toggle handled inside render()) - inventoryScreen.renderCharacterScreen(gameHandler); - if (inventoryScreen.consumeEquipmentDirty() || gameHandler.consumeOnlineEquipmentDirty()) { updateCharacterGeosets(gameHandler.getInventory()); updateCharacterTextures(gameHandler.getInventory()); @@ -7411,22 +7411,13 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { if (iconTex) { ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(nameCol, "%s", info->name.c_str()); - if (!info->description.empty()) - ImGui::TextWrapped("%s", info->description.c_str()); - ImGui::EndTooltip(); - } + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); ImGui::SameLine(); } ImGui::TextColored(nameCol, " %s", label.c_str()); - if (ImGui::IsItemHovered() && info && info->valid && !info->description.empty()) { - ImGui::BeginTooltip(); - ImGui::TextColored(nameCol, "%s", info->name.c_str()); - ImGui::TextWrapped("%s", info->description.c_str()); - ImGui::EndTooltip(); - } + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); }; if (!quest.rewardChoiceItems.empty()) { From e34357a0a4d8c37bc6d2146d8f7edf0631f4718c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:14:27 -0700 Subject: [PATCH 015/111] Use rich item tooltips in mail attachments and guild bank slots --- src/ui/game_screen.cpp | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 983f2cf9..19219f28 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10802,12 +10802,8 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { } ImGui::InvisibleButton("##mailatt", ImVec2(MAIL_SLOT, MAIL_SLOT)); - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", name.c_str()); - if (att.stackCount > 1) ImGui::Text("Count: %u", att.stackCount); - ImGui::EndTooltip(); - } + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); ImGui::SameLine(); ImGui::TextColored(qc, "%s", name.c_str()); ImGui::SameLine(); @@ -11272,12 +11268,8 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { gameHandler.guildBankWithdrawItem(activeTab, item.slotId, 0xFF, 0); } - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", name.c_str()); - if (item.stackCount > 1) ImGui::Text("Count: %u", item.stackCount); - ImGui::EndTooltip(); - } + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); } ImGui::PopID(); } From 95ac97a41cdb616caebc9137a5394dfa5af87332 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:15:41 -0700 Subject: [PATCH 016/111] Use rich item tooltips in bank window slots --- src/ui/game_screen.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 19219f28..1f762719 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11094,10 +11094,14 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { // Tooltip if (ImGui::IsItemHovered() && !isHolding) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", item.name.c_str()); - if (item.stackCount > 1) ImGui::Text("Count: %u", item.stackCount); - ImGui::EndTooltip(); + auto* info = gameHandler.getItemInfo(item.itemId); + if (info && info->valid) + inventoryScreen.renderItemTooltip(*info); + else { + ImGui::BeginTooltip(); + ImGui::TextColored(qc, "%s", item.name.c_str()); + ImGui::EndTooltip(); + } } } }; From 7ab0b036c77435ea9ca3d7b2230eee60058d1096 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:17:29 -0700 Subject: [PATCH 017/111] Add rich item tooltip to buyback item row in vendor window --- src/ui/game_screen.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1f762719..e94d790a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7841,6 +7841,8 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { } else { ImGui::TextColored(bbQc, "%s", name); } + if (ImGui::IsItemHovered() && bbInfo && bbInfo->valid) + inventoryScreen.renderItemTooltip(*bbInfo); ImGui::TableSetColumnIndex(2); if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); ImGui::Text("%ug %us %uc", g, s, c); From bbf4806fe89b507be61c8c9bdca4ad0a1a7d37ea Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:19:47 -0700 Subject: [PATCH 018/111] Add item search filter to vendor window --- include/ui/game_screen.hpp | 3 +++ src/ui/game_screen.cpp | 24 ++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index d81b69a3..4d41ea86 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -362,6 +362,9 @@ private: char mailBodyBuffer_[2048] = ""; int mailComposeMoney_[3] = {0, 0, 0}; // gold, silver, copper + // Vendor search filter + char vendorSearchFilter_[128] = ""; + // Auction house UI state char auctionSearchName_[256] = ""; int auctionLevelMin_ = 0; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e94d790a..19d55fda 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7862,6 +7862,10 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (vendor.items.empty()) { ImGui::TextDisabled("This vendor has nothing for sale."); } else { + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##VendorSearch", "Search...", vendorSearchFilter_, sizeof(vendorSearchFilter_)); + ImGui::Spacing(); + if (ImGui::BeginTable("VendorTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); @@ -7870,15 +7874,31 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f); ImGui::TableHeadersRow(); + std::string vendorFilter(vendorSearchFilter_); + // Lowercase filter for case-insensitive match + for (char& c : vendorFilter) c = static_cast(std::tolower(static_cast(c))); + for (int vi = 0; vi < static_cast(vendor.items.size()); ++vi) { const auto& item = vendor.items[vi]; - ImGui::TableNextRow(); - ImGui::PushID(vi); // Proactively ensure vendor item info is loaded gameHandler.ensureItemInfo(item.itemId); auto* info = gameHandler.getItemInfo(item.itemId); + // Apply search filter + if (!vendorFilter.empty()) { + std::string nameLC = info && info->valid ? info->name : ("Item " + std::to_string(item.itemId)); + for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); + if (nameLC.find(vendorFilter) == std::string::npos) { + ImGui::PushID(vi); + ImGui::PopID(); + continue; + } + } + + ImGui::TableNextRow(); + ImGui::PushID(vi); + // Icon column ImGui::TableSetColumnIndex(0); { From 43c0e9b2e860de79eeffe38d889e4a4d6417a025 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:21:14 -0700 Subject: [PATCH 019/111] Add spell search filter to trainer window --- include/ui/game_screen.hpp | 3 +++ src/ui/game_screen.cpp | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 4d41ea86..229ab28d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -365,6 +365,9 @@ private: // Vendor search filter char vendorSearchFilter_[128] = ""; + // Trainer search filter + char trainerSearchFilter_[128] = ""; + // Auction house UI state char auctionSearchName_[256] = ""; int auctionLevelMin_ = 0; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 19d55fda..d48a9f07 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8003,9 +8003,12 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t mc = static_cast(money % 100); ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); - // Filter checkbox + // Filter controls static bool showUnavailable = false; ImGui::Checkbox("Show unavailable spells", &showUnavailable); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##TrainerSearch", "Search...", trainerSearchFilter_, sizeof(trainerSearchFilter_)); ImGui::Separator(); if (trainer.spells.empty()) { @@ -8055,6 +8058,20 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { continue; } + // Apply text search filter + if (trainerSearchFilter_[0] != '\0') { + std::string trainerFilter(trainerSearchFilter_); + for (char& c : trainerFilter) c = static_cast(std::tolower(static_cast(c))); + const std::string& spellName = gameHandler.getSpellName(spell->spellId); + std::string nameLC = spellName.empty() ? std::to_string(spell->spellId) : spellName; + for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); + if (nameLC.find(trainerFilter) == std::string::npos) { + ImGui::PushID(static_cast(spell->spellId)); + ImGui::PopID(); + continue; + } + } + ImGui::TableNextRow(); ImGui::PushID(static_cast(spell->spellId)); From 0075fdd5e1fe4bd662776d6e4c2e447892be9908 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:24:03 -0700 Subject: [PATCH 020/111] Show item icons in quest objective tracker --- src/ui/game_screen.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d48a9f07..1ef56f74 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5131,7 +5131,16 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; const auto* info = gameHandler.getItemInfo(itemId); const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; - if (itemName) { + + // Show small icon if available + uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12)); + ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + "%s: %u/%u", itemName ? itemName : "Item", count, required); + } else if (itemName) { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), " %s: %u/%u", itemName, count, required); } else { From 3d40e4dee5fab9fa45640c62eb4d15cfab51bc65 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:27:16 -0700 Subject: [PATCH 021/111] Shift-click items in loot and vendor windows to insert chat links --- src/ui/game_screen.cpp | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1ef56f74..950859d7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -46,6 +46,17 @@ #include namespace { + // Build a WoW-format item link string for chat insertion. + // Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r + std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) { + static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000"}; + uint8_t qi = quality < 6 ? quality : 1; + char buf[512]; + snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + kQualHex[qi], itemId, name.c_str()); + return buf; + } + std::string trim(const std::string& s) { size_t first = s.find_first_not_of(" \t\r\n"); if (first == std::string::npos) return ""; @@ -7145,7 +7156,18 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Invisible selectable for click handling if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) { - lootSlotClicked = item.slotIndex; + if (ImGui::GetIO().KeyShift && info && !info->name.empty()) { + // Shift-click: insert item link into chat + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } else { + lootSlotClicked = item.slotIndex; + } } if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { lootSlotClicked = item.slotIndex; @@ -7927,6 +7949,16 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) { inventoryScreen.renderItemTooltip(*info); } + // Shift-click: insert item link into chat + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } } else { ImGui::Text("Item %u", item.itemId); } From 7cfeed1e28b3fb81c08e5f3a9608e9f62aaa933e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:31:09 -0700 Subject: [PATCH 022/111] Shift-click items in quest/AH windows to insert chat links Adds shift-click-to-link support in auction house browse results, quest details reward items, quest offer/reward window choice and fixed items, and quest request-items required item list. --- src/ui/game_screen.cpp | 54 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 950859d7..4614bef9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7449,6 +7449,16 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { ImGui::TextColored(nameCol, " %s", label.c_str()); if (ImGui::IsItemHovered() && info && info->valid) inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } }; if (!quest.rewardChoiceItems.empty()) { @@ -7580,6 +7590,16 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { } else { ImGui::TextColored(textCol, "Item %u %u/%u", item.itemId, have, item.count); } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } } } @@ -7702,7 +7722,17 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } ImGui::PushStyleColor(ImGuiCol_Text, qualityColor); if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) { - selectedChoice = static_cast(i); + if (ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } else { + selectedChoice = static_cast(i); + } } ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); @@ -7732,6 +7762,16 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } ImGui::TextColored(qualityColor, " %s", label.c_str()); if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } } } @@ -11606,10 +11646,20 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } ImGui::TextColored(qc, "%s", name.c_str()); - // Item tooltip on hover + // Item tooltip on hover; shift-click to insert chat link if (ImGui::IsItemHovered() && info && info->valid) { inventoryScreen.renderItemTooltip(*info); } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", auction.stackCount); From 99d1f5778c45645f94d2a960c49e8c6bae353884 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:32:54 -0700 Subject: [PATCH 023/111] Fix trade window peer tooltips; add shift-click links in trade and loot roll Trade window now shows rich item tooltips for both sides (peer items were missing tooltips). Both trade sides and the loot roll popup now support shift-click to insert item links into the chat input. --- src/ui/game_screen.cpp | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4614bef9..86ff9184 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6156,9 +6156,19 @@ void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { gameHandler.clearTradeItem(static_cast(i)); } - if (isMine && ImGui::IsItemHovered()) { + if (ImGui::IsItemHovered()) { if (info && info->valid) inventoryScreen.renderItemTooltip(*info); - else ImGui::SetTooltip("Double-click to remove"); + else if (isMine) ImGui::SetTooltip("Double-click to remove"); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } } } else { ImGui::TextDisabled(" %d. (empty)", i + 1); @@ -6285,6 +6295,16 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { inventoryScreen.renderItemTooltip(*rollInfo); } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && rollInfo && rollInfo->valid && !rollInfo->name.empty()) { + std::string link = buildItemChatLink(rollInfo->entry, rollInfo->quality, rollInfo->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::Spacing(); if (ImGui::Button("Need", ImVec2(80, 30))) { From 23cfb9b6405b01137bf12e131c73fcabcc6b6147 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:34:28 -0700 Subject: [PATCH 024/111] Add shift-click chat links to AH bids and owner auctions tabs --- src/ui/game_screen.cpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 86ff9184..5236b477 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11858,9 +11858,19 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } ImGui::TextColored(bqc, "%s", name.c_str()); - // Tooltip + // Tooltip and shift-click if (ImGui::IsItemHovered() && info && info->valid) inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); @@ -11925,6 +11935,16 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TextColored(oqc, "%s", name.c_str()); if (ImGui::IsItemHovered() && info && info->valid) inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); From 54eae9bffc842757efea8e23185c6f0fac13e8b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:37:15 -0700 Subject: [PATCH 025/111] Add shift-click links and Take All to mail attachments Mail attachment icons and names now support shift-click to insert item links. Item names also show rich tooltips on hover. Adds a "Take All" button when a mail has multiple attachments. --- src/ui/game_screen.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5236b477..ae2f6c3b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10944,8 +10944,30 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { ImGui::InvisibleButton("##mailatt", ImVec2(MAIL_SLOT, MAIL_SLOT)); if (ImGui::IsItemHovered() && info && info->valid) inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::SameLine(); ImGui::TextColored(qc, "%s", name.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::SameLine(); if (ImGui::SmallButton("Take")) { gameHandler.mailTakeItem(mail.messageId, att.slot); @@ -10953,6 +10975,14 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { ImGui::PopID(); } + // "Take All" button when there are multiple attachments + if (mail.attachments.size() > 1) { + if (ImGui::SmallButton("Take All")) { + for (const auto& att2 : mail.attachments) { + gameHandler.mailTakeItem(mail.messageId, att2.slot); + } + } + } } ImGui::Spacing(); From 300e3ba71f57c67c3072607d3b452d4b34264a01 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:39:32 -0700 Subject: [PATCH 026/111] Show quest status icons in gossip window based on QuestGiverStatus Quest list in NPC gossip now shows colored status icons: ! (yellow) = available, ? (yellow) = ready to turn in, ! or ? (gray) = available low-level or in-progress incomplete. --- src/ui/game_screen.cpp | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ae2f6c3b..f33e33fc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7385,9 +7385,38 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { for (size_t qi = 0; qi < gossip.quests.size(); qi++) { const auto& quest = gossip.quests[qi]; ImGui::PushID(static_cast(qi)); + + // Determine icon and color based on QuestGiverStatus stored in questIcon + // 5=INCOMPLETE (gray?), 6=REWARD_REP (yellow?), 7=AVAILABLE_LOW (gray!), + // 8=AVAILABLE (yellow!), 10=REWARD (yellow?) + const char* statusIcon = "!"; + ImVec4 statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + switch (quest.questIcon) { + case 5: // INCOMPLETE — in progress but not done + statusIcon = "?"; + statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray + break; + case 6: // REWARD_REP — repeatable, ready to turn in + case 10: // REWARD — ready to turn in + statusIcon = "?"; + statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + break; + case 7: // AVAILABLE_LOW — available but gray (low-level) + statusIcon = "!"; + statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray + break; + default: // AVAILABLE (8) and any others + statusIcon = "!"; + statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + break; + } + + // Render: colored icon glyph then [Lv] Title + ImGui::TextColored(statusColor, "%s", statusIcon); + ImGui::SameLine(0, 4); char qlabel[256]; snprintf(qlabel, sizeof(qlabel), "[%d] %s", quest.questLevel, quest.title.c_str()); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 0.3f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, statusColor); if (ImGui::Selectable(qlabel)) { gameHandler.selectGossipQuest(quest.questId); } From fc7cc44ef7c8a15f4b24800aa35196aeecaa5a45 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:47:10 -0700 Subject: [PATCH 027/111] Add right-click context menu to raid frame cells with leader kick support --- src/ui/game_screen.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f33e33fc..64b09cd7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5671,6 +5671,33 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { gameHandler.setTarget(m.guid); } + if (ImGui::BeginPopupContextItem("RaidMemberCtx")) { + ImGui::TextDisabled("%s", m.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(m.guid); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(m.guid); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, m.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(m.guid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(m.guid); + gameHandler.inspectTarget(); + } + bool isLeader = (partyData.leaderGuid == gameHandler.getPlayerGuid()); + if (isLeader) { + ImGui::Separator(); + if (ImGui::MenuItem("Kick from Raid")) + gameHandler.uninvitePlayer(m.name); + } + ImGui::EndPopup(); + } ImGui::PopID(); } colIdx++; @@ -5819,6 +5846,14 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { gameHandler.setTarget(member.guid); gameHandler.inspectTarget(); } + // Leader-only actions + bool isLeader = (gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()); + if (isLeader) { + ImGui::Separator(); + if (ImGui::MenuItem("Kick from Group")) { + gameHandler.uninvitePlayer(member.name); + } + } ImGui::EndPopup(); } From c09ebae5afb3ad65e1a4358318fd58ad64cd6574 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:50:07 -0700 Subject: [PATCH 028/111] Add shift-click item linking to bank and guild bank windows --- src/ui/game_screen.cpp | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 64b09cd7..1f93905a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11336,6 +11336,24 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { ImGui::TextColored(qc, "%s", item.name.c_str()); ImGui::EndTooltip(); } + + // Shift-click to insert item link into chat + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift + && !item.name.empty()) { + auto* info2 = gameHandler.getItemInfo(item.itemId); + uint8_t q = (info2 && info2->valid) + ? static_cast(info2->quality) + : static_cast(item.quality); + const std::string& lname = (info2 && info2->valid && !info2->name.empty()) + ? info2->name : item.name; + std::string link = buildItemChatLink(item.itemId, q, lname); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } } } }; @@ -11503,11 +11521,25 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { } ImGui::InvisibleButton("##gbslot", ImVec2(GB_SLOT, GB_SLOT)); - if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::GetIO().KeyShift) { gameHandler.guildBankWithdrawItem(activeTab, item.slotId, 0xFF, 0); } - if (ImGui::IsItemHovered() && info && info->valid) - inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered()) { + if (info && info->valid) + inventoryScreen.renderItemTooltip(*info); + // Shift-click to insert item link into chat + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift + && !name.empty() && item.itemEntry != 0) { + uint8_t q = static_cast(quality); + std::string link = buildItemChatLink(item.itemEntry, q, name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + } } ImGui::PopID(); } From 25c5d257aee1787729d1520880ed49e4db03918c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:53:15 -0700 Subject: [PATCH 029/111] Enhance Friends tab with add/remove/note/whisper and add Ignore List tab --- src/ui/game_screen.cpp | 145 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 11 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1f93905a..2bce02a7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6939,12 +6939,31 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { guildRosterTab_ = 2; const auto& contacts = gameHandler.getContacts(); + // Add Friend row + static char addFriendBuf[64] = {}; + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##addfriend", addFriendBuf, sizeof(addFriendBuf)); + ImGui::SameLine(); + if (ImGui::Button("Add Friend") && addFriendBuf[0] != '\0') { + gameHandler.addFriend(addFriendBuf); + addFriendBuf[0] = '\0'; + } + ImGui::Separator(); + + // Note-edit state + static std::string friendNoteTarget; + static char friendNoteBuf[256] = {}; + static bool openNotePopup = false; + // Filter to friends only int friendCount = 0; - for (const auto& c : contacts) { + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; if (!c.isFriend()) continue; ++friendCount; + ImGui::PushID(static_cast(ci)); + // Status dot ImU32 dotColor = c.isOnline() ? IM_COL32(80, 200, 80, 255) @@ -6955,33 +6974,137 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::Dummy(ImVec2(14.0f, 0.0f)); ImGui::SameLine(); - // Name + // Name as Selectable for right-click context menu const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); ImVec4 nameCol = c.isOnline() ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); - ImGui::TextColored(nameCol, "%s", displayName); + ImGui::PushStyleColor(ImGuiCol_Text, nameCol); + ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap, ImVec2(130.0f, 0.0f)); + ImGui::PopStyleColor(); - // Level and status on same line (right-aligned) + // Double-click to whisper + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) + && !c.name.empty()) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper") && !c.name.empty()) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Edit Note")) { + friendNoteTarget = c.name; + strncpy(friendNoteBuf, c.note.c_str(), sizeof(friendNoteBuf) - 1); + friendNoteBuf[sizeof(friendNoteBuf) - 1] = '\0'; + openNotePopup = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Remove Friend")) { + gameHandler.removeFriend(c.name); + } + ImGui::EndPopup(); + } + + // Note tooltip on hover + if (ImGui::IsItemHovered() && !c.note.empty()) { + ImGui::BeginTooltip(); + ImGui::TextDisabled("Note: %s", c.note.c_str()); + ImGui::EndTooltip(); + } + + // Level and status if (c.isOnline()) { - ImGui::SameLine(); + ImGui::SameLine(160.0f); const char* statusLabel = - (c.status == 2) ? "(AFK)" : - (c.status == 3) ? "(DND)" : ""; + (c.status == 2) ? " (AFK)" : + (c.status == 3) ? " (DND)" : ""; if (c.level > 0) { - ImGui::TextDisabled("Lv %u %s", c.level, statusLabel); + ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); } else if (*statusLabel) { - ImGui::TextDisabled("%s", statusLabel); + ImGui::TextDisabled("%s", statusLabel + 1); } } + + ImGui::PopID(); } if (friendCount == 0) { - ImGui::TextDisabled("No friends online."); + ImGui::TextDisabled("No friends found."); } + // Note edit modal + if (openNotePopup) { + ImGui::OpenPopup("EditFriendNote"); + openNotePopup = false; + } + if (ImGui::BeginPopupModal("EditFriendNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Note for %s:", friendNoteTarget.c_str()); + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##fnote", friendNoteBuf, sizeof(friendNoteBuf)); + if (ImGui::Button("Save", ImVec2(110, 0))) { + gameHandler.setFriendNote(friendNoteTarget, friendNoteBuf); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(110, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::EndTabItem(); + } + + // ---- Ignore List tab ---- + if (ImGui::BeginTabItem("Ignore")) { + guildRosterTab_ = 3; + const auto& contacts = gameHandler.getContacts(); + + // Add Ignore row + static char addIgnoreBuf[64] = {}; + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##addignore", addIgnoreBuf, sizeof(addIgnoreBuf)); + ImGui::SameLine(); + if (ImGui::Button("Ignore Player") && addIgnoreBuf[0] != '\0') { + gameHandler.addIgnore(addIgnoreBuf); + addIgnoreBuf[0] = '\0'; + } ImGui::Separator(); - ImGui::TextDisabled("Right-click a player's name in chat to add friends."); + + int ignoreCount = 0; + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isIgnored()) continue; + ++ignoreCount; + + ImGui::PushID(static_cast(ci) + 10000); + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap); + if (ImGui::BeginPopupContextItem("IgnoreCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (ImGui::MenuItem("Remove Ignore")) { + gameHandler.removeIgnore(c.name); + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + + if (ignoreCount == 0) { + ImGui::TextDisabled("Ignore list is empty."); + } + ImGui::EndTabItem(); } From f5de4d2031d6fb367328858ee33602751dae5cc4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 21:57:13 -0700 Subject: [PATCH 030/111] Add shift-click spell linking to chat from spellbook --- include/ui/spellbook_screen.hpp | 10 ++++++++++ src/ui/game_screen.cpp | 13 +++++++++++++ src/ui/spellbook_screen.cpp | 16 +++++++++++++--- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 470cb233..6cc13270 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -54,6 +54,13 @@ public: uint32_t getDragSpellId() const { return dragSpellId_; } void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; } + /// Returns a WoW spell link string if the user shift-clicked a spell, then clears it. + std::string getAndClearPendingChatLink() { + std::string out = std::move(pendingChatSpellLink_); + pendingChatSpellLink_.clear(); + return out; + } + private: bool open = false; bool pKeyWasDown = false; @@ -87,6 +94,9 @@ private: uint32_t dragSpellId_ = 0; VkDescriptorSet dragSpellIconTex_ = VK_NULL_HANDLE; + // Pending chat spell link from shift-click + std::string pendingChatSpellLink_; + void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); void loadSkillLineDBCs(pipeline::AssetManager* assetManager); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2bce02a7..99c0fe77 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -476,6 +476,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Spellbook (P key toggle handled inside) spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager()); + // Insert spell link into chat if player shift-clicked a spellbook entry + { + std::string pendingSpellLink = spellbookScreen.getAndClearPendingChatLink(); + if (!pendingSpellLink.empty()) { + size_t curLen = strlen(chatInputBuffer); + if (curLen + pendingSpellLink.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, pendingSpellLink.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + } + // Talents (N key toggle handled inside) talentScreen.render(gameHandler); diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 8f3edb0f..bed0dbf0 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -757,15 +757,25 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana // Interaction if (rowHovered) { - // Start drag on click (not passive) - if (rowClicked && !isPassive) { + // Shift-click to insert spell link into chat + if (rowClicked && ImGui::GetIO().KeyShift && !info->name.empty()) { + // WoW spell link format: |cffffd000|Hspell:|h[Name]|h|r + char linkBuf[256]; + snprintf(linkBuf, sizeof(linkBuf), + "|cffffd000|Hspell:%u|h[%s]|h|r", + info->spellId, info->name.c_str()); + pendingChatSpellLink_ = linkBuf; + } + // Start drag on click (not passive, not shift-click) + else if (rowClicked && !isPassive && !ImGui::GetIO().KeyShift) { draggingSpell_ = true; dragSpellId_ = info->spellId; dragSpellIconTex_ = iconTex; } // Double-click to cast - if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) { + if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown + && !ImGui::GetIO().KeyShift) { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; From 386de826afe5bb2a6593a22feda48921eabe7b1a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:00:30 -0700 Subject: [PATCH 031/111] Add right-click context menu on chat messages for whisper/friend/ignore --- src/ui/game_screen.cpp | 78 ++++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 99c0fe77..b1ec12e1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1242,6 +1242,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } }; + int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); @@ -1279,46 +1280,35 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { else if (msg.chatTag & 0x01) tagPrefix = " "; else if (msg.chatTag & 0x02) tagPrefix = " "; - if (msg.type == game::ChatType::SYSTEM) { - renderTextWithLinks(tsPrefix + processedMessage, color); - } else if (msg.type == game::ChatType::TEXT_EMOTE) { - renderTextWithLinks(tsPrefix + processedMessage, color); + // Build full message string for this entry + std::string fullMsg; + if (msg.type == game::ChatType::SYSTEM || msg.type == game::ChatType::TEXT_EMOTE) { + fullMsg = tsPrefix + processedMessage; } else if (!resolvedSenderName.empty()) { if (msg.type == game::ChatType::SAY || msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; } else if (msg.type == game::ChatType::WHISPER || msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; } else if (msg.type == game::ChatType::WHISPER_INFORM) { - // Outgoing whisper — show "To Name: message" (WoW-style) const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; - std::string fullMsg = tsPrefix + "To " + target + ": " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + "To " + target + ": " + processedMessage; } else if (msg.type == game::ChatType::EMOTE || msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { int chIdx = gameHandler.getChannelIndex(msg.channelName); std::string chDisplay = chIdx > 0 ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" : "[" + msg.channelName + "]"; - std::string fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; } } else { - // No sender name. For group/channel types show a bracket prefix; - // for sender-specific types (SAY, YELL, WHISPER, etc.) just show the - // raw message — these are server-side announcements without a speaker. bool isGroupType = msg.type == game::ChatType::PARTY || msg.type == game::ChatType::GUILD || @@ -1329,13 +1319,49 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { msg.type == game::ChatType::BATTLEGROUND || msg.type == game::ChatType::BATTLEGROUND_LEADER; if (isGroupType) { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; } else { - // SAY, YELL, WHISPER, unknown BG_SYSTEM_* types, etc. — no prefix - renderTextWithLinks(tsPrefix + processedMessage, color); + fullMsg = tsPrefix + processedMessage; } } + + // Render message in a group so we can attach a right-click context menu + ImGui::PushID(chatMsgIdx++); + ImGui::BeginGroup(); + renderTextWithLinks(fullMsg, color); + ImGui::EndGroup(); + + // Right-click context menu (only for player messages with a sender) + bool isPlayerMsg = !resolvedSenderName.empty() && + msg.type != game::ChatType::SYSTEM && + msg.type != game::ChatType::TEXT_EMOTE && + msg.type != game::ChatType::MONSTER_SAY && + msg.type != game::ChatType::MONSTER_YELL && + msg.type != game::ChatType::MONSTER_WHISPER && + msg.type != game::ChatType::MONSTER_EMOTE && + msg.type != game::ChatType::MONSTER_PARTY && + msg.type != game::ChatType::RAID_BOSS_WHISPER && + msg.type != game::ChatType::RAID_BOSS_EMOTE; + + if (isPlayerMsg && ImGui::BeginPopupContextItem("ChatMsgCtx")) { + ImGui::TextDisabled("%s", resolvedSenderName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; // WHISPER + strncpy(whisperTargetBuffer, resolvedSenderName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(resolvedSenderName); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(resolvedSenderName); + } + ImGui::EndPopup(); + } + + ImGui::PopID(); } // Auto-scroll to bottom From a207ceef6c7146950be3dfbef3f209f2189cab0a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:03:33 -0700 Subject: [PATCH 032/111] Add secondary stats (AP, SP, hit, crit, haste, etc.) to character stats panel --- src/ui/inventory_screen.cpp | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 12c8ff9f..ae5e22bd 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1399,6 +1399,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play int32_t serverArmor, const int32_t* serverStats) { // Sum equipment stats for item-query bonus display int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; + // Secondary stat sums from extraStats + int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0; + int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; @@ -1407,6 +1410,20 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play itemSta += slot.item.stamina; itemInt += slot.item.intellect; itemSpi += slot.item.spirit; + for (const auto& es : slot.item.extraStats) { + switch (es.statType) { + case 16: case 17: case 18: case 31: itemHit += es.statValue; break; + case 19: case 20: case 21: case 32: itemCrit += es.statValue; break; + case 28: case 29: case 30: case 36: itemHaste += es.statValue; break; + case 35: itemResil += es.statValue; break; + case 37: itemExpertise += es.statValue; break; + case 38: case 39: itemAP += es.statValue; break; + case 41: case 42: case 45: itemSP += es.statValue; break; + case 43: itemMp5 += es.statValue; break; + case 46: itemHp5 += es.statValue; break; + default: break; + } + } } // Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available. @@ -1465,6 +1482,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play renderStat("Intellect", itemInt); renderStat("Spirit", itemSpi); } + + // Secondary stats from equipped items + bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste || + itemResil || itemExpertise || itemMp5 || itemHp5; + if (hasSecondary) { + ImGui::Spacing(); + ImGui::Separator(); + auto renderSecondary = [&](const char* name, int32_t val) { + if (val > 0) { + ImGui::TextColored(green, "+%d %s", val, name); + } + }; + renderSecondary("Attack Power", itemAP); + renderSecondary("Spell Power", itemSP); + renderSecondary("Hit Rating", itemHit); + renderSecondary("Crit Rating", itemCrit); + renderSecondary("Haste Rating", itemHaste); + renderSecondary("Resilience", itemResil); + renderSecondary("Expertise", itemExpertise); + renderSecondary("Mana per 5 sec", itemMp5); + renderSecondary("Health per 5 sec",itemHp5); + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { From c9ea61aba7e981fc33038189e1c992a459c12e78 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:06:16 -0700 Subject: [PATCH 033/111] Fix Exalted reputation tier not displaying correctly (off-by-one in getTier loop) --- src/ui/inventory_screen.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index ae5e22bd..b1328524 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1220,8 +1220,9 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { { "Exalted", 42000, 42000, ImVec4(1.0f, 0.84f, 0.0f, 1.0f) }, }; + constexpr int kNumTiers = static_cast(sizeof(tiers) / sizeof(tiers[0])); auto getTier = [&](int32_t val) -> const RepTier& { - for (int i = 6; i >= 0; --i) { + for (int i = kNumTiers - 1; i >= 0; --i) { if (val >= tiers[i].floor) return tiers[i]; } return tiers[0]; From c9cfa864bf35e5ab97dd74ce23b272f6de0bc996 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:07:44 -0700 Subject: [PATCH 034/111] Add Achievements tab to character screen with search filter --- include/game/game_handler.hpp | 7 +++++ src/ui/inventory_screen.cpp | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index adbd0e33..5f6ad349 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1215,6 +1215,13 @@ public: using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + /// Returns the name of an achievement by ID, or empty string if unknown. + const std::string& getAchievementName(uint32_t id) const { + auto it = achievementNameCache_.find(id); + if (it != achievementNameCache_.end()) return it->second; + static const std::string kEmpty; + return kEmpty; + } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index b1328524..c6e04f38 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1180,6 +1180,57 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Achievements")) { + const auto& earned = gameHandler.getEarnedAchievements(); + if (earned.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("No achievements earned yet."); + } else { + static char achieveFilter[128] = {}; + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##achsearch", "Search achievements...", + achieveFilter, sizeof(achieveFilter)); + ImGui::Separator(); + + char filterLower[128]; + for (size_t i = 0; i < sizeof(achieveFilter); ++i) + filterLower[i] = static_cast(tolower(static_cast(achieveFilter[i]))); + + ImGui::BeginChild("##AchList", ImVec2(0, 0), false); + // Sort by ID for stable ordering + std::vector sortedIds(earned.begin(), earned.end()); + std::sort(sortedIds.begin(), sortedIds.end()); + int shown = 0; + for (uint32_t id : sortedIds) { + const std::string& name = gameHandler.getAchievementName(id); + const char* displayName = name.empty() ? nullptr : name.c_str(); + if (displayName == nullptr) continue; // skip unknown achievements + + // Apply filter + if (filterLower[0] != '\0') { + // simple case-insensitive substring match + std::string lower; + lower.reserve(name.size()); + for (char c : name) lower += static_cast(tolower(static_cast(c))); + if (lower.find(filterLower) == std::string::npos) continue; + } + + ImGui::PushID(static_cast(id)); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "[Achievement]"); + ImGui::SameLine(); + ImGui::Text("%s", displayName); + ImGui::PopID(); + ++shown; + } + if (shown == 0 && filterLower[0] != '\0') { + ImGui::TextDisabled("No achievements match the filter."); + } + ImGui::Text("Total: %d", static_cast(earned.size())); + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } From 861bb3404f0c268ffd5968739ed4b7d2f04304af Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:10:43 -0700 Subject: [PATCH 035/111] Improve vendor window: quantity selector, stock status colors, disable out-of-stock items --- src/ui/game_screen.cpp | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b1ec12e1..e06f451b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8179,8 +8179,17 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (vendor.items.empty()) { ImGui::TextDisabled("This vendor has nothing for sale."); } else { - ImGui::SetNextItemWidth(-1.0f); + // Search + quantity controls on one row + ImGui::SetNextItemWidth(200.0f); ImGui::InputTextWithHint("##VendorSearch", "Search...", vendorSearchFilter_, sizeof(vendorSearchFilter_)); + ImGui::SameLine(); + ImGui::Text("Qty:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60.0f); + static int vendorBuyQty = 1; + ImGui::InputInt("##VendorQty", &vendorBuyQty, 1, 5); + if (vendorBuyQty < 1) vendorBuyQty = 1; + if (vendorBuyQty > 99) vendorBuyQty = 99; ImGui::Spacing(); if (ImGui::BeginTable("VendorTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { @@ -8265,16 +8274,26 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(3); if (item.maxCount < 0) { - ImGui::Text("Inf"); + ImGui::TextDisabled("Inf"); + } else if (item.maxCount == 0) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Out"); + } else if (item.maxCount <= 5) { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), "%d", item.maxCount); } else { ImGui::Text("%d", item.maxCount); } ImGui::TableSetColumnIndex(4); + bool outOfStock = (item.maxCount == 0); + if (outOfStock) ImGui::BeginDisabled(); std::string buyBtnId = "Buy##vendor_" + std::to_string(vi); if (ImGui::SmallButton(buyBtnId.c_str())) { - gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, 1); + int qty = vendorBuyQty; + if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount; + gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, + static_cast(qty)); } + if (outOfStock) ImGui::EndDisabled(); ImGui::PopID(); } From 2f1c9eb01beb51ae06b136c10ad6138e9abca4d9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:13:22 -0700 Subject: [PATCH 036/111] Add FOV slider to settings, expand combat chat tab with skills/achievements/NPC speech --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 28 ++++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 229ab28d..35f83743 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -109,6 +109,7 @@ private: float pendingMouseSensitivity = 0.2f; bool pendingInvertMouse = false; bool pendingExtendedZoom = false; + float pendingFov = 70.0f; // degrees, default matches WoW's ~70° horizontal FOV int pendingUiOpacity = 65; bool pendingMinimapRotate = false; bool pendingMinimapSquare = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e06f451b..cf7d9f46 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -144,9 +144,15 @@ void GameScreen::initChatTabs() { chatTabs_.clear(); // General tab: shows everything chatTabs_.push_back({"General", 0xFFFFFFFF}); - // Combat tab: system + loot messages + // Combat tab: system, loot, skills, achievements, and NPC speech/emotes chatTabs_.push_back({"Combat", (1u << static_cast(game::ChatType::SYSTEM)) | - (1u << static_cast(game::ChatType::LOOT))}); + (1u << static_cast(game::ChatType::LOOT)) | + (1u << static_cast(game::ChatType::SKILL)) | + (1u << static_cast(game::ChatType::ACHIEVEMENT)) | + (1u << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | + (1u << static_cast(game::ChatType::MONSTER_SAY)) | + (1u << static_cast(game::ChatType::MONSTER_YELL)) | + (1u << static_cast(game::ChatType::MONSTER_EMOTE))}); // Whispers tab chatTabs_.push_back({"Whispers", (1u << static_cast(game::ChatType::WHISPER)) | (1u << static_cast(game::ChatType::WHISPER_INFORM))}); @@ -9615,6 +9621,17 @@ void GameScreen::renderSettingsWindow() { if (ImGui::IsItemHovered()) ImGui::SetTooltip("Allow the camera to zoom out further than normal"); + if (ImGui::SliderFloat("Field of View", &pendingFov, 45.0f, 110.0f, "%.0f°")) { + if (renderer) { + if (auto* camera = renderer->getCamera()) { + camera->setFov(pendingFov); + } + } + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Camera field of view in degrees (default: 70)"); + ImGui::Spacing(); ImGui::Spacing(); @@ -10866,6 +10883,7 @@ void GameScreen::saveSettings() { out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n"; out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; + out << "fov=" << pendingFov << "\n"; // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; @@ -10992,6 +11010,12 @@ void GameScreen::loadSettings() { else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0); else if (key == "extended_zoom") pendingExtendedZoom = (std::stoi(val) != 0); + else if (key == "fov") { + pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov); + } + } // Chat else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); From 9a199e20b696fe779ed0e6fcccc96111b32d12c0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:16:19 -0700 Subject: [PATCH 037/111] Add Loot All button to loot window --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cf7d9f46..5279fcc1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7455,6 +7455,15 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { } ImGui::Spacing(); + bool hasItems = !loot.items.empty(); + if (hasItems) { + if (ImGui::Button("Loot All", ImVec2(-1, 0))) { + for (const auto& item : loot.items) { + gameHandler.lootItem(item.slotIndex); + } + } + ImGui::Spacing(); + } if (ImGui::Button("Close", ImVec2(-1, 0))) { gameHandler.closeLoot(); } From b44857dad9b28be911ea43da10af9a9a244fc454 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:23:26 -0700 Subject: [PATCH 038/111] Add Train All Available button to trainer window Shows a footer button listing the count and total cost of all currently trainable spells. Clicking it sends a train request for each spell that meets level, prerequisite, and gold requirements. Button is disabled when nothing is trainable or the player cannot afford the full batch. --- src/ui/game_screen.cpp | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5279fcc1..08380204 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8600,6 +8600,63 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } renderSpellTable("TrainerTable", allSpells); } + + // Count how many spells are trainable right now + int trainableCount = 0; + uint64_t totalCost = 0; + for (const auto& spell : trainer.spells) { + bool prereq1Met = isKnown(spell.chainNode1); + bool prereq2Met = isKnown(spell.chainNode2); + bool prereq3Met = isKnown(spell.chainNode3); + bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; + bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); + bool alreadyKnown = isKnown(spell.spellId); + uint8_t effectiveState = spell.state; + if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; + bool canTrain = !alreadyKnown && effectiveState == 0 + && prereqsMet && levelMet + && (money >= spell.spellCost); + if (canTrain) { + ++trainableCount; + totalCost += spell.spellCost; + } + } + + ImGui::Separator(); + bool canAffordAll = (money >= totalCost); + bool hasTrainable = (trainableCount > 0) && canAffordAll; + if (!hasTrainable) ImGui::BeginDisabled(); + uint32_t tag = static_cast(totalCost / 10000); + uint32_t tas = static_cast((totalCost / 100) % 100); + uint32_t tac = static_cast(totalCost % 100); + char trainAllLabel[80]; + if (trainableCount == 0) { + snprintf(trainAllLabel, sizeof(trainAllLabel), "Train All Available (none)"); + } else { + snprintf(trainAllLabel, sizeof(trainAllLabel), + "Train All Available (%d spell%s, %ug %us %uc)", + trainableCount, trainableCount == 1 ? "" : "s", + tag, tas, tac); + } + if (ImGui::Button(trainAllLabel, ImVec2(-1.0f, 0.0f))) { + for (const auto& spell : trainer.spells) { + bool prereq1Met = isKnown(spell.chainNode1); + bool prereq2Met = isKnown(spell.chainNode2); + bool prereq3Met = isKnown(spell.chainNode3); + bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; + bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); + bool alreadyKnown = isKnown(spell.spellId); + uint8_t effectiveState = spell.state; + if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; + bool canTrain = !alreadyKnown && effectiveState == 0 + && prereqsMet && levelMet + && (money >= spell.spellCost); + if (canTrain) { + gameHandler.trainSpell(spell.spellId); + } + } + } + if (!hasTrainable) ImGui::EndDisabled(); } } ImGui::End(); From 1693abffd3d467a5978a741a0cbcfa0b41911fda Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:25:15 -0700 Subject: [PATCH 039/111] Improve cooldown text format and show HP values on party health bars Cooldowns now display as "Xs" (seconds), "XmYs" (minutes+seconds), or "Xh" (hours) instead of the previous bare number or "Xm"-only format. Party member health bars now show "current/max" HP text (abbreviated to "Xk/Yk" for large values) directly on the progress bar. --- src/ui/game_screen.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 08380204..28321e88 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4483,8 +4483,9 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { char cdText[16]; float cd = slot.cooldownRemaining; - if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm", (int)cd / 60); - else snprintf(cdText, sizeof(cdText), "%.0f", cd); + if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", (int)cd / 3600); + else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", (int)cd / 60, (int)cd % 60); + else snprintf(cdText, sizeof(cdText), "%ds", (int)cd); ImVec2 textSize = ImGui::CalcTextSize(cdText); float tx = cx - textSize.x * 0.5f; float ty = cy - textSize.y * 0.5f; @@ -5830,7 +5831,13 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) : ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); - ImGui::ProgressBar(pct, ImVec2(-1, 12), ""); + char hpText[32]; + if (maxHp >= 10000) + snprintf(hpText, sizeof(hpText), "%dk/%dk", + (int)hp / 1000, (int)maxHp / 1000); + else + snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); ImGui::PopStyleColor(); } From c9c20ce433f4cff2c43a2ac2bf184cb0fdf4772d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:30:16 -0700 Subject: [PATCH 040/111] Display quest rewards (money and items) in quest log details pane Parses reward money, guaranteed items, and choice items from SMSG_QUEST_QUERY_RESPONSE fixed header for both Classic/TBC (40-field) and WotLK (55-field) layouts. Rewards are shown in the quest details pane below objective progress with icons, names, and counts. --- include/game/game_handler.hpp | 4 ++ src/game/game_handler.cpp | 59 ++++++++++++++++++++++++++++ src/ui/quest_log_screen.cpp | 74 +++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5f6ad349..4173fbdb 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1131,6 +1131,10 @@ public: uint32_t required = 0; }; std::array itemObjectives{}; // zeroed by default + // Reward data parsed from SMSG_QUEST_QUERY_RESPONSE + int32_t rewardMoney = 0; // copper; positive=reward, negative=cost + std::array rewardItems{}; // guaranteed reward items + std::array rewardChoiceItems{}; // player picks one of these }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b84edf19..b267f67b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -438,6 +438,49 @@ QuestQueryObjectives extractQuestQueryObjectives(const std::vector& dat } } +// Parse quest reward fields from SMSG_QUEST_QUERY_RESPONSE fixed header. +// Classic/TBC: 40 fixed fields; WotLK: 55 fixed fields. +struct QuestQueryRewards { + int32_t rewardMoney = 0; + std::array itemId{}; + std::array itemCount{}; + std::array choiceItemId{}; + std::array choiceItemCount{}; + bool valid = false; +}; + +static QuestQueryRewards tryParseQuestRewards(const std::vector& data, + bool classicLayout) { + const size_t base = 8; // after questId(4) + questMethod(4) + const size_t fieldCount = classicLayout ? 40u : 55u; + const size_t headerEnd = base + fieldCount * 4u; + if (data.size() < headerEnd) return {}; + + // Field indices (0-based) for each expansion: + // Classic/TBC: rewardMoney=[14], rewardItemId[4]=[20..23], rewardItemCount[4]=[24..27], + // rewardChoiceItemId[6]=[28..33], rewardChoiceItemCount[6]=[34..39] + // WotLK: rewardMoney=[17], rewardItemId[4]=[30..33], rewardItemCount[4]=[34..37], + // rewardChoiceItemId[6]=[38..43], rewardChoiceItemCount[6]=[44..49] + const size_t moneyField = classicLayout ? 14u : 17u; + const size_t itemIdField = classicLayout ? 20u : 30u; + const size_t itemCountField = classicLayout ? 24u : 34u; + const size_t choiceIdField = classicLayout ? 28u : 38u; + const size_t choiceCntField = classicLayout ? 34u : 44u; + + QuestQueryRewards out; + out.rewardMoney = static_cast(readU32At(data, base + moneyField * 4u)); + for (size_t i = 0; i < 4; ++i) { + out.itemId[i] = readU32At(data, base + (itemIdField + i) * 4u); + out.itemCount[i] = readU32At(data, base + (itemCountField + i) * 4u); + } + for (size_t i = 0; i < 6; ++i) { + out.choiceItemId[i] = readU32At(data, base + (choiceIdField + i) * 4u); + out.choiceItemCount[i] = readU32At(data, base + (choiceCntField + i) * 4u); + } + out.valid = true; + return out; +} + } // namespace @@ -4529,6 +4572,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout); + const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout); for (auto& q : questLog_) { if (q.questId != questId) continue; @@ -4584,6 +4628,21 @@ void GameHandler::handlePacket(network::Packet& packet) { objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ", objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]"); } + + // Store reward data and pre-fetch item info for icons. + if (rwds.valid) { + q.rewardMoney = rwds.rewardMoney; + for (int i = 0; i < 4; ++i) { + q.rewardItems[i].itemId = rwds.itemId[i]; + q.rewardItems[i].count = (rwds.itemId[i] != 0) ? rwds.itemCount[i] : 0; + if (rwds.itemId[i] != 0) queryItemInfo(rwds.itemId[i], 0); + } + for (int i = 0; i < 6; ++i) { + q.rewardChoiceItems[i].itemId = rwds.choiceItemId[i]; + q.rewardChoiceItems[i].count = (rwds.choiceItemId[i] != 0) ? rwds.choiceItemCount[i] : 0; + if (rwds.choiceItemId[i] != 0) queryItemInfo(rwds.choiceItemId[i], 0); + } + } break; } diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index ef54d6fc..09eca800 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -414,6 +414,80 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv } } + // Reward summary + bool hasAnyReward = (sel.rewardMoney != 0); + for (const auto& ri : sel.rewardItems) if (ri.itemId) hasAnyReward = true; + for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) hasAnyReward = true; + if (hasAnyReward) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "Rewards"); + + // Money reward + if (sel.rewardMoney > 0) { + uint32_t rg = static_cast(sel.rewardMoney) / 10000; + uint32_t rs = static_cast(sel.rewardMoney % 10000) / 100; + uint32_t rc = static_cast(sel.rewardMoney % 100); + if (rg > 0) + ImGui::Text("%ug %us %uc", rg, rs, rc); + else if (rs > 0) + ImGui::Text("%us %uc", rs, rc); + else + ImGui::Text("%uc", rc); + } + + // Guaranteed reward items + bool anyFixed = false; + for (const auto& ri : sel.rewardItems) if (ri.itemId) { anyFixed = true; break; } + if (anyFixed) { + ImGui::TextDisabled("You will receive:"); + for (const auto& ri : sel.rewardItems) { + if (!ri.itemId) continue; + std::string name = "Item " + std::to_string(ri.itemId); + uint32_t dispId = 0; + const auto* info = gameHandler.getItemInfo(ri.itemId); + if (info && info->valid) { + if (!info->name.empty()) name = info->name; + dispId = info->displayInfoId; + } + VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16)); + ImGui::SameLine(); + } + if (ri.count > 1) + ImGui::Text("%s x%u", name.c_str(), ri.count); + else + ImGui::Text("%s", name.c_str()); + } + } + + // Choice reward items + bool anyChoice = false; + for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) { anyChoice = true; break; } + if (anyChoice) { + ImGui::TextDisabled("Choose one of:"); + for (const auto& ri : sel.rewardChoiceItems) { + if (!ri.itemId) continue; + std::string name = "Item " + std::to_string(ri.itemId); + uint32_t dispId = 0; + const auto* info = gameHandler.getItemInfo(ri.itemId); + if (info && info->valid) { + if (!info->name.empty()) name = info->name; + dispId = info->displayInfoId; + } + VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16)); + ImGui::SameLine(); + } + if (ri.count > 1) + ImGui::Text("%s x%u", name.c_str(), ri.count); + else + ImGui::Text("%s", name.c_str()); + } + } + } + // Track / Abandon buttons ImGui::Separator(); bool isTracked = gameHandler.isQuestTracked(sel.questId); From 18e42c22d45cfb972dde4934bcf25cae9be2c6e6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:31:55 -0700 Subject: [PATCH 041/111] Add HP percentage text to raid frame health bars Each raid member cell now shows the health percentage centered on the health bar, with a drop shadow for readability. The text is omitted when no health data is available. --- src/ui/game_screen.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 28321e88..a071151f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5689,6 +5689,14 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { pct > 0.2f ? IM_COL32(200, 180, 50, 255) : IM_COL32(200, 60, 60, 255); draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); + // HP percentage text centered on bar + char hpPct[8]; + snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(pct * 100.0f + 0.5f)); + ImVec2 ts = ImGui::CalcTextSize(hpPct); + float tx = (barBg.x + barBgEnd.x - ts.x) * 0.5f; + float ty = barBg.y + (BAR_H - ts.y) * 0.5f; + draw->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), hpPct); + draw->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 230), hpPct); } // Power bar From 69fd0b03a28c5314751ad1630682f32629dd4dc9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:36:58 -0700 Subject: [PATCH 042/111] Add right-click context menu to player unit frame Right-clicking the player name in the unit frame opens a popup with 'Open Character' (opens the character/equipment screen) and 'Toggle PvP' options, consistent with the existing right-click menus on party and raid frames. --- src/ui/game_screen.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a071151f..7951110d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1928,11 +1928,20 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { playerHp = playerMaxHp; } - // Name in green (friendly player color) — clickable for self-target + // Name in green (friendly player color) — clickable for self-target, right-click for menu ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 1.0f, 0.3f, 1.0f)); if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) { gameHandler.setTarget(gameHandler.getPlayerGuid()); } + if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) { + if (ImGui::Selectable("Open Character")) { + inventoryScreen.setCharacterOpen(true); + } + if (ImGui::Selectable("Toggle PvP")) { + gameHandler.togglePvp(); + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextDisabled("Lv %u", playerLevel); From 355001c2369770114e75c557d826d1da625f821e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:39:59 -0700 Subject: [PATCH 043/111] Add action bar scale setting to Interface settings tab Adds a 0.5x-1.5x scale slider under Action Bars in the Interface settings tab. The scale multiplies the base 48px slot size for both the main bar and XP bar layout calculations. The setting is persisted to settings.cfg as 'action_bar_scale'. --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 35f83743..18e78f24 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -123,6 +123,7 @@ private: bool awaitingKeyPress = false; bool pendingUseOriginalSoundtrack = true; bool pendingShowActionBar2 = true; // Show second action bar above main bar + float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5) float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1) bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7951110d..c7d541c5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4259,7 +4259,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* assetMgr = core::Application::getInstance().getAssetManager(); - float slotSize = 48.0f; + float slotSize = 48.0f * pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -4896,7 +4896,7 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { (void)window; // Not used for positioning; kept for AssetManager if needed // Position just above both action bars (bar1 at screenH-barH, bar2 above that) - float slotSize = 48.0f; + float slotSize = 48.0f * pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -9463,6 +9463,11 @@ void GameScreen::renderSettingsWindow() { ImGui::SeparatorText("Action Bars"); ImGui::Spacing(); + ImGui::SetNextItemWidth(200.0f); + if (ImGui::SliderFloat("Action Bar Scale", &pendingActionBarScale, 0.5f, 1.5f, "%.2fx")) { + saveSettings(); + } + ImGui::Spacing(); if (ImGui::Checkbox("Show Second Action Bar", &pendingShowActionBar2)) { saveSettings(); @@ -10925,6 +10930,7 @@ void GameScreen::saveSettings() { out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; + out << "action_bar_scale=" << pendingActionBarScale << "\n"; out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; @@ -11031,6 +11037,8 @@ void GameScreen::loadSettings() { } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); + } else if (key == "action_bar_scale") { + pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); } else if (key == "show_action_bar2") { pendingShowActionBar2 = (std::stoi(val) != 0); } else if (key == "action_bar2_offset_x") { From b2d1edc9dbacbf523affa0a01b467b7f5e8520de Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:41:26 -0700 Subject: [PATCH 044/111] Show corpse distance on reclaim corpse button When the player is a ghost, the 'Resurrect from Corpse' popup now shows how many yards away the corpse is, updating in real-time as the ghost moves. Distance is only shown when the corpse is on the same map. --- include/game/game_handler.hpp | 8 ++++++++ src/ui/game_screen.cpp | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 4173fbdb..b2901486 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -888,6 +888,14 @@ public: void cancelTalentWipe() { talentWipePending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; + /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ + float getCorpseDistance() const { + if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f; + float dx = movementInfo.x - corpseX_; + float dy = movementInfo.y - corpseY_; + float dz = movementInfo.z - corpseZ_; + return std::sqrt(dx*dx + dy*dy + dz*dz); + } /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ void reclaimCorpse(); void releaseSpirit(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c7d541c5..fdcda962 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8942,6 +8942,14 @@ void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { gameHandler.reclaimCorpse(); } ImGui::PopStyleColor(2); + float corpDist = gameHandler.getCorpseDistance(); + if (corpDist >= 0.0f) { + char distBuf[48]; + snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); + float dw = ImGui::CalcTextSize(distBuf).x; + ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); + ImGui::TextDisabled("%s", distBuf); + } } ImGui::End(); ImGui::PopStyleColor(); From 823e2bcec6b3159738958432560c6aa9d25f8b5b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:44:23 -0700 Subject: [PATCH 045/111] Add Sell All Junk button to vendor window Scans the backpack and all bags for grey (POOR quality) items with a sell price and shows a 'Sell All Junk (N items)' button at the top of the vendor window. Clicking it sells all matching items in one action. Button only appears when there are sellable junk items. --- src/ui/game_screen.cpp | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fdcda962..a0cca663 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8148,6 +8148,41 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Right-click bag items to sell"); + + // Count grey (POOR quality) sellable items across backpack and bags + const auto& inv = gameHandler.getInventory(); + int junkCount = 0; + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& sl = inv.getBackpackSlot(i); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + ++junkCount; + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < inv.getBagSize(b); ++s) { + const auto& sl = inv.getBagSlot(b, s); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + ++junkCount; + } + } + if (junkCount > 0) { + char junkLabel[64]; + snprintf(junkLabel, sizeof(junkLabel), "Sell All Junk (%d item%s)", + junkCount, junkCount == 1 ? "" : "s"); + if (ImGui::Button(junkLabel, ImVec2(-1, 0))) { + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& sl = inv.getBackpackSlot(i); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + gameHandler.sellItemBySlot(i); + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < inv.getBagSize(b); ++s) { + const auto& sl = inv.getBagSlot(b, s); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + gameHandler.sellItemInBag(b, s); + } + } + } + } ImGui::Separator(); const auto& buyback = gameHandler.getBuybackItems(); From d31c483944442fa37dea3d79510678b074b87f9b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:47:17 -0700 Subject: [PATCH 046/111] Add player position arrow to minimap Draws a yellow directional arrow at the minimap center showing the player's facing direction, with a white dot at the player's position. The arrow rotates with the camera in rotate-with-camera mode and always points in the player's current facing direction. --- src/ui/game_screen.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a0cca663..852a5d00 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10412,6 +10412,27 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { return true; }; + // Player position marker — always drawn at minimap center with a directional arrow. + { + // The player is always at centerX, centerY on the minimap. + // Draw a yellow arrow pointing in the player's facing direction. + glm::vec3 fwd = camera->getForward(); + float facing = std::atan2(-fwd.x, fwd.y); // bearing relative to north + float cosF = std::cos(facing - bearing); + float sinF = std::sin(facing - bearing); + float arrowLen = 8.0f; + float arrowW = 4.0f; + ImVec2 tip(centerX + sinF * arrowLen, centerY - cosF * arrowLen); + ImVec2 left(centerX - cosF * arrowW - sinF * arrowLen * 0.3f, + centerY - sinF * arrowW + cosF * arrowLen * 0.3f); + ImVec2 right(centerX + cosF * arrowW - sinF * arrowLen * 0.3f, + centerY + sinF * arrowW + cosF * arrowLen * 0.3f); + drawList->AddTriangleFilled(tip, left, right, IM_COL32(255, 220, 0, 255)); + drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); + // White dot at player center + drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220)); + } + // Optional base nearby NPC dots (independent of quest status packets). if (minimapNpcDots_) { for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { From 6e7a32ec7f9e9f967433f4d5620322ca674a5ebe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:49:54 -0700 Subject: [PATCH 047/111] Add nameplate scale setting to Interface settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 0.5x-2.0x scale slider under Nameplates in the Interface settings tab. The scale multiplies the base 80×8px nameplate health bar dimensions. Setting is persisted to settings.cfg as 'nameplate_scale'. --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 18e78f24..3dadb2c6 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -65,6 +65,7 @@ private: bool showChatWindow = true; bool showMinimap_ = true; // M key toggles minimap bool showNameplates_ = true; // V key toggles nameplates + float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 852a5d00..fa075aef 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5484,8 +5484,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { : IM_COL32(20, 20, 20, A(180)); // Bar geometry - constexpr float barW = 80.0f; - constexpr float barH = 8.0f; + const float barW = 80.0f * nameplateScale_; + const float barH = 8.0f * nameplateScale_; const float barX = sx - barW * 0.5f; float healthPct = std::clamp( @@ -9562,6 +9562,14 @@ void GameScreen::renderSettingsWindow() { } } + ImGui::Spacing(); + ImGui::SeparatorText("Nameplates"); + ImGui::Spacing(); + ImGui::SetNextItemWidth(200.0f); + if (ImGui::SliderFloat("Nameplate Scale", &nameplateScale_, 0.5f, 2.0f, "%.2fx")) { + saveSettings(); + } + ImGui::Spacing(); ImGui::SeparatorText("Network"); ImGui::Spacing(); @@ -10995,6 +11003,7 @@ void GameScreen::saveSettings() { out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; out << "action_bar_scale=" << pendingActionBarScale << "\n"; + out << "nameplate_scale=" << nameplateScale_ << "\n"; out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; @@ -11103,6 +11112,8 @@ void GameScreen::loadSettings() { inventoryScreen.setSeparateBags(pendingSeparateBags); } else if (key == "action_bar_scale") { pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); + } else if (key == "nameplate_scale") { + nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); } else if (key == "show_action_bar2") { pendingShowActionBar2 = (std::stoi(val) != 0); } else if (key == "action_bar2_offset_x") { From 97662800d563769cbe681f148af45bcf7aed5920 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 22:57:04 -0700 Subject: [PATCH 048/111] Add /target command and screen damage flash vignette /target searches visible entities by case-insensitive prefix match. Red vignette flashes on screen edges when player HP drops, fading over 0.5s. --- include/ui/game_screen.hpp | 2 + src/ui/game_screen.cpp | 81 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 3dadb2c6..4dcf1ff4 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -66,6 +66,8 @@ private: bool showMinimap_ = true; // M key toggles minimap bool showNameplates_ = true; // V key toggles nameplates float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions + uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection + float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fa075aef..ad110cc3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -654,6 +654,52 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Screen edge damage flash — red vignette that fires on HP decrease + { + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + uint32_t currentHp = 0; + if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || + playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) + currentHp = unit->getHealth(); + } + + // Detect HP drop (ignore transitions from 0 — entity just spawned or uninitialized) + if (lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0) + damageFlashAlpha_ = 1.0f; + lastPlayerHp_ = currentHp; + + // Fade out over ~0.5 seconds + if (damageFlashAlpha_ > 0.0f) { + damageFlashAlpha_ -= ImGui::GetIO().DeltaTime * 2.0f; + if (damageFlashAlpha_ < 0.0f) damageFlashAlpha_ = 0.0f; + + // Draw four red gradient rectangles along each screen edge (vignette style) + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const int alpha = static_cast(damageFlashAlpha_ * 180.0f); + const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); + const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); + const float thickness = std::min(W, H) * 0.12f; + + // Top + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + edgeCol, edgeCol, fadeCol, fadeCol); + // Bottom + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + fadeCol, fadeCol, edgeCol, edgeCol); + // Left + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + edgeCol, fadeCol, fadeCol, edgeCol); + // Right + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + fadeCol, edgeCol, edgeCol, fadeCol); + } + } + // Restore previous alpha ImGui::GetStyle().Alpha = prevAlpha; } @@ -3398,6 +3444,41 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "target" && spacePos != std::string::npos) { + // Search visible entities for name match (case-insensitive prefix) + std::string targetArg = command.substr(spacePos + 1); + std::string targetArgLower = targetArg; + for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(targetArgLower) == 0) { + bestGuid = guid; + if (nameLower == targetArgLower) break; // Exact match wins + } + } + if (bestGuid) { + gameHandler.setTarget(bestGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No target matching '" + targetArg + "' found."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "targetenemy") { gameHandler.targetEnemy(false); chatInputBuffer[0] = '\0'; From ae8f900410f83a61523cc056169c14f903ee5e56 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:00:03 -0700 Subject: [PATCH 049/111] Add Ctrl+click minimap ping sending Ctrl+clicking on the minimap converts screen position to world coordinates and sends MSG_MINIMAP_PING to the server. A local ping is also added immediately so the sender sees their own ping. --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 23 +++++++++++++++++++++++ src/ui/game_screen.cpp | 22 ++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b2901486..870e774c 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -388,6 +388,9 @@ public: // PvP void togglePvp(); + // Minimap ping (Ctrl+click on minimap; wowX/wowY in canonical WoW coords) + void sendMinimapPing(float wowX, float wowY); + // Guild commands void requestGuildInfo(); void requestGuildRoster(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b267f67b..dc33651f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7710,6 +7710,29 @@ void GameHandler::sendPing() { socket->send(packet); } +void GameHandler::sendMinimapPing(float wowX, float wowY) { + if (state != WorldState::IN_WORLD) return; + + // MSG_MINIMAP_PING (CMSG direction): float posX + float posY + // Server convention: posX = east/west axis = canonical Y (west) + // posY = north/south axis = canonical X (north) + const float serverX = wowY; // canonical Y (west) → server posX + const float serverY = wowX; // canonical X (north) → server posY + + network::Packet pkt(wireOpcode(Opcode::MSG_MINIMAP_PING)); + pkt.writeFloat(serverX); + pkt.writeFloat(serverY); + socket->send(pkt); + + // Add ping locally so the sender sees their own ping immediately + MinimapPing localPing; + localPing.senderGuid = activeCharacterGuid_; + localPing.wowX = wowX; + localPing.wowY = wowY; + localPing.age = 0.0f; + minimapPings_.push_back(localPing); +} + void GameHandler::handlePong(network::Packet& packet) { LOG_DEBUG("Handling SMSG_PONG"); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ad110cc3..deefa228 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10654,6 +10654,28 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Ctrl+click on minimap → send minimap ping to party + if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + float distSq = mdx * mdx + mdy * mdy; + if (distSq <= mapRadius * mapRadius) { + // Invert projectToMinimap: px=mdx, py=mdy → rx=px*viewRadius/mapRadius + float rx = mdx * viewRadius / mapRadius; + float ry = mdy * viewRadius / mapRadius; + // rx/ry are in rotated frame; unrotate to get world dx/dy + // rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + // Solving: dx = -(rx*cosB - ry*sinB), dy = -(rx*sinB + ry*cosB) + float wdx = -(rx * cosB - ry * sinB); + float wdy = -(rx * sinB + ry * cosB); + // playerRender is in render coords; add delta to get render position then convert to canonical + glm::vec3 clickRender = playerRender + glm::vec3(wdx, wdy, 0.0f); + glm::vec3 clickCanon = core::coords::renderToCanonical(clickRender); + gameHandler.sendMinimapPing(clickCanon.x, clickCanon.y); + } + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; From e002266607552655844b747dcac9b1cc2ca090fe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:01:37 -0700 Subject: [PATCH 050/111] Add scroll wheel zoom to minimap --- src/ui/game_screen.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index deefa228..93617ca5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10654,6 +10654,22 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Scroll wheel over minimap → zoom in/out + { + float wheel = ImGui::GetIO().MouseWheel; + if (wheel != 0.0f) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { + if (wheel > 0.0f) + minimap->zoomIn(); + else + minimap->zoomOut(); + } + } + } + // Ctrl+click on minimap → send minimap ping to party if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) { ImVec2 mouse = ImGui::GetMousePos(); From 682cb8d44bcb399efcf1e06d613ca52e8fffd12f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:06:24 -0700 Subject: [PATCH 051/111] Add chat input history navigation with Up/Down arrows Up arrow in the chat input field recalls previously sent messages. Down arrow moves forward through history; going past the end clears input. History stores up to 50 unique entries and resets position after each send. --- include/ui/game_screen.hpp | 4 +++ src/ui/game_screen.cpp | 53 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 4dcf1ff4..97cec9ca 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -50,6 +50,10 @@ private: int lastChatType = 0; // Track chat type changes bool chatInputMoveCursorToEnd = false; + // Chat sent-message history (Up/Down arrow recall) + std::vector chatSentHistory_; + int chatHistoryIdx_ = -1; // -1 = not browsing history + // Chat tabs int activeChatTab_ = 0; struct ChatTab { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 93617ca5..0ca54b97 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1541,17 +1541,50 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { auto* self = static_cast(data->UserData); - if (self && self->chatInputMoveCursorToEnd) { + if (!self) return 0; + + // Cursor-to-end after channel switch + if (self->chatInputMoveCursorToEnd) { int len = static_cast(std::strlen(data->Buf)); data->CursorPos = len; data->SelectionStart = len; data->SelectionEnd = len; self->chatInputMoveCursorToEnd = false; } + + // Up/Down arrow: cycle through sent message history + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + const int histSize = static_cast(self->chatSentHistory_.size()); + if (histSize == 0) return 0; + + if (data->EventKey == ImGuiKey_UpArrow) { + // Go back in history + if (self->chatHistoryIdx_ == -1) + self->chatHistoryIdx_ = histSize - 1; + else if (self->chatHistoryIdx_ > 0) + --self->chatHistoryIdx_; + } else if (data->EventKey == ImGuiKey_DownArrow) { + if (self->chatHistoryIdx_ == -1) return 0; + ++self->chatHistoryIdx_; + if (self->chatHistoryIdx_ >= histSize) { + self->chatHistoryIdx_ = -1; + data->DeleteChars(0, data->BufTextLen); + return 0; + } + } + + if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { + const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, entry.c_str()); + } + } return 0; }; - ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways; + ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_CallbackAlways | + ImGuiInputTextFlags_CallbackHistory; if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. @@ -2786,6 +2819,22 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); + + // Save to sent-message history (skip pure whitespace, cap at 50 entries) + { + bool allSpace = true; + for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } + if (!allSpace) { + // Remove duplicate of last entry if identical + if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { + chatSentHistory_.push_back(input); + if (chatSentHistory_.size() > 50) + chatSentHistory_.erase(chatSentHistory_.begin()); + } + } + } + chatHistoryIdx_ = -1; // reset browsing position after send + game::ChatType type = game::ChatType::SAY; std::string message = input; std::string target; From 745768511bde5ccd8a137b56cf47ef0cdad87d92 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:08:35 -0700 Subject: [PATCH 052/111] Show all buyback items in vendor window (not just the most recent) --- src/ui/game_screen.cpp | 100 +++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0ca54b97..65e53feb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8324,57 +8324,59 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 110.0f); ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 62.0f); ImGui::TableHeadersRow(); - // Show only the most recently sold item (LIFO). - const int i = 0; - const auto& entry = buyback[0]; - // Proactively ensure buyback item info is loaded - gameHandler.ensureItemInfo(entry.item.itemId); - auto* bbInfo = gameHandler.getItemInfo(entry.item.itemId); - uint32_t sellPrice = entry.item.sellPrice; - if (sellPrice == 0) { - if (bbInfo && bbInfo->valid) sellPrice = bbInfo->sellPrice; - } - uint64_t price = static_cast(sellPrice) * - static_cast(entry.count > 0 ? entry.count : 1); - uint32_t g = static_cast(price / 10000); - uint32_t s = static_cast((price / 100) % 100); - uint32_t c = static_cast(price % 100); - bool canAfford = money >= price; - - ImGui::TableNextRow(); - ImGui::PushID(8000 + i); - ImGui::TableSetColumnIndex(0); - { - uint32_t dispId = entry.item.displayInfoId; - if (bbInfo && bbInfo->valid && bbInfo->displayInfoId != 0) dispId = bbInfo->displayInfoId; - if (dispId != 0) { - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); - if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + // Show all buyback items (most recently sold first) + for (int i = 0; i < static_cast(buyback.size()); ++i) { + const auto& entry = buyback[i]; + gameHandler.ensureItemInfo(entry.item.itemId); + auto* bbInfo = gameHandler.getItemInfo(entry.item.itemId); + uint32_t sellPrice = entry.item.sellPrice; + if (sellPrice == 0) { + if (bbInfo && bbInfo->valid) sellPrice = bbInfo->sellPrice; } + uint64_t price = static_cast(sellPrice) * + static_cast(entry.count > 0 ? entry.count : 1); + uint32_t g = static_cast(price / 10000); + uint32_t s = static_cast((price / 100) % 100); + uint32_t c = static_cast(price % 100); + bool canAfford = money >= price; + + ImGui::TableNextRow(); + ImGui::PushID(8000 + i); + ImGui::TableSetColumnIndex(0); + { + uint32_t dispId = entry.item.displayInfoId; + if (bbInfo && bbInfo->valid && bbInfo->displayInfoId != 0) dispId = bbInfo->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + } + } + ImGui::TableSetColumnIndex(1); + game::ItemQuality bbQuality = entry.item.quality; + if (bbInfo && bbInfo->valid) bbQuality = static_cast(bbInfo->quality); + ImVec4 bbQc = InventoryScreen::getQualityColor(bbQuality); + const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); + if (entry.count > 1) { + ImGui::TextColored(bbQc, "%s x%u", name, entry.count); + } else { + ImGui::TextColored(bbQc, "%s", name); + } + if (ImGui::IsItemHovered() && bbInfo && bbInfo->valid) + inventoryScreen.renderItemTooltip(*bbInfo); + ImGui::TableSetColumnIndex(2); + 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(3); + if (!canAfford) ImGui::BeginDisabled(); + char bbLabel[32]; + snprintf(bbLabel, sizeof(bbLabel), "Buy Back##bb%d", i); + if (ImGui::SmallButton(bbLabel)) { + gameHandler.buyBackItem(static_cast(i)); + } + if (!canAfford) ImGui::EndDisabled(); + ImGui::PopID(); } - ImGui::TableSetColumnIndex(1); - game::ItemQuality bbQuality = entry.item.quality; - if (bbInfo && bbInfo->valid) bbQuality = static_cast(bbInfo->quality); - ImVec4 bbQc = InventoryScreen::getQualityColor(bbQuality); - const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); - if (entry.count > 1) { - ImGui::TextColored(bbQc, "%s x%u", name, entry.count); - } else { - ImGui::TextColored(bbQc, "%s", name); - } - if (ImGui::IsItemHovered() && bbInfo && bbInfo->valid) - inventoryScreen.renderItemTooltip(*bbInfo); - ImGui::TableSetColumnIndex(2); - 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(3); - if (!canAfford) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Buy Back##buyback_0")) { - gameHandler.buyBackItem(0); - } - if (!canAfford) ImGui::EndDisabled(); - ImGui::PopID(); ImGui::EndTable(); } ImGui::Separator(); From 7e271df032736a37363a775553398e15d32a4e09 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:10:21 -0700 Subject: [PATCH 053/111] Add level-up golden burst overlay effect When the player gains a level, a golden vignette flashes on screen edges and a large "Level X!" text briefly appears in the center, both fading over ~1 second. Uses the existing LevelUpCallback from GameHandler. --- include/ui/game_screen.hpp | 3 +++ src/ui/game_screen.cpp | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 97cec9ca..73e318b9 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -72,6 +72,8 @@ private: float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) + float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) + uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; @@ -364,6 +366,7 @@ private: }; std::vector chatBubbles_; bool chatBubbleCallbackSet_ = false; + bool levelUpCallbackSet_ = false; // Mail compose state char mailRecipientBuffer_[256] = ""; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 65e53feb..d9fc21c3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -209,6 +209,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { chatBubbleCallbackSet_ = true; } + // Set up level-up callback (once) + if (!levelUpCallbackSet_) { + gameHandler.setLevelUpCallback([this](uint32_t newLevel) { + levelUpFlashAlpha_ = 1.0f; + levelUpDisplayLevel_ = newLevel; + }); + levelUpCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -700,6 +709,43 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Level-up golden burst overlay + if (levelUpFlashAlpha_ > 0.0f) { + levelUpFlashAlpha_ -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second + if (levelUpFlashAlpha_ < 0.0f) levelUpFlashAlpha_ = 0.0f; + + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const int alpha = static_cast(levelUpFlashAlpha_ * 160.0f); + const ImU32 goldEdge = IM_COL32(255, 210, 50, alpha); + const ImU32 goldFade = IM_COL32(255, 210, 50, 0); + const float thickness = std::min(W, H) * 0.18f; + + // Four golden gradient edges + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + goldEdge, goldEdge, goldFade, goldFade); + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + goldFade, goldFade, goldEdge, goldEdge); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + goldEdge, goldFade, goldFade, goldEdge); + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + goldFade, goldEdge, goldEdge, goldFade); + + // "Level X!" text in the center during the first half of the animation + if (levelUpFlashAlpha_ > 0.5f && levelUpDisplayLevel_ > 0) { + char lvlText[32]; + snprintf(lvlText, sizeof(lvlText), "Level %u!", levelUpDisplayLevel_); + ImVec2 ts = ImGui::CalcTextSize(lvlText); + float tx = (W - ts.x) * 0.5f; + float ty = H * 0.35f; + // Large shadow + bright gold text + fg->AddText(nullptr, 28.0f, ImVec2(tx + 2, ty + 2), IM_COL32(0, 0, 0, alpha), lvlText); + fg->AddText(nullptr, 28.0f, ImVec2(tx, ty), IM_COL32(255, 230, 80, alpha), lvlText); + } + } + // Restore previous alpha ImGui::GetStyle().Alpha = prevAlpha; } From 3446fffe868590ece38babed52ce5f397ac04f74 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:13:31 -0700 Subject: [PATCH 054/111] Add local time clock below minimap indicators --- src/ui/game_screen.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d9fc21c3..8f6c0224 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10971,6 +10971,35 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { ImGui::TextColored(latColor, "%u ms", latMs); } ImGui::End(); + nextIndicatorY += kIndicatorH; + } + + // Local time clock — always visible below minimap indicators + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + struct tm tmBuf; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockStr[16]; + snprintf(clockStr, sizeof(clockStr), "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min); + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + ImGuiWindowFlags clockFlags = indicatorFlags & ~ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##ClockIndicator", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 0.75f), "%s", clockStr); + if (ImGui::IsItemHovered()) { + char fullTime[32]; + snprintf(fullTime, sizeof(fullTime), "%02d:%02d:%02d (local)", + tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec); + ImGui::SetTooltip("%s", fullTime); + } + } + ImGui::End(); } } From b3d3814ce985daa8f77568837bd2c4b240817cd6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:17:38 -0700 Subject: [PATCH 055/111] Add search bar and Active/Ready filter to quest log Adds a name search input and All/Active/Ready radio buttons above the quest list. Clears the filter automatically when openAndSelectQuest() is called so the target quest is always visible. --- include/ui/quest_log_screen.hpp | 4 ++++ src/ui/quest_log_screen.cpp | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp index eef78289..0bb791e0 100644 --- a/include/ui/quest_log_screen.hpp +++ b/include/ui/quest_log_screen.hpp @@ -31,6 +31,10 @@ private: uint32_t lastDetailRequestQuestId_ = 0; double lastDetailRequestAt_ = 0.0; std::unordered_set questDetailQueryNoResponse_; + // Search / filter + char questSearchFilter_[64] = {}; + // 0=all, 1=active only, 2=complete only + int questFilterMode_ = 0; }; }} // namespace wowee::ui diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 09eca800..e52a2085 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -248,6 +248,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv else activeCount++; } + // Search bar + filter buttons on one row + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 210.0f); + ImGui::InputTextWithHint("##qsearch", "Search quests...", questSearchFilter_, sizeof(questSearchFilter_)); + ImGui::SameLine(); + if (ImGui::RadioButton("All", questFilterMode_ == 0)) questFilterMode_ = 0; + ImGui::SameLine(); + if (ImGui::RadioButton("Active", questFilterMode_ == 1)) questFilterMode_ = 1; + ImGui::SameLine(); + if (ImGui::RadioButton("Ready", questFilterMode_ == 2)) questFilterMode_ = 2; + + // Summary counts ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.35f, 1.0f), "Active: %d", activeCount); ImGui::SameLine(); ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.45f, 1.0f), "Ready: %d", completeCount); @@ -270,14 +281,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv for (size_t i = 0; i < quests.size(); i++) { if (quests[i].questId == pendingSelectQuestId_) { selectedIndex = static_cast(i); + // Clear filter so the target quest is visible + questSearchFilter_[0] = '\0'; + questFilterMode_ = 0; break; } } pendingSelectQuestId_ = 0; } + // Build a case-insensitive lowercase copy of the search filter once + char filterLower[64] = {}; + for (size_t fi = 0; fi < sizeof(questSearchFilter_) && questSearchFilter_[fi]; ++fi) + filterLower[fi] = static_cast(std::tolower(static_cast(questSearchFilter_[fi]))); + + int visibleQuestCount = 0; for (size_t i = 0; i < quests.size(); i++) { const auto& q = quests[i]; + + // Apply mode filter + if (questFilterMode_ == 1 && q.complete) continue; + if (questFilterMode_ == 2 && !q.complete) continue; + + // Apply name search filter + if (filterLower[0]) { + std::string titleLower = cleanQuestTitleForUi(q.title, q.questId); + for (char& c : titleLower) c = static_cast(std::tolower(static_cast(c))); + if (titleLower.find(filterLower) == std::string::npos) continue; + } + + visibleQuestCount++; ImGui::PushID(static_cast(i)); bool selected = (selectedIndex == static_cast(i)); @@ -321,6 +354,13 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv } ImGui::PopID(); } + if (visibleQuestCount == 0) { + ImGui::Spacing(); + if (filterLower[0] || questFilterMode_ != 0) + ImGui::TextDisabled("No quests match the filter."); + else + ImGui::TextDisabled("No active quests."); + } ImGui::EndChild(); ImGui::SameLine(); From fe61d6acce789883a079f5b46155858fdbe755b5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:19:48 -0700 Subject: [PATCH 056/111] Add corpse direction indicator on minimap for ghost players When the player is a ghost, shows a small X marker at the corpse position on the minimap. If the corpse is off-screen, draws an edge arrow on the minimap border pointing toward it. --- include/game/game_handler.hpp | 8 +++++ src/ui/game_screen.cpp | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 870e774c..dadc30ae 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -899,6 +899,14 @@ public: float dz = movementInfo.z - corpseZ_; return std::sqrt(dx*dx + dy*dy + dz*dz); } + /** Corpse position in canonical WoW coords (X=north, Y=west). + * Returns false if no corpse data or on a different map. */ + bool getCorpseCanonicalPos(float& outX, float& outY) const { + if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return false; + outX = corpseY_; // server Y = canonical X (north) + outY = corpseX_; // server X = canonical Y (west) + return true; + } /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ void reclaimCorpse(); void releaseSpirit(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8f6c0224..e59444fe 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10751,6 +10751,74 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Corpse direction indicator — shown when player is a ghost + if (gameHandler.isPlayerGhost()) { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + if (gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY)) { + glm::vec3 corpseRender = core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)); + float csx = 0.0f, csy = 0.0f; + bool onMap = projectToMinimap(corpseRender, csx, csy); + + if (onMap) { + // Draw a small skull-like X marker at the corpse position + const float r = 5.0f; + drawList->AddCircleFilled(ImVec2(csx, csy), r + 1.0f, IM_COL32(0, 0, 0, 140), 12); + drawList->AddCircle(ImVec2(csx, csy), r + 1.0f, IM_COL32(200, 200, 220, 220), 12, 1.5f); + // Draw an X in the circle + drawList->AddLine(ImVec2(csx - 3.0f, csy - 3.0f), ImVec2(csx + 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + drawList->AddLine(ImVec2(csx + 3.0f, csy - 3.0f), ImVec2(csx - 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - csx, mdy = mouse.y - csy; + if (mdx * mdx + mdy * mdy < 64.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } else { + // Corpse is outside minimap — draw an edge arrow pointing toward it + float dx = corpseRender.x - playerRender.x; + float dy = corpseRender.y - playerRender.y; + // Rotate delta into minimap frame (same as projectToMinimap) + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; + float len = std::sqrt(rx * rx + ry * ry); + if (len > 0.001f) { + float nx = rx / len; + float ny = ry / len; + // Place arrow at the minimap edge + float edgeR = mapRadius - 7.0f; + float ax = centerX + nx * edgeR; + float ay = centerY + ny * edgeR; + // Arrow pointing outward (toward corpse) + float arrowLen = 6.0f; + float arrowW = 3.5f; + ImVec2 tip(ax + nx * arrowLen, ay + ny * arrowLen); + ImVec2 left(ax - ny * arrowW - nx * arrowLen * 0.4f, + ay + nx * arrowW - ny * arrowLen * 0.4f); + ImVec2 right(ax + ny * arrowW - nx * arrowLen * 0.4f, + ay - nx * arrowW - ny * arrowLen * 0.4f); + drawList->AddTriangleFilled(tip, left, right, IM_COL32(180, 180, 240, 230)); + drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - ax, mdy = mouse.y - ay; + if (mdx * mdx + mdy * mdy < 100.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } + } + } + } + // Scroll wheel over minimap → zoom in/out { float wheel = ImGui::GetIO().MouseWheel; From c433188edbad18f83190f36787b596bed7163b9b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:21:27 -0700 Subject: [PATCH 057/111] Show AFK/DND status badge on player frame Adds a yellow or orange label next to the player name when those modes are active, with a tooltip explaining how to cancel. --- include/game/game_handler.hpp | 2 ++ src/ui/game_screen.cpp | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index dadc30ae..4821c4b0 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -447,6 +447,8 @@ public: // AFK/DND status void toggleAfk(const std::string& message = ""); void toggleDnd(const std::string& message = ""); + bool isAfk() const { return afkStatus_; } + bool isDnd() const { return dndStatus_; } void replyToLastWhisper(const std::string& message); std::string getLastWhisperSender() const { return lastWhisperSender_; } void setLastWhisperSender(const std::string& name) { lastWhisperSender_ = name; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e59444fe..97351e7f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2074,6 +2074,15 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD"); } + if (gameHandler.isAfk()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Away from keyboard — /afk to cancel"); + } else if (gameHandler.isDnd()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); + } // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); From 88436fa400bbdc2bc864a23743e758876c10e099 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:24:27 -0700 Subject: [PATCH 058/111] Add jump-to-bottom indicator in chat when scrolled up Shows a "New messages" button below the chat history area when the user has scrolled away from the latest messages. Clicking it jumps back to the most recent messages. --- include/ui/game_screen.hpp | 2 ++ src/ui/game_screen.cpp | 26 +++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 73e318b9..a5ebaf62 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -91,6 +91,8 @@ private: bool showAddRankModal_ = false; bool refocusChatInput = false; bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session + bool chatScrolledUp_ = false; // true when user has scrolled above the latest messages + bool chatForceScrollToBottom_ = false; // set to true to jump to bottom next frame bool chatWindowLocked = true; ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); bool chatWindowPosInit_ = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 97351e7f..2052d3fd 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1462,9 +1462,18 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::PopID(); } - // Auto-scroll to bottom - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) { - ImGui::SetScrollHereY(1.0f); + // Auto-scroll to bottom; track whether user has scrolled up + { + float scrollY = ImGui::GetScrollY(); + float scrollMaxY = ImGui::GetScrollMaxY(); + bool atBottom = (scrollMaxY <= 0.0f) || (scrollY >= scrollMaxY - 2.0f); + if (atBottom || chatForceScrollToBottom_) { + ImGui::SetScrollHereY(1.0f); + chatScrolledUp_ = false; + chatForceScrollToBottom_ = false; + } else { + chatScrolledUp_ = true; + } } ImGui::EndChild(); @@ -1472,6 +1481,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Reset font scale after chat history ImGui::SetWindowFontScale(1.0f); + // "Jump to bottom" indicator when scrolled up + if (chatScrolledUp_) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.35f, 0.7f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); + if (ImGui::SmallButton(" v New messages ")) { + chatForceScrollToBottom_ = true; + } + ImGui::PopStyleColor(2); + ImGui::SameLine(); + } + ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); From 971ada639736fd110d41e8bfe552a49a299b5d84 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:32:43 -0700 Subject: [PATCH 059/111] Add Shift+right-click destroy for inventory items with confirmation popup Holding Shift while right-clicking any non-quest inventory item opens a destroy confirmation popup instead of performing the normal equip/use action. Item tooltips now show a 'Shift+RClick to destroy' hint at the bottom (highlighted in red when Shift is held). --- include/ui/inventory_screen.hpp | 9 ++++- src/ui/inventory_screen.cpp | 58 ++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 9d4f18ef..3453e966 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -171,11 +171,18 @@ private: void renderHeldItem(); bool bagHasAnyItems(const game::Inventory& inventory, int bagIndex) const; - // Drop confirmation + // Drop confirmation (drag-outside-window destroy) bool dropConfirmOpen_ = false; int dropBackpackIndex_ = -1; std::string dropItemName_; + // Destroy confirmation (Shift+right-click destroy) + bool destroyConfirmOpen_ = false; + uint8_t destroyBag_ = 0xFF; + uint8_t destroySlot_ = 0; + uint8_t destroyCount_ = 1; + std::string destroyItemName_; + // Pending chat item link from shift-click std::string pendingChatItemLink_; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index c6e04f38..899944c6 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -834,6 +834,33 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { ImGui::EndPopup(); } + // Shift+right-click destroy confirmation popup + if (destroyConfirmOpen_) { + ImVec2 mousePos = ImGui::GetIO().MousePos; + ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always); + ImGui::OpenPopup("##DestroyItem"); + destroyConfirmOpen_ = false; + } + if (ImGui::BeginPopup("##DestroyItem", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Destroy"); + ImGui::TextUnformatted(destroyItemName_.c_str()); + ImGui::Spacing(); + if (ImGui::Button("Yes, Destroy", ImVec2(110, 0))) { + if (gameHandler_) { + gameHandler_->destroyItem(destroyBag_, destroySlot_, destroyCount_); + } + destroyItemName_.clear(); + inventoryDirty = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(70, 0))) { + destroyItemName_.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + // Draw held item at cursor renderHeldItem(); } @@ -1783,9 +1810,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } + // Shift+right-click: open destroy confirmation for non-quest items + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && + !holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) { + destroyConfirmOpen_ = true; + destroyItemName_ = item.name; + destroyCount_ = static_cast(std::clamp( + std::max(1u, item.stackCount), 1u, 255u)); + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + destroyBag_ = static_cast(19 + bagIndex); + destroySlot_ = static_cast(bagSlotIndex); + } else if (kind == SlotKind::EQUIPMENT) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(equipSlot); + } + } + // Right-click: bank deposit (if bank open), vendor sell (if vendor mode), or auto-equip/use // Note: InvisibleButton only tracks left-click by default, so use IsItemHovered+IsMouseClicked - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) { + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && !ImGui::GetIO().KeyShift && gameHandler_) { LOG_WARNING("Right-click slot: kind=", (int)kind, " backpackIndex=", backpackIndex, " bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex, @@ -2192,6 +2238,16 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // Destroy hint (not shown for quest items) + if (item.itemId != 0 && item.bindType != 4) { + ImGui::Spacing(); + if (ImGui::GetIO().KeyShift) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.45f, 0.9f), "Shift+RClick to destroy"); + } else { + ImGui::TextDisabled("Shift+RClick to destroy"); + } + } + ImGui::EndTooltip(); } From 08bdd9eb36d44f75f23aa564bc984f3de813ba54 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:35:51 -0700 Subject: [PATCH 060/111] Add low durability warning indicator below minimap Shows 'Low durability' in orange when any equipped item falls below 20% durability, and a pulsing red 'Item breaking!' warning below 5%. Uses the same stacked indicator slot system as the latency and mail notices. --- src/ui/game_screen.cpp | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2052d3fd..18e6d1ce 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11071,6 +11071,39 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { nextIndicatorY += kIndicatorH; } + // Low durability warning — shown when any equipped item has < 20% durability + if (gameHandler.getState() == game::WorldState::IN_WORLD) { + const auto& inv = gameHandler.getInventory(); + float lowestDurPct = 1.0f; + for (int i = 0; i < game::Inventory::NUM_EQUIP_SLOTS; ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty()) continue; + const auto& it = slot.item; + if (it.maxDurability > 0) { + float pct = static_cast(it.curDurability) / static_cast(it.maxDurability); + if (pct < lowestDurPct) lowestDurPct = pct; + } + } + if (lowestDurPct < 0.20f) { + bool critical = (lowestDurPct < 0.05f); + float pulse = critical + ? (0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 4.0f)) + : 1.0f; + ImVec4 durWarnColor = critical + ? ImVec4(1.0f, 0.2f, 0.2f, pulse) + : ImVec4(1.0f, 0.65f, 0.1f, 0.9f); + const char* durWarnText = critical ? "Item breaking!" : "Low durability"; + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##DurabilityIndicator", nullptr, indicatorFlags)) { + ImGui::TextColored(durWarnColor, "%s", durWarnText); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + // Local time clock — always visible below minimap indicators { auto now = std::chrono::system_clock::now(); From 54750d465631bba5b00d0c96a91121d1b57bb5fc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:41:05 -0700 Subject: [PATCH 061/111] Add right-click context menu to target frame name Right-clicking the target's name now shows: Set Focus, Clear Target, and for player targets: Whisper, Invite to Group, Trade, Add Friend, Ignore. --- src/ui/game_screen.cpp | 49 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 18e6d1ce..7f57e067 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2499,13 +2499,58 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); } - // Entity name and type + // Entity name and type — Selectable so we can attach a right-click context menu std::string name = getEntityName(target); ImVec4 nameColor = hostileColor; ImGui::SameLine(0.0f, 0.0f); - ImGui::TextColored(nameColor, "%s", name.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + // Right-click context menu on the target name + if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { + const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); + const uint64_t tGuid = target->getGuid(); + + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(tGuid); + } + if (ImGui::MenuItem("Clear Target")) { + gameHandler.clearTarget(); + } + if (isPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(name); + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(tGuid); + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(name); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(name); + } + } + ImGui::EndPopup(); + } // Level (for units/players) — colored by difficulty if (target->getType() == game::ObjectType::UNIT || target->getType() == game::ObjectType::PLAYER) { From 9b8bc2e97752a03bb602d2c69ea5f17e57828930 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:42:28 -0700 Subject: [PATCH 062/111] Add right-click context menu to quest objective tracker Right-clicking a quest title in the HUD tracker shows options to open it in the Quest Log or toggle tracking (track/stop tracking). --- src/ui/game_screen.cpp | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7f57e067..2ce44594 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5410,13 +5410,33 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); ImGui::PushStyleColor(ImGuiCol_Text, titleCol); if (ImGui::Selectable(q.title.c_str(), false, - ImGuiSelectableFlags_None, ImVec2(TRACKER_W - 12.0f, 0))) { + ImGuiSelectableFlags_DontClosePopups, ImVec2(TRACKER_W - 12.0f, 0))) { questLogScreen.openAndSelectQuest(q.questId); } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Click to open Quest Log"); + if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) { + ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options"); } ImGui::PopStyleColor(); + + // Right-click context menu for quest tracker entry + if (ImGui::BeginPopupContextItem("##QTCtx")) { + ImGui::TextDisabled("%s", q.title.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open in Quest Log")) { + questLogScreen.openAndSelectQuest(q.questId); + } + bool tracked = gameHandler.isQuestTracked(q.questId); + if (tracked) { + if (ImGui::MenuItem("Stop Tracking")) { + gameHandler.setQuestTracked(q.questId, false); + } + } else { + if (ImGui::MenuItem("Track")) { + gameHandler.setQuestTracked(q.questId, true); + } + } + ImGui::EndPopup(); + } ImGui::PopID(); // Objectives line (condensed) From 8eb451aab56f9a6cab867cfa427d492c4530a18d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:46:47 -0700 Subject: [PATCH 063/111] Add right-click context menu to spellbook spell rows Allows casting or copying a spell link from the context menu, mirroring WoW's standard spellbook right-click behavior. --- src/ui/spellbook_screen.cpp | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index bed0dbf0..c2514e22 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -666,9 +666,36 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana // Row selectable ImGui::Selectable("##row", false, - ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight)); + ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_DontClosePopups, + ImVec2(0, rowHeight)); bool rowHovered = ImGui::IsItemHovered(); bool rowClicked = ImGui::IsItemClicked(0); + + // Right-click context menu + if (ImGui::BeginPopupContextItem("##SpellCtx")) { + ImGui::TextDisabled("%s", info->name.c_str()); + if (!info->rank.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", info->rank.c_str()); + } + ImGui::Separator(); + if (!isPassive) { + if (onCooldown) ImGui::BeginDisabled(); + if (ImGui::MenuItem("Cast")) { + uint64_t tgt = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(info->spellId, tgt); + } + if (onCooldown) ImGui::EndDisabled(); + } + if (ImGui::MenuItem("Copy Spell Link")) { + char linkBuf[256]; + snprintf(linkBuf, sizeof(linkBuf), + "|cffffd000|Hspell:%u|h[%s]|h|r", + info->spellId, info->name.c_str()); + pendingChatSpellLink_ = linkBuf; + } + ImGui::EndPopup(); + } ImVec2 rMin = ImGui::GetItemRectMin(); ImVec2 rMax = ImGui::GetItemRectMax(); auto* dl = ImGui::GetWindowDrawList(); From 72e07fbe3f4748aff84fe28c4e6a03ff7baef14d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:48:07 -0700 Subject: [PATCH 064/111] Add Whisper and Invite to Group to guild member context menu Social actions appear at the top of the right-click menu when the member is online, matching WoW's guild roster behavior. --- src/ui/game_screen.cpp | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2ce44594..157aaa58 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7076,8 +7076,26 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Context menu popup if (ImGui::BeginPopup("GuildMemberContext")) { - ImGui::Text("%s", selectedGuildMember_.c_str()); + ImGui::TextDisabled("%s", selectedGuildMember_.c_str()); ImGui::Separator(); + // Social actions — only for online members + bool memberOnline = false; + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { memberOnline = mem.online; break; } + } + if (memberOnline) { + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, selectedGuildMember_.c_str(), + sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(selectedGuildMember_); + } + ImGui::Separator(); + } if (ImGui::MenuItem("Promote")) { gameHandler.promoteGuildMember(selectedGuildMember_); } From 2cd4672912d4ef39c2257642cf84afce01888c02 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:49:37 -0700 Subject: [PATCH 065/111] Add Invite to Group to chat message right-click menu Allows inviting players directly from chat messages, consistent with the target frame and party frame context menus. --- src/ui/game_screen.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 157aaa58..032667c0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1450,6 +1450,9 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; refocusChatInput = true; } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(resolvedSenderName); + } if (ImGui::MenuItem("Add Friend")) { gameHandler.addFriend(resolvedSenderName); } From d3221ff253f01af54a204d0a8f4f5910c3f76368 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:50:41 -0700 Subject: [PATCH 066/111] Add right-click context menu to target-of-target frame Right-clicking the ToT name shows Target and Set Focus options; clicking the name still targets the unit. --- src/ui/game_screen.cpp | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 032667c0..fdb4e923 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2777,7 +2777,27 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { std::string totName = getEntityName(totEntity); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "%s", totName.c_str()); + // Selectable so we can attach a right-click context menu + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.8f, 0.8f, 0.8f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + if (ImGui::Selectable(totName.c_str(), false, + ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(totName.c_str()).x, 0))) { + gameHandler.setTarget(totGuid); + } + ImGui::PopStyleColor(4); + + if (ImGui::BeginPopupContextItem("##ToTCtx")) { + ImGui::TextDisabled("%s", totName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(totGuid); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(totGuid); + ImGui::EndPopup(); + } if (totEntity->getType() == game::ObjectType::UNIT || totEntity->getType() == game::ObjectType::PLAYER) { @@ -2798,10 +2818,6 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } } - // Click to target the target-of-target - if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { - gameHandler.setTarget(totGuid); - } } ImGui::End(); ImGui::PopStyleColor(2); From 109b0a984a4afbbcc082df4b7fb418fad6dacd3b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:51:27 -0700 Subject: [PATCH 067/111] Add right-click context menu to focus frame Shows Target, Clear Focus, and player-only actions (Whisper, Invite, Trade) when right-clicking the focus name. --- src/ui/game_screen.cpp | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fdb4e923..1e92f155 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2882,7 +2882,38 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); std::string focusName = getEntityName(focus); - ImGui::TextColored(focusColor, "%s", focusName.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, focusColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(focusName.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { + const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); + const uint64_t fGuid = focus->getGuid(); + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(fGuid); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focusIsPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(fGuid); + } + ImGui::EndPopup(); + } if (focus->getType() == game::ObjectType::UNIT || focus->getType() == game::ObjectType::PLAYER) { From 716c0c0e4cb332c0308875f6ac2ec920d078c763 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:54:19 -0700 Subject: [PATCH 068/111] Add right-click context menu to quest log list entries --- src/ui/quest_log_screen.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index e52a2085..81f8657d 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -352,6 +352,27 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv questDetailQueryNoResponse_.erase(q.questId); } } + + // Right-click context menu on quest row + if (ImGui::BeginPopupContextItem("QuestRowCtx")) { + selectedIndex = static_cast(i); // select on right-click too + ImGui::TextDisabled("%s", displayTitle.c_str()); + ImGui::Separator(); + bool tracked = gameHandler.isQuestTracked(q.questId); + if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) { + gameHandler.setQuestTracked(q.questId, !tracked); + } + if (!q.complete) { + ImGui::Separator(); + if (ImGui::MenuItem("Abandon Quest")) { + gameHandler.abandonQuest(q.questId); + gameHandler.setQuestTracked(q.questId, false); + selectedIndex = -1; + } + } + ImGui::EndPopup(); + } + ImGui::PopID(); } if (visibleQuestCount == 0) { From 7943edf25258b308843240d68df9d8958e1a01b2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:56:57 -0700 Subject: [PATCH 069/111] Add Duel and Inspect to target, focus, and party member context menus --- src/ui/game_screen.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1e92f155..fdf9f59b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2544,6 +2544,12 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Trade")) { gameHandler.initiateTrade(tGuid); } + if (ImGui::MenuItem("Duel")) { + gameHandler.proposeDuel(tGuid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) { gameHandler.addFriend(name); @@ -2911,6 +2917,12 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { gameHandler.inviteToGroup(focusName); if (ImGui::MenuItem("Trade")) gameHandler.initiateTrade(fGuid); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(fGuid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(fGuid); + gameHandler.inspectTarget(); + } } ImGui::EndPopup(); } @@ -6231,6 +6243,9 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Trade")) { gameHandler.initiateTrade(member.guid); } + if (ImGui::MenuItem("Duel")) { + gameHandler.proposeDuel(member.guid); + } if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(member.guid); gameHandler.inspectTarget(); From 97059040527a09309b15b8786fda5d440a1124eb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:58:37 -0700 Subject: [PATCH 070/111] Add Follow option to target frame and party member context menus --- src/ui/game_screen.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fdf9f59b..c9752fb8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2538,6 +2538,9 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; refocusChatInput = true; } + if (ImGui::MenuItem("Follow")) { + gameHandler.followTarget(); + } if (ImGui::MenuItem("Invite to Group")) { gameHandler.inviteToGroup(name); } @@ -6240,6 +6243,10 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; refocusChatInput = true; } + if (ImGui::MenuItem("Follow")) { + gameHandler.setTarget(member.guid); + gameHandler.followTarget(); + } if (ImGui::MenuItem("Trade")) { gameHandler.initiateTrade(member.guid); } From 00a66b71147ddb5775dceb01bb4be2ce2b0afd8e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 23:59:51 -0700 Subject: [PATCH 071/111] Add right-click context menu to action bar slots with Cast/Use and Clear Slot --- src/ui/game_screen.cpp | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c9752fb8..27b8d9af 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4694,7 +4694,6 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - bool rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseReleased(ImGuiMouseButton_Left); @@ -4721,9 +4720,40 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); } - } else if (rightClicked && !slot.isEmpty()) { - actionBarDragSlot_ = absSlot; - actionBarDragIcon_ = iconTex; + } + + // Right-click context menu for non-empty slots + if (!slot.isEmpty()) { + // Use a unique popup ID per slot so multiple slots don't share state + char ctxId[32]; + snprintf(ctxId, sizeof(ctxId), "##ABCtx%d", absSlot); + if (ImGui::BeginPopupContextItem(ctxId)) { + if (slot.type == game::ActionBarSlot::SPELL) { + std::string spellName = getSpellName(slot.id); + ImGui::TextDisabled("%s", spellName.c_str()); + ImGui::Separator(); + if (onCooldown) ImGui::BeginDisabled(); + if (ImGui::MenuItem("Cast")) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(slot.id, target); + } + if (onCooldown) ImGui::EndDisabled(); + } else if (slot.type == game::ActionBarSlot::ITEM) { + const char* iName = (barItemDef && !barItemDef->name.empty()) + ? barItemDef->name.c_str() + : (!itemNameFromQuery.empty() ? itemNameFromQuery.c_str() : "Item"); + ImGui::TextDisabled("%s", iName); + ImGui::Separator(); + if (ImGui::MenuItem("Use")) { + gameHandler.useItemById(slot.id); + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Slot")) { + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::EMPTY, 0); + } + ImGui::EndPopup(); + } } // Tooltip From c170216e1c873c03fd304dde8d3201ada5f20348 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:02:20 -0700 Subject: [PATCH 072/111] Add Invite to Group to friends list right-click menu for online friends --- src/ui/game_screen.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 27b8d9af..d954200b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7461,6 +7461,9 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; refocusChatInput = true; } + if (c.isOnline() && ImGui::MenuItem("Invite to Group") && !c.name.empty()) { + gameHandler.inviteToGroup(c.name); + } if (ImGui::MenuItem("Edit Note")) { friendNoteTarget = c.name; strncpy(friendNoteBuf, c.note.c_str(), sizeof(friendNoteBuf) - 1); From 347e95870302f31e51b9e9caff839e1fc3c987d5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:03:23 -0700 Subject: [PATCH 073/111] Improve player frame context menu: name header, Leave Group when in group --- src/ui/game_screen.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d954200b..1a273235 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2082,12 +2082,20 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { gameHandler.setTarget(gameHandler.getPlayerGuid()); } if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) { - if (ImGui::Selectable("Open Character")) { + ImGui::TextDisabled("%s", playerName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open Character")) { inventoryScreen.setCharacterOpen(true); } - if (ImGui::Selectable("Toggle PvP")) { + if (ImGui::MenuItem("Toggle PvP")) { gameHandler.togglePvp(); } + if (gameHandler.isInGroup()) { + ImGui::Separator(); + if (ImGui::MenuItem("Leave Group")) { + gameHandler.leaveGroup(); + } + } ImGui::EndPopup(); } ImGui::PopStyleColor(); From 1cab2e1156e11df0debe952202c5719a5caadc1a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:04:11 -0700 Subject: [PATCH 074/111] Add Abandon Quest option to quest tracker right-click menu --- src/ui/game_screen.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1a273235..3be86cbb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5538,6 +5538,13 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { gameHandler.setQuestTracked(q.questId, true); } } + if (!q.complete) { + ImGui::Separator(); + if (ImGui::MenuItem("Abandon Quest")) { + gameHandler.abandonQuest(q.questId); + gameHandler.setQuestTracked(q.questId, false); + } + } ImGui::EndPopup(); } ImGui::PopID(); From 5fdcb5df81ca248e41b56e28bd8cd292cfd0cd83 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:05:55 -0700 Subject: [PATCH 075/111] Wire up TOGGLE_QUEST_LOG keybinding (Q key) to open quest log screen --- src/ui/game_screen.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3be86cbb..aa044474 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1728,6 +1728,10 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showRaidFrames_ = !showRaidFrames_; } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) { + questLogScreen.toggle(); + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, From d072c852f37ca40aad9e8f0455a2e92cb38f7f3d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:10:54 -0700 Subject: [PATCH 076/111] Add AFK/DND toggles to player frame menu and right-click context to pet frame --- src/ui/game_screen.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index aa044474..1cfab9a7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2094,6 +2094,15 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Toggle PvP")) { gameHandler.togglePvp(); } + ImGui::Separator(); + bool afk = gameHandler.isAfk(); + bool dnd = gameHandler.isDnd(); + if (ImGui::MenuItem(afk ? "Cancel AFK" : "Set AFK")) { + gameHandler.toggleAfk(); + } + if (ImGui::MenuItem(dnd ? "Cancel DND" : "Set DND")) { + gameHandler.toggleDnd(); + } if (gameHandler.isInGroup()) { ImGui::Separator(); if (ImGui::MenuItem("Leave Group")) { @@ -2295,6 +2304,18 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) { gameHandler.setTarget(petGuid); } + // Right-click context menu on pet name + if (ImGui::BeginPopupContextItem("PetNameCtx")) { + ImGui::TextDisabled("%s", petLabel); + ImGui::Separator(); + if (ImGui::MenuItem("Target Pet")) { + gameHandler.setTarget(petGuid); + } + if (ImGui::MenuItem("Dismiss Pet")) { + gameHandler.dismissPet(); + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); if (petLevel > 0) { ImGui::SameLine(); From 928f00de4137eb2140a2df57d75abee7a3012be6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:13:48 -0700 Subject: [PATCH 077/111] Add Show Helm/Cloak checkboxes to Equipment tab; expose isHelmVisible/isCloakVisible --- include/game/game_handler.hpp | 2 ++ src/ui/inventory_screen.cpp | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 4821c4b0..ae2b5622 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -380,6 +380,8 @@ public: // Display toggles void toggleHelm(); void toggleCloak(); + bool isHelmVisible() const { return helmVisible_; } + bool isCloakVisible() const { return cloakVisible_; } // Follow/Assist void followTarget(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 899944c6..bcc3d0e0 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1121,6 +1121,18 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (ImGui::BeginTabBar("##CharacterTabs")) { if (ImGui::BeginTabItem("Equipment")) { renderEquipmentPanel(inventory); + ImGui::Spacing(); + ImGui::Separator(); + // Appearance visibility toggles + bool helmVis = gameHandler.isHelmVisible(); + bool cloakVis = gameHandler.isCloakVisible(); + if (ImGui::Checkbox("Show Helm", &helmVis)) { + gameHandler.toggleHelm(); + } + ImGui::SameLine(); + if (ImGui::Checkbox("Show Cloak", &cloakVis)) { + gameHandler.toggleCloak(); + } ImGui::EndTabItem(); } From e13993de9b51b6a899fbe438c722e33548a6f6dc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:16:35 -0700 Subject: [PATCH 078/111] Add 'Add to Action Bar' option to spellbook right-click context menu --- src/ui/spellbook_screen.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index c2514e22..e2c81756 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -687,6 +687,19 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana } if (onCooldown) ImGui::EndDisabled(); } + if (!isPassive) { + if (ImGui::MenuItem("Add to Action Bar")) { + const auto& bar = gameHandler.getActionBar(); + int firstEmpty = -1; + for (int si = 0; si < game::GameHandler::SLOTS_PER_BAR; ++si) { + if (bar[si].isEmpty()) { firstEmpty = si; break; } + } + if (firstEmpty >= 0) { + gameHandler.setActionBarSlot(firstEmpty, + game::ActionBarSlot::SPELL, info->spellId); + } + } + } if (ImGui::MenuItem("Copy Spell Link")) { char linkBuf[256]; snprintf(linkBuf, sizeof(linkBuf), From 778363bfaf13927f67abec7729a34b8032194a58 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:19:10 -0700 Subject: [PATCH 079/111] Add Add Friend/Ignore to party member context menu --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1cfab9a7..c35c38b9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6327,6 +6327,15 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { gameHandler.setTarget(member.guid); gameHandler.inspectTarget(); } + ImGui::Separator(); + if (!member.name.empty()) { + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(member.name); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(member.name); + } + } // Leader-only actions bool isLeader = (gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()); if (isLeader) { From d0f29168858699655586ef8eaab86b0bf64baefb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:21:25 -0700 Subject: [PATCH 080/111] Add right-click context menus to bag bar slots and backpack --- src/ui/game_screen.cpp | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c35c38b9..62f03d11 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5129,6 +5129,27 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f); } + // Right-click context menu + if (ImGui::BeginPopupContextItem("##bagSlotCtx")) { + if (!bagItem.empty()) { + ImGui::TextDisabled("%s", bagItem.item.name.c_str()); + ImGui::Separator(); + bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i); + if (ImGui::MenuItem(isOpen ? "Close Bag" : "Open Bag")) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBag(i); + else + inventoryScreen.toggle(); + } + if (ImGui::MenuItem("Unequip Bag")) { + gameHandler.unequipToBackpack(bagSlot); + } + } else { + ImGui::TextDisabled("Empty Bag Slot"); + } + ImGui::EndPopup(); + } + // Accept dragged item from inventory if (hovered && inventoryScreen.isHoldingItem()) { const auto& heldItem = inventoryScreen.getHeldItem(); @@ -5219,6 +5240,24 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Backpack"); } + // Right-click context menu on backpack + if (ImGui::BeginPopupContextItem("##backpackCtx")) { + bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBackpackOpen(); + if (ImGui::MenuItem(isOpen ? "Close Backpack" : "Open Backpack")) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBackpack(); + else + inventoryScreen.toggle(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Open All Bags")) { + inventoryScreen.openAllBags(); + } + if (ImGui::MenuItem("Close All Bags")) { + inventoryScreen.closeAllBags(); + } + ImGui::EndPopup(); + } if (inventoryScreen.isSeparateBags() && inventoryScreen.isBackpackOpen()) { ImDrawList* dl = ImGui::GetWindowDrawList(); From c0f19f588364f54380fdb7c900ebc9179cf96166 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:26:47 -0700 Subject: [PATCH 081/111] Add missing context menu items and nameplate right-click menus - Focus frame: add Add Friend / Ignore items for player targets - Guild roster: add Add Friend / Ignore items to member context menu - Nameplates: right-click shows Target/Set Focus/Whisper/Invite/Friend/Ignore popup --- include/ui/game_screen.hpp | 2 ++ src/ui/game_screen.cpp | 68 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index a5ebaf62..660a25ec 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -70,6 +70,8 @@ private: bool showMinimap_ = true; // M key toggles minimap bool showNameplates_ = true; // V key toggles nameplates float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions + uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none) + ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 62f03d11..08475d1e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2959,6 +2959,11 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { gameHandler.setTarget(fGuid); gameHandler.inspectTarget(); } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); } ImGui::EndPopup(); } @@ -6010,18 +6015,68 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } } - // Click to target: detect left-click inside the combined nameplate region - if (!ImGui::GetIO().WantCaptureMouse && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + // Click to target / right-click context: detect clicks inside the nameplate region + if (!ImGui::GetIO().WantCaptureMouse) { ImVec2 mouse = ImGui::GetIO().MousePos; float nx0 = nameX - 2.0f; float ny0 = nameY - 1.0f; float nx1 = nameX + textSize.x + 2.0f; float ny1 = sy + barH + 2.0f; if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { - gameHandler.setTarget(guid); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + gameHandler.setTarget(guid); + } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + nameplateCtxGuid_ = guid; + nameplateCtxPos_ = mouse; + ImGui::OpenPopup("##NameplateCtx"); + } } } } + + // Render nameplate context popup (uses a tiny overlay window as host) + if (nameplateCtxGuid_ != 0) { + ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always); + ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_AlwaysAutoResize; + if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) { + if (ImGui::BeginPopup("##NameplateCtx")) { + auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_); + std::string ctxName = entityPtr ? getEntityName(entityPtr) : ""; + if (!ctxName.empty()) { + ImGui::TextDisabled("%s", ctxName.c_str()); + ImGui::Separator(); + } + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(nameplateCtxGuid_); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(nameplateCtxGuid_); + bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER; + if (isPlayer && !ctxName.empty()) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, ctxName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(ctxName); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(ctxName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(ctxName); + } + ImGui::EndPopup(); + } else { + nameplateCtxGuid_ = 0; + } + } + ImGui::End(); + } } // ============================================================ @@ -7286,6 +7341,13 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { } ImGui::Separator(); } + if (!selectedGuildMember_.empty()) { + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(selectedGuildMember_); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(selectedGuildMember_); + ImGui::Separator(); + } if (ImGui::MenuItem("Promote")) { gameHandler.promoteGuildMember(selectedGuildMember_); } From c13e18cb559ac90401ea5da838322b029916b6a1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:39:56 -0700 Subject: [PATCH 082/111] Add Set Raid Mark submenu to target, party, and raid frame context menus Implements setRaidMark() using the existing RaidTargetUpdatePacket and exposes it via right-click on target frame, party member frames, and raid cell frames. --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 23 ++++++++++++++++++ src/ui/game_screen.cpp | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ae2b5622..d6df5254 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1031,6 +1031,8 @@ public: if (raidTargetGuids_[i] == guid) return static_cast(i); return 0xFF; } + // Set or clear a raid mark on a guid (icon 0-7, or 0xFF to clear) + void setRaidMark(uint64_t guid, uint8_t icon); // ---- LFG / Dungeon Finder ---- enum class LfgState : uint8_t { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index dc33651f..b2b51503 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10546,6 +10546,29 @@ void GameHandler::clearMainAssist() { LOG_INFO("Cleared main assist"); } +void GameHandler::setRaidMark(uint64_t guid, uint8_t icon) { + if (state != WorldState::IN_WORLD || !socket) return; + + static const char* kMarkNames[] = { + "Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull" + }; + + if (icon == 0xFF) { + // Clear mark: find which slot this guid holds and send 0 GUID + for (int i = 0; i < 8; ++i) { + if (raidTargetGuids_[i] == guid) { + auto packet = RaidTargetUpdatePacket::build(static_cast(i), 0); + socket->send(packet); + break; + } + } + } else if (icon < 8) { + auto packet = RaidTargetUpdatePacket::build(icon, guid); + socket->send(packet); + LOG_INFO("Set raid mark %s on guid %llu", kMarkNames[icon], (unsigned long long)guid); + } +} + void GameHandler::requestRaidInfo() { if (state != WorldState::IN_WORLD || !socket) { LOG_WARNING("Cannot request raid info: not in world or not connected"); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 08475d1e..959fb6a1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2594,6 +2594,21 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { gameHandler.addIgnore(name); } } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(tGuid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(tGuid, 0xFF); + ImGui::EndMenu(); + } ImGui::EndPopup(); } @@ -6258,6 +6273,21 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Kick from Raid")) gameHandler.uninvitePlayer(m.name); } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(m.guid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(m.guid, 0xFF); + ImGui::EndMenu(); + } ImGui::EndPopup(); } ImGui::PopID(); @@ -6438,6 +6468,21 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { gameHandler.uninvitePlayer(member.name); } } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(member.guid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(member.guid, 0xFF); + ImGui::EndMenu(); + } ImGui::EndPopup(); } From c89dc50b6cb2671be64b76b8778ca1128a4893a8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:43:29 -0700 Subject: [PATCH 083/111] Distinguish channeled spells in cast bar with blue color and draining animation Adds castIsChannel flag set on MSG_CHANNEL_START, cleared on all cast resets. Cast bar now drains right-to-left in blue for channels vs gold fill for casts. --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 14 ++++++++++++++ src/ui/game_screen.cpp | 17 +++++++++++++---- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d6df5254..2001e4eb 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -553,6 +553,7 @@ public: } bool isCasting() const { return casting; } + bool isChanneling() const { return casting && castIsChannel; } bool isGameObjectInteractionCasting() const { return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0; } @@ -2046,6 +2047,7 @@ private: std::vector minimapPings_; uint8_t castCount = 0; bool casting = false; + bool castIsChannel = false; uint32_t currentCastSpellId = 0; float castTimeRemaining = 0.0f; // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b2b51503..5bda0189 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -904,6 +904,7 @@ void GameHandler::update(float deltaTime) { (autoAttacking || autoAttackRequested_)) { pendingGameObjectInteractGuid_ = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; addSystemChatMessage("Interrupted."); @@ -917,6 +918,7 @@ void GameHandler::update(float deltaTime) { performGameObjectInteractionNow(interactGuid); } casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; } @@ -1947,6 +1949,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) { if (castResult != 0) { casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; // Pass player's power type so result 85 says "Not enough rage/energy/etc." @@ -2837,6 +2840,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed casting = false; + castIsChannel = false; currentCastSpellId = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { @@ -5528,6 +5532,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (totalMs > 0) { if (caster == playerGuid) { casting = true; + castIsChannel = false; currentCastSpellId = spellId; castTimeTotal = totalMs / 1000.0f; castTimeRemaining = remainMs / 1000.0f; @@ -5556,6 +5561,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (chanTotalMs > 0 && chanCaster != 0) { if (chanCaster == playerGuid) { casting = true; + castIsChannel = true; currentCastSpellId = chanSpellId; castTimeTotal = chanTotalMs / 1000.0f; castTimeRemaining = castTimeTotal; @@ -5583,6 +5589,7 @@ void GameHandler::handlePacket(network::Packet& packet) { castTimeRemaining = chanRemainMs / 1000.0f; if (chanRemainMs == 0) { casting = false; + castIsChannel = false; currentCastSpellId = 0; } } else if (chanCaster2 != 0) { @@ -6585,6 +6592,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { autoAttacking = false; autoAttackTarget = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; castTimeRemaining = 0.0f; @@ -10633,6 +10641,7 @@ void GameHandler::stopCasting() { // Reset casting state casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; castTimeRemaining = 0.0f; @@ -13937,6 +13946,7 @@ void GameHandler::cancelCast() { } pendingGameObjectInteractGuid_ = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; } @@ -14075,6 +14085,7 @@ void GameHandler::handleCastFailed(network::Packet& packet) { if (!ok) return; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; @@ -14133,6 +14144,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) { // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { casting = true; + castIsChannel = false; currentCastSpellId = data.spellId; castTimeTotal = data.castTime / 1000.0f; castTimeRemaining = castTimeTotal; @@ -14203,6 +14215,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; @@ -17206,6 +17219,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { areaTriggerSuppressFirst_ = true; // first check just marks active triggers, doesn't fire stopAutoAttack(); casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; castTimeRemaining = 0.0f; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 959fb6a1..c8759cbf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5462,19 +5462,28 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f)); if (ImGui::Begin("##CastBar", nullptr, flags)) { - float progress = gameHandler.getCastProgress(); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.6f, 0.2f, 1.0f)); + const bool channeling = gameHandler.isChanneling(); + // Channels drain right-to-left; regular casts fill left-to-right + float progress = channeling + ? (1.0f - gameHandler.getCastProgress()) + : gameHandler.getCastProgress(); + + ImVec4 barColor = channeling + ? ImVec4(0.3f, 0.6f, 0.9f, 1.0f) // blue for channels + : ImVec4(0.8f, 0.6f, 0.2f, 1.0f); // gold for casts + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); char overlay[64]; uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); - if (gameHandler.getCurrentCastSpellId() == 0) { + if (currentSpellId == 0) { snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining()); } else { const std::string& spellName = gameHandler.getSpellName(currentSpellId); + const char* verb = channeling ? "Channeling" : "Casting"; if (!spellName.empty()) snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); else - snprintf(overlay, sizeof(overlay), "Casting... (%.1fs)", gameHandler.getCastTimeRemaining()); + snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); } ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); ImGui::PopStyleColor(); From d8f2fedae17f55ea1a977d2de6f84ca495634e34 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:53:57 -0700 Subject: [PATCH 084/111] Implement renderSocialFrame: compact friends panel with minimap toggle button Shows online/AFK/DND/offline status dots, whisper/invite/remove context menus, and inline add-friend field. Minimap gets a smiley-face button (top-left) with a green dot badge when friends are online, toggling the panel. --- src/ui/game_screen.cpp | 162 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c8759cbf..408a1457 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -451,6 +451,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBgInvitePopup(gameHandler); renderLfgProposalPopup(gameHandler); renderGuildRoster(gameHandler); + renderSocialFrame(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); renderGossipWindow(gameHandler); @@ -7781,6 +7782,117 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { showGuildRoster_ = open; } +// ============================================================ +// Social Frame — compact online friends panel (toggled by showSocialFrame_) +// ============================================================ + +void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { + if (!showSocialFrame_) return; + + const auto& contacts = gameHandler.getContacts(); + // Count online friends for early-out + int onlineCount = 0; + for (const auto& c : contacts) + if (c.isFriend() && c.isOnline()) ++onlineCount; + + 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 - 230.0f, 240.0f), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(220.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f)); + + bool open = showSocialFrame_; + if (ImGui::Begin("Friends##SocialFrame", &open, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { + + // Online friends + char onlineHeader[32]; + snprintf(onlineHeader, sizeof(onlineHeader), "Online (%d)", onlineCount); + ImGui::TextDisabled("%s", onlineHeader); + ImGui::Separator(); + + int shown = 0; + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isFriend()) continue; + + ImGui::PushID(static_cast(ci)); + + // Status dot: green=online, yellow=AFK, orange=DND, grey=offline + ImU32 dotColor; + if (!c.isOnline()) dotColor = IM_COL32(100, 100, 100, 200); + else if (c.status == 2) dotColor = IM_COL32(255, 200, 50, 255); // AFK + else if (c.status == 3) dotColor = IM_COL32(255, 120, 50, 255); // DND + else dotColor = IM_COL32( 50, 220, 50, 255); // online + + ImVec2 dotMin = ImGui::GetCursorScreenPos(); + dotMin.y += 4.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotMin.x + 5.0f, dotMin.y + 5.0f), 4.5f, dotColor); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? ImVec4(0.9f, 0.9f, 0.9f, 1.0f) + : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(nameCol, "%s", displayName); + + if (c.isOnline() && c.level > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("%u", c.level); + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (c.isOnline()) { + if (ImGui::MenuItem("Whisper")) { + showSocialFrame_ = false; + strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + selectedChatType = 4; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(c.name); + } + if (ImGui::MenuItem("Remove Friend")) + gameHandler.removeFriend(c.name); + ImGui::EndPopup(); + } + + ++shown; + ImGui::PopID(); + } + + if (shown == 0) { + ImGui::TextDisabled("No friends."); + } + + ImGui::Separator(); + // Add friend inline + static char addFriendBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_addfriend", addFriendBuf, sizeof(addFriendBuf)); + ImGui::SameLine(); + if (ImGui::Button("+") && addFriendBuf[0] != '\0') { + gameHandler.addFriend(addFriendBuf); + addFriendBuf[0] = '\0'; + } + } + ImGui::End(); + showSocialFrame_ = open; + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + // ============================================================ // Buff/Debuff Bar (Phase 3) // ============================================================ @@ -11373,6 +11485,56 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); + // Friends button at top-left of minimap + { + const auto& contacts = gameHandler.getContacts(); + int onlineCount = 0; + for (const auto& c : contacts) + if (c.isFriend() && c.isOnline()) ++onlineCount; + + ImGui::SetNextWindowPos(ImVec2(centerX - mapRadius + 4.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always); + ImGuiWindowFlags friendsBtnFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##MinimapFriendsBtn", nullptr, friendsBtnFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 p = ImGui::GetCursorScreenPos(); + ImVec2 sz(20.0f, 20.0f); + if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) { + showSocialFrame_ = !showSocialFrame_; + } + bool hovered = ImGui::IsItemHovered(); + ImU32 bg = showSocialFrame_ + ? IM_COL32(42, 100, 42, 230) + : IM_COL32(38, 38, 38, 210); + if (hovered) bg = showSocialFrame_ ? IM_COL32(58, 130, 58, 230) : IM_COL32(65, 65, 65, 220); + draw->AddRectFilled(p, ImVec2(p.x + sz.x, p.y + sz.y), bg, 4.0f); + draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), + ImVec2(p.x + sz.x - 0.5f, p.y + sz.y - 0.5f), + IM_COL32(255, 255, 255, 42), 4.0f); + // Simple smiley-face dots as "social" icon + ImU32 fg = IM_COL32(255, 255, 255, 245); + draw->AddCircle(ImVec2(p.x + 10.0f, p.y + 10.0f), 6.5f, fg, 16, 1.2f); + draw->AddCircleFilled(ImVec2(p.x + 7.5f, p.y + 8.0f), 1.2f, fg); + draw->AddCircleFilled(ImVec2(p.x + 12.5f, p.y + 8.0f), 1.2f, fg); + draw->PathArcTo(ImVec2(p.x + 10.0f, p.y + 11.5f), 3.0f, 0.2f, 2.9f, 8); + draw->PathStroke(fg, 0, 1.2f); + // Small green dot if friends online + if (onlineCount > 0) { + draw->AddCircleFilled(ImVec2(p.x + sz.x - 3.5f, p.y + 3.5f), + 3.5f, IM_COL32(50, 220, 50, 255)); + } + if (hovered) { + if (onlineCount > 0) + ImGui::SetTooltip("Friends (%d online)", onlineCount); + else + ImGui::SetTooltip("Friends"); + } + } + ImGui::End(); + } + // Zoom buttons at the bottom edge of the minimap ImGui::SetNextWindowPos(ImVec2(centerX - 22, centerY + mapRadius - 30), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(44, 24), ImGuiCond_Always); From 9e5f7c481e26046ca63d40c71fb6803b10b401e6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:56:31 -0700 Subject: [PATCH 085/111] Wire achievement toast and ding effect callbacks Level-up now calls triggerDing() (sound + emote + fade text) in addition to the screen flash. Achievement earned now calls triggerAchievementToast() via setAchievementEarnedCallback(), making the existing toast animation visible. --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 660a25ec..a27f1808 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -371,6 +371,7 @@ private: std::vector chatBubbles_; bool chatBubbleCallbackSet_ = false; bool levelUpCallbackSet_ = false; + bool achievementCallbackSet_ = false; // Mail compose state char mailRecipientBuffer_[256] = ""; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 408a1457..5acec3f6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -214,10 +214,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { gameHandler.setLevelUpCallback([this](uint32_t newLevel) { levelUpFlashAlpha_ = 1.0f; levelUpDisplayLevel_ = newLevel; + triggerDing(newLevel); }); levelUpCallbackSet_ = true; } + // Set up achievement toast callback (once) + if (!achievementCallbackSet_) { + gameHandler.setAchievementEarnedCallback([this](uint32_t id, const std::string& name) { + triggerAchievementToast(id, name); + }); + achievementCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; From eb9ca8e227dee1de2a31348e2a9e51ccbbe97d62 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 00:59:25 -0700 Subject: [PATCH 086/111] Fix item cooldowns not showing on action bar item-type slots SMSG_ITEM_COOLDOWN now resolves itemId via onlineItems_ and applies cooldown to both SPELL-type and ITEM-type action bar slots. Classic SMSG_SPELL_COOLDOWN also uses the embedded itemId field to update ITEM-type slots. --- src/game/game_handler.cpp | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5bda0189..b08bb247 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2909,19 +2909,26 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint64 itemGuid + uint32 spellId + uint32 cooldownMs size_t rem = packet.getSize() - packet.getReadPos(); if (rem >= 16) { - /*uint64_t itemGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); - uint32_t cdMs = packet.readUInt32(); + uint64_t itemGuid = packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + uint32_t cdMs = packet.readUInt32(); float cdSec = cdMs / 1000.0f; - if (spellId != 0 && cdSec > 0.0f) { - spellCooldowns[spellId] = cdSec; + if (cdSec > 0.0f) { + if (spellId != 0) spellCooldowns[spellId] = cdSec; + // Resolve itemId from the GUID so item-type slots are also updated + uint32_t itemId = 0; + auto iit = onlineItems_.find(itemGuid); + if (iit != onlineItems_.end()) itemId = iit->second.entry; for (auto& slot : actionBar) { - if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId) + || (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId); + if (match) { slot.cooldownTotal = cdSec; slot.cooldownRemaining = cdSec; } } - LOG_DEBUG("SMSG_ITEM_COOLDOWN: spellId=", spellId, " cd=", cdSec, "s"); + LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec, + " spellId=", spellId, " itemId=", itemId, " cd=", cdSec, "s"); } } break; @@ -14286,14 +14293,17 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) { const size_t entrySize = isClassicFormat ? 12u : 8u; while (packet.getSize() - packet.getReadPos() >= entrySize) { uint32_t spellId = packet.readUInt32(); - if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used + uint32_t cdItemId = 0; + if (isClassicFormat) cdItemId = packet.readUInt32(); // itemId in Classic format uint32_t cooldownMs = packet.readUInt32(); float seconds = cooldownMs / 1000.0f; spellCooldowns[spellId] = seconds; for (auto& slot : actionBar) { - if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { - slot.cooldownTotal = seconds; + bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) + || (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId); + if (match) { + slot.cooldownTotal = seconds; slot.cooldownRemaining = seconds; } } From 955b22841ea8fb4310da500ac2fd6bd6bb0e018d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 01:04:16 -0700 Subject: [PATCH 087/111] Wire SMSG_FORCE_ANIM animId to emoteAnimCallback --- src/game/game_handler.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b08bb247..47d1f2ca 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2209,9 +2209,11 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FORCE_ANIM: { // packed_guid + uint32 animId — force entity to play animation if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); + uint64_t animGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t animId =*/ packet.readUInt32(); + uint32_t animId = packet.readUInt32(); + if (emoteAnimCallback_) + emoteAnimCallback_(animGuid, animId); } } break; From 25e2c606030da497cb9daf57c152c15ce5e4f547 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 01:15:11 -0700 Subject: [PATCH 088/111] Add UIErrorsFrame: center-bottom spell error overlay with fade-out --- include/game/game_handler.hpp | 8 ++++ include/ui/game_screen.hpp | 7 ++++ src/game/game_handler.cpp | 9 ++++- src/ui/game_screen.cpp | 70 +++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2001e4eb..84a3170d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1269,6 +1269,11 @@ public: using PlayPositionalSoundCallback = std::function; void setPlayPositionalSoundCallback(PlayPositionalSoundCallback cb) { playPositionalSoundCallback_ = std::move(cb); } + // UI error frame: prominent on-screen error messages (spell can't be cast, etc.) + using UIErrorCallback = std::function; + void setUIErrorCallback(UIErrorCallback cb) { uiErrorCallback_ = std::move(cb); } + void addUIError(const std::string& msg) { if (uiErrorCallback_) uiErrorCallback_(msg); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -2548,6 +2553,9 @@ private: PlayMusicCallback playMusicCallback_; PlaySoundCallback playSoundCallback_; PlayPositionalSoundCallback playPositionalSoundCallback_; + + // ---- UI error frame callback ---- + UIErrorCallback uiErrorCallback_; }; } // namespace game diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index a27f1808..e2640ff6 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -76,6 +76,12 @@ private: float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text + + // UIErrorsFrame: WoW-style center-bottom error messages (spell fails, out of range, etc.) + struct UIErrorEntry { std::string text; float age = 0.0f; }; + std::vector uiErrors_; + bool uiErrorCallbackSet_ = false; + static constexpr float kUIErrorLifetime = 2.5f; bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; @@ -256,6 +262,7 @@ private: void renderCombatText(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); void renderBossFrames(game::GameHandler& gameHandler); + void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 47d1f2ca..97b08e22 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1124,6 +1124,7 @@ void GameHandler::update(float deltaTime) { autoAttackOutOfRangeTime_ += deltaTime; if (autoAttackRangeWarnCooldown_ <= 0.0f) { addSystemChatMessage("Target is too far away."); + addUIError("Target is too far away."); autoAttackRangeWarnCooldown_ = 1.25f; } // Stop chasing stale swings when the target remains out of range. @@ -1959,11 +1960,13 @@ void GameHandler::handlePacket(network::Packet& packet) { playerPowerType = static_cast(pu->getPowerType()); } const char* reason = getSpellCastResultString(castResult, playerPowerType); + std::string errMsg = reason ? reason + : ("Spell cast failed (error " + std::to_string(castResult) + ")"); + addUIError(errMsg); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - msg.message = reason ? reason - : ("Spell cast failed (error " + std::to_string(castResult) + ")"); + msg.message = errMsg; addLocalChatMessage(msg); } } @@ -2274,6 +2277,7 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); break; case Opcode::SMSG_FEIGN_DEATH_RESISTED: + addUIError("Your Feign Death was resisted."); addSystemChatMessage("Your Feign Death attempt was resisted."); LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED"); break; @@ -3173,6 +3177,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleDuelWinner(packet); break; case Opcode::SMSG_DUEL_OUTOFBOUNDS: + addUIError("You are out of the duel area!"); addSystemChatMessage("You are out of the duel area!"); break; case Opcode::SMSG_DUEL_INBOUNDS: diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5acec3f6..ce7dbe05 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -227,6 +227,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { achievementCallbackSet_ = true; } + // Set up UI error frame callback (once) + if (!uiErrorCallbackSet_) { + gameHandler.setUIErrorCallback([this](const std::string& msg) { + uiErrors_.push_back({msg, 0.0f}); + if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin()); + }); + uiErrorCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -443,6 +452,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); renderCombatText(gameHandler); + renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); if (showRaidFrames_) { renderPartyFrames(gameHandler); } @@ -6515,6 +6525,66 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// UI Error Frame (WoW-style center-bottom error overlay) +// ============================================================ + +void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) { + // Age out old entries + for (auto& e : uiErrors_) e.age += deltaTime; + uiErrors_.erase( + std::remove_if(uiErrors_.begin(), uiErrors_.end(), + [](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }), + uiErrors_.end()); + + if (uiErrors_.empty()) 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; + + // Fixed invisible overlay + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + if (ImGui::Begin("##UIErrors", nullptr, flags)) { + // Render messages stacked above the action bar (~200px from bottom) + // The newest message is on top; older ones fade below it. + const float baseY = screenH - 200.0f; + const float lineH = 20.0f; + const int count = static_cast(uiErrors_.size()); + + ImDrawList* draw = ImGui::GetWindowDrawList(); + for (int i = count - 1; i >= 0; --i) { + const auto& e = uiErrors_[i]; + float alpha = 1.0f - (e.age / kUIErrorLifetime); + alpha = std::max(0.0f, std::min(1.0f, alpha)); + + // Fade fast in the last 0.5 s + if (e.age > kUIErrorLifetime - 0.5f) + alpha *= (kUIErrorLifetime - e.age) / 0.5f; + + uint8_t a8 = static_cast(alpha * 255.0f); + ImU32 textCol = IM_COL32(255, 50, 50, a8); + ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast(alpha * 180)); + + const char* txt = e.text.c_str(); + ImVec2 sz = ImGui::CalcTextSize(txt); + float x = std::round((screenW - sz.x) * 0.5f); + float y = std::round(baseY - (count - 1 - i) * lineH); + + // Drop shadow + draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt); + draw->AddText(ImVec2(x, y), textCol, txt); + } + } + ImGui::End(); + ImGui::PopStyleVar(); +} + // ============================================================ // Boss Encounter Frames // ============================================================ From 06456faa63769649eecab1e64aff75c68792f2b9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 01:22:42 -0700 Subject: [PATCH 089/111] Extend UIErrorsFrame to spell failures, interrupts, server shutdown warnings --- src/game/game_handler.cpp | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 97b08e22..b6b00998 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -907,6 +907,7 @@ void GameHandler::update(float deltaTime) { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + addUIError("Interrupted."); addSystemChatMessage("Interrupted."); } if (casting && castTimeRemaining > 0.0f) { @@ -2828,13 +2829,14 @@ void GameHandler::handlePacket(network::Packet& packet) { // Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table uint8_t failReason = isClassic ? static_cast(rawFailReason + 1) : rawFailReason; if (failGuid == playerGuid && failReason != 0) { - // Show interruption/failure reason in chat for player + // Show interruption/failure reason in chat and error overlay for player int pt = -1; if (auto pe = entityManager.getEntity(playerGuid)) if (auto pu = std::dynamic_pointer_cast(pe)) pt = static_cast(pu->getPowerType()); const char* reason = getSpellCastResultString(failReason, pt); if (reason) { + addUIError(reason); MessageChatData emsg; emsg.type = ChatType::SYSTEM; emsg.language = ChatLanguage::UNIVERSAL; @@ -3790,11 +3792,22 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_SERVER_MESSAGE: { - // uint32 type, string message + // uint32 type + string message + // Types: 1=shutdown_time, 2=restart_time, 3=string, 4=shutdown_cancelled, 5=restart_cancelled if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t msgType =*/ packet.readUInt32(); + uint32_t msgType = packet.readUInt32(); std::string msg = packet.readString(); - if (!msg.empty()) addSystemChatMessage("[Server] " + msg); + if (!msg.empty()) { + std::string prefix; + switch (msgType) { + case 1: prefix = "[Shutdown] "; addUIError("Server shutdown: " + msg); break; + case 2: prefix = "[Restart] "; addUIError("Server restart: " + msg); break; + case 4: prefix = "[Shutdown cancelled] "; break; + case 5: prefix = "[Restart cancelled] "; break; + default: prefix = "[Server] "; break; + } + addSystemChatMessage(prefix + msg); + } } break; } From fb6630a7aedaa00792fb8c3731d8a042d2f292af Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 01:31:44 -0700 Subject: [PATCH 090/111] Add Ignore tab to social frame with view/unignore/add support Social frame now has Friends and Ignore tabs. Friends tab shows online players first, then offline with a separator, and supports right-click Whisper/Invite/Remove. Ignore tab lists all ignored players from ignoreCache with right-click Unignore and an inline add-ignore field. --- include/game/game_handler.hpp | 1 + src/ui/game_screen.cpp | 180 ++++++++++++++++++++++------------ 2 files changed, 119 insertions(+), 62 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 84a3170d..5f0459e1 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -343,6 +343,7 @@ public: void setFriendNote(const std::string& playerName, const std::string& note); void addIgnore(const std::string& playerName); void removeIgnore(const std::string& playerName); + const std::unordered_map& getIgnoreCache() const { return ignoreCache; } // Random roll void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ce7dbe05..da3c7347 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7885,84 +7885,140 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f)); bool open = showSocialFrame_; - if (ImGui::Begin("Friends##SocialFrame", &open, + char socialTitle[32]; + snprintf(socialTitle, sizeof(socialTitle), "Social (%d online)##SocialFrame", onlineCount); + if (ImGui::Begin(socialTitle, &open, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { - // Online friends - char onlineHeader[32]; - snprintf(onlineHeader, sizeof(onlineHeader), "Online (%d)", onlineCount); - ImGui::TextDisabled("%s", onlineHeader); - ImGui::Separator(); + if (ImGui::BeginTabBar("##SocialTabs")) { + // ---- Friends tab ---- + if (ImGui::BeginTabItem("Friends")) { + ImGui::BeginChild("##FriendsList", ImVec2(200, 200), false); - int shown = 0; - for (size_t ci = 0; ci < contacts.size(); ++ci) { - const auto& c = contacts[ci]; - if (!c.isFriend()) continue; + // Online friends first + int shown = 0; + for (int pass = 0; pass < 2; ++pass) { + bool wantOnline = (pass == 0); + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isFriend()) continue; + if (c.isOnline() != wantOnline) continue; - ImGui::PushID(static_cast(ci)); + ImGui::PushID(static_cast(ci)); - // Status dot: green=online, yellow=AFK, orange=DND, grey=offline - ImU32 dotColor; - if (!c.isOnline()) dotColor = IM_COL32(100, 100, 100, 200); - else if (c.status == 2) dotColor = IM_COL32(255, 200, 50, 255); // AFK - else if (c.status == 3) dotColor = IM_COL32(255, 120, 50, 255); // DND - else dotColor = IM_COL32( 50, 220, 50, 255); // online + // Status dot + ImU32 dotColor; + if (!c.isOnline()) dotColor = IM_COL32(100, 100, 100, 200); + else if (c.status == 2) dotColor = IM_COL32(255, 200, 50, 255); // AFK + else if (c.status == 3) dotColor = IM_COL32(255, 120, 50, 255); // DND + else dotColor = IM_COL32( 50, 220, 50, 255); // online - ImVec2 dotMin = ImGui::GetCursorScreenPos(); - dotMin.y += 4.0f; - ImGui::GetWindowDrawList()->AddCircleFilled( - ImVec2(dotMin.x + 5.0f, dotMin.y + 5.0f), 4.5f, dotColor); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + ImVec2 dotMin = ImGui::GetCursorScreenPos(); + dotMin.y += 4.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotMin.x + 5.0f, dotMin.y + 5.0f), 4.5f, dotColor); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); - const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); - ImVec4 nameCol = c.isOnline() - ? ImVec4(0.9f, 0.9f, 0.9f, 1.0f) - : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - ImGui::TextColored(nameCol, "%s", displayName); + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? ImVec4(0.9f, 0.9f, 0.9f, 1.0f) + : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(nameCol, "%s", displayName); - if (c.isOnline() && c.level > 0) { - ImGui::SameLine(); - ImGui::TextDisabled("%u", c.level); - } + if (c.isOnline() && c.level > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv%u", c.level); + } - // Right-click context menu - if (ImGui::BeginPopupContextItem("FriendCtx")) { - ImGui::TextDisabled("%s", displayName); - ImGui::Separator(); - if (c.isOnline()) { - if (ImGui::MenuItem("Whisper")) { - showSocialFrame_ = false; - strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - selectedChatType = 4; - refocusChatInput = true; + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (c.isOnline()) { + if (ImGui::MenuItem("Whisper")) { + showSocialFrame_ = false; + strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + selectedChatType = 4; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(c.name); + } + if (ImGui::MenuItem("Remove Friend")) + gameHandler.removeFriend(c.name); + ImGui::EndPopup(); + } + + ++shown; + ImGui::PopID(); + } + // Separator between online and offline if there are both + if (pass == 0 && shown > 0) { + ImGui::Separator(); } - if (ImGui::MenuItem("Invite to Group")) - gameHandler.inviteToGroup(c.name); } - if (ImGui::MenuItem("Remove Friend")) - gameHandler.removeFriend(c.name); - ImGui::EndPopup(); + + if (shown == 0) { + ImGui::TextDisabled("No friends yet."); + } + + ImGui::EndChild(); + ImGui::Separator(); + + // Add friend + static char addFriendBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_addfriend", addFriendBuf, sizeof(addFriendBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##addfriend") && addFriendBuf[0] != '\0') { + gameHandler.addFriend(addFriendBuf); + addFriendBuf[0] = '\0'; + } + + ImGui::EndTabItem(); } - ++shown; - ImGui::PopID(); - } + // ---- Ignore tab ---- + if (ImGui::BeginTabItem("Ignore")) { + const auto& ignores = gameHandler.getIgnoreCache(); + ImGui::BeginChild("##IgnoreList", ImVec2(200, 200), false); - if (shown == 0) { - ImGui::TextDisabled("No friends."); - } + if (ignores.empty()) { + ImGui::TextDisabled("Ignore list is empty."); + } else { + for (const auto& kv : ignores) { + ImGui::PushID(kv.first.c_str()); + ImGui::TextUnformatted(kv.first.c_str()); + if (ImGui::BeginPopupContextItem("IgnoreCtx")) { + ImGui::TextDisabled("%s", kv.first.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Unignore")) + gameHandler.removeIgnore(kv.first); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } - ImGui::Separator(); - // Add friend inline - static char addFriendBuf[64] = {}; - ImGui::SetNextItemWidth(140.0f); - ImGui::InputText("##sf_addfriend", addFriendBuf, sizeof(addFriendBuf)); - ImGui::SameLine(); - if (ImGui::Button("+") && addFriendBuf[0] != '\0') { - gameHandler.addFriend(addFriendBuf); - addFriendBuf[0] = '\0'; + ImGui::EndChild(); + ImGui::Separator(); + + // Add ignore + static char addIgnBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_addignore", addIgnBuf, sizeof(addIgnBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##addignore") && addIgnBuf[0] != '\0') { + gameHandler.addIgnore(addIgnBuf); + addIgnBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); } } ImGui::End(); From 1bc3e6b6770125fa310040bd6e690892742ffbd3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 01:51:18 -0700 Subject: [PATCH 091/111] Add Channels tab to social frame and reputation change toast Social frame now has three tabs: Friends, Ignore, and Channels. The Channels tab lists joined channels with right-click Leave and an input to join new channels. Also adds a slide-in reputation change toast in the lower-right corner: shows faction name, delta (+/-), and current standing tier (Honored, Revered, etc.) whenever SMSG_SET_FACTION_STANDING fires a rep change. --- include/game/game_handler.hpp | 7 ++ include/ui/game_screen.hpp | 7 ++ src/game/game_handler.cpp | 1 + src/ui/game_screen.cpp | 131 ++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5f0459e1..c418494a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1275,6 +1275,10 @@ public: void setUIErrorCallback(UIErrorCallback cb) { uiErrorCallback_ = std::move(cb); } void addUIError(const std::string& msg) { if (uiErrorCallback_) uiErrorCallback_(msg); } + // Reputation change toast: factionName, delta, new standing + using RepChangeCallback = std::function; + void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -2557,6 +2561,9 @@ private: // ---- UI error frame callback ---- UIErrorCallback uiErrorCallback_; + + // ---- Reputation change callback ---- + RepChangeCallback repChangeCallback_; }; } // namespace game diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e2640ff6..f39d5061 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -82,6 +82,12 @@ private: std::vector uiErrors_; bool uiErrorCallbackSet_ = false; static constexpr float kUIErrorLifetime = 2.5f; + + // Reputation change toast: brief colored slide-in below minimap + struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; }; + std::vector repToasts_; + bool repChangeCallbackSet_ = false; + static constexpr float kRepToastLifetime = 3.5f; bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; @@ -263,6 +269,7 @@ private: void renderPartyFrames(game::GameHandler& gameHandler); void renderBossFrames(game::GameHandler& gameHandler); void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); + void renderRepToasts(float deltaTime); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b6b00998..1d77ac6a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3517,6 +3517,7 @@ void GameHandler::handlePacket(network::Packet& packet) { delta > 0 ? "increased" : "decreased", std::abs(delta)); addSystemChatMessage(buf); + if (repChangeCallback_) repChangeCallback_(name, delta, standing); } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index da3c7347..468d100d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -236,6 +236,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { uiErrorCallbackSet_ = true; } + // Set up reputation change toast callback (once) + if (!repChangeCallbackSet_) { + gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) { + repToasts_.push_back({name, delta, standing, 0.0f}); + if (repToasts_.size() > 4) repToasts_.erase(repToasts_.begin()); + }); + repChangeCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -453,6 +462,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBattlegroundScore(gameHandler); renderCombatText(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); + renderRepToasts(ImGui::GetIO().DeltaTime); if (showRaidFrames_) { renderPartyFrames(gameHandler); } @@ -6585,6 +6595,89 @@ void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaT ImGui::PopStyleVar(); } +// ============================================================ +// Reputation change toasts +// ============================================================ + +void GameScreen::renderRepToasts(float deltaTime) { + for (auto& e : repToasts_) e.age += deltaTime; + repToasts_.erase( + std::remove_if(repToasts_.begin(), repToasts_.end(), + [](const RepToastEntry& e) { return e.age >= kRepToastLifetime; }), + repToasts_.end()); + + if (repToasts_.empty()) 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; + + // Stack toasts in the lower-right corner (above the action bar), newest on top + const float toastW = 220.0f; + const float toastH = 26.0f; + const float padY = 4.0f; + const float rightEdge = screenW - 14.0f; + const float baseY = screenH - 180.0f; + + const int count = static_cast(repToasts_.size()); + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + // Compute standing tier label (Exalted, Revered, Honored, Friendly, Neutral, Unfriendly, Hostile, Hated) + auto standingLabel = [](int32_t s) -> const char* { + if (s >= 42000) return "Exalted"; + if (s >= 21000) return "Revered"; + if (s >= 9000) return "Honored"; + if (s >= 3000) return "Friendly"; + if (s >= 0) return "Neutral"; + if (s >= -3000) return "Unfriendly"; + if (s >= -6000) return "Hostile"; + return "Hated"; + }; + + for (int i = 0; i < count; ++i) { + const auto& e = repToasts_[i]; + // Slide in from right on appear, slide out at end + constexpr float kSlideDur = 0.3f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kRepToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + + float alpha = std::clamp(slide, 0.0f, 1.0f); + float xFull = rightEdge - toastW; + float xStart = screenW + 10.0f; + float toastX = xStart + (xFull - xStart) * slide; + float toastY = baseY - i * (toastH + padY); + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + // Background + draw->AddRectFilled(tl, br, IM_COL32(15, 15, 20, (int)(alpha * 200)), 4.0f); + // Border: green for gain, red for loss + ImU32 borderCol = (e.delta > 0) + ? IM_COL32(80, 200, 80, (int)(alpha * 220)) + : IM_COL32(200, 60, 60, (int)(alpha * 220)); + draw->AddRect(tl, br, borderCol, 4.0f, 0, 1.5f); + + // Delta text: "+250" or "-250" + char deltaBuf[16]; + snprintf(deltaBuf, sizeof(deltaBuf), "%+d", e.delta); + ImU32 deltaCol = (e.delta > 0) ? IM_COL32(80, 220, 80, (int)(alpha * 255)) + : IM_COL32(220, 70, 70, (int)(alpha * 255)); + draw->AddText(font, fontSize, ImVec2(tl.x + 6.0f, tl.y + (toastH - fontSize) * 0.5f), + deltaCol, deltaBuf); + + // Faction name + standing + char nameBuf[64]; + snprintf(nameBuf, sizeof(nameBuf), "%s (%s)", e.factionName.c_str(), standingLabel(e.standing)); + draw->AddText(font, fontSize * 0.85f, ImVec2(tl.x + 44.0f, tl.y + (toastH - fontSize * 0.85f) * 0.5f), + IM_COL32(210, 210, 210, (int)(alpha * 220)), nameBuf); + } +} + // ============================================================ // Boss Encounter Frames // ============================================================ @@ -8018,6 +8111,44 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // ---- Channels tab ---- + if (ImGui::BeginTabItem("Channels")) { + const auto& channels = gameHandler.getJoinedChannels(); + ImGui::BeginChild("##ChannelList", ImVec2(200, 200), false); + + if (channels.empty()) { + ImGui::TextDisabled("Not in any channels."); + } else { + for (size_t ci = 0; ci < channels.size(); ++ci) { + ImGui::PushID(static_cast(ci)); + ImGui::TextUnformatted(channels[ci].c_str()); + if (ImGui::BeginPopupContextItem("ChanCtx")) { + ImGui::TextDisabled("%s", channels[ci].c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Leave Channel")) + gameHandler.leaveChannel(channels[ci]); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } + + ImGui::EndChild(); + ImGui::Separator(); + + // Join a channel + static char joinChanBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_joinchan", joinChanBuf, sizeof(joinChanBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##joinchan") && joinChanBuf[0] != '\0') { + gameHandler.joinChannel(joinChanBuf); + joinChanBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } } From 7a1f3306557a22bbe220a058178402319163b055 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 01:57:03 -0700 Subject: [PATCH 092/111] Add minimap coordinate tooltip and play time warning display Hovering over the minimap now shows a tooltip with the player's WoW canonical coordinates (X=North, Y=West) and a hint about Ctrl+click pinging. SMSG_PLAY_TIME_WARNING is now parsed (type + minutes) and shown as both a chat message and a UIError overlay rather than silently dropped. --- src/game/game_handler.cpp | 25 ++++++++++++++++++++++++- src/ui/game_screen.cpp | 15 +++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1d77ac6a..10cd17a6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6125,10 +6125,33 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: - case Opcode::SMSG_PLAY_TIME_WARNING: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PLAY_TIME_WARNING: { + // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t warnType = packet.readUInt32(); + uint32_t minutesPlayed = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readUInt32() : 0; + const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] "; + char buf[128]; + if (minutesPlayed > 0) { + uint32_t h = minutesPlayed / 60; + uint32_t m = minutesPlayed % 60; + if (h > 0) + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m); + else + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m); + } else { + std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity); + } + addSystemChatMessage(buf); + addUIError(buf); + } + break; + } + // ---- Item query multiple (same format as single, re-use handler) ---- case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE: handleItemQueryResponse(packet); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 468d100d..9a020d6a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11656,6 +11656,21 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Hover tooltip: show player's WoW coordinates (canonical X=North, Y=West) + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { + glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.5f, 1.0f), + "%.1f, %.1f", playerCanon.x, playerCanon.y); + ImGui::TextDisabled("Ctrl+click to ping"); + ImGui::EndTooltip(); + } + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; From f4754797bc0037f5a2000a1023274c4a3083c075 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 02:09:35 -0700 Subject: [PATCH 093/111] Add Achievements list window (Y key toggle) with search filter --- include/ui/game_screen.hpp | 5 +++ include/ui/keybinding_manager.hpp | 1 + src/ui/game_screen.cpp | 69 +++++++++++++++++++++++++++++++ src/ui/keybinding_manager.cpp | 4 ++ 4 files changed, 79 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f39d5061..7c17820d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -354,6 +354,11 @@ private: // Dungeon Finder state bool showDungeonFinder_ = false; + + // Achievements window + bool showAchievementWindow_ = false; + char achievementSearchBuf_[128] = {}; + void renderAchievementWindow(game::GameHandler& gameHandler); uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) diff --git a/include/ui/keybinding_manager.hpp b/include/ui/keybinding_manager.hpp index 09c9ac05..385340ab 100644 --- a/include/ui/keybinding_manager.hpp +++ b/include/ui/keybinding_manager.hpp @@ -30,6 +30,7 @@ public: TOGGLE_NAMEPLATES, TOGGLE_RAID_FRAMES, TOGGLE_QUEST_LOG, + TOGGLE_ACHIEVEMENTS, ACTION_COUNT }; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9a020d6a..f8f95801 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -497,6 +497,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAuctionHouseWindow(gameHandler); renderDungeonFinderWindow(gameHandler); renderInstanceLockouts(gameHandler); + renderAchievementWindow(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -1762,6 +1763,10 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { questLogScreen.toggle(); } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { + showAchievementWindow_ = !showAchievementWindow_; + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -14326,4 +14331,68 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ─── Achievement Window ─────────────────────────────────────────────────────── +void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { + if (!showAchievementWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(420, 480), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(200, 150), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Achievements", &showAchievementWindow_)) { + ImGui::End(); + return; + } + + const auto& earned = gameHandler.getEarnedAchievements(); + ImGui::Text("Earned: %u", static_cast(earned.size())); + ImGui::SameLine(); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##achsearch", achievementSearchBuf_, sizeof(achievementSearchBuf_)); + ImGui::SameLine(); + if (ImGui::Button("Clear")) achievementSearchBuf_[0] = '\0'; + ImGui::Separator(); + + if (earned.empty()) { + ImGui::TextDisabled("No achievements earned yet."); + ImGui::End(); + return; + } + + ImGui::BeginChild("##achlist", ImVec2(0, 0), false); + + std::string filter(achievementSearchBuf_); + // lower-case filter for case-insensitive matching + for (char& c : filter) c = static_cast(tolower(static_cast(c))); + + // Collect and sort ids for stable display + std::vector ids(earned.begin(), earned.end()); + std::sort(ids.begin(), ids.end()); + + for (uint32_t id : ids) { + const std::string& name = gameHandler.getAchievementName(id); + const std::string& display = name.empty() ? std::to_string(id) : name; + + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + if (lower.find(filter) == std::string::npos) continue; + } + + ImGui::PushID(static_cast(id)); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "[Achievement]"); + ImGui::SameLine(); + ImGui::TextUnformatted(display.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("ID: %u", id); + if (!name.empty()) ImGui::TextDisabled("%s", name.c_str()); + ImGui::EndTooltip(); + } + ImGui::PopID(); + } + + ImGui::EndChild(); + ImGui::End(); +} + }} // namespace wowee::ui diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index 212d2af0..5ac79927 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -31,6 +31,7 @@ void KeybindingManager::initializeDefaults() { bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) bindings_[static_cast(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q; + bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) } bool KeybindingManager::isActionPressed(Action action, bool repeat) { @@ -71,6 +72,7 @@ const char* KeybindingManager::getActionName(Action action) { case Action::TOGGLE_NAMEPLATES: return "Nameplates"; case Action::TOGGLE_RAID_FRAMES: return "Raid Frames"; case Action::TOGGLE_QUEST_LOG: return "Quest Log"; + case Action::TOGGLE_ACHIEVEMENTS: return "Achievements"; case Action::ACTION_COUNT: break; } return "Unknown"; @@ -135,6 +137,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { else if (action == "toggle_nameplates") actionIdx = static_cast(Action::TOGGLE_NAMEPLATES); else if (action == "toggle_raid_frames") actionIdx = static_cast(Action::TOGGLE_RAID_FRAMES); else if (action == "toggle_quest_log") actionIdx = static_cast(Action::TOGGLE_QUEST_LOG); + else if (action == "toggle_achievements") actionIdx = static_cast(Action::TOGGLE_ACHIEVEMENTS); if (actionIdx < 0) continue; @@ -226,6 +229,7 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const { {Action::TOGGLE_NAMEPLATES, "toggle_nameplates"}, {Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"}, {Action::TOGGLE_QUEST_LOG, "toggle_quest_log"}, + {Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"}, }; for (const auto& [action, nameStr] : actionMap) { From adf8e6414ed60764519ce5857eb9e641b0251ee9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 02:17:49 -0700 Subject: [PATCH 094/111] Show boot vote progress in LFG UI; fix unused screenH warning --- include/game/game_handler.hpp | 8 +++++++ src/game/game_handler.cpp | 41 +++++++++++++++++++++++++++++------ src/ui/game_screen.cpp | 11 ++++++++-- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c418494a..4c6f325b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1061,6 +1061,10 @@ public: uint32_t getLfgProposalId() const { return lfgProposalId_; } int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; } uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; } + uint32_t getLfgBootVotes() const { return lfgBootVotes_; } + uint32_t getLfgBootTotal() const { return lfgBootTotal_; } + uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; } + uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; } // ---- Phase 5: Loot ---- void lootTarget(uint64_t guid); @@ -2128,6 +2132,10 @@ private: uint32_t lfgProposalId_ = 0; // pending proposal id (0 = none) int32_t lfgAvgWaitSec_ = -1; // estimated wait, -1=unknown uint32_t lfgTimeInQueueMs_= 0; // ms already in queue + uint32_t lfgBootVotes_ = 0; // current boot-yes votes + uint32_t lfgBootTotal_ = 0; // total votes cast + uint32_t lfgBootTimeLeft_ = 0; // seconds remaining + uint32_t lfgBootNeeded_ = 0; // votes needed to kick // Ready check state bool pendingReadyCheck_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 10cd17a6..8157d44c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5001,10 +5001,34 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_QUERY_NEXT_MAIL_TIME: handleQueryNextMailTime(packet); break; - case Opcode::SMSG_CHANNEL_LIST: - // Channel member listing currently not rendered in UI. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_CHANNEL_LIST: { + // string channelName + uint8 flags + uint32 count + count×(uint64 guid + uint8 memberFlags) + std::string chanName = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 5) break; + /*uint8_t chanFlags =*/ packet.readUInt8(); + uint32_t memberCount = packet.readUInt32(); + memberCount = std::min(memberCount, 200u); + addSystemChatMessage(chanName + " has " + std::to_string(memberCount) + " member(s):"); + for (uint32_t i = 0; i < memberCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 9) break; + uint64_t memberGuid = packet.readUInt64(); + uint8_t memberFlags = packet.readUInt8(); + // Look up the name from our entity manager + auto entity = entityManager.getEntity(memberGuid); + std::string name = "(unknown)"; + if (entity) { + auto player = std::dynamic_pointer_cast(entity); + if (player && !player->getName().empty()) name = player->getName(); + } + std::string entry = " " + name; + if (memberFlags & 0x01) entry += " [Moderator]"; + if (memberFlags & 0x02) entry += " [Muted]"; + addSystemChatMessage(entry); + LOG_DEBUG(" channel member: 0x", std::hex, memberGuid, std::dec, + " flags=", (int)memberFlags, " name=", name); + } break; + } case Opcode::SMSG_INSPECT_RESULTS_UPDATE: handleInspectResults(packet); break; @@ -12971,15 +12995,18 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { uint32_t timeLeft = packet.readUInt32(); uint32_t votesNeeded = packet.readUInt32(); - (void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded; + (void)myVote; + + lfgBootVotes_ = bootVotes; + lfgBootTotal_ = totalVotes; + lfgBootTimeLeft_ = timeLeft; + lfgBootNeeded_ = votesNeeded; if (inProgress) { lfgState_ = LfgState::Boot; - addSystemChatMessage( - std::string("Dungeon Finder: Vote to kick in progress (") + - std::to_string(timeLeft) + "s remaining)."); } else { // Boot vote ended — return to InDungeon state regardless of outcome + lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; lfgState_ = LfgState::InDungeon; if (myAnswer) { addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f8f95801..b9232000 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7973,8 +7973,7 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { if (c.isFriend() && c.isOnline()) ++onlineCount; auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; ImGui::SetNextWindowPos(ImVec2(screenW - 230.0f, 240.0f), ImGuiCond_Once); ImGui::SetNextWindowSize(ImVec2(220.0f, 0.0f), ImGuiCond_Always); @@ -13983,6 +13982,14 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // ---- Vote-to-kick buttons ---- if (state == LfgState::Boot) { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Vote to kick in progress:"); + uint32_t bootVotes = gameHandler.getLfgBootVotes(); + uint32_t bootTotal = gameHandler.getLfgBootTotal(); + uint32_t bootNeeded = gameHandler.getLfgBootNeeded(); + uint32_t bootTimeLeft= gameHandler.getLfgBootTimeLeft(); + if (bootNeeded > 0) { + ImGui::Text("Votes: %u / %u (need %u) %us left", + bootVotes, bootTotal, bootNeeded, bootTimeLeft); + } ImGui::Spacing(); if (ImGui::Button("Vote Yes (kick)", ImVec2(140, 0))) { gameHandler.lfgSetBootVote(true); From 3964a33c55878b7d8dee516b667f4db39ba9ba84 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 02:23:24 -0700 Subject: [PATCH 095/111] Add /help slash command listing all available commands --- src/ui/game_screen.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b9232000..6732ced8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3161,6 +3161,36 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /help command — list available slash commands + if (cmdLower == "help" || cmdLower == "?") { + static const char* kHelpLines[] = { + "--- Wowee Slash Commands ---", + "Chat: /s /y /p /g /raid /rw /o /bg /w [msg] /r [msg]", + "Social: /who [filter] /whois /friend add/remove ", + " /ignore /unignore ", + "Party: /invite /uninvite /leave /readycheck", + " /maintank /mainassist /roll [min-max]", + "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", + " /gleader /groster /ginfo /gcreate /gdisband", + "Combat: /startattack /stopattack /stopcasting /duel /pvp", + " /forfeit /follow /assist", + "Target: /target /cleartarget /focus /clearfocus", + "Movement: /sit /stand /kneel /dismount", + "Misc: /played /time /afk [msg] /dnd [msg] /inspect", + " /helm /cloak /trade /join /leave ", + " /unstuck /logout /help", + }; + for (const char* line : kHelpLines) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // /who commands if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { std::string query; From 2bdd024f1951363883e9f50475b2c27f1eebdaaf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 02:31:12 -0700 Subject: [PATCH 096/111] Add GM Ticket window (/ticket, /gm commands and Esc menu button) --- include/game/game_handler.hpp | 4 +++ include/ui/game_screen.hpp | 5 ++++ src/game/game_handler.cpp | 28 ++++++++++++++++++ src/ui/game_screen.cpp | 56 +++++++++++++++++++++++++++++++++-- 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 4c6f325b..ce836072 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -409,6 +409,10 @@ public: void setGuildOfficerNote(const std::string& name, const std::string& note); void acceptGuildInvite(); void declineGuildInvite(); + + // GM Ticket + void submitGmTicket(const std::string& text); + void deleteGmTicket(); void queryGuildInfo(uint32_t guildId); void createGuild(const std::string& guildName); void addGuildRank(const std::string& rankName); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 7c17820d..1fc31818 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -359,6 +359,11 @@ private: bool showAchievementWindow_ = false; char achievementSearchBuf_[128] = {}; void renderAchievementWindow(game::GameHandler& gameHandler); + + // GM Ticket window + bool showGmTicketWindow_ = false; + char gmTicketBuf_[2048] = {}; + void renderGmTicketWindow(game::GameHandler& gameHandler); uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8157d44c..e792f459 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14968,6 +14968,34 @@ void GameHandler::declineGuildInvite() { LOG_INFO("Declined guild invite"); } +void GameHandler::submitGmTicket(const std::string& text) { + if (state != WorldState::IN_WORLD || !socket) return; + + // CMSG_GMTICKET_CREATE (WotLK 3.3.5a): + // string ticket_text + // float[3] position (server coords) + // float facing + // uint32 mapId + // uint8 need_response (1 = yes) + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_CREATE)); + pkt.writeString(text); + pkt.writeFloat(movementInfo.x); + pkt.writeFloat(movementInfo.y); + pkt.writeFloat(movementInfo.z); + pkt.writeFloat(movementInfo.orientation); + pkt.writeUInt32(currentMapId_); + pkt.writeUInt8(1); // need_response = yes + socket->send(pkt); + LOG_INFO("Submitted GM ticket: '", text, "'"); +} + +void GameHandler::deleteGmTicket() { + if (state != WorldState::IN_WORLD || !socket) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); + socket->send(pkt); + LOG_INFO("Deleting GM ticket"); +} + void GameHandler::queryGuildInfo(uint32_t guildId) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GuildQueryPacket::build(guildId); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6732ced8..4a77261d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -498,6 +498,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDungeonFinderWindow(gameHandler); renderInstanceLockouts(gameHandler); renderAchievementWindow(gameHandler); + renderGmTicketWindow(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -3161,6 +3162,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /ticket command — open GM ticket window + if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { + showGmTicketWindow_ = true; + chatInputBuffer[0] = '\0'; + return; + } + // /help command — list available slash commands if (cmdLower == "help" || cmdLower == "?") { static const char* kHelpLines[] = { @@ -3178,7 +3186,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", - " /unstuck /logout /help", + " /unstuck /logout /ticket /help", }; for (const char* line : kHelpLines) { game::MessageChatData helpMsg; @@ -9750,7 +9758,7 @@ void GameScreen::renderEscapeMenu() { ImGuiIO& io = ImGui::GetIO(); float screenW = io.DisplaySize.x; float screenH = io.DisplaySize.y; - ImVec2 size(260.0f, 220.0f); + ImVec2 size(260.0f, 248.0f); ImVec2 pos((screenW - size.x) * 0.5f, (screenH - size.y) * 0.5f); ImGui::SetNextWindowPos(pos, ImGuiCond_Always); @@ -9786,6 +9794,10 @@ void GameScreen::renderEscapeMenu() { showInstanceLockouts_ = true; showEscapeMenu = false; } + if (ImGui::Button("Help / GM Ticket", ImVec2(-1, 0))) { + showGmTicketWindow_ = true; + showEscapeMenu = false; + } ImGui::Spacing(); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); @@ -14432,4 +14444,44 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── GM Ticket Window ───────────────────────────────────────────────────────── +void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { + if (!showGmTicketWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); + ImGui::Spacing(); + ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), + ImVec2(-1, 160)); + ImGui::Spacing(); + + bool hasText = (gmTicketBuf_[0] != '\0'); + if (!hasText) ImGui::BeginDisabled(); + if (ImGui::Button("Submit Ticket", ImVec2(160, 0))) { + gameHandler.submitGmTicket(gmTicketBuf_); + gmTicketBuf_[0] = '\0'; + showGmTicketWindow_ = false; + } + if (!hasText) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + showGmTicketWindow_ = false; + } + ImGui::SameLine(); + if (ImGui::Button("Delete Ticket", ImVec2(100, 0))) { + gameHandler.deleteGmTicket(); + } + + ImGui::End(); +} + }} // namespace wowee::ui From 92db25038c457528a3b950f6b0630de58f93cef0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 02:35:29 -0700 Subject: [PATCH 097/111] Parse SMSG_ARENA_TEAM_STATS and display in character screen PvP tab --- include/game/game_handler.hpp | 16 ++++++++++++++++ src/game/game_handler.cpp | 31 ++++++++++++++++++++++++++++++- src/ui/inventory_screen.cpp | 28 ++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ce836072..b50204fd 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1070,6 +1070,18 @@ public: uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; } uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; } + // ---- Arena Team Stats ---- + struct ArenaTeamStats { + uint32_t teamId = 0; + uint32_t rating = 0; + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + uint32_t rank = 0; + }; + const std::vector& getArenaTeamStats() const { return arenaTeamStats_; } + // ---- Phase 5: Loot ---- void lootTarget(uint64_t guid); void lootItem(uint8_t slotIndex); @@ -1774,6 +1786,7 @@ private: void handleArenaTeamQueryResponse(network::Packet& packet); void handleArenaTeamInvite(network::Packet& packet); void handleArenaTeamEvent(network::Packet& packet); + void handleArenaTeamStats(network::Packet& packet); void handleArenaError(network::Packet& packet); // ---- Bank handlers ---- @@ -2127,6 +2140,9 @@ private: // Instance / raid lockouts std::vector instanceLockouts_; + // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) + std::vector arenaTeamStats_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e792f459..aeb9a2e9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4924,7 +4924,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaTeamEvent(packet); break; case Opcode::SMSG_ARENA_TEAM_STATS: - LOG_INFO("Received SMSG_ARENA_TEAM_STATS"); + handleArenaTeamStats(packet); break; case Opcode::SMSG_ARENA_ERROR: handleArenaError(packet); @@ -13276,6 +13276,35 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2); } +void GameHandler::handleArenaTeamStats(network::Packet& packet) { + // SMSG_ARENA_TEAM_STATS (WotLK 3.3.5a): + // uint32 teamId, uint32 rating, uint32 weekGames, uint32 weekWins, + // uint32 seasonGames, uint32 seasonWins, uint32 rank + if (packet.getSize() - packet.getReadPos() < 28) return; + + ArenaTeamStats stats; + stats.teamId = packet.readUInt32(); + stats.rating = packet.readUInt32(); + stats.weekGames = packet.readUInt32(); + stats.weekWins = packet.readUInt32(); + stats.seasonGames = packet.readUInt32(); + stats.seasonWins = packet.readUInt32(); + stats.rank = packet.readUInt32(); + + // Update or insert for this team + for (auto& s : arenaTeamStats_) { + if (s.teamId == stats.teamId) { + s = stats; + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, + " rating=", stats.rating, " rank=", stats.rank); + return; + } + } + arenaTeamStats_.push_back(stats); + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, + " rating=", stats.rating, " rank=", stats.rank); +} + void GameHandler::handleArenaError(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t error = packet.readUInt32(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index bcc3d0e0..f2b40f7c 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1270,6 +1270,34 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("PvP")) { + const auto& arenaStats = gameHandler.getArenaTeamStats(); + if (arenaStats.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("Not a member of any Arena team."); + } else { + for (const auto& team : arenaStats) { + ImGui::PushID(static_cast(team.teamId)); + char header[64]; + snprintf(header, sizeof(header), "Team ID %u (Rating: %u)", team.teamId, team.rating); + if (ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Columns(2, "##arenacols", false); + ImGui::Text("Rating:"); ImGui::NextColumn(); + ImGui::Text("%u", team.rating); ImGui::NextColumn(); + ImGui::Text("Rank:"); ImGui::NextColumn(); + ImGui::Text("#%u", team.rank); ImGui::NextColumn(); + ImGui::Text("This week:"); ImGui::NextColumn(); + ImGui::Text("%u / %u (W/G)", team.weekWins, team.weekGames); ImGui::NextColumn(); + ImGui::Text("Season:"); ImGui::NextColumn(); + ImGui::Text("%u / %u (W/G)", team.seasonWins, team.seasonGames); ImGui::NextColumn(); + ImGui::Columns(1); + } + ImGui::PopID(); + } + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } From 43de2be1f26c7eded72bac431796f030f9ade687 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 02:52:40 -0700 Subject: [PATCH 098/111] Add inspect window showing talent summary and gear for inspected players Store inspect results (talent points, dual-spec state, gear entries) in a new InspectResult struct instead of discarding them as chat messages. Open the inspect window automatically from all Inspect menu items and /inspect. --- include/game/game_handler.hpp | 14 +++++ include/ui/game_screen.hpp | 4 ++ src/game/game_handler.cpp | 23 +++++---- src/ui/game_screen.cpp | 97 +++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 9 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b50204fd..70dd0eec 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -332,6 +332,19 @@ public: // Inspection void inspectTarget(); + struct InspectResult { + uint64_t guid = 0; + std::string playerName; + uint32_t totalTalents = 0; + uint32_t unspentTalents = 0; + uint8_t talentGroups = 0; + uint8_t activeTalentGroup = 0; + std::array itemEntries{}; // 0=head…18=ranged + }; + const InspectResult* getInspectResult() const { + return inspectResult_.guid ? &inspectResult_ : nullptr; + } + // Server info commands void queryServerTime(); void requestPlayedTime(); @@ -2019,6 +2032,7 @@ private: // Inspect fallback (when visible item fields are missing/unreliable) std::unordered_map> inspectedPlayerItemEntries_; + InspectResult inspectResult_; // most-recently received inspect response std::unordered_set pendingAutoInspect_; float inspectRateLimit_ = 0.0f; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 1fc31818..1c5105ad 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -364,6 +364,10 @@ private: bool showGmTicketWindow_ = false; char gmTicketBuf_[2048] = {}; void renderGmTicketWindow(game::GameHandler& gameHandler); + + // Inspect window + bool showInspectWindow_ = false; + void renderInspectWindow(game::GameHandler& gameHandler); uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index aeb9a2e9..25935eff 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11258,16 +11258,21 @@ void GameHandler::handleInspectResults(network::Packet& packet) { } } - // Display inspect results - std::string msg = "Inspect: " + playerName; - msg += " - " + std::to_string(totalTalents) + " talent points spent"; - if (unspentTalents > 0) { - msg += ", " + std::to_string(unspentTalents) + " unspent"; + // Store inspect result for UI display + inspectResult_.guid = guid; + inspectResult_.playerName = playerName; + inspectResult_.totalTalents = totalTalents; + inspectResult_.unspentTalents = unspentTalents; + inspectResult_.talentGroups = talentGroupCount; + inspectResult_.activeTalentGroup = activeTalentGroup; + + // Merge any gear we already have from a prior inspect request + auto gearIt = inspectedPlayerItemEntries_.find(guid); + if (gearIt != inspectedPlayerItemEntries_.end()) { + inspectResult_.itemEntries = gearIt->second; + } else { + inspectResult_.itemEntries = {}; } - if (talentGroupCount > 1) { - msg += " (dual spec, active: " + std::to_string(activeTalentGroup + 1) + ")"; - } - addSystemChatMessage(msg); LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ", unspentTalents, " unspent, ", (int)talentGroupCount, " specs"); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4a77261d..54b4c95c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -499,6 +499,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderInstanceLockouts(gameHandler); renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); + renderInspectWindow(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -2621,6 +2622,7 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } if (ImGui::MenuItem("Inspect")) { gameHandler.inspectTarget(); + showInspectWindow_ = true; } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) { @@ -3009,6 +3011,7 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(fGuid); gameHandler.inspectTarget(); + showInspectWindow_ = true; } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) @@ -3144,6 +3147,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { // /inspect command if (cmdLower == "inspect") { gameHandler.inspectTarget(); + showInspectWindow_ = true; chatInputBuffer[0] = '\0'; return; } @@ -6348,6 +6352,7 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(m.guid); gameHandler.inspectTarget(); + showInspectWindow_ = true; } bool isLeader = (partyData.leaderGuid == gameHandler.getPlayerGuid()); if (isLeader) { @@ -6532,6 +6537,7 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(member.guid); gameHandler.inspectTarget(); + showInspectWindow_ = true; } ImGui::Separator(); if (!member.name.empty()) { @@ -14484,4 +14490,95 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── Inspect Window ─────────────────────────────────────────────────────────── +void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { + if (!showInspectWindow_) return; + + // Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server) + static const char* kSlotNames[19] = { + "Head", "Neck", "Shoulder", "Shirt", "Chest", + "Waist", "Legs", "Feet", "Wrist", "Hands", + "Finger 1", "Finger 2", "Trinket 1", "Trinket 2", "Back", + "Main Hand", "Off Hand", "Ranged", "Tabard" + }; + + ImGui::SetNextWindowSize(ImVec2(360, 440), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(350, 120), ImGuiCond_FirstUseEver); + + const game::GameHandler::InspectResult* result = gameHandler.getInspectResult(); + + std::string title = result ? ("Inspect: " + result->playerName + "###InspectWin") + : "Inspect###InspectWin"; + if (!ImGui::Begin(title.c_str(), &showInspectWindow_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!result) { + ImGui::TextDisabled("No inspect data yet. Target a player and use Inspect."); + ImGui::End(); + return; + } + + // Talent summary + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.82f, 0.0f, 1.0f)); // gold + ImGui::Text("%s", result->playerName.c_str()); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled(" %u talent pts", result->totalTalents); + if (result->unspentTalents > 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "(%u unspent)", result->unspentTalents); + } + if (result->talentGroups > 1) { + ImGui::SameLine(); + ImGui::TextDisabled(" Dual spec (active %u)", (unsigned)result->activeTalentGroup + 1); + } + + ImGui::Separator(); + + // Equipment list + bool hasAnyGear = false; + for (int s = 0; s < 19; ++s) { + if (result->itemEntries[s] != 0) { hasAnyGear = true; break; } + } + + if (!hasAnyGear) { + ImGui::TextDisabled("Equipment data not yet available."); + ImGui::TextDisabled("(Gear loads after the player is inspected in-range)"); + } else { + if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { + for (int s = 0; s < 19; ++s) { + uint32_t entry = result->itemEntries[s]; + if (entry == 0) continue; + + const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); + if (!info) { + gameHandler.ensureItemInfo(entry); + ImGui::TextDisabled("[%s] (loading…)", kSlotNames[s]); + continue; + } + + ImGui::TextDisabled("%s", kSlotNames[s]); + ImGui::SameLine(90); + auto qColor = InventoryScreen::getQualityColor( + static_cast(info->quality)); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + if (info->itemLevel > 0) + ImGui::Text("Item Level %u", info->itemLevel); + if (info->armor > 0) + ImGui::Text("Armor: %d", info->armor); + ImGui::EndTooltip(); + } + } + } + ImGui::EndChild(); + } + + ImGui::End(); +} + }} // namespace wowee::ui From 920950dfbd8d00087fb55b686c514b4fbe0197e2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 02:59:09 -0700 Subject: [PATCH 099/111] Add threat list window showing live aggro data for current target Store SMSG_THREAT_UPDATE/SMSG_HIGHEST_THREAT_UPDATE in a per-unit map (sorted descending by threat) and clear on SMSG_THREAT_REMOVE/CLEAR. Show a threat window (/threat or via target frame button) with a progress bar per player and gold highlight for the tank, red if local player has aggro. --- include/game/game_handler.hpp | 17 +++++++ include/ui/game_screen.hpp | 4 ++ src/game/game_handler.cpp | 65 ++++++++++++++----------- src/ui/game_screen.cpp | 90 +++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 27 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 70dd0eec..495e31ed 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -514,6 +514,21 @@ public: const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); + // Threat + struct ThreatEntry { + uint64_t victimGuid = 0; + uint32_t threat = 0; + }; + // Returns the current threat list for a given unit GUID (from last SMSG_THREAT_UPDATE) + const std::vector* getThreatList(uint64_t unitGuid) const { + auto it = threatLists_.find(unitGuid); + return (it != threatLists_.end()) ? &it->second : nullptr; + } + // Returns the threat list for the player's current target, or nullptr + const std::vector* getTargetThreatList() const { + return targetGuid ? getThreatList(targetGuid) : nullptr; + } + // ---- Phase 3: Spells ---- void castSpell(uint32_t spellId, uint64_t targetGuid = 0); void cancelCast(); @@ -2047,6 +2062,8 @@ private: float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing std::unordered_set hostileAttackers_; std::vector combatText; + // unitGuid → sorted threat list (descending by threat value) + std::unordered_map> threatLists_; // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 1c5105ad..2f3ff0aa 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -368,6 +368,10 @@ private: // Inspect window bool showInspectWindow_ = false; void renderInspectWindow(game::GameHandler& gameHandler); + + // Threat window + bool showThreatWindow_ = false; + void renderThreatWindow(game::GameHandler& gameHandler); uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 25935eff..f7d8350c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2331,24 +2331,51 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_THREAT_CLEAR: // All threat dropped on the local player (e.g. Vanish, Feign Death) - // No local state to clear — informational + threatLists_.clear(); LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped"); break; case Opcode::SMSG_THREAT_REMOVE: { // packed_guid (unit) + packed_guid (victim whose threat was removed) - if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); - } + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + auto it = threatLists_.find(unitGuid); + if (it != threatLists_.end()) { + auto& list = it->second; + list.erase(std::remove_if(list.begin(), list.end(), + [victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }), + list.end()); + if (list.empty()) threatLists_.erase(it); } break; } - case Opcode::SMSG_HIGHEST_THREAT_UPDATE: { - // packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count - // + count × (packed_guid victim + uint32 threat) - // Informational — no threat UI yet; consume to suppress warnings - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_HIGHEST_THREAT_UPDATE: + case Opcode::SMSG_THREAT_UPDATE: { + // Both packets share the same format: + // packed_guid (unit) + packed_guid (highest-threat target or target, unused here) + // + uint32 count + count × (packed_guid victim + uint32 threat) + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t cnt = packet.readUInt32(); + if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity + std::vector list; + list.reserve(cnt); + for (uint32_t i = 0; i < cnt; ++i) { + if (packet.getSize() - packet.getReadPos() < 1) break; + ThreatEntry entry; + entry.victimGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + entry.threat = packet.readUInt32(); + list.push_back(entry); + } + // Sort descending by threat so highest is first + std::sort(list.begin(), list.end(), + [](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; }); + threatLists_[unitGuid] = std::move(list); break; } @@ -5656,22 +5683,6 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } - case Opcode::SMSG_THREAT_UPDATE: { - // packed_guid (unit) + packed_guid (target) + uint32 count - // + count × (packed_guid victim + uint32 threat) — consume to suppress warnings - if (packet.getSize() - packet.getReadPos() < 1) break; - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 1) break; - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 4) break; - uint32_t cnt = packet.readUInt32(); - for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) { - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() >= 4) - packet.readUInt32(); - } - break; - } case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: { // uint32 slot + packed_guid unit (0 packed = clear slot) if (packet.getSize() - packet.getReadPos() < 5) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 54b4c95c..40d330e6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -500,6 +500,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); + renderThreatWindow(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -2728,6 +2729,15 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float distance = std::sqrt(dx*dx + dy*dy + dz*dz); ImGui::TextDisabled("%.1f yd", distance); + // Threat button (shown when in combat and threat data is available) + if (gameHandler.getTargetThreatList()) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f)); + if (ImGui::SmallButton("Threat")) showThreatWindow_ = !showThreatWindow_; + ImGui::PopStyleColor(2); + } + // Target auras (buffs/debuffs) const auto& targetAuras = gameHandler.getTargetAuras(); int activeAuras = 0; @@ -3152,6 +3162,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /threat command + if (cmdLower == "threat") { + showThreatWindow_ = !showThreatWindow_; + chatInputBuffer[0] = '\0'; + return; + } + // /time command if (cmdLower == "time") { gameHandler.queryServerTime(); @@ -14490,6 +14507,79 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── Threat Window ──────────────────────────────────────────────────────────── +void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { + if (!showThreatWindow_) return; + + const auto* list = gameHandler.getTargetThreatList(); + + ImGui::SetNextWindowSize(ImVec2(280, 220), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(10, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.85f); + + if (!ImGui::Begin("Threat###ThreatWin", &showThreatWindow_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + if (!list || list->empty()) { + ImGui::TextDisabled("No threat data for current target."); + ImGui::End(); + return; + } + + uint32_t maxThreat = list->front().threat; + + ImGui::TextDisabled("%-19s Threat", "Player"); + ImGui::Separator(); + + uint64_t playerGuid = gameHandler.getPlayerGuid(); + int rank = 0; + for (const auto& entry : *list) { + ++rank; + bool isPlayer = (entry.victimGuid == playerGuid); + + // Resolve name + std::string victimName; + auto entity = gameHandler.getEntityManager().getEntity(entry.victimGuid); + if (entity) { + if (entity->getType() == game::ObjectType::PLAYER) { + auto p = std::static_pointer_cast(entity); + victimName = p->getName().empty() ? "Player" : p->getName(); + } else if (entity->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(entity); + victimName = u->getName().empty() ? "NPC" : u->getName(); + } + } + if (victimName.empty()) + victimName = "0x" + [&](){ + char buf[20]; snprintf(buf, sizeof(buf), "%llX", + static_cast(entry.victimGuid)); return std::string(buf); }(); + + // Colour: gold for #1 (tank), red if player is highest, white otherwise + ImVec4 col = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + if (rank == 1) col = ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // gold + if (isPlayer && rank == 1) col = ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // red — you have aggro + + // Threat bar + float pct = (maxThreat > 0) ? (float)entry.threat / (float)maxThreat : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f)); + char barLabel[48]; + snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f); + ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel); + ImGui::PopStyleColor(); + ImGui::SameLine(); + + ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat); + + if (rank >= 10) break; // cap display at 10 entries + } + + ImGui::End(); +} + // ─── Inspect Window ─────────────────────────────────────────────────────────── void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { if (!showInspectWindow_) return; From 46eb66b77f2e554cb3af93f694fa8faafa622c88 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:03:02 -0700 Subject: [PATCH 100/111] Store and display achievement criteria progress from SMSG_CRITERIA_UPDATE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track criteria progress (criteriaId → counter) from SMSG_CRITERIA_UPDATE and SMSG_ALL_ACHIEVEMENT_DATA. Add a Criteria tab to the achievement window showing live progress values alongside the existing Earned achievements tab. --- include/game/game_handler.hpp | 3 ++ src/game/game_handler.cpp | 15 +++--- src/ui/game_screen.cpp | 97 +++++++++++++++++++++++------------ 3 files changed, 75 insertions(+), 40 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 495e31ed..c708c2be 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1294,6 +1294,7 @@ public: using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + const std::unordered_map& getCriteriaProgress() const { return criteriaProgress_; } /// Returns the name of an achievement by ID, or empty string if unknown. const std::string& getAchievementName(uint32_t id) const { auto it = achievementNameCache_.find(id); @@ -2439,6 +2440,8 @@ private: void loadAchievementNameCache(); // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) std::unordered_set earnedAchievements_; + // Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE) + std::unordered_map criteriaProgress_; void handleAllAchievementData(network::Packet& packet); // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f7d8350c..0f53c4af 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4153,12 +4153,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_CRITERIA_UPDATE: { // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime - // Achievement criteria progress (informational — no criteria UI yet). if (packet.getSize() - packet.getReadPos() >= 20) { uint32_t criteriaId = packet.readUInt32(); uint64_t progress = packet.readUInt64(); - /*uint32_t elapsedTime =*/ packet.readUInt32(); - /*uint32_t createTime =*/ packet.readUInt32(); + packet.readUInt32(); // elapsedTime + packet.readUInt32(); // creationTime + criteriaProgress_[criteriaId] = progress; LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); } break; @@ -20080,18 +20080,21 @@ void GameHandler::handleAllAchievementData(network::Packet& packet) { earnedAchievements_.insert(id); } - // Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF) + // Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF + criteriaProgress_.clear(); while (packet.getSize() - packet.getReadPos() >= 4) { uint32_t id = packet.readUInt32(); if (id == 0xFFFFFFFF) break; // counter(8) + date(4) + unknown(4) = 16 bytes if (packet.getSize() - packet.getReadPos() < 16) break; - packet.readUInt64(); // counter + uint64_t counter = packet.readUInt64(); packet.readUInt32(); // date packet.readUInt32(); // unknown / flags + criteriaProgress_[id] = counter; } - LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements"); + LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), + " achievements, ", criteriaProgress_.size(), " criteria"); } // --------------------------------------------------------------------------- diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 40d330e6..94a9d05a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14416,54 +14416,83 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { } const auto& earned = gameHandler.getEarnedAchievements(); - ImGui::Text("Earned: %u", static_cast(earned.size())); - ImGui::SameLine(); + const auto& criteria = gameHandler.getCriteriaProgress(); + ImGui::SetNextItemWidth(180.0f); ImGui::InputText("##achsearch", achievementSearchBuf_, sizeof(achievementSearchBuf_)); ImGui::SameLine(); if (ImGui::Button("Clear")) achievementSearchBuf_[0] = '\0'; ImGui::Separator(); - if (earned.empty()) { - ImGui::TextDisabled("No achievements earned yet."); - ImGui::End(); - return; - } - - ImGui::BeginChild("##achlist", ImVec2(0, 0), false); - std::string filter(achievementSearchBuf_); - // lower-case filter for case-insensitive matching for (char& c : filter) c = static_cast(tolower(static_cast(c))); - // Collect and sort ids for stable display - std::vector ids(earned.begin(), earned.end()); - std::sort(ids.begin(), ids.end()); - - for (uint32_t id : ids) { - const std::string& name = gameHandler.getAchievementName(id); - const std::string& display = name.empty() ? std::to_string(id) : name; - - if (!filter.empty()) { - std::string lower = display; - for (char& c : lower) c = static_cast(tolower(static_cast(c))); - if (lower.find(filter) == std::string::npos) continue; + if (ImGui::BeginTabBar("##achtabs")) { + // --- Earned tab --- + char earnedLabel[32]; + snprintf(earnedLabel, sizeof(earnedLabel), "Earned (%u)###earned", (unsigned)earned.size()); + if (ImGui::BeginTabItem(earnedLabel)) { + if (earned.empty()) { + ImGui::TextDisabled("No achievements earned yet."); + } else { + ImGui::BeginChild("##achlist", ImVec2(0, 0), false); + std::vector ids(earned.begin(), earned.end()); + std::sort(ids.begin(), ids.end()); + for (uint32_t id : ids) { + const std::string& name = gameHandler.getAchievementName(id); + const std::string& display = name.empty() ? std::to_string(id) : name; + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + if (lower.find(filter) == std::string::npos) continue; + } + ImGui::PushID(static_cast(id)); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "\xE2\x98\x85"); + ImGui::SameLine(); + ImGui::TextUnformatted(display.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("ID: %u", id); + ImGui::EndTooltip(); + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); } - ImGui::PushID(static_cast(id)); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "[Achievement]"); - ImGui::SameLine(); - ImGui::TextUnformatted(display.c_str()); - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::Text("ID: %u", id); - if (!name.empty()) ImGui::TextDisabled("%s", name.c_str()); - ImGui::EndTooltip(); + // --- Criteria progress tab --- + char critLabel[32]; + snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", (unsigned)criteria.size()); + if (ImGui::BeginTabItem(critLabel)) { + if (criteria.empty()) { + ImGui::TextDisabled("No criteria progress received yet."); + } else { + ImGui::BeginChild("##critlist", ImVec2(0, 0), false); + // Sort criteria by id for stable display + std::vector> clist(criteria.begin(), criteria.end()); + std::sort(clist.begin(), clist.end()); + for (const auto& [cid, cval] : clist) { + std::string label = std::to_string(cid); + if (!filter.empty()) { + std::string lower = label; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + if (lower.find(filter) == std::string::npos) continue; + } + ImGui::PushID(static_cast(cid)); + ImGui::TextDisabled("Criteria %u:", cid); + ImGui::SameLine(); + ImGui::Text("%llu", static_cast(cval)); + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); } - ImGui::PopID(); + ImGui::EndTabBar(); } - ImGui::EndChild(); ImGui::End(); } From 6ab9ba65f9725b39dc0514dc8cc91f6a33cde730 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:09:52 -0700 Subject: [PATCH 101/111] Store and display played time on character Stats tab Save totalTimePlayed/levelTimePlayed from SMSG_PLAYED_TIME. Request a fresh update whenever the character screen is opened. Show total and level-played time in a two-column layout below the stats panel. --- include/game/game_handler.hpp | 4 ++++ src/game/game_handler.cpp | 3 +++ src/ui/inventory_screen.cpp | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c708c2be..79460a17 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -349,6 +349,8 @@ public: void queryServerTime(); void requestPlayedTime(); void queryWho(const std::string& playerName = ""); + uint32_t getTotalTimePlayed() const { return totalTimePlayed_; } + uint32_t getLevelTimePlayed() const { return levelTimePlayed_; } // Social commands void addFriend(const std::string& playerName, const std::string& note = ""); @@ -2224,6 +2226,8 @@ private: uint64_t summonerGuid_ = 0; std::string summonerName_; float summonTimeoutSec_ = 0.0f; + uint32_t totalTimePlayed_ = 0; + uint32_t levelTimePlayed_ = 0; // Trade state TradeStatus tradeStatus_ = TradeStatus::None; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0f53c4af..163b44c6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18149,6 +18149,9 @@ void GameHandler::handlePlayedTime(network::Packet& packet) { return; } + totalTimePlayed_ = data.totalTimePlayed; + levelTimePlayed_ = data.levelTimePlayed; + if (data.triggerMessage) { // Format total time played uint32_t totalDays = data.totalTimePlayed / 86400; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index f2b40f7c..e5735977 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -730,6 +730,9 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN, false); if (characterDown && !cKeyWasDown) { characterOpen = !characterOpen; + if (characterOpen && gameHandler_) { + gameHandler_->requestPlayedTime(); + } } cKeyWasDown = characterDown; @@ -1142,6 +1145,30 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats); + + // Played time (shown if available, fetched on character screen open) + uint32_t totalSec = gameHandler.getTotalTimePlayed(); + uint32_t levelSec = gameHandler.getLevelTimePlayed(); + if (totalSec > 0 || levelSec > 0) { + ImGui::Separator(); + // Helper lambda to format seconds as "Xd Xh Xm" + auto fmtTime = [](uint32_t sec) -> std::string { + uint32_t d = sec / 86400, h = (sec % 86400) / 3600, m = (sec % 3600) / 60; + char buf[48]; + if (d > 0) snprintf(buf, sizeof(buf), "%ud %uh %um", d, h, m); + else if (h > 0) snprintf(buf, sizeof(buf), "%uh %um", h, m); + else snprintf(buf, sizeof(buf), "%um", m); + return buf; + }; + ImGui::TextDisabled("Time Played"); + ImGui::Columns(2, "##playtime", false); + ImGui::SetColumnWidth(0, 130); + ImGui::Text("Total:"); ImGui::NextColumn(); + ImGui::Text("%s", fmtTime(totalSec).c_str()); ImGui::NextColumn(); + ImGui::Text("This level:"); ImGui::NextColumn(); + ImGui::Text("%s", fmtTime(levelSec).c_str()); ImGui::NextColumn(); + ImGui::Columns(1); + } ImGui::EndTabItem(); } From 40a98f2436305050ac1bf0c4e2821293e36484c5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:15:56 -0700 Subject: [PATCH 102/111] Fix talent tab crash and missing talent points at level 10 Unique child window and button IDs per tab prevent ImGui state corruption when switching talent tabs. Parse SMSG_INSPECT_TALENT type=0 properly to populate unspentTalentPoints_ and learnedTalents_ from the server response. --- src/game/game_handler.cpp | 47 +++++++++++++++++++++++++++++++++++++-- src/ui/talent_screen.cpp | 9 +++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 163b44c6..b3e57ccc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11192,8 +11192,51 @@ void GameHandler::handleInspectResults(network::Packet& packet) { uint8_t talentType = packet.readUInt8(); if (talentType == 0) { - // Own talent info — silently consume (sent on login, talent changes, respecs) - LOG_DEBUG("SMSG_TALENTS_INFO: received own talent data, ignoring"); + // Own talent info (type 0): uint32 unspentTalents, uint8 groupCount, uint8 activeGroup + // Per group: uint8 talentCount, [talentId(4)+rank(1)]..., uint8 glyphCount, [glyphId(2)]... + if (packet.getSize() - packet.getReadPos() < 6) { + LOG_DEBUG("SMSG_TALENTS_INFO type=0: too short"); + return; + } + uint32_t unspentTalents = packet.readUInt32(); + uint8_t talentGroupCount = packet.readUInt8(); + uint8_t activeTalentGroup = packet.readUInt8(); + + if (activeTalentGroup > 1) activeTalentGroup = 0; + activeTalentSpec_ = activeTalentGroup; + + for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t talentCount = packet.readUInt8(); + learnedTalents_[g].clear(); + for (uint8_t t = 0; t < talentCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint32_t talentId = packet.readUInt32(); + uint8_t rank = packet.readUInt8(); + learnedTalents_[g][talentId] = rank; + } + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t glyphCount = packet.readUInt8(); + for (uint8_t gl = 0; gl < glyphCount; ++gl) { + if (packet.getSize() - packet.getReadPos() < 2) break; + packet.readUInt16(); // glyphId (skip) + } + } + + unspentTalentPoints_[activeTalentGroup] = static_cast( + unspentTalents > 255 ? 255 : unspentTalents); + + if (!talentsInitialized_) { + talentsInitialized_ = true; + if (unspentTalents > 0) { + addSystemChatMessage("You have " + std::to_string(unspentTalents) + + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); + } + } + + LOG_INFO("SMSG_TALENTS_INFO type=0: unspent=", unspentTalents, + " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, + " learned=", learnedTalents_[activeTalentGroup].size()); return; } diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index d1ee6627..5c6bdaf9 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -216,7 +216,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab float availW = ImGui::GetContentRegionAvail().x; float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f); - ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false); + char childId[32]; + snprintf(childId, sizeof(childId), "TalentGrid_%u", tabId); + ImGui::BeginChild(childId, ImVec2(0, 0), false); ImVec2 gridOrigin = ImGui::GetCursorScreenPos(); gridOrigin.x += offsetX; @@ -326,8 +328,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab renderTalent(gameHandler, *talent, pointsInTree); } else { // Empty cell — invisible placeholder - ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(), - ImVec2(iconSize, iconSize)); + char emptyId[32]; + snprintf(emptyId, sizeof(emptyId), "e_%u_%u_%u", tabId, row, col); + ImGui::InvisibleButton(emptyId, ImVec2(iconSize, iconSize)); } } } From 6cf511aa7fc02cdc3792982c2ec9ea9a3fbecd8a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:21:49 -0700 Subject: [PATCH 103/111] Add damage flash toggle setting and fix map explored zone reveal Persist damage_flash to settings.cfg; checkbox in Interface > Screen Effects. Fix world map fog: trust server exploration mask unconditionally when present, always reveal the current zone immediately regardless of server mask state. --- include/ui/game_screen.hpp | 1 + src/rendering/world_map.cpp | 11 +++++++---- src/ui/game_screen.cpp | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 2f3ff0aa..c175274b 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -74,6 +74,7 @@ private: ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) + bool damageFlashEnabled_ = true; float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index cf4c70fd..9c30a3b5 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -752,7 +752,6 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { return (serverExplorationMask[word] & (1u << (bitIndex % 32))) != 0; }; - bool markedAny = false; if (hasServerExplorationMask) { exploredZones.clear(); for (int i = 0; i < static_cast(zones.size()); i++) { @@ -761,15 +760,19 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { for (uint32_t bit : z.exploreBits) { if (isBitSet(bit)) { exploredZones.insert(i); - markedAny = true; break; } } } + // Always trust the server mask when available — even if empty (unexplored character). + // Also reveal the zone the player is currently standing in so the map isn't pitch-black + // the moment they first enter a new zone (the server bit arrives on the next update). + int curZone = findZoneForPlayer(playerRenderPos); + if (curZone >= 0) exploredZones.insert(curZone); + return; } - if (markedAny) return; - // Server mask unavailable or empty — fall back to locally-accumulated position tracking. + // Server mask unavailable — fall back to locally-accumulated position tracking. // Add the zone the player is currently in to the local set and display that. float wowX = playerRenderPos.y; float wowY = playerRenderPos.x; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 94a9d05a..810a2f52 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -709,7 +709,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { } // Detect HP drop (ignore transitions from 0 — entity just spawned or uninitialized) - if (lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0) + if (damageFlashEnabled_ && lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0) damageFlashAlpha_ = 1.0f; lastPlayerHp_ = currentHp; @@ -10626,6 +10626,16 @@ void GameScreen::renderSettingsWindow() { ImGui::SameLine(); ImGui::TextDisabled("(ms indicator near minimap)"); + ImGui::Spacing(); + ImGui::SeparatorText("Screen Effects"); + ImGui::Spacing(); + if (ImGui::Checkbox("Damage Flash", &damageFlashEnabled_)) { + if (!damageFlashEnabled_) damageFlashAlpha_ = 0.0f; + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(red vignette on taking damage)"); + ImGui::EndChild(); ImGui::EndTabItem(); } @@ -12290,6 +12300,7 @@ void GameScreen::saveSettings() { out << "show_left_bar=" << (pendingShowLeftBar ? 1 : 0) << "\n"; out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n"; out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n"; + out << "damage_flash=" << (damageFlashEnabled_ ? 1 : 0) << "\n"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -12407,6 +12418,8 @@ void GameScreen::loadSettings() { pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } else if (key == "left_bar_offset_y") { pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "damage_flash") { + damageFlashEnabled_ = (std::stoi(val) != 0); } // Audio else if (key == "sound_muted") { From d70db7fa0b028f2024806bdad3e3360e3df1dabb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:24:25 -0700 Subject: [PATCH 104/111] Reduce damage flash vignette opacity for subtler combat feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peak alpha reduced from 180 to 100 (71% → 39%) so the red edge flash is noticeable but less intrusive during combat. --- src/ui/game_screen.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 810a2f52..7747dddc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -723,7 +723,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { ImGuiIO& io = ImGui::GetIO(); const float W = io.DisplaySize.x; const float H = io.DisplaySize.y; - const int alpha = static_cast(damageFlashAlpha_ * 180.0f); + const int alpha = static_cast(damageFlashAlpha_ * 100.0f); const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); const float thickness = std::min(W, H) * 0.12f; From 63c8e82913b7fa8c93b8294fd6aeee18d78df5af Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:31:09 -0700 Subject: [PATCH 105/111] Move latency meter to top-center of screen Relocate the ms indicator from below the minimap to a small centered overlay at the top of the screen, with a semi-transparent background for better readability during gameplay. --- src/ui/game_screen.cpp | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7747dddc..fc2bf4e4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11967,22 +11967,29 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { break; // Show at most one queue slot indicator } - // Latency indicator (toggleable in Interface settings) + // Latency indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { ImVec4 latColor; - if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.8f); // Green < 100ms - else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.8f); // Yellow < 250ms - else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.8f); // Orange < 500ms - else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.8f); // Red >= 500ms + if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f); + else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f); - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + char latBuf[32]; + snprintf(latBuf, sizeof(latBuf), "%u ms", latMs); + ImVec2 textSize = ImGui::CalcTextSize(latBuf); + float latW = textSize.x + 16.0f; + float latH = textSize.y + 8.0f; + ImGuiIO& lio = ImGui::GetIO(); + float latX = (lio.DisplaySize.x - latW) * 0.5f; + ImGui::SetNextWindowPos(ImVec2(latX, 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { - ImGui::TextColored(latColor, "%u ms", latMs); + ImGui::TextColored(latColor, "%s", latBuf); } ImGui::End(); - nextIndicatorY += kIndicatorH; } // Low durability warning — shown when any equipped item has < 20% durability From fa947eb9c73c9302f5e5c1f638250e655133a409 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:39:10 -0700 Subject: [PATCH 106/111] Add quest objective tracker overlay on right side of screen Shows tracked quests (or first 5 active quests if none tracked) below the minimap with live kill/item objective counts and completion status. --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index c175274b..769b1558 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -312,6 +312,7 @@ private: void renderGuildBankWindow(game::GameHandler& gameHandler); void renderAuctionHouseWindow(game::GameHandler& gameHandler); void renderDungeonFinderWindow(game::GameHandler& gameHandler); + void renderObjectiveTracker(game::GameHandler& gameHandler); void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fc2bf4e4..563a1225 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -501,6 +501,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderThreatWindow(gameHandler); + renderObjectiveTracker(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -14629,6 +14630,106 @@ void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── Quest Objective Tracker ────────────────────────────────────────────────── +void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { + if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + + const auto& questLog = gameHandler.getQuestLog(); + const auto& tracked = gameHandler.getTrackedQuestIds(); + + // Collect quests to show: tracked ones first, then in-progress quests up to a max of 5 total. + std::vector toShow; + for (const auto& q : questLog) { + if (q.questId == 0) continue; + if (tracked.count(q.questId)) toShow.push_back(&q); + } + if (toShow.empty()) { + // No explicitly tracked quests — show up to 5 in-progress quests + for (const auto& q : questLog) { + if (q.questId == 0) continue; + if (!tracked.count(q.questId)) toShow.push_back(&q); + if (toShow.size() >= 5) break; + } + } + + if (toShow.empty()) return; + + ImVec2 display = ImGui::GetIO().DisplaySize; + float screenW = display.x > 0.0f ? display.x : 1280.0f; + float trackerW = 220.0f; + float trackerX = screenW - trackerW - 12.0f; + float trackerY = 230.0f; // below minimap + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoFocusOnAppearing; + + ImGui::SetNextWindowPos(ImVec2(trackerX, trackerY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(trackerW, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##ObjectiveTracker", nullptr, flags)) { + for (const auto* q : toShow) { + // Quest title + ImVec4 titleColor = q->complete ? ImVec4(0.45f, 1.0f, 0.45f, 1.0f) + : ImVec4(1.0f, 0.84f, 0.0f, 1.0f); + std::string titleStr = q->title.empty() + ? ("Quest #" + std::to_string(q->questId)) : q->title; + // Truncate to fit + if (titleStr.size() > 26) { titleStr.resize(23); titleStr += "..."; } + ImGui::TextColored(titleColor, "%s", titleStr.c_str()); + + // Kill/entity objectives + bool hasObjectives = false; + for (const auto& ko : q->killObjectives) { + if (ko.npcOrGoId == 0 || ko.required == 0) continue; + hasObjectives = true; + uint32_t entry = (uint32_t)std::abs(ko.npcOrGoId); + auto it = q->killCounts.find(entry); + uint32_t cur = it != q->killCounts.end() ? it->second.first : 0; + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + if (ko.npcOrGoId < 0) { + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo) name = goInfo->name; + } + if (name.empty()) name = "Objective"; + } + if (name.size() > 20) { name.resize(17); name += "..."; } + bool done = (cur >= ko.required); + ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, ko.required); + } + + // Item objectives + for (const auto& io : q->itemObjectives) { + if (io.itemId == 0 || io.required == 0) continue; + hasObjectives = true; + auto it = q->itemCounts.find(io.itemId); + uint32_t cur = it != q->itemCounts.end() ? it->second : 0; + std::string name; + if (const auto* info = gameHandler.getItemInfo(io.itemId)) name = info->name; + if (name.empty()) name = "Item #" + std::to_string(io.itemId); + if (name.size() > 20) { name.resize(17); name += "..."; } + bool done = (cur >= io.required); + ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, io.required); + } + + if (!hasObjectives && q->complete) { + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), " Ready to turn in!"); + } + + ImGui::Dummy(ImVec2(0.0f, 2.0f)); + } + } + ImGui::End(); + ImGui::PopStyleVar(2); +} + // ─── Inspect Window ─────────────────────────────────────────────────────────── void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { if (!showInspectWindow_) return; From 6068d0d68d655e9e9faa546ce9a9e69890d96f09 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:44:32 -0700 Subject: [PATCH 107/111] feat: add HP% text and cast bars to nameplates - Show health percentage centered on each nameplate health bar - Show purple cast bar below health bar when a unit is actively casting - Display spell name (from Spell.dbc cache) above cast bar - Show time remaining (e.g. "1.4s") centered on cast bar fill - All elements respect the existing nameplate alpha fade-out at distance --- src/ui/game_screen.cpp | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 563a1225..ae09ed3a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6067,6 +6067,59 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); + // HP % text centered on health bar (non-corpse, non-full-health for readability) + if (!isCorpse && unit->getMaxHealth() > 0) { + int hpPct = static_cast(healthPct * 100.0f + 0.5f); + char hpBuf[8]; + snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct); + ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf); + float hpTx = sx - hpTextSz.x * 0.5f; + float hpTy = sy + (barH - hpTextSz.y) * 0.5f; + drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf); + drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf); + } + + // Cast bar below health bar when unit is casting + float castBarBaseY = sy + barH + 2.0f; + { + const auto* cs = gameHandler.getUnitCastState(guid); + if (cs && cs->casting && cs->timeTotal > 0.0f) { + float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f); + const float cbH = 6.0f * nameplateScale_; + + // Spell name above the cast bar + const std::string& spellName = gameHandler.getSpellName(cs->spellId); + if (!spellName.empty()) { + ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); + float snX = sx - snSz.x * 0.5f; + float snY = castBarBaseY; + drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); + drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); + castBarBaseY += snSz.y + 2.0f; + } + + // Cast bar background + fill + ImU32 cbBg = IM_COL32(40, 30, 60, A(180)); + ImU32 cbFill = IM_COL32(140, 80, 220, A(200)); // purple cast bar + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f); + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f); + drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f), + ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f), + IM_COL32(20, 10, 40, A(200)), 2.0f); + + // Time remaining text + char timeBuf[12]; + snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining); + ImVec2 timeSz = ImGui::CalcTextSize(timeBuf); + float timeX = sx - timeSz.x * 0.5f; + float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f; + drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf); + drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf); + } + } + // Name + level label above health bar uint32_t level = unit->getLevel(); const std::string& unitName = unit->getName(); From 66ec35b106c0166f0324c6fdfb80004a2a46c3ae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:48:12 -0700 Subject: [PATCH 108/111] feat: show decimal precision for short action bar cooldowns Display "1.5" instead of "1s" for cooldowns under 5 seconds, matching WoW's default cooldown text behaviour for GCDs and short ability cooldowns where sub-second timing matters. --- src/ui/game_screen.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ae09ed3a..06bf184c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4985,9 +4985,10 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { char cdText[16]; float cd = slot.cooldownRemaining; - if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", (int)cd / 3600); - else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", (int)cd / 60, (int)cd % 60); - else snprintf(cdText, sizeof(cdText), "%ds", (int)cd); + if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", (int)cd / 3600); + else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", (int)cd / 60, (int)cd % 60); + else if (cd >= 5.0f) snprintf(cdText, sizeof(cdText), "%ds", (int)cd); + else snprintf(cdText, sizeof(cdText), "%.1f", cd); ImVec2 textSize = ImGui::CalcTextSize(cdText); float tx = cx - textSize.x * 0.5f; float ty = cy - textSize.y * 0.5f; From 797bb5d964964a8b770e0549dc8a9396b524b092 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 03:52:54 -0700 Subject: [PATCH 109/111] feat: add center-screen raid warning and boss emote overlay RAID_WARNING messages show as flashing red/yellow large text. RAID_BOSS_EMOTE and MONSTER_EMOTE show as amber text. Each message fades in quickly, holds for 5 seconds, then fades out. Up to 3 messages stack vertically below the target frame area. Dark semi-transparent background box improves readability. Messages are detected from new chat history entries each frame. --- include/ui/game_screen.hpp | 12 +++++ src/ui/game_screen.cpp | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 769b1558..6abea2f8 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -78,6 +78,17 @@ private: float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text + // Raid Warning / Boss Emote big-text overlay (center-screen, fades after 5s) + struct RaidWarnEntry { + std::string text; + float age = 0.0f; + bool isBossEmote = false; // true = amber, false (raid warning) = red+yellow + static constexpr float LIFETIME = 5.0f; + }; + std::vector raidWarnEntries_; + bool raidWarnCallbackSet_ = false; + size_t raidWarnChatSeenCount_ = 0; // index into chat history for unread scan + // UIErrorsFrame: WoW-style center-bottom error messages (spell fails, out of range, etc.) struct UIErrorEntry { std::string text; float age = 0.0f; }; std::vector uiErrors_; @@ -267,6 +278,7 @@ private: void renderCastBar(game::GameHandler& gameHandler); void renderMirrorTimers(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); + void renderRaidWarningOverlay(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); void renderBossFrames(game::GameHandler& gameHandler); void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 06bf184c..66174ece 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -460,6 +460,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestObjectiveTracker(gameHandler); renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); + renderRaidWarningOverlay(gameHandler); renderCombatText(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); @@ -5806,6 +5807,96 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } +// ============================================================ +// Raid Warning / Boss Emote Center-Screen Overlay +// ============================================================ + +void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) { + // Scan chat history for new RAID_WARNING / RAID_BOSS_EMOTE messages + const auto& chatHistory = gameHandler.getChatHistory(); + size_t newCount = chatHistory.size(); + if (newCount > raidWarnChatSeenCount_) { + // Walk only the new messages (deque — iterate from back by skipping old ones) + size_t toScan = newCount - raidWarnChatSeenCount_; + size_t startIdx = newCount > toScan ? newCount - toScan : 0; + for (size_t i = startIdx; i < newCount; ++i) { + const auto& msg = chatHistory[i]; + if (msg.type == game::ChatType::RAID_WARNING || + msg.type == game::ChatType::RAID_BOSS_EMOTE || + msg.type == game::ChatType::MONSTER_EMOTE) { + bool isBoss = (msg.type != game::ChatType::RAID_WARNING); + // Limit display text length to avoid giant overlay + std::string text = msg.message; + if (text.size() > 200) text = text.substr(0, 200) + "..."; + raidWarnEntries_.push_back({text, 0.0f, isBoss}); + if (raidWarnEntries_.size() > 3) + raidWarnEntries_.erase(raidWarnEntries_.begin()); + } + } + raidWarnChatSeenCount_ = newCount; + } + + // Age and remove expired entries + float dt = ImGui::GetIO().DeltaTime; + for (auto& e : raidWarnEntries_) e.age += dt; + raidWarnEntries_.erase( + std::remove_if(raidWarnEntries_.begin(), raidWarnEntries_.end(), + [](const RaidWarnEntry& e){ return e.age >= RaidWarnEntry::LIFETIME; }), + raidWarnEntries_.end()); + + if (raidWarnEntries_.empty()) return; + + ImGuiIO& io = ImGui::GetIO(); + float screenW = io.DisplaySize.x; + float screenH = io.DisplaySize.y; + ImDrawList* fg = ImGui::GetForegroundDrawList(); + + // Stack entries vertically near upper-center (below target frame area) + float baseY = screenH * 0.28f; + for (const auto& e : raidWarnEntries_) { + float alpha = std::clamp(1.0f - (e.age / RaidWarnEntry::LIFETIME), 0.0f, 1.0f); + // Fade in quickly, hold, then fade out last 20% + if (e.age < 0.3f) alpha = e.age / 0.3f; + + // Truncate to fit screen width reasonably + const char* txt = e.text.c_str(); + const float fontSize = 22.0f; + ImFont* font = ImGui::GetFont(); + + // Word-wrap manually: compute text size, center horizontally + float maxW = screenW * 0.7f; + ImVec2 textSz = font->CalcTextSizeA(fontSize, maxW, maxW, txt); + float tx = (screenW - textSz.x) * 0.5f; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 200)); + ImU32 mainCol; + if (e.isBossEmote) { + mainCol = IM_COL32(255, 185, 60, static_cast(alpha * 255)); // amber + } else { + // Raid warning: alternating red/yellow flash during first second + float flashT = std::fmod(e.age * 4.0f, 1.0f); + if (flashT < 0.5f) + mainCol = IM_COL32(255, 50, 50, static_cast(alpha * 255)); + else + mainCol = IM_COL32(255, 220, 50, static_cast(alpha * 255)); + } + + // Background dim box for readability + float pad = 8.0f; + fg->AddRectFilled(ImVec2(tx - pad, baseY - pad), + ImVec2(tx + textSz.x + pad, baseY + textSz.y + pad), + IM_COL32(0, 0, 0, static_cast(alpha * 120)), 4.0f); + + // Shadow + main text + fg->AddText(font, fontSize, ImVec2(tx + 2.0f, baseY + 2.0f), shadowCol, txt, + nullptr, maxW); + fg->AddText(font, fontSize, ImVec2(tx, baseY), mainCol, txt, + nullptr, maxW); + + baseY += textSz.y + 6.0f; + } +} + // ============================================================ // Floating Combat Text (Phase 2) // ============================================================ From d14982d1257d4c9d8187b4aa88293f427091545f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:04:27 -0700 Subject: [PATCH 110/111] feat: add DPS/HPS meter showing real-time damage and healing output Floating window right of the cast bar showing player's DPS and healing per second, derived from combat text entries. Uses actual combat duration as denominator for accurate readings at fight start. Toggle in Settings > Network. Saves to settings.cfg. --- include/ui/game_screen.hpp | 6 ++ src/ui/game_screen.cpp | 112 +++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 6abea2f8..709dc502 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -328,6 +328,7 @@ private: void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); + void renderDPSMeter(game::GameHandler& gameHandler); /** * Inventory screen @@ -472,6 +473,11 @@ private: std::string lastKnownZoneName_; void renderZoneText(); + // DPS / HPS meter + bool showDPSMeter_ = false; + float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) + bool dpsWasInCombat_ = false; + public: void triggerDing(uint32_t newLevel); void triggerAchievementToast(uint32_t achievementId, std::string name = {}); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 66174ece..35661c30 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -462,6 +462,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBattlegroundScore(gameHandler); renderRaidWarningOverlay(gameHandler); renderCombatText(gameHandler); + renderDPSMeter(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); if (showRaidFrames_) { @@ -6033,6 +6034,108 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { ImGui::End(); } +// ============================================================ +// DPS / HPS Meter +// ============================================================ + +void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { + if (!showDPSMeter_) return; + if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + + const float dt = ImGui::GetIO().DeltaTime; + + // Track combat duration for accurate DPS denominator in short fights + bool inCombat = gameHandler.isInCombat(); + if (inCombat) { + dpsCombatAge_ += dt; + } else if (dpsWasInCombat_) { + // Just left combat — let meter show last reading for LIFETIME then reset + dpsCombatAge_ = 0.0f; + } + dpsWasInCombat_ = inCombat; + + // Sum all player-source damage and healing in the current combat-text window + float totalDamage = 0.0f, totalHeal = 0.0f; + for (const auto& e : gameHandler.getCombatText()) { + if (!e.isPlayerSource) continue; + switch (e.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + case game::CombatTextEntry::CRIT_DAMAGE: + case game::CombatTextEntry::PERIODIC_DAMAGE: + totalDamage += static_cast(e.amount); + break; + case game::CombatTextEntry::HEAL: + case game::CombatTextEntry::CRIT_HEAL: + case game::CombatTextEntry::PERIODIC_HEAL: + totalHeal += static_cast(e.amount); + break; + default: break; + } + } + + // Only show if there's something to report + if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat) return; + + // DPS window = min(combat age, combat-text lifetime) to avoid under-counting + // at the start of a fight and over-counting when entries expire. + float window = std::min(dpsCombatAge_, game::CombatTextEntry::LIFETIME); + if (window < 0.1f) window = 0.1f; + + float dps = totalDamage / window; + float hps = totalHeal / window; + + // Format numbers with K/M suffix for readability + auto fmtNum = [](float v, char* buf, int bufSz) { + if (v >= 1e6f) snprintf(buf, bufSz, "%.1fM", v / 1e6f); + else if (v >= 1000.f) snprintf(buf, bufSz, "%.1fK", v / 1000.f); + else snprintf(buf, bufSz, "%.0f", v); + }; + + char dpsBuf[16], hpsBuf[16]; + fmtNum(dps, dpsBuf, sizeof(dpsBuf)); + fmtNum(hps, hpsBuf, sizeof(hpsBuf)); + + // Position: small floating label just above the action bar, right of center + auto* appWin = core::Application::getInstance().getWindow(); + float screenW = appWin ? static_cast(appWin->getWidth()) : 1280.0f; + float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f; + + constexpr float WIN_W = 90.0f; + constexpr float WIN_H = 36.0f; + float wx = screenW * 0.5f + 160.0f; // right of cast bar + float wy = screenH - 130.0f; // above action bar area + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoInputs; + ImGui::SetNextWindowPos(ImVec2(wx, wy), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(WIN_W, WIN_H), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.55f); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 3)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.7f)); + + if (ImGui::Begin("##DPSMeter", nullptr, flags)) { + if (dps > 0.5f) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "%s", dpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("dps"); + } + if (hps > 0.5f) { + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), "%s", hpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("hps"); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); +} + // ============================================================ // Nameplates — world-space health bars projected to screen // ============================================================ @@ -10772,6 +10875,12 @@ void GameScreen::renderSettingsWindow() { ImGui::SameLine(); ImGui::TextDisabled("(ms indicator near minimap)"); + if (ImGui::Checkbox("Show DPS/HPS Meter", &showDPSMeter_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(damage/healing per second above action bar)"); + ImGui::Spacing(); ImGui::SeparatorText("Screen Effects"); ImGui::Spacing(); @@ -12443,6 +12552,7 @@ void GameScreen::saveSettings() { out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n"; out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n"; + out << "show_dps_meter=" << (showDPSMeter_ ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; out << "action_bar_scale=" << pendingActionBarScale << "\n"; out << "nameplate_scale=" << nameplateScale_ << "\n"; @@ -12550,6 +12660,8 @@ void GameScreen::loadSettings() { } else if (key == "show_latency_meter") { showLatencyMeter_ = (std::stoi(val) != 0); pendingShowLatencyMeter = showLatencyMeter_; + } else if (key == "show_dps_meter") { + showDPSMeter_ = (std::stoi(val) != 0); } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); From 5adb5e0e9f5c26ec0ea6c34cdedb950193bbc7a2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:06:46 -0700 Subject: [PATCH 111/111] feat: add health bar color transitions for player and pet frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player health bar now transitions green→orange→pulsing red as HP drops (>50%=green, 20-50%=orange, <20%=pulsing red). Pet frame gets the same 3-tier color scheme. Target and party frames already had color coding. --- src/ui/game_screen.cpp | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 35661c30..2ba78329 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2179,9 +2179,21 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } } - // Health bar + // Health bar — color transitions green→yellow→red as HP drops float pct = static_cast(playerHp) / static_cast(playerMaxHp); - ImVec4 hpColor = isDead ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) : ImVec4(0.2f, 0.8f, 0.2f, 1.0f); + ImVec4 hpColor; + if (isDead) { + hpColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (pct > 0.5f) { + hpColor = ImVec4(0.2f, 0.8f, 0.2f, 1.0f); // green + } else if (pct > 0.2f) { + float t = (pct - 0.2f) / 0.3f; // 0 at 20%, 1 at 50% + hpColor = ImVec4(0.9f - 0.7f * t, 0.4f + 0.4f * t, 0.0f, 1.0f); // orange→yellow + } else { + // Critical — pulse red when < 20% + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.5f); + hpColor = ImVec4(0.9f * pulse, 0.05f, 0.05f, 1.0f); // pulsing red + } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor); char overlay[64]; snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp); @@ -2368,7 +2380,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { uint32_t maxHp = petUnit->getMaxHealth(); if (maxHp > 0) { float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); + ImVec4 petHpColor = pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) + : pct > 0.2f ? ImVec4(0.9f, 0.6f, 0.0f, 1.0f) + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, petHpColor); char hpText[32]; snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText);