From 3014c79c1f69de63592b40df27c64338114befee Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:12:07 -0700 Subject: [PATCH 001/105] feat: show item stack count on action bar slots Consumable items (potions, food, etc.) on the action bar now show their remaining stack count in the bottom-right corner of the icon. Shows red when count is 1 (last one), white otherwise. Counts across all bag slots. --- 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 2ba78329..867f3779 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5013,6 +5013,36 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); } + // Item stack count overlay — bottom-right corner of icon + if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + // Count total of this item across all inventory slots + auto& inv = gameHandler.getInventory(); + int totalCount = 0; + for (int bi = 0; bi < inv.getBackpackSize(); bi++) { + const auto& bs = inv.getBackpackSlot(bi); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) { + for (int si = 0; si < inv.getBagSize(bag); si++) { + const auto& bs = inv.getBagSlot(bag, si); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + } + if (totalCount > 0) { + char countStr[8]; + snprintf(countStr, sizeof(countStr), "%d", totalCount); + ImVec2 btnMax = ImGui::GetItemRectMax(); + ImVec2 tsz = ImGui::CalcTextSize(countStr); + float cx2 = btnMax.x - tsz.x - 2.0f; + float cy2 = btnMax.y - tsz.y - 1.0f; + auto* cdl = ImGui::GetWindowDrawList(); + cdl->AddText(ImVec2(cx2 + 1.0f, cy2 + 1.0f), IM_COL32(0, 0, 0, 200), countStr); + cdl->AddText(ImVec2(cx2, cy2), + totalCount <= 1 ? IM_COL32(220, 100, 100, 255) : IM_COL32(255, 255, 255, 255), + countStr); + } + } + // Key label below ImGui::TextDisabled("%s", keyLabel); From b8141262d2e889c4380d135f9818826862cf1f93 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:18:39 -0700 Subject: [PATCH 002/105] feat: add low mana pulse and interrupt alert on cast bars - Mana bar pulses dim blue when below 20% (matches health bar low-hp pulse) - Target, focus, and boss cast bars pulse orange when cast is > 80% complete, signalling the closing interrupt window across all frame types --- src/ui/game_screen.cpp | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 867f3779..cc90f1f9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2213,7 +2213,16 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { float mpPct = static_cast(power) / static_cast(maxPower); ImVec4 powerColor; switch (powerType) { - case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue) + case 0: { + // Mana: pulse desaturated blue when critically low (< 20%) + if (mpPct < 0.2f) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + powerColor = ImVec4(0.1f, 0.1f, 0.8f * pulse, 1.0f); + } else { + powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); + } + break; + } case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red) case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange) case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow) @@ -2729,7 +2738,15 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float castLeft = gameHandler.getTargetCastTimeRemaining(); uint32_t tspell = gameHandler.getTargetCastSpellId(); const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Pulse bright orange when cast is > 80% complete — interrupt window closing + ImVec4 castBarColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + castBarColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + castBarColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor); char castLabel[72]; if (!castName.empty()) snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); @@ -3099,7 +3116,15 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { float rem = focusCast->timeRemaining; float prog = std::clamp(1.0f - rem / total, 0.f, 1.f); const std::string& spName = gameHandler.getSpellName(focusCast->spellId); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Pulse orange when > 80% complete — interrupt window closing + ImVec4 focusCastColor; + if (prog > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + focusCastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + focusCastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, focusCastColor); char castBuf[64]; if (!spName.empty()) snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); @@ -7108,7 +7133,15 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { uint32_t bspell = cs->spellId; const std::string& bcastName = (bspell != 0) ? gameHandler.getSpellName(bspell) : ""; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Pulse bright orange when > 80% complete — interrupt window closing + ImVec4 bcastColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + bcastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + bcastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor); char bcastLabel[72]; if (!bcastName.empty()) snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)", From 10e9e94a73bfce243f5985549bb67549f6fc51ae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:21:33 -0700 Subject: [PATCH 003/105] feat: add interrupt pulse to nameplate cast bars for hostile casters --- src/ui/game_screen.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cc90f1f9..cb81dc4b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6363,9 +6363,15 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { 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 + // Cast bar background + fill (pulse orange when >80% = interrupt window closing) + ImU32 cbBg = IM_COL32(40, 30, 60, A(180)); + ImU32 cbFill; + if (castPct > 0.8f && unit->isHostile()) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + cbFill = IM_COL32(static_cast(255 * pulse), static_cast(130 * pulse), 0, A(220)); + } else { + 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), From 2e504232ecb0610822ed439ba0518e75ba977a2a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:24:37 -0700 Subject: [PATCH 004/105] feat: add item icons and full tooltips to inspect window gear list --- src/ui/game_screen.cpp | 47 ++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cb81dc4b..6b72c1ca 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15129,6 +15129,7 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::TextDisabled("(Gear loads after the player is inspected in-range)"); } else { if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { + constexpr float kIconSz = 28.0f; for (int s = 0; s < 19; ++s) { uint32_t entry = result->itemEntries[s]; if (entry == 0) continue; @@ -15136,24 +15137,48 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); if (!info) { gameHandler.ensureItemInfo(entry); + ImGui::PushID(s); ImGui::TextDisabled("[%s] (loading…)", kSlotNames[s]); + ImGui::PopID(); continue; } - ImGui::TextDisabled("%s", kSlotNames[s]); - ImGui::SameLine(90); + ImGui::PushID(s); 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(); + + // Item icon + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(kIconSz, kIconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(1,1,1,1), qColor); + } else { + ImGui::GetWindowDrawList()->AddRectFilled( + ImGui::GetCursorScreenPos(), + ImVec2(ImGui::GetCursorScreenPos().x + kIconSz, + ImGui::GetCursorScreenPos().y + kIconSz), + IM_COL32(40, 40, 50, 200)); + ImGui::Dummy(ImVec2(kIconSz, kIconSz)); } + bool hovered = ImGui::IsItemHovered(); + + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (kIconSz - ImGui::GetTextLineHeight()) * 0.5f); + ImGui::BeginGroup(); + ImGui::TextDisabled("%s", kSlotNames[s]); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + ImGui::EndGroup(); + hovered = hovered || ImGui::IsItemHovered(); + + if (hovered && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered) { + ImGui::SetTooltip("%s", info->name.c_str()); + } + + ImGui::PopID(); + ImGui::Spacing(); } } ImGui::EndChild(); From 7475a4fff3067be47ab2023d67ab23784b25d819 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:27:26 -0700 Subject: [PATCH 005/105] feat: add persistent coordinate display below minimap Always-visible player coordinates (X, Y in canonical WoW space) rendered as warm-yellow text on a semi-transparent pill just below the minimap circle, eliminating the need to hover for position info. --- src/ui/game_screen.cpp | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6b72c1ca..f634609e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12074,17 +12074,37 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Persistent coordinate display below the minimap + { + glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender); + char coordBuf[32]; + std::snprintf(coordBuf, sizeof(coordBuf), "%.1f, %.1f", playerCanon.x, playerCanon.y); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, coordBuf); + + float tx = centerX - textSz.x * 0.5f; + float ty = centerY + mapRadius + 3.0f; + + // Semi-transparent dark background pill + float pad = 3.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + textSz.x + pad, ty + textSz.y + pad), + IM_COL32(0, 0, 0, 140), 4.0f); + // Coordinate text in warm yellow + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); + } + // 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::TextColored(ImVec4(0.9f, 0.9f, 0.5f, 1.0f), "Ctrl+click to ping"); ImGui::EndTooltip(); } } From 1b3dc52563f931b1ad2c729d80e62ab4ddf5f168 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:31:01 -0700 Subject: [PATCH 006/105] feat: improve party frame dead/offline member display Dead party members now show a gray "Dead" progress bar instead of "0/2000" health values, and offline members show a dimmed "Offline" bar. The power bar is suppressed for both states to reduce clutter. --- src/ui/game_screen.cpp | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f634609e..6930b9ed 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6800,7 +6800,30 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { maxHp = unit->getMaxHealth(); } } - if (maxHp > 0) { + // Check dead/ghost state for health bar rendering + bool memberDead = false; + bool memberOffline = false; + if (member.hasPartyStats) { + bool isOnline2 = (member.onlineStatus & 0x0001) != 0; + bool isDead2 = (member.onlineStatus & 0x0020) != 0; + bool isGhost2 = (member.onlineStatus & 0x0010) != 0; + memberDead = isDead2 || isGhost2; + memberOffline = !isOnline2; + } + + if (memberDead) { + // Gray "Dead" bar for fallen party members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Dead"); + ImGui::PopStyleColor(2); + } else if (memberOffline) { + // Dim bar for offline members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.25f, 0.25f, 0.25f, 0.6f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.1f, 0.1f, 0.1f, 0.6f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Offline"); + ImGui::PopStyleColor(2); + } else if (maxHp > 0) { float pct = static_cast(hp) / static_cast(maxHp); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : @@ -6816,8 +6839,8 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - // Power bar (mana/rage/energy) from party stats - if (member.hasPartyStats && member.maxPower > 0) { + // Power bar (mana/rage/energy) from party stats — hidden for dead/offline + if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) { float powerPct = static_cast(member.curPower) / static_cast(member.maxPower); ImVec4 powerColor; switch (member.powerType) { From f04a5c8f3e8f267aa148bddb7d1ae79682341454 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:34:00 -0700 Subject: [PATCH 007/105] feat: add buff expiry color warning on timer overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buff/debuff countdown timers now change color as expiry approaches: white (>30s) → orange (<30s) → pulsing red (<10s). This gives players a clear visual cue to reapply important buffs before they fall off. --- src/ui/game_screen.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6930b9ed..a7aceb78 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8676,11 +8676,26 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(timeStr); float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; float cy = iconMax.y - textSize.y - 2.0f; + // Choose timer color based on urgency + ImU32 timerColor; + if (remainMs < 10000) { + // < 10s: pulse red + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + timerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (remainMs < 30000) { + timerColor = IM_COL32(255, 165, 0, 255); // orange + } else { + timerColor = IM_COL32(255, 255, 255, 255); // white + } // Drop shadow for readability over any icon colour ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 200), timeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 255, 255), timeStr); + timerColor, timeStr); } // Stack / charge count overlay — upper-left corner of the icon From 271518ee08d720eff9355f92f5b69fe4ba744152 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:39:38 -0700 Subject: [PATCH 008/105] feat: use WoW standard class colors for player name in player frame Player name in the unit frame now shows in the official WoW class color (warrior=tan, paladin=pink, hunter=green, rogue=yellow, priest=white, DK=red, shaman=blue, mage=cyan, warlock=purple, druid=orange) matching the familiar in-game appearance. --- src/ui/game_screen.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a7aceb78..55359b65 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2121,8 +2121,25 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { playerHp = playerMaxHp; } - // 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)); + // Derive class color (WoW standard class colors) + ImVec4 classColor(0.3f, 1.0f, 0.3f, 1.0f); // default green + if (activeChar) { + switch (activeChar->characterClass) { + case game::Class::WARRIOR: classColor = ImVec4(0.78f, 0.61f, 0.43f, 1.0f); break; + case game::Class::PALADIN: classColor = ImVec4(0.96f, 0.55f, 0.73f, 1.0f); break; + case game::Class::HUNTER: classColor = ImVec4(0.67f, 0.83f, 0.45f, 1.0f); break; + case game::Class::ROGUE: classColor = ImVec4(1.00f, 0.96f, 0.41f, 1.0f); break; + case game::Class::PRIEST: classColor = ImVec4(1.00f, 1.00f, 1.00f, 1.0f); break; + case game::Class::DEATH_KNIGHT: classColor = ImVec4(0.77f, 0.12f, 0.23f, 1.0f); break; + case game::Class::SHAMAN: classColor = ImVec4(0.00f, 0.44f, 0.87f, 1.0f); break; + case game::Class::MAGE: classColor = ImVec4(0.41f, 0.80f, 0.94f, 1.0f); break; + case game::Class::WARLOCK: classColor = ImVec4(0.58f, 0.51f, 0.79f, 1.0f); break; + case game::Class::DRUID: classColor = ImVec4(1.00f, 0.49f, 0.04f, 1.0f); break; + } + } + + // Name in class color — clickable for self-target, right-click for menu + ImGui::PushStyleColor(ImGuiCol_Text, classColor); if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) { gameHandler.setTarget(gameHandler.getPlayerGuid()); } From fb6e7c7b572454a139521c066c8681b2197b7ecf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:42:48 -0700 Subject: [PATCH 009/105] feat: color-code quest tracker objectives green when complete Completed kill/item objectives now display in green instead of gray, giving an immediate visual cue about which objectives are done vs. still in progress on the on-screen quest tracker. --- src/ui/game_screen.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 55359b65..896eff16 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5831,28 +5831,33 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { if (q.complete) { ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), " (Complete)"); } else { - // Kill counts + // Kill counts — green when complete, gray when in progress for (const auto& [entry, progress] : q.killCounts) { + bool objDone = (progress.first >= progress.second && progress.second > 0); + ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); std::string name = gameHandler.getCachedCreatureName(entry); if (name.empty()) { - // May be a game object objective; fall back to GO name cache. const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); if (goInfo && !goInfo->name.empty()) name = goInfo->name; } if (!name.empty()) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " %s: %u/%u", name.c_str(), progress.first, progress.second); } else { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " %u/%u", progress.first, progress.second); } } - // Item counts + // Item counts — green when complete, gray when in progress for (const auto& [itemId, count] : q.itemCounts) { uint32_t required = 1; auto reqIt = q.requiredItemCounts.find(itemId); if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; + bool objDone = (count >= required); + ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); const auto* info = gameHandler.getItemInfo(itemId); const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; @@ -5862,13 +5867,13 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { 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), + ImGui::TextColored(objColor, "%s: %u/%u", itemName ? itemName : "Item", count, required); } else if (itemName) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " %s: %u/%u", itemName, count, required); } else { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " Item: %u/%u", count, required); } } From 102b34db2f0fd000156be6caa139a1f4c83886a4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:44:48 -0700 Subject: [PATCH 010/105] feat: apply buff expiry color warning to target aura timers Target frame debuff/buff timers now use the same urgency colors as the player buff bar: white >30s, orange <30s, pulsing red <10s. --- src/ui/game_screen.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 896eff16..25e87e42 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2857,10 +2857,24 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(timeStr); float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; float cy = iconMax.y - textSize.y - 1.0f; + // Color by urgency (matches player buff bar) + ImU32 tTimerColor; + if (tRemainMs < 10000) { + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + tTimerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (tRemainMs < 30000) { + tTimerColor = IM_COL32(255, 165, 0, 255); + } else { + tTimerColor = IM_COL32(255, 255, 255, 255); + } ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 200), timeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 255, 255), timeStr); + tTimerColor, timeStr); } // Stack / charge count — upper-left corner From 71df1ccf6ff9136eba722233b2d8996b2354f5fe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:46:25 -0700 Subject: [PATCH 011/105] feat: apply WoW class colors to guild roster class column Online guild members now show their class name in the standard WoW class color in the guild roster table, matching the familiar in-game appearance. Offline members retain the dimmed gray style. --- src/ui/game_screen.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 25e87e42..0f0451ae 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7980,7 +7980,23 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::TableNextColumn(); const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; - ImGui::TextColored(textColor, "%s", className); + // Class-colored text for online members (gray for offline) + ImVec4 classCol = textColor; + if (m.online) { + switch (m.classId) { + case 1: classCol = ImVec4(0.78f, 0.61f, 0.43f, 1.0f); break; // Warrior + case 2: classCol = ImVec4(0.96f, 0.55f, 0.73f, 1.0f); break; // Paladin + case 3: classCol = ImVec4(0.67f, 0.83f, 0.45f, 1.0f); break; // Hunter + case 4: classCol = ImVec4(1.00f, 0.96f, 0.41f, 1.0f); break; // Rogue + case 5: classCol = ImVec4(1.00f, 1.00f, 1.00f, 1.0f); break; // Priest + case 6: classCol = ImVec4(0.77f, 0.12f, 0.23f, 1.0f); break; // Death Knight + case 7: classCol = ImVec4(0.00f, 0.44f, 0.87f, 1.0f); break; // Shaman + case 8: classCol = ImVec4(0.41f, 0.80f, 0.94f, 1.0f); break; // Mage + case 9: classCol = ImVec4(0.58f, 0.51f, 0.79f, 1.0f); break; // Warlock + case 11: classCol = ImVec4(1.00f, 0.49f, 0.04f, 1.0f); break; // Druid + } + } + ImGui::TextColored(classCol, "%s", className); ImGui::TableNextColumn(); // Zone name lookup From b34bf397469347d7bbc8a6cdf175c2b9e85d1654 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:53:03 -0700 Subject: [PATCH 012/105] feat: add quest completion toast notification When a quest is turned in (SMSG_QUESTGIVER_QUEST_COMPLETE), a gold-bordered toast slides in from the right showing "Quest Complete" header with the quest title, consistent with the rep change and achievement toast systems. --- include/game/game_handler.hpp | 7 ++++ include/ui/game_screen.hpp | 7 ++++ src/game/game_handler.cpp | 4 ++ src/ui/game_screen.cpp | 72 +++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 79460a17..72bcdd54 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1330,6 +1330,10 @@ public: using RepChangeCallback = std::function; void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); } + // Quest turn-in completion callback + using QuestCompleteCallback = std::function; + void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -2630,6 +2634,9 @@ private: // ---- Reputation change callback ---- RepChangeCallback repChangeCallback_; + + // ---- Quest completion callback ---- + QuestCompleteCallback questCompleteCallback_; }; } // namespace game diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 709dc502..4ae2a668 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -100,6 +100,12 @@ private: std::vector repToasts_; bool repChangeCallbackSet_ = false; static constexpr float kRepToastLifetime = 3.5f; + + // Quest completion toast: slide-in when a quest is turned in + struct QuestCompleteToastEntry { uint32_t questId = 0; std::string title; float age = 0.0f; }; + std::vector questCompleteToasts_; + bool questCompleteCallbackSet_ = false; + static constexpr float kQuestCompleteToastLifetime = 4.0f; bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; @@ -283,6 +289,7 @@ private: void renderBossFrames(game::GameHandler& gameHandler); void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); void renderRepToasts(float deltaTime); + void renderQuestCompleteToasts(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 b3e57ccc..1d525ecb 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4381,6 +4381,10 @@ void GameHandler::handlePacket(network::Packet& packet) { } for (auto it = questLog_.begin(); it != questLog_.end(); ++it) { if (it->questId == questId) { + // Fire toast callback before erasing + if (questCompleteCallback_) { + questCompleteCallback_(questId, it->title); + } questLog_.erase(it); LOG_INFO(" Removed quest ", questId, " from quest log"); break; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0f0451ae..692365c0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -245,6 +245,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { repChangeCallbackSet_ = true; } + // Set up quest completion toast callback (once) + if (!questCompleteCallbackSet_) { + gameHandler.setQuestCompleteCallback([this](uint32_t id, const std::string& title) { + questCompleteToasts_.push_back({id, title, 0.0f}); + if (questCompleteToasts_.size() > 3) questCompleteToasts_.erase(questCompleteToasts_.begin()); + }); + questCompleteCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -465,6 +474,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDPSMeter(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); + renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); if (showRaidFrames_) { renderPartyFrames(gameHandler); } @@ -7128,6 +7138,68 @@ void GameScreen::renderRepToasts(float deltaTime) { } } +void GameScreen::renderQuestCompleteToasts(float deltaTime) { + for (auto& e : questCompleteToasts_) e.age += deltaTime; + questCompleteToasts_.erase( + std::remove_if(questCompleteToasts_.begin(), questCompleteToasts_.end(), + [](const QuestCompleteToastEntry& e) { return e.age >= kQuestCompleteToastLifetime; }), + questCompleteToasts_.end()); + + if (questCompleteToasts_.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; + + const float toastW = 260.0f; + const float toastH = 40.0f; + const float padY = 4.0f; + const float baseY = screenH - 220.0f; // above rep toasts + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + for (int i = 0; i < static_cast(questCompleteToasts_.size()); ++i) { + const auto& e = questCompleteToasts_[i]; + constexpr float kSlideDur = 0.3f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kQuestCompleteToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + float xFull = screenW - 14.0f - 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 + gold border (quest completion) + draw->AddRectFilled(tl, br, IM_COL32(20, 18, 8, (int)(alpha * 210)), 5.0f); + draw->AddRect(tl, br, IM_COL32(220, 180, 30, (int)(alpha * 230)), 5.0f, 0, 1.5f); + + // Scroll icon placeholder (gold diamond) + float iconCx = tl.x + 18.0f; + float iconCy = tl.y + toastH * 0.5f; + draw->AddCircleFilled(ImVec2(iconCx, iconCy), 7.0f, IM_COL32(210, 170, 20, (int)(alpha * 230))); + draw->AddCircle (ImVec2(iconCx, iconCy), 7.0f, IM_COL32(255, 220, 50, (int)(alpha * 200))); + + // "Quest Complete" header in gold + const char* header = "Quest Complete"; + draw->AddText(font, fontSize * 0.78f, + ImVec2(tl.x + 34.0f, tl.y + 4.0f), + IM_COL32(240, 200, 40, (int)(alpha * 240)), header); + + // Quest title in off-white + const char* titleStr = e.title.empty() ? "Unknown Quest" : e.title.c_str(); + draw->AddText(font, fontSize * 0.82f, + ImVec2(tl.x + 34.0f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(220, 215, 195, (int)(alpha * 220)), titleStr); + } +} + // ============================================================ // Boss Encounter Frames // ============================================================ From b682e8c686b347ed290a8372a48945212c2e831d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:57:36 -0700 Subject: [PATCH 013/105] feat: add countdown timer to loot roll popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a color-coded progress bar (green→yellow→pulsing red) in the loot roll window indicating time remaining to make a roll decision. The countdown duration is read from SMSG_LOOT_START_ROLL (or defaults to 60s for the SMSG_LOOT_ROLL path). Remaining seconds are displayed on the bar itself. --- include/game/game_handler.hpp | 2 ++ src/game/game_handler.cpp | 6 +++++- src/ui/game_screen.cpp | 28 ++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 72bcdd54..6e993d89 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1129,6 +1129,8 @@ public: uint32_t itemId = 0; std::string itemName; uint8_t itemQuality = 0; + uint32_t rollCountdownMs = 60000; // Duration of roll window in ms + std::chrono::steady_clock::time_point rollStartedAt{}; }; bool hasPendingLootRoll() const { return pendingLootRollActive_; } const LootRollEntry& getPendingLootRoll() const { return pendingLootRoll_; } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1d525ecb..8403759c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2028,7 +2028,7 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t randSuffix =*/ packet.readUInt32(); /*uint32_t randProp =*/ packet.readUInt32(); } - /*uint32_t countdown =*/ packet.readUInt32(); + uint32_t countdown = packet.readUInt32(); /*uint8_t voteMask =*/ packet.readUInt8(); // Trigger the roll popup for local player pendingLootRollActive_ = true; @@ -2038,6 +2038,8 @@ void GameHandler::handlePacket(network::Packet& packet) { auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000; + pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot); break; @@ -19931,6 +19933,8 @@ void GameHandler::handleLootRoll(network::Packet& packet) { auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + pendingLootRoll_.rollCountdownMs = 60000; + pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_ROLL: need/greed prompt for item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot); return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 692365c0..dc6a4208 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7657,6 +7657,34 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { uint8_t q = roll.itemQuality; ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; + // Countdown bar + { + auto now = std::chrono::steady_clock::now(); + float elapsedMs = static_cast( + std::chrono::duration_cast(now - roll.rollStartedAt).count()); + float totalMs = static_cast(roll.rollCountdownMs > 0 ? roll.rollCountdownMs : 60000); + float fraction = 1.0f - std::min(elapsedMs / totalMs, 1.0f); + float remainSec = (totalMs - elapsedMs) / 1000.0f; + if (remainSec < 0.0f) remainSec = 0.0f; + + // Color: green → yellow → red + ImVec4 barColor; + if (fraction > 0.5f) + barColor = ImVec4(0.2f + (1.0f - fraction) * 1.4f, 0.85f, 0.2f, 1.0f); + else if (fraction > 0.2f) + barColor = ImVec4(1.0f, fraction * 1.7f, 0.1f, 1.0f); + else { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 6.0f); + barColor = ImVec4(pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); + } + + char timeBuf[16]; + std::snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remainSec); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + ImGui::ProgressBar(fraction, ImVec2(-1, 12), timeBuf); + ImGui::PopStyleColor(); + } + ImGui::Text("An item is up for rolls:"); // Show item icon if available From 0a03bf902805ba70c58d45c7aef77016b84a23fc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 04:59:24 -0700 Subject: [PATCH 014/105] feat: add cast bar to pet frame Show an orange cast bar in the pet frame when the pet is casting a spell, matching the party frame cast bar pattern. Displays spell name and time remaining; falls back to 'Casting...' when spell name is unavailable from Spell.dbc. --- 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 dc6a4208..894bb817 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2448,6 +2448,22 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Pet cast bar + if (auto* pcs = gameHandler.getUnitCastState(petGuid)) { + float castPct = (pcs->timeTotal > 0.0f) + ? (pcs->timeTotal - pcs->timeRemaining) / pcs->timeTotal : 0.0f; + // Orange color to distinguish from health/power bars + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.85f, 0.55f, 0.1f, 1.0f)); + char petCastLabel[48]; + const std::string& spellNm = gameHandler.getSpellName(pcs->spellId); + if (!spellNm.empty()) + snprintf(petCastLabel, sizeof(petCastLabel), "%s (%.1fs)", spellNm.c_str(), pcs->timeRemaining); + else + snprintf(petCastLabel, sizeof(petCastLabel), "Casting... (%.1fs)", pcs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), petCastLabel); + ImGui::PopStyleColor(); + } + // Dismiss button (compact, right-aligned) ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); if (ImGui::SmallButton("Dismiss")) { From 29a989e1f42d4e46ed62897b73fd895690f7f217 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:03:03 -0700 Subject: [PATCH 015/105] feat: add reputation bar above XP bar Show a color-coded reputation progress bar for the most recently gained faction above the XP bar. The bar is auto-shown when any faction rep changes (watchedFactionId_ tracks the last changed faction). Colors follow WoW conventions: red=Hated/Hostile, orange=Unfriendly, yellow=Neutral, green=Friendly, blue=Honored, purple=Revered, gold=Exalted. Tooltip shows exact standing values on hover. --- include/game/game_handler.hpp | 3 + include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 1 + src/ui/game_screen.cpp | 121 ++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 6e993d89..03bcecd4 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1270,6 +1270,8 @@ public: const std::vector& getInitialFactions() const { return initialFactions_; } const std::unordered_map& getFactionStandings() const { return factionStandings_; } const std::string& getFactionNamePublic(uint32_t factionId) const; + uint32_t getWatchedFactionId() const { return watchedFactionId_; } + void setWatchedFactionId(uint32_t id) { watchedFactionId_ = id; } uint32_t getLastContactListMask() const { return lastContactListMask_; } uint32_t getLastContactListCount() const { return lastContactListCount_; } bool isServerMovementAllowed() const { return serverMovementAllowed_; } @@ -2636,6 +2638,7 @@ private: // ---- Reputation change callback ---- RepChangeCallback repChangeCallback_; + uint32_t watchedFactionId_ = 0; // auto-set to most recently changed faction // ---- Quest completion callback ---- QuestCompleteCallback questCompleteCallback_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 4ae2a668..a8ac222d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -281,6 +281,7 @@ private: void renderActionBar(game::GameHandler& gameHandler); void renderBagBar(game::GameHandler& gameHandler); void renderXpBar(game::GameHandler& gameHandler); + void renderRepBar(game::GameHandler& gameHandler); void renderCastBar(game::GameHandler& gameHandler); void renderMirrorTimers(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8403759c..b68d6d02 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3546,6 +3546,7 @@ void GameHandler::handlePacket(network::Packet& packet) { delta > 0 ? "increased" : "decreased", std::abs(delta)); addSystemChatMessage(buf); + watchedFactionId_ = factionId; 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 894bb817..fe76f89c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -464,6 +464,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderActionBar(gameHandler); renderBagBar(gameHandler); renderXpBar(gameHandler); + renderRepBar(gameHandler); renderCastBar(gameHandler); renderMirrorTimers(gameHandler); renderQuestObjectiveTracker(gameHandler); @@ -5661,6 +5662,126 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ============================================================ +// Reputation Bar +// ============================================================ + +void GameScreen::renderRepBar(game::GameHandler& gameHandler) { + uint32_t factionId = gameHandler.getWatchedFactionId(); + if (factionId == 0) return; + + const auto& standings = gameHandler.getFactionStandings(); + auto it = standings.find(factionId); + if (it == standings.end()) return; + + int32_t standing = it->second; + + // WoW reputation rank thresholds + struct RepRank { const char* name; int32_t min; int32_t max; ImU32 color; }; + static const RepRank kRanks[] = { + { "Hated", -42000, -6001, IM_COL32(180, 40, 40, 255) }, + { "Hostile", -6000, -3001, IM_COL32(180, 40, 40, 255) }, + { "Unfriendly", -3000, -1, IM_COL32(220, 100, 50, 255) }, + { "Neutral", 0, 2999, IM_COL32(200, 200, 60, 255) }, + { "Friendly", 3000, 8999, IM_COL32( 60, 180, 60, 255) }, + { "Honored", 9000, 20999, IM_COL32( 60, 160, 220, 255) }, + { "Revered", 21000, 41999, IM_COL32(140, 80, 220, 255) }, + { "Exalted", 42000, 42999, IM_COL32(255, 200, 50, 255) }, + }; + constexpr int kNumRanks = static_cast(sizeof(kRanks) / sizeof(kRanks[0])); + + int rankIdx = kNumRanks - 1; // default to Exalted + for (int i = 0; i < kNumRanks; ++i) { + if (standing <= kRanks[i].max) { rankIdx = i; break; } + } + const RepRank& rank = kRanks[rankIdx]; + + float fraction = 1.0f; + if (rankIdx < kNumRanks - 1) { + float range = static_cast(rank.max - rank.min + 1); + fraction = static_cast(standing - rank.min) / range; + fraction = std::max(0.0f, std::min(1.0f, fraction)); + } + + const std::string& factionName = gameHandler.getFactionNamePublic(factionId); + + // Position directly above the XP bar + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + float slotSize = 48.0f * pendingActionBarScale; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH_ab = slotSize + 24.0f; + float xpBarH = 20.0f; + float repBarH = 12.0f; + float xpBarW = barW; + float xpBarX = (screenW - xpBarW) / 2.0f; + + float bar1TopY = screenH - barH_ab; + float xpBarY; + if (pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH_ab - 2.0f + pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } + float repBarY = xpBarY - repBarH - 2.0f; + + ImGui::SetNextWindowPos(ImVec2(xpBarX, repBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(xpBarW, repBarH + 4.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); + + if (ImGui::Begin("##RepBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, repBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* dl = ImGui::GetWindowDrawList(); + + dl->AddRectFilled(barMin, barMax, IM_COL32(15, 15, 20, 220), 2.0f); + dl->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); + + float fillW = barSize.x * fraction; + if (fillW > 0.0f) + dl->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), rank.color, 2.0f); + + // Label: "FactionName - Rank" + char label[96]; + snprintf(label, sizeof(label), "%s - %s", factionName.c_str(), rank.name); + ImVec2 textSize = ImGui::CalcTextSize(label); + float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; + float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; + dl->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), label); + + // Tooltip with exact values on hover + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + float cr = ((rank.color ) & 0xFF) / 255.0f; + float cg = ((rank.color >> 8) & 0xFF) / 255.0f; + float cb = ((rank.color >> 16) & 0xFF) / 255.0f; + ImGui::TextColored(ImVec4(cr, cg, cb, 1.0f), "%s", rank.name); + int32_t rankMin = rank.min; + int32_t rankMax = (rankIdx < kNumRanks - 1) ? rank.max : 42000; + ImGui::Text("%s: %d / %d", factionName.c_str(), standing - rankMin, rankMax - rankMin + 1); + ImGui::EndTooltip(); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); +} + // ============================================================ // Cast Bar (Phase 3) // ============================================================ From c35bf8d953430a9fd2dd3ddca64a9b30261a9a07 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:06:14 -0700 Subject: [PATCH 016/105] feat: add duel countdown overlay (3-2-1-Fight!) Parse SMSG_DUEL_COUNTDOWN to get the countdown duration, track the start time, and render a large centered countdown overlay. Numbers display in pulsing gold; transitions to pulsing red 'Fight!' for the last 0.5 seconds. Countdown clears on SMSG_DUEL_COMPLETE. --- include/game/game_handler.hpp | 10 +++++++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 12 ++++++++-- src/ui/game_screen.cpp | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 03bcecd4..a182822b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1035,6 +1035,14 @@ public: const std::string& getDuelChallengerName() const { return duelChallengerName_; } void acceptDuel(); // forfeitDuel() already declared at line ~399 + // Returns remaining duel countdown seconds, or 0 if no active countdown + float getDuelCountdownRemaining() const { + if (duelCountdownMs_ == 0) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - duelCountdownStartedAt_).count(); + float rem = (static_cast(duelCountdownMs_) - static_cast(elapsed)) / 1000.0f; + return rem > 0.0f ? rem : 0.0f; + } // ---- Instance lockouts ---- struct InstanceLockout { @@ -2251,6 +2259,8 @@ private: uint64_t duelChallengerGuid_= 0; uint64_t duelFlagGuid_ = 0; std::string duelChallengerName_; + uint32_t duelCountdownMs_ = 0; // 0 = no active countdown + std::chrono::steady_clock::time_point duelCountdownStartedAt_{}; // ---- Guild state ---- std::string guildName_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index a8ac222d..417a6609 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -293,6 +293,7 @@ private: void renderQuestCompleteToasts(float deltaTime); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); + void renderDuelCountdown(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); void renderTradeRequestPopup(game::GameHandler& gameHandler); void renderTradeWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b68d6d02..ab4b0c99 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3214,9 +3214,16 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DUEL_INBOUNDS: // Re-entered the duel area; no special action needed. break; - case Opcode::SMSG_DUEL_COUNTDOWN: - // Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update. + case Opcode::SMSG_DUEL_COUNTDOWN: { + // uint32 countdown in milliseconds (typically 3000 ms) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t ms = packet.readUInt32(); + duelCountdownMs_ = (ms > 0 && ms <= 30000) ? ms : 3000; + duelCountdownStartedAt_ = std::chrono::steady_clock::now(); + LOG_INFO("SMSG_DUEL_COUNTDOWN: ", duelCountdownMs_, " ms"); + } break; + } case Opcode::SMSG_PARTYKILLLOG: { // uint64 killerGuid + uint64 victimGuid if (packet.getSize() - packet.getReadPos() < 16) break; @@ -10472,6 +10479,7 @@ void GameHandler::handleDuelComplete(network::Packet& packet) { uint8_t started = packet.readUInt8(); // started=1: duel began, started=0: duel was cancelled before starting pendingDuelRequest_ = false; + duelCountdownMs_ = 0; // clear countdown once duel is resolved if (!started) { addSystemChatMessage("The duel was cancelled."); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fe76f89c..23159d83 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -482,6 +482,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBossFrames(gameHandler); renderGroupInvitePopup(gameHandler); renderDuelRequestPopup(gameHandler); + renderDuelCountdown(gameHandler); renderLootRollPopup(gameHandler); renderTradeRequestPopup(gameHandler); renderTradeWindow(gameHandler); @@ -7488,6 +7489,47 @@ void GameScreen::renderDuelRequestPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderDuelCountdown(game::GameHandler& gameHandler) { + float remaining = gameHandler.getDuelCountdownRemaining(); + if (remaining <= 0.0f) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + auto* dl = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + // Show integer countdown or "Fight!" when under 0.5s + char buf[32]; + if (remaining > 0.5f) { + snprintf(buf, sizeof(buf), "%d", static_cast(std::ceil(remaining))); + } else { + snprintf(buf, sizeof(buf), "Fight!"); + } + + // Large font by scaling — use 4x font size for dramatic effect + float scale = 4.0f; + float scaledSize = fontSize * scale; + ImVec2 textSz = font->CalcTextSizeA(scaledSize, FLT_MAX, 0.0f, buf); + float tx = (screenW - textSz.x) * 0.5f; + float ty = screenH * 0.35f - textSz.y * 0.5f; + + // Pulsing alpha: fades in and out per second + float pulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 6.28f); + uint8_t alpha = static_cast(255 * pulse); + + // Color: golden countdown, red "Fight!" + ImU32 color = (remaining > 0.5f) + ? IM_COL32(255, 200, 50, alpha) + : IM_COL32(255, 60, 60, alpha); + + // Drop shadow + dl->AddText(font, scaledSize, ImVec2(tx + 2.0f, ty + 2.0f), IM_COL32(0, 0, 0, alpha / 2), buf); + dl->AddText(font, scaledSize, ImVec2(tx, ty), color, buf); +} + void GameScreen::renderItemTextWindow(game::GameHandler& gameHandler) { if (!gameHandler.isItemTextOpen()) return; From 8efdaed7e47fe4d5f1a363f9a5ecc7e1d6336607 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:12:58 -0700 Subject: [PATCH 017/105] feat: add gold glow when action bar spell comes off cooldown When a spell's cooldown expires, its action bar slot briefly animates with a pulsing gold border (4 pulses over 1.5 seconds, fading out) to draw attention that the ability is ready again. Uses per-slot state tracking with static maps inside the render lambda. --- src/ui/game_screen.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 23159d83..a469d94c 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5127,6 +5127,40 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } + // Ready glow: animate a gold border for ~1.5s when a cooldown just expires + { + static std::unordered_map slotGlowTimers; // absSlot -> remaining glow seconds + static std::unordered_map slotWasOnCooldown; // absSlot -> last frame state + + float dt = ImGui::GetIO().DeltaTime; + bool wasOnCd = slotWasOnCooldown.count(absSlot) ? slotWasOnCooldown[absSlot] : false; + + // Trigger glow when transitioning from on-cooldown to ready (and slot isn't empty) + if (wasOnCd && !onCooldown && !slot.isEmpty()) { + slotGlowTimers[absSlot] = 1.5f; + } + slotWasOnCooldown[absSlot] = onCooldown; + + auto git = slotGlowTimers.find(absSlot); + if (git != slotGlowTimers.end() && git->second > 0.0f) { + git->second -= dt; + float t = git->second / 1.5f; // 1.0 → 0.0 over lifetime + // Pulse: bright when fresh, fading out + float pulse = std::sin(t * IM_PI * 4.0f) * 0.5f + 0.5f; // 4 pulses + uint8_t alpha = static_cast(200 * t * (0.5f + 0.5f * pulse)); + if (alpha > 0) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* gdl = ImGui::GetWindowDrawList(); + // Gold glow border (2px inset, 3px thick) + gdl->AddRect(ImVec2(bMin.x - 2, bMin.y - 2), + ImVec2(bMax.x + 2, bMax.y + 2), + IM_COL32(255, 215, 0, alpha), 3.0f, 0, 3.0f); + } + if (git->second <= 0.0f) slotGlowTimers.erase(git); + } + } + // Key label below ImGui::TextDisabled("%s", keyLabel); From 5827a8fcdd3a4ab4b57facefd3df14b205b57551 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:16:43 -0700 Subject: [PATCH 018/105] feat: add Shaman totem bar in player frame Store active totem state (slot, spellId, duration, placedAt) from SMSG_TOTEM_CREATED. Render 4 element slots (Earth/Fire/Water/Air) as color-coded duration bars in the player frame for Shamans (class 7). Shows countdown seconds, element letter when inactive, and tooltip with spell name + remaining time on hover. --- include/game/game_handler.hpp | 23 ++++++++++ src/game/game_handler.cpp | 5 +++ src/ui/game_screen.cpp | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a182822b..52ecb967 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1277,6 +1277,26 @@ public: }; const std::vector& getInitialFactions() const { return initialFactions_; } const std::unordered_map& getFactionStandings() const { return factionStandings_; } + // Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air) + struct TotemSlot { + uint32_t spellId = 0; + uint32_t durationMs = 0; + std::chrono::steady_clock::time_point placedAt{}; + bool active() const { return spellId != 0 && remainingMs() > 0; } + float remainingMs() const { + if (spellId == 0 || durationMs == 0) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - placedAt).count(); + float rem = static_cast(durationMs) - static_cast(elapsed); + return rem > 0.0f ? rem : 0.0f; + } + }; + static constexpr int NUM_TOTEM_SLOTS = 4; + const TotemSlot& getTotemSlot(int slot) const { + static TotemSlot empty; + return (slot >= 0 && slot < NUM_TOTEM_SLOTS) ? activeTotemSlots_[slot] : empty; + } + const std::string& getFactionNamePublic(uint32_t factionId) const; uint32_t getWatchedFactionId() const { return watchedFactionId_; } void setWatchedFactionId(uint32_t id) { watchedFactionId_ = id; } @@ -2254,6 +2274,9 @@ private: uint64_t myTradeGold_ = 0; uint64_t peerTradeGold_ = 0; + // Shaman totem state + TotemSlot activeTotemSlots_[NUM_TOTEM_SLOTS]; + // Duel state bool pendingDuelRequest_ = false; uint64_t duelChallengerGuid_= 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ab4b0c99..7168a37d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3061,6 +3061,11 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t spellId = packet.readUInt32(); LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, " spellId=", spellId, " duration=", duration, "ms"); + if (slot < NUM_TOTEM_SLOTS) { + activeTotemSlots_[slot].spellId = spellId; + activeTotemSlots_[slot].durationMs = duration; + activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now(); + } break; } case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a469d94c..3a7b8343 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2348,6 +2348,85 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } } } + + // Shaman totem bar (class 7) — 4 slots: Earth, Fire, Water, Air + if (gameHandler.getPlayerClass() == 7) { + static const ImVec4 kTotemColors[] = { + ImVec4(0.80f, 0.55f, 0.25f, 1.0f), // Earth — brown + ImVec4(1.00f, 0.35f, 0.10f, 1.0f), // Fire — orange-red + ImVec4(0.20f, 0.55f, 0.90f, 1.0f), // Water — blue + ImVec4(0.70f, 0.90f, 1.00f, 1.0f), // Air — pale sky + }; + static const char* kTotemNames[] = { "Earth", "Fire", "Water", "Air" }; + + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + float spacing = 3.0f; + float slotW = (totalW - spacing * 3.0f) / 4.0f; + float slotH = 14.0f; + ImDrawList* tdl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; i++) { + const auto& ts = gameHandler.getTotemSlot(i); + float x0 = cursor.x + i * (slotW + spacing); + float y0 = cursor.y; + float x1 = x0 + slotW; + float y1 = y0 + slotH; + + // Background + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), IM_COL32(20, 20, 20, 200), 2.0f); + + if (ts.active()) { + float rem = ts.remainingMs(); + float frac = rem / static_cast(ts.durationMs); + float fillX = x0 + (x1 - x0) * frac; + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), + ImGui::ColorConvertFloat4ToU32(kTotemColors[i]), 2.0f); + // Remaining seconds label + char secBuf[8]; + snprintf(secBuf, sizeof(secBuf), "%.0f", rem / 1000.0f); + ImVec2 tsz = ImGui::CalcTextSize(secBuf); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx + 1, ly + 1), IM_COL32(0, 0, 0, 180), secBuf); + tdl->AddText(ImVec2(lx, ly), IM_COL32(255, 255, 255, 230), secBuf); + } else { + // Inactive — show element letter + const char* letter = kTotemNames[i]; + char single[2] = { letter[0], '\0' }; + ImVec2 tsz = ImGui::CalcTextSize(single); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx, ly), IM_COL32(80, 80, 80, 200), single); + } + + // Border + ImU32 borderCol = ts.active() + ? ImGui::ColorConvertFloat4ToU32(kTotemColors[i]) + : IM_COL32(60, 60, 60, 160); + tdl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); + + // Tooltip on hover + ImGui::SetCursorScreenPos(ImVec2(x0, y0)); + ImGui::InvisibleButton(("##totem" + std::to_string(i)).c_str(), ImVec2(slotW, slotH)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + if (ts.active()) { + const std::string& spellNm = gameHandler.getSpellName(ts.spellId); + ImGui::TextColored(ImVec4(kTotemColors[i].x, kTotemColors[i].y, + kTotemColors[i].z, 1.0f), + "%s Totem", kTotemNames[i]); + if (!spellNm.empty()) ImGui::Text("%s", spellNm.c_str()); + ImGui::Text("%.1fs remaining", ts.remainingMs() / 1000.0f); + } else { + ImGui::TextDisabled("%s Totem (empty)", kTotemNames[i]); + } + ImGui::EndTooltip(); + } + } + ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 2.0f)); + } } ImGui::End(); From f5d67c3c7fe665e4a73387fb4cddf3a89243612c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:20:44 -0700 Subject: [PATCH 019/105] feat: add Shift+hover item comparison in vendor window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend renderItemTooltip(ItemQueryResponseData) to accept an optional Inventory* parameter. When Shift is held and an equipped item in the same slot exists, show: equipped item name, item level diff (▲/▼/=), and stat diffs for Armor/Str/Agi/Sta/Int/Spi. Pass the player's inventory from the vendor window hover handler to enable this. --- include/ui/inventory_screen.hpp | 2 +- src/ui/game_screen.cpp | 2 +- src/ui/inventory_screen.cpp | 44 ++++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 3453e966..31cae856 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -96,7 +96,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); - void renderItemTooltip(const game::ItemQueryResponseData& info); + void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr); private: // Character model preview diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3a7b8343..e08475c6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10173,7 +10173,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImVec4 qc = InventoryScreen::getQualityColor(static_cast(info->quality)); ImGui::TextColored(qc, "%s", info->name.c_str()); if (ImGui::IsItemHovered()) { - inventoryScreen.renderItemTooltip(*info); + inventoryScreen.renderItemTooltip(*info, &gameHandler.getInventory()); } // Shift-click: insert item link into chat if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index e5735977..8b80be85 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2321,7 +2321,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I // --------------------------------------------------------------------------- // Tooltip overload for ItemQueryResponseData (used by loot window, etc.) // --------------------------------------------------------------------------- -void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) { +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory) { ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(static_cast(info.quality)); @@ -2480,6 +2480,48 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); } + // Shift-hover: compare with currently equipped item + if (inventory && ImGui::GetIO().KeyShift && info.inventoryType > 0) { + if (const game::ItemSlot* eq = findComparableEquipped(*inventory, static_cast(info.inventoryType))) { + ImGui::Separator(); + ImGui::TextDisabled("Equipped:"); + VkDescriptorSet eqIcon = getItemIcon(eq->item.displayInfoId); + if (eqIcon) { ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); ImGui::SameLine(); } + ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + + auto showDiff = [](const char* label, float nv, float ev) { + if (nv == 0.0f && ev == 0.0f) return; + float diff = nv - ev; + char buf[96]; + if (diff > 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, nv, diff); ImGui::TextColored(ImVec4(0.0f,1.0f,0.0f,1.0f), "%s", buf); } + else if (diff < 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, nv, -diff); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "%s", buf); } + else { std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, nv); ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", buf); } + }; + + float ilvlDiff = static_cast(info.itemLevel) - static_cast(eq->item.itemLevel); + if (info.itemLevel > 0 || eq->item.itemLevel > 0) { + char ilvlBuf[64]; + if (ilvlDiff > 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", info.itemLevel, ilvlDiff); + else if (ilvlDiff < 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", info.itemLevel, -ilvlDiff); + else std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", info.itemLevel); + ImVec4 ic = ilvlDiff > 0 ? ImVec4(0,1,0,1) : ilvlDiff < 0 ? ImVec4(1,0.3f,0.3f,1) : ImVec4(0.7f,0.7f,0.7f,1); + ImGui::TextColored(ic, "%s", ilvlBuf); + } + + showDiff("Armor", static_cast(info.armor), static_cast(eq->item.armor)); + showDiff("Str", static_cast(info.strength), static_cast(eq->item.strength)); + showDiff("Agi", static_cast(info.agility), static_cast(eq->item.agility)); + showDiff("Sta", static_cast(info.stamina), static_cast(eq->item.stamina)); + showDiff("Int", static_cast(info.intellect), static_cast(eq->item.intellect)); + showDiff("Spi", static_cast(info.spirit), static_cast(eq->item.spirit)); + + // Hint text + ImGui::TextDisabled("Hold Shift to compare"); + } + } else if (info.inventoryType > 0) { + ImGui::TextDisabled("Hold Shift to compare"); + } + ImGui::EndTooltip(); } From 162fd790efeb1f32efbe95096cd886bddda65fc9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:25:46 -0700 Subject: [PATCH 020/105] feat: add right-click context menu to minimap Adds a WoW-style popup on right-click within the minimap circle with: - Zoom In / Zoom Out controls - Rotate with Camera toggle (with checkmark state) - Square Shape toggle (with checkmark state) - Show NPC Dots toggle (with checkmark state) --- src/ui/game_screen.cpp | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e08475c6..71861f2d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12579,15 +12579,54 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); } - // Hover tooltip: show player's WoW coordinates (canonical X=North, Y=West) + // Hover tooltip and right-click context menu { ImVec2 mouse = ImGui::GetMousePos(); float mdx = mouse.x - centerX; float mdy = mouse.y - centerY; - if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { + bool overMinimap = (mdx * mdx + mdy * mdy <= mapRadius * mapRadius); + + if (overMinimap) { ImGui::BeginTooltip(); ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.5f, 1.0f), "Ctrl+click to ping"); ImGui::EndTooltip(); + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup("##minimapContextMenu"); + } + } + + if (ImGui::BeginPopup("##minimapContextMenu")) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Minimap"); + ImGui::Separator(); + + // Zoom controls + if (ImGui::MenuItem("Zoom In")) { + minimap->zoomIn(); + } + if (ImGui::MenuItem("Zoom Out")) { + minimap->zoomOut(); + } + + ImGui::Separator(); + + // Toggle options with checkmarks + bool rotWithCam = minimap->isRotateWithCamera(); + if (ImGui::MenuItem("Rotate with Camera", nullptr, rotWithCam)) { + minimap->setRotateWithCamera(!rotWithCam); + } + + bool squareShape = minimap->isSquareShape(); + if (ImGui::MenuItem("Square Shape", nullptr, squareShape)) { + minimap->setSquareShape(!squareShape); + } + + bool npcDots = minimapNpcDots_; + if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) { + minimapNpcDots_ = !minimapNpcDots_; + } + + ImGui::EndPopup(); } } From 8fd9b6afc974454578af5fb9de0b5d625501b7ae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:28:47 -0700 Subject: [PATCH 021/105] feat: add pulsing combat status indicators to player and target frames - Player frame shows pulsing red [Combat] badge next to level when in combat - Target frame shows pulsing [Attacking] badge when engaged with target - Both pulse at 4Hz and include hover tooltips for clarity --- src/ui/game_screen.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 71861f2d..621999ff 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2197,6 +2197,12 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); } + if (inCombatConfirmed && !isDead) { + float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * combatPulse, 0.2f * combatPulse, 1.0f), "[Combat]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); + } // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); @@ -2812,6 +2818,12 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { levelColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + if (confirmedCombatWithTarget) { + float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * cPulse, 0.2f * cPulse, 1.0f), "[Attacking]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Engaged in combat with this target"); + } // Health bar uint32_t hp = unit->getHealth(); From 61c0b91e393b3fb033f0fced38bb8818ba09d429 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:34:56 -0700 Subject: [PATCH 022/105] feat: show weather icon next to zone name above minimap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends a color-coded Unicode weather symbol to the zone name: - Rain (type 1): blue ⛆ when intensity > 5% - Snow (type 2): ice-blue ❄ when intensity > 5% - Storm/Fog (type 3): gray ☁ when intensity > 5% Symbol is hidden when weather is clear or absent. --- src/ui/game_screen.cpp | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 621999ff..fcfbcd8a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12686,12 +12686,40 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { auto* fgDl = ImGui::GetForegroundDrawList(); float zoneTextY = centerY - mapRadius - 16.0f; ImFont* font = ImGui::GetFont(); - ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + + // Weather icon appended to zone name when active + uint32_t wType = gameHandler.getWeatherType(); + float wIntensity = gameHandler.getWeatherIntensity(); + const char* weatherIcon = nullptr; + ImU32 weatherColor = IM_COL32(255, 255, 255, 200); + if (wType == 1 && wIntensity > 0.05f) { // Rain + weatherIcon = " \xe2\x9b\x86"; // U+26C6 ⛆ + weatherColor = IM_COL32(140, 180, 240, 220); + } else if (wType == 2 && wIntensity > 0.05f) { // Snow + weatherIcon = " \xe2\x9d\x84"; // U+2744 ❄ + weatherColor = IM_COL32(210, 230, 255, 220); + } else if (wType == 3 && wIntensity > 0.05f) { // Storm/Fog + weatherIcon = " \xe2\x98\x81"; // U+2601 ☁ + weatherColor = IM_COL32(160, 160, 190, 220); + } + + std::string displayName = zoneName; + // Build combined string if weather active + std::string fullLabel = weatherIcon ? (zoneName + weatherIcon) : zoneName; + ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, fullLabel.c_str()); float tzx = centerX - tsz.x * 0.5f; + + // Shadow pass fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), IM_COL32(0, 0, 0, 180), zoneName.c_str()); + // Zone name in gold fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), IM_COL32(255, 220, 120, 230), zoneName.c_str()); + // Weather symbol in its own color appended after + if (weatherIcon) { + ImVec2 nameSz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + fgDl->AddText(font, 12.0f, ImVec2(tzx + nameSz.x, zoneTextY), weatherColor, weatherIcon); + } } } From b6a43d6ce7537ed33509168000b5f2ae3c67efc5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:38:13 -0700 Subject: [PATCH 023/105] feat: track and visualize global cooldown (GCD) on action bar - GameHandler tracks GCD in gcdTotal_/gcdStartedAt_ (time-based) - SMSG_SPELL_COOLDOWN: spellId=0 entries (<=2s) are treated as GCD - castSpell(): optimistically starts 1.5s GCD client-side on cast - Action bar: non-cooldown slots show subtle dark sweep + dim tint during the GCD window, matching WoW standard behavior --- include/game/game_handler.hpp | 15 +++++++++++++++ src/game/game_handler.cpp | 12 ++++++++++++ src/ui/game_screen.cpp | 31 +++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 52ecb967..8dbebe1d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -743,6 +743,17 @@ public: float getGameTime() const { return gameTime_; } float getTimeSpeed() const { return timeSpeed_; } + // Global Cooldown (GCD) — set when the server sends a spellId=0 cooldown entry + float getGCDRemaining() const { + if (gcdTotal_ <= 0.0f) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - gcdStartedAt_).count() / 1000.0f; + float rem = gcdTotal_ - elapsed; + return rem > 0.0f ? rem : 0.0f; + } + float getGCDTotal() const { return gcdTotal_; } + bool isGCDActive() const { return getGCDRemaining() > 0.0f; } + // Weather state (updated by SMSG_WEATHER) // weatherType: 0=clear, 1=rain, 2=snow, 3=storm/fog uint32_t getWeatherType() const { return weatherType_; } @@ -2559,6 +2570,10 @@ private: float timeSpeed_ = 0.0166f; // Time scale (default: 1 game day = 1 real hour) void handleLoginSetTimeSpeed(network::Packet& packet); + // ---- Global Cooldown (GCD) ---- + float gcdTotal_ = 0.0f; + std::chrono::steady_clock::time_point gcdStartedAt_{}; + // ---- Weather state (SMSG_WEATHER) ---- uint32_t weatherType_ = 0; // 0=clear, 1=rain, 2=snow, 3=storm float weatherIntensity_ = 0.0f; // 0.0 to 1.0 diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7168a37d..3f1d9b8d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14119,6 +14119,10 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { : CastSpellPacket::build(spellId, target, ++castCount); socket->send(packet); LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec); + + // Optimistically start GCD immediately on cast — server will confirm or override + gcdTotal_ = 1.5f; + gcdStartedAt_ = std::chrono::steady_clock::now(); } void GameHandler::cancelCast() { @@ -14477,6 +14481,14 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) { uint32_t cooldownMs = packet.readUInt32(); float seconds = cooldownMs / 1000.0f; + + // spellId=0 is the Global Cooldown marker (server sends it for GCD triggers) + if (spellId == 0 && cooldownMs > 0 && cooldownMs <= 2000) { + gcdTotal_ = seconds; + gcdStartedAt_ = std::chrono::steady_clock::now(); + continue; + } + spellCooldowns[spellId] = seconds; for (auto& slot : actionBar) { bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fcfbcd8a..40cb4207 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4952,6 +4952,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { const auto& slot = bar[absSlot]; bool onCooldown = !slot.isReady(); + const bool onGCD = gameHandler.isGCDActive() && !onCooldown && !slot.isEmpty(); auto getSpellName = [&](uint32_t spellId) -> std::string { std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); @@ -5004,6 +5005,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImVec4 tintColor(1, 1, 1, 1); ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } clicked = ImGui::ImageButton("##icon", (ImTextureID)(uintptr_t)iconTex, ImVec2(slotSize, slotSize), @@ -5188,6 +5190,35 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); } + // GCD overlay — subtle dark fan sweep (thinner/lighter than regular cooldown) + if (onGCD) { + ImVec2 btnMin = ImGui::GetItemRectMin(); + ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + auto* dl = ImGui::GetWindowDrawList(); + float gcdRem = gameHandler.getGCDRemaining(); + float gcdTotal = gameHandler.getGCDTotal(); + if (gcdTotal > 0.0f) { + float elapsed = gcdTotal - gcdRem; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / gcdTotal)); + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 24; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.4f; + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); + } + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 110)); + } + } + } + // Item stack count overlay — bottom-right corner of icon if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { // Count total of this item across all inventory slots From bc5a7867a9ed2d82ae632d5d1935b88dd239a668 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:44:25 -0700 Subject: [PATCH 024/105] feat: zone entry toast and unspent talent points indicator - Zone entry toast: centered slide-down banner when entering a new zone (tracks renderer's zone name, fires on change) - Talent indicator: pulsing green '! N Talent Points Available' below minimap alongside existing New Mail / BG queue indicators --- include/ui/game_screen.hpp | 7 +++ src/ui/game_screen.cpp | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 417a6609..83f04567 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -106,6 +106,13 @@ private: std::vector questCompleteToasts_; bool questCompleteCallbackSet_ = false; static constexpr float kQuestCompleteToastLifetime = 4.0f; + + // Zone entry toast: brief banner when entering a new zone + struct ZoneToastEntry { std::string zoneName; float age = 0.0f; }; + std::vector zoneToasts_; + std::string lastKnownZone_; + static constexpr float kZoneToastLifetime = 3.0f; + void renderZoneToasts(float deltaTime); 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 40cb4207..80f62e09 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -419,6 +419,20 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Apply auto-loot setting to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); + // Zone entry detection — fire a toast when the renderer's zone name changes + if (auto* rend = core::Application::getInstance().getRenderer()) { + const std::string& curZone = rend->getCurrentZoneName(); + if (!curZone.empty() && curZone != lastKnownZone_) { + if (!lastKnownZone_.empty()) { + // Genuine zone change (not first entry) + zoneToasts_.push_back({curZone, 0.0f}); + if (zoneToasts_.size() > 3) + zoneToasts_.erase(zoneToasts_.begin()); + } + lastKnownZone_ = curZone; + } + } + // Sync chat auto-join settings to GameHandler gameHandler.chatAutoJoin.general = chatAutoJoinGeneral_; gameHandler.chatAutoJoin.trade = chatAutoJoinTrade_; @@ -476,6 +490,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); + renderZoneToasts(ImGui::GetIO().DeltaTime); if (showRaidFrames_) { renderPartyFrames(gameHandler); } @@ -7494,6 +7509,64 @@ void GameScreen::renderQuestCompleteToasts(float deltaTime) { } } +// ============================================================ +// Zone Entry Toast +// ============================================================ + +void GameScreen::renderZoneToasts(float deltaTime) { + for (auto& e : zoneToasts_) e.age += deltaTime; + zoneToasts_.erase( + std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), + [](const ZoneToastEntry& e) { return e.age >= kZoneToastLifetime; }), + zoneToasts_.end()); + + if (zoneToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + + for (int i = 0; i < static_cast(zoneToasts_.size()); ++i) { + const auto& e = zoneToasts_[i]; + constexpr float kSlideDur = 0.35f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kZoneToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + // Measure text to size the toast + ImVec2 nameSz = font->CalcTextSizeA(14.0f, FLT_MAX, 0.0f, e.zoneName.c_str()); + const char* header = "Entering:"; + ImVec2 hdrSz = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, header); + + float toastW = std::max(nameSz.x, hdrSz.x) + 28.0f; + float toastH = 42.0f; + + // Center the toast horizontally, appear just below the zone name area (top-center) + float toastX = (screenW - toastW) * 0.5f; + float toastY = 56.0f + i * (toastH + 4.0f); + // Slide down from above + float offY = (1.0f - slide) * (-toastH - 10.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(10, 10, 16, (int)(alpha * 200)), 6.0f); + draw->AddRect(tl, br, IM_COL32(160, 140, 80, (int)(alpha * 220)), 6.0f, 0, 1.2f); + + float cx = tl.x + toastW * 0.5f; + draw->AddText(font, 11.0f, + ImVec2(cx - hdrSz.x * 0.5f, tl.y + 5.0f), + IM_COL32(180, 170, 120, (int)(alpha * 200)), header); + draw->AddText(font, 14.0f, + ImVec2(cx - nameSz.x * 0.5f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(255, 230, 140, (int)(alpha * 240)), e.zoneName.c_str()); + } +} + // ============================================================ // Boss Encounter Frames // ============================================================ @@ -12887,6 +12960,24 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { nextIndicatorY += kIndicatorH; } + // Unspent talent points indicator + { + uint8_t unspent = gameHandler.getUnspentTalentPoints(); + if (unspent > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TalentIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.5f); + char talentBuf[40]; + snprintf(talentBuf, sizeof(talentBuf), "! %u Talent Point%s Available", + static_cast(unspent), unspent == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f * pulse, pulse), "%s", talentBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + // BG queue status indicator (when in queue but not yet invited) for (const auto& slot : gameHandler.getBgQueues()) { if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only From 8081a43d8562fb6d0597758a23cdf0a13dc5afa4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 05:57:45 -0700 Subject: [PATCH 025/105] feat: add out-of-range tint to action bar spell slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ranged spell icons dim to a red tint when the current target is farther than the spell's max range (read from SpellRange.dbc via spellbook data). Melee/self spells (max range ≤ 5 yd or unknown) are excluded. The spell tooltip also shows "Out of range" in red when applicable. Adds SpellbookScreen::getSpellMaxRange() as a public accessor so game_screen can query DBC range data without duplicating DBC loading. --- include/ui/spellbook_screen.hpp | 4 ++++ src/ui/game_screen.cpp | 26 ++++++++++++++++++++++++++ src/ui/spellbook_screen.cpp | 9 +++++++++ 3 files changed, 39 insertions(+) diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 6cc13270..8059ddf2 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -54,6 +54,10 @@ public: uint32_t getDragSpellId() const { return dragSpellId_; } void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; } + /// Returns the max range in yards for a spell (0 if self-cast, unknown, or melee). + /// Triggers DBC load if needed. Used by the action bar for out-of-range tinting. + uint32_t getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager); + /// 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_); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 80f62e09..bd3a7921 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4969,6 +4969,27 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { bool onCooldown = !slot.isReady(); const bool onGCD = gameHandler.isGCDActive() && !onCooldown && !slot.isEmpty(); + // Out-of-range check: red tint when a targeted spell cannot reach the current target. + // Only applies to SPELL slots with a known max range (>5 yd) and an active target. + bool outOfRange = false; + if (!slot.isEmpty() && slot.type == game::ActionBarSlot::SPELL && slot.id != 0 + && !onCooldown && gameHandler.hasTarget()) { + uint32_t maxRange = spellbookScreen.getSpellMaxRange(slot.id, assetMgr); + if (maxRange > 5) { // >5 yd = not melee/self + auto& em = gameHandler.getEntityManager(); + auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); + auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); + if (playerEnt && targetEnt) { + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist > static_cast(maxRange)) + outOfRange = true; + } + } + } + auto getSpellName = [&](uint32_t spellId) -> std::string { std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); if (!name.empty()) return name; @@ -5021,6 +5042,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } + else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } clicked = ImGui::ImageButton("##icon", (ImTextureID)(uintptr_t)iconTex, ImVec2(slotSize, slotSize), @@ -5028,6 +5050,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { bgColor, tintColor); } else { if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); else if (slot.isEmpty())ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); @@ -5137,6 +5160,9 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); } } + if (outOfRange) { + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Out of range"); + } if (onCooldown) { float cd = slot.cooldownRemaining; if (cd >= 60.0f) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index e2c81756..60211f3f 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -203,6 +203,15 @@ std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetMa return {}; } +uint32_t SpellbookScreen::getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager) { + if (!dbcLoadAttempted) { + loadSpellDBC(assetManager); + } + auto it = spellData.find(spellId); + if (it != spellData.end()) return it->second.rangeIndex; + return 0; +} + void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { if (iconDbLoaded) return; iconDbLoaded = true; From 39634f442bd782f88ba2a7ceefa4fb855a6d3b30 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:01:42 -0700 Subject: [PATCH 026/105] feat: add insufficient-power tint to action bar spell slots Spell icons now render with a purple desaturated tint when the player lacks enough mana/rage/energy/runic power to cast them. Power cost and type are read from Spell.dbc via the spellbook's DBC cache. The spell tooltip also shows "Not enough power" in purple when applicable. Priority: cooldown > GCD > out-of-range > insufficient-power so states don't conflict. Adds SpellbookScreen::getSpellPowerInfo() as a public DBC accessor. --- include/ui/spellbook_screen.hpp | 6 ++++++ src/ui/game_screen.cpp | 33 +++++++++++++++++++++++++++++---- src/ui/spellbook_screen.cpp | 14 ++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 8059ddf2..2bc0f866 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -58,6 +58,12 @@ public: /// Triggers DBC load if needed. Used by the action bar for out-of-range tinting. uint32_t getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager); + /// Returns the power cost and type for a spell (cost=0 if unknown/free). + /// powerType: 0=mana, 1=rage, 2=focus, 3=energy, 6=runic power. + /// Triggers DBC load if needed. Used by the action bar for insufficient-power tinting. + void getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager, + uint32_t& outCost, uint32_t& outPowerType); + /// 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_); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bd3a7921..17b9a518 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4990,6 +4990,26 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } + // Insufficient-power check: orange tint when player doesn't have enough power to cast. + // Only applies to SPELL slots with a known power cost and when not already on cooldown. + bool insufficientPower = false; + if (!slot.isEmpty() && slot.type == game::ActionBarSlot::SPELL && slot.id != 0 + && !onCooldown) { + uint32_t spellCost = 0, spellPowerType = 0; + spellbookScreen.getSpellPowerInfo(slot.id, assetMgr, spellCost, spellPowerType); + if (spellCost > 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt && (playerEnt->getType() == game::ObjectType::PLAYER || + playerEnt->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEnt); + if (unit->getPowerType() == static_cast(spellPowerType)) { + if (unit->getPower() < spellCost) + insufficientPower = true; + } + } + } + } + auto getSpellName = [&](uint32_t spellId) -> std::string { std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); if (!name.empty()) return name; @@ -5043,16 +5063,18 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } + else if (insufficientPower) { tintColor = ImVec4(0.6f, 0.5f, 0.9f, 0.85f); } clicked = ImGui::ImageButton("##icon", (ImTextureID)(uintptr_t)iconTex, ImVec2(slotSize, slotSize), ImVec2(0, 0), ImVec2(1, 1), bgColor, tintColor); } else { - if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); - else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); - else if (slot.isEmpty())ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); + if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); + else if (insufficientPower)ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.15f, 0.4f, 0.9f)); + else if (slot.isEmpty()) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); char label[32]; if (slot.type == game::ActionBarSlot::SPELL) { @@ -5163,6 +5185,9 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (outOfRange) { ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Out of range"); } + if (insufficientPower) { + ImGui::TextColored(ImVec4(0.75f, 0.55f, 1.0f, 1.0f), "Not enough power"); + } if (onCooldown) { float cd = slot.cooldownRemaining; if (cd >= 60.0f) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 60211f3f..8c78ab7d 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -212,6 +212,20 @@ uint32_t SpellbookScreen::getSpellMaxRange(uint32_t spellId, pipeline::AssetMana return 0; } +void SpellbookScreen::getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager, + uint32_t& outCost, uint32_t& outPowerType) { + outCost = 0; + outPowerType = 0; + if (!dbcLoadAttempted) { + loadSpellDBC(assetManager); + } + auto it = spellData.find(spellId); + if (it != spellData.end()) { + outCost = it->second.manaCost; + outPowerType = it->second.powerType; + } +} + void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { if (iconDbLoaded) return; iconDbLoaded = true; From 10ad246e29032f049682b4456b805d4addcb90ce Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:03:04 -0700 Subject: [PATCH 027/105] feat: grey out action bar item slots when item is not in inventory Item slots on the action bar now display a dark grey tint when the item is no longer in the player's backpack, bags, or equipment slots. This mirrors WoW's visual feedback for consumed or missing items, matching the priority chain: cooldown > GCD > out-of-range > insufficient-power > item-missing. --- src/ui/game_screen.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 17b9a518..e29efee0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5056,14 +5056,19 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); } + // Item-missing check: grey out item slots whose item is not in the player's inventory. + const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 + && barItemDef == nullptr && !onCooldown); + bool clicked = false; if (iconTex) { ImVec4 tintColor(1, 1, 1, 1); ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); - if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } - else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } - else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } + if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } + else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } else if (insufficientPower) { tintColor = ImVec4(0.6f, 0.5f, 0.9f, 0.85f); } + else if (itemMissing) { tintColor = ImVec4(0.35f, 0.35f, 0.35f, 0.7f); } clicked = ImGui::ImageButton("##icon", (ImTextureID)(uintptr_t)iconTex, ImVec2(slotSize, slotSize), @@ -5073,6 +5078,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); else if (insufficientPower)ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.15f, 0.4f, 0.9f)); + else if (itemMissing) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.12f, 0.12f, 0.7f)); else if (slot.isEmpty()) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); From 3e8f03c7b7e96671c21b35f1a79960a2562656aa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:06:41 -0700 Subject: [PATCH 028/105] feat: add PROC_TRIGGER floating combat text for spell procs Handles SMSG_SPELL_CHANCE_PROC_LOG (previously silently ignored) to display gold "PROC!" floating text when the player triggers a spell proc. Reads caster/target packed GUIDs and spell ID from the packet header; skips variable-length effect payload. Adds CombatTextEntry::PROC_TRIGGER type with gold color rendering, visible alongside existing damage/heal/energize floating numbers. --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 21 ++++++++++++++++++++- src/ui/game_screen.cpp | 4 ++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index c4d70380..e0f070aa 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,7 +51,7 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST + ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER }; Type type; int32_t amount = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3f1d9b8d..a0f274a7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5578,9 +5578,28 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } + case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { + // Format (all expansions): PackedGuid target + PackedGuid caster + uint32 spellId + ... + if (packet.getSize() - packet.getReadPos() < 3) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t procCasterGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t procSpellId = packet.readUInt32(); + // Show a "PROC!" floating text when the player triggers the proc + if (procCasterGuid == playerGuid && procSpellId > 0) + addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true); + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_SPELLINSTAKILLLOG: case Opcode::SMSG_SPELLLOGEXECUTE: - case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: packet.setReadPos(packet.getSize()); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e29efee0..b6b9e119 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6498,6 +6498,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Resisted"); color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist break; + case game::CombatTextEntry::PROC_TRIGGER: + snprintf(text, sizeof(text), "PROC!"); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); From bcd984c1c50f0709ba94de95769d4887440a450f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:08:26 -0700 Subject: [PATCH 029/105] feat: sort buff/debuff icons by remaining duration Both the player buff bar and target frame aura display now sort auras so that shorter-duration (more urgent) buffs/debuffs appear first. Permanent auras (no duration) sort to the end. In the target frame, debuffs are sorted before buffs. In the player buff bar, the existing buffs-first / debuffs-second pass ordering is preserved, with ascending duration sort within each group. --- src/ui/game_screen.cpp | 50 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b6b9e119..db6402c8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2937,8 +2937,31 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::Separator(); + // Build sorted index list: debuffs before buffs, shorter duration first + uint64_t tNowSort = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector sortedIdx; + sortedIdx.reserve(targetAuras.size()); + for (size_t i = 0; i < targetAuras.size(); ++i) + if (!targetAuras[i].isEmpty()) sortedIdx.push_back(i); + std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = targetAuras[a]; const auto& ab = targetAuras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; // debuffs first + int32_t ra = aa.getRemainingMs(tNowSort); + int32_t rb = ab.getRemainingMs(tNowSort); + // Permanent (-1) goes last; shorter remaining goes first + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + int shown = 0; - for (size_t i = 0; i < targetAuras.size() && shown < 16; ++i) { + for (size_t si = 0; si < sortedIdx.size() && shown < 16; ++si) { + size_t i = sortedIdx[si]; const auto& aura = targetAuras[i]; if (aura.isEmpty()) continue; @@ -9236,12 +9259,33 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); if (ImGui::Begin("##BuffBar", nullptr, flags)) { - // Separate buffs and debuffs; show buffs first, then debuffs with a visual gap + // Pre-sort auras: buffs first, then debuffs; within each group, shorter remaining first + uint64_t buffNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector buffSortedIdx; + buffSortedIdx.reserve(auras.size()); + for (size_t i = 0; i < auras.size(); ++i) + if (!auras[i].isEmpty()) buffSortedIdx.push_back(i); + std::sort(buffSortedIdx.begin(), buffSortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = auras[a]; const auto& ab = auras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff < bDebuff; // buffs (0) first + int32_t ra = aa.getRemainingMs(buffNowMs); + int32_t rb = ab.getRemainingMs(buffNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + // Render one pass for buffs, one for debuffs for (int pass = 0; pass < 2; ++pass) { bool wantBuff = (pass == 0); int shown = 0; - for (size_t i = 0; i < auras.size() && shown < 40; ++i) { + for (size_t si = 0; si < buffSortedIdx.size() && shown < 40; ++si) { + size_t i = buffSortedIdx[si]; const auto& aura = auras[i]; if (aura.isEmpty()) continue; From 39bf8fb01e5fd07153fb359a8c10ec8a43757aff Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:12:37 -0700 Subject: [PATCH 030/105] feat: play audio notification when a whisper is received Adds UiSoundManager::playWhisperReceived() which uses the dedicated Whisper_TellMale/Female.wav files (or falls back to iSelectTarget.wav if absent). The sound is triggered once per new incoming CHAT_MSG_WHISPER message by scanning new chat history entries in the raid warning overlay update loop. --- include/audio/ui_sound_manager.hpp | 4 ++++ src/audio/ui_sound_manager.cpp | 11 +++++++++++ src/ui/game_screen.cpp | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/include/audio/ui_sound_manager.hpp b/include/audio/ui_sound_manager.hpp index 241014ae..6423d460 100644 --- a/include/audio/ui_sound_manager.hpp +++ b/include/audio/ui_sound_manager.hpp @@ -75,6 +75,9 @@ public: void playTargetSelect(); void playTargetDeselect(); + // Chat notifications + void playWhisperReceived(); + private: struct UISample { std::string path; @@ -122,6 +125,7 @@ private: std::vector errorSounds_; std::vector selectTargetSounds_; std::vector deselectTargetSounds_; + std::vector whisperSounds_; // State tracking float volumeScale_ = 1.0f; diff --git a/src/audio/ui_sound_manager.cpp b/src/audio/ui_sound_manager.cpp index f32f0d9b..8ef800f0 100644 --- a/src/audio/ui_sound_manager.cpp +++ b/src/audio/ui_sound_manager.cpp @@ -122,6 +122,14 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { deselectTargetSounds_.resize(1); loadSound("Sound\\Interface\\iDeselectTarget.wav", deselectTargetSounds_[0], assets); + // Whisper notification (falls back to iSelectTarget if the dedicated file is absent) + whisperSounds_.resize(1); + if (!loadSound("Sound\\Interface\\Whisper_TellMale.wav", whisperSounds_[0], assets)) { + if (!loadSound("Sound\\Interface\\Whisper_TellFemale.wav", whisperSounds_[0], assets)) { + whisperSounds_ = selectTargetSounds_; + } + } + LOG_INFO("UISoundManager: Window sounds - Bag: ", (bagOpenLoaded && bagCloseLoaded) ? "YES" : "NO", ", QuestLog: ", (questLogOpenLoaded && questLogCloseLoaded) ? "YES" : "NO", ", CharSheet: ", (charSheetOpenLoaded && charSheetCloseLoaded) ? "YES" : "NO"); @@ -225,5 +233,8 @@ void UiSoundManager::playError() { playSound(errorSounds_); } void UiSoundManager::playTargetSelect() { playSound(selectTargetSounds_); } void UiSoundManager::playTargetDeselect() { playSound(deselectTargetSounds_); } +// Chat notifications +void UiSoundManager::playWhisperReceived() { playSound(whisperSounds_); } + } // namespace audio } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index db6402c8..0da74826 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6325,6 +6325,7 @@ void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) { // 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; + auto* renderer = core::Application::getInstance().getRenderer(); for (size_t i = startIdx; i < newCount; ++i) { const auto& msg = chatHistory[i]; if (msg.type == game::ChatType::RAID_WARNING || @@ -6338,6 +6339,11 @@ void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) { if (raidWarnEntries_.size() > 3) raidWarnEntries_.erase(raidWarnEntries_.begin()); } + // Whisper audio notification + if (msg.type == game::ChatType::WHISPER && renderer) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } } raidWarnChatSeenCount_ = newCount; } From e8fe53650b790bb6fe5d7cca007062d59447f21a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:14:18 -0700 Subject: [PATCH 031/105] feat: add respawn countdown timer to the death dialog The "You are dead." dialog now shows a "Release in M:SS" countdown tracking time elapsed since death. The countdown runs from 6 minutes (WoW's forced-release window) and disappears once it reaches zero. Timer resets automatically when the player is no longer dead. --- include/ui/game_screen.hpp | 6 ++++++ src/ui/game_screen.cpp | 27 +++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 83f04567..b7cf2f53 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -112,6 +112,12 @@ private: std::vector zoneToasts_; std::string lastKnownZone_; static constexpr float kZoneToastLifetime = 3.0f; + + // Death screen: elapsed time since the death dialog first appeared + float deathElapsed_ = 0.0f; + bool deathTimerRunning_ = false; + // WoW forces release after ~6 minutes; show countdown until then + static constexpr float kForcedReleaseSec = 360.0f; void renderZoneToasts(float deltaTime); bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0da74826..7ddfe7a3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10988,7 +10988,18 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { - if (!gameHandler.showDeathDialog()) return; + if (!gameHandler.showDeathDialog()) { + deathTimerRunning_ = false; + deathElapsed_ = 0.0f; + return; + } + float dt = ImGui::GetIO().DeltaTime; + if (!deathTimerRunning_) { + deathElapsed_ = 0.0f; + deathTimerRunning_ = true; + } else { + deathElapsed_ += dt; + } auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; @@ -11006,7 +11017,7 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { // "Release Spirit" dialog centered on screen float dlgW = 280.0f; - float dlgH = 100.0f; + float dlgH = 130.0f; ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); @@ -11025,6 +11036,18 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::SetCursorPosX((dlgW - textW) / 2); ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "%s", deathText); + // Respawn timer: show how long until forced release + float timeLeft = kForcedReleaseSec - deathElapsed_; + if (timeLeft > 0.0f) { + int mins = static_cast(timeLeft) / 60; + int secs = static_cast(timeLeft) % 60; + char timerBuf[48]; + snprintf(timerBuf, sizeof(timerBuf), "Release in %d:%02d", mins, secs); + float tw = ImGui::CalcTextSize(timerBuf).x; + ImGui::SetCursorPosX((dlgW - tw) / 2); + ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "%s", timerBuf); + } + ImGui::Spacing(); ImGui::Spacing(); From 68251b647d7b49acac76fdf1ef6bdebec2f96f42 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:30:30 -0700 Subject: [PATCH 032/105] feat: add spell/quest/achievement hyperlink rendering in chat Extend chat renderTextWithLinks to handle |Hspell:, |Hquest:, and |Hachievement: link types in addition to |Hitem:. Spell links show a small icon and tooltip via renderSpellInfoTooltip; quest links open the quest log on click; achievement links show a tooltip. Also wire assetMgr into renderChatWindow for icon lookup. --- src/ui/game_screen.cpp | 159 +++++++++++++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 37 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7ddfe7a3..b4b2f299 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -960,6 +960,7 @@ void GameScreen::renderEntityList(game::GameHandler& gameHandler) { void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { auto* window = core::Application::getInstance().getWindow(); + auto* assetMgr = core::Application::getInstance().getAssetManager(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; float chatW = std::min(500.0f, screenW * 0.4f); @@ -1216,10 +1217,13 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Find next special element: URL or WoW link size_t urlStart = text.find("https://", pos); - // Find next WoW item link: |cXXXXXXXX|Hitem:ENTRY:...|h[Name]|h|r + // Find next WoW link (may be colored with |c prefix or bare |H) size_t linkStart = text.find("|c", pos); - // Also handle bare |Hitem: without color prefix - size_t bareLinkStart = text.find("|Hitem:", pos); + // Also handle bare |H links without color prefix + size_t bareItem = text.find("|Hitem:", pos); + size_t bareSpell = text.find("|Hspell:", pos); + size_t bareQuest = text.find("|Hquest:", pos); + size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); // Determine which comes first size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart}); @@ -1252,18 +1256,30 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (nextSpecial == linkStart && text.size() > linkStart + 10) { // Parse |cAARRGGBB color linkColor = parseWowColor(text, linkStart); - hStart = text.find("|Hitem:", linkStart + 10); + // Find the nearest |H link of any supported type + size_t hItem = text.find("|Hitem:", linkStart + 10); + size_t hSpell = text.find("|Hspell:", linkStart + 10); + size_t hQuest = text.find("|Hquest:", linkStart + 10); + size_t hAch = text.find("|Hachievement:", linkStart + 10); + hStart = std::min({hItem, hSpell, hQuest, hAch}); } else if (nextSpecial == bareLinkStart) { hStart = bareLinkStart; } if (hStart != std::string::npos) { - // Parse item entry: |Hitem:ENTRY:... - size_t entryStart = hStart + 7; // skip "|Hitem:" + // Determine link type + const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); + const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); + const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); + // Default: item link + + // Parse the first numeric ID after |Htype: + size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); + size_t entryStart = hStart + idOffset; size_t entryEnd = text.find(':', entryStart); - uint32_t itemEntry = 0; + uint32_t linkId = 0; if (entryEnd != std::string::npos) { - itemEntry = static_cast(strtoul( + linkId = static_cast(strtoul( text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10)); } @@ -1272,53 +1288,122 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { size_t nameTagEnd = (nameTagStart != std::string::npos) ? text.find("]|h", nameTagStart + 3) : std::string::npos; - std::string itemName = "Unknown Item"; + std::string linkName = isSpellLink ? "Unknown Spell" + : isQuestLink ? "Unknown Quest" + : isAchievLink ? "Unknown Achievement" + : "Unknown Item"; if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { - itemName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); + linkName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); } // Find end of entire link sequence (|r or after ]|h) - size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + 7; + size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + idOffset; size_t resetPos = text.find("|r", linkEnd); if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { linkEnd = resetPos + 2; } - // Ensure item info is cached (trigger query if needed) - if (itemEntry > 0) { - gameHandler.ensureItemInfo(itemEntry); - } + if (!isSpellLink && !isQuestLink && !isAchievLink) { + // --- Item link --- + uint32_t itemEntry = linkId; + if (itemEntry > 0) { + 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); + // 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); } - ImGui::SameLine(0, 2); } } - } - // Render bracketed item name in quality color - std::string display = "[" + itemName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, linkColor); - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); + // Render bracketed item name in quality color + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - if (itemEntry > 0) { - renderItemLinkTooltip(itemEntry); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (itemEntry > 0) { + renderItemLinkTooltip(itemEntry); + } + } + } else if (isSpellLink) { + // --- Spell link: |Hspell:SPELLID:RANK|h[Name]|h --- + // Small icon (use spell icon cache if available) + VkDescriptorSet spellIcon = (linkId > 0) ? getSpellIcon(linkId, assetMgr) : VK_NULL_HANDLE; + if (spellIcon) { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + ImGui::SameLine(0, 2); + } + + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (linkId > 0) { + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + } + } else if (isQuestLink) { + // --- Quest link: |Hquest:QUESTID:QUESTLEVEL|h[Name]|h --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.84f, 0.0f, 1.0f)); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%s", linkName.c_str()); + // Parse quest level (second field after questId) + if (entryEnd != std::string::npos) { + size_t lvlEnd = text.find(':', entryEnd + 1); + if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); + if (lvlEnd != std::string::npos) { + uint32_t qLvl = static_cast(strtoul( + text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); + if (qLvl > 0) ImGui::TextDisabled("Level %u Quest", qLvl); + } + } + ImGui::TextDisabled("Click quest log to view details"); + ImGui::EndTooltip(); + } + // Click: open quest log and select this quest if we have it + if (ImGui::IsItemClicked() && linkId > 0) { + questLogScreen.openAndSelectQuest(linkId); + } + } else { + // --- Achievement link --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Achievement: %s", linkName.c_str()); } } - // Shift-click: insert item link into chat input + // Shift-click: insert entire link back into chat input if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { std::string linkText = text.substr(nextSpecial, linkEnd - nextSpecial); size_t curLen = strlen(chatInputBuffer); From c14b338a923fc1a407b217975df83231fb4389e5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:38:10 -0700 Subject: [PATCH 033/105] feat: add Tab autocomplete for slash commands in chat input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Tab while typing a slash command cycles through all matching commands (e.g. /em → /emote, /emote → /emote again). Unambiguous matches append a trailing space. Repeated Tab presses cycle forward through all matches. History navigation (Up/Down) resets the autocomplete session. --- include/ui/game_screen.hpp | 5 +++ src/ui/game_screen.cpp | 65 +++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index b7cf2f53..b07a2d2d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -54,6 +54,11 @@ private: std::vector chatSentHistory_; int chatHistoryIdx_ = -1; // -1 = not browsing history + // Tab-completion state for slash commands + std::string chatTabPrefix_; // prefix captured on first Tab press + std::vector chatTabMatches_; // matching command list + int chatTabMatchIdx_ = -1; // active match index (-1 = inactive) + // Chat tabs int activeChatTab_ = 0; struct ChatTab { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b4b2f299..a119f1e8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1770,8 +1770,70 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { self->chatInputMoveCursorToEnd = false; } + // Tab: slash-command autocomplete + if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { + if (data->BufTextLen > 0 && data->Buf[0] == '/') { + // Split buffer into command word and trailing args + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + // Normalize to lowercase for matching + std::string lowerWord = word; + for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); + + static const std::vector kCmds = { + "/afk", "/away", "/cast", "/chathelp", "/clear", + "/dance", "/do", "/dnd", "/e", "/emote", + "/follow", "/g", "/guild", "/guildinfo", + "/gmticket", "/grouploot", "/i", "/instance", + "/invite", "/j", "/join", "/kick", + "/l", "/leave", "/local", "/me", + "/p", "/party", "/r", "/raid", + "/raidwarning", "/random", "/reply", "/roll", + "/s", "/say", "/setloot", "/shout", + "/stopattack", "/stopfollow", "/t", "/time", + "/trade", "/uninvite", "/w", "/whisper", + "/who", "/wts", "/wtb", "/y", "/yell", "/zone" + }; + + // New session if prefix changed + if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) { + self->chatTabPrefix_ = lowerWord; + self->chatTabMatches_.clear(); + for (const auto& cmd : kCmds) { + if (cmd.size() >= lowerWord.size() && + cmd.compare(0, lowerWord.size(), lowerWord) == 0) + self->chatTabMatches_.push_back(cmd); + } + self->chatTabMatchIdx_ = 0; + } else { + // Cycle forward through matches + ++self->chatTabMatchIdx_; + if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) + self->chatTabMatchIdx_ = 0; + } + + if (!self->chatTabMatches_.empty()) { + std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + // Append trailing space when match is unambiguous + if (self->chatTabMatches_.size() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } + return 0; + } + // Up/Down arrow: cycle through sent message history if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + // Any history navigation resets autocomplete + self->chatTabMatchIdx_ = -1; + self->chatTabMatches_.clear(); + const int histSize = static_cast(self->chatSentHistory_.size()); if (histSize == 0) return 0; @@ -1802,7 +1864,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways | - ImGuiInputTextFlags_CallbackHistory; + ImGuiInputTextFlags_CallbackHistory | + ImGuiInputTextFlags_CallbackCompletion; if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. From 9a21e19486d8418d9f44fb15b9ec2617641710bd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:45:27 -0700 Subject: [PATCH 034/105] feat: highlight chat messages that mention the local player When a chat message contains the player's character name, the message is rendered with a golden highlight background and bright yellow text. A whisper notification sound plays (at most once per new-message scan) to alert the player. Outgoing whispers and system messages are excluded from mention detection. --- include/ui/game_screen.hpp | 3 +++ src/ui/game_screen.cpp | 55 +++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index b07a2d2d..ba1602d1 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -59,6 +59,9 @@ private: std::vector chatTabMatches_; // matching command list int chatTabMatchIdx_ = -1; // active match index (-1 = inactive) + // Mention notification: plays a sound when the player's name appears in chat + size_t chatMentionSeenCount_ = 0; // how many messages have been scanned for mentions + // Chat tabs int activeChatTab_ = 0; struct ChatTab { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a119f1e8..bd1bcaed 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1489,6 +1489,39 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } }; + // Determine local player name for mention detection (case-insensitive) + std::string selfNameLower; + { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && !ch->name.empty()) { + selfNameLower = ch->name; + for (auto& c : selfNameLower) c = static_cast(std::tolower(static_cast(c))); + } + } + + // Scan NEW messages (beyond chatMentionSeenCount_) for mentions and play notification sound + if (!selfNameLower.empty() && chatHistory.size() > chatMentionSeenCount_) { + for (size_t mi = chatMentionSeenCount_; mi < chatHistory.size(); ++mi) { + const auto& mMsg = chatHistory[mi]; + // Skip outgoing whispers, system, and monster messages + if (mMsg.type == game::ChatType::WHISPER_INFORM || + mMsg.type == game::ChatType::SYSTEM) continue; + // Case-insensitive search in message body + std::string bodyLower = mMsg.message; + for (auto& c : bodyLower) c = static_cast(std::tolower(static_cast(c))); + if (bodyLower.find(selfNameLower) != std::string::npos) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } + break; // play at most once per scan pass + } + } + chatMentionSeenCount_ = chatHistory.size(); + } else if (chatHistory.size() <= chatMentionSeenCount_) { + chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared + } + int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; @@ -1572,10 +1605,30 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } } + // Detect mention: does this message contain the local player's name? + bool isMention = false; + if (!selfNameLower.empty() && + msg.type != game::ChatType::WHISPER_INFORM && + msg.type != game::ChatType::SYSTEM) { + std::string msgLower = fullMsg; + for (auto& c : msgLower) c = static_cast(std::tolower(static_cast(c))); + isMention = (msgLower.find(selfNameLower) != std::string::npos); + } + // Render message in a group so we can attach a right-click context menu ImGui::PushID(chatMsgIdx++); + if (isMention) { + // Golden highlight strip behind the text + ImVec2 groupMin = ImGui::GetCursorScreenPos(); + float availW = ImGui::GetContentRegionAvail().x; + float lineH = ImGui::GetTextLineHeightWithSpacing(); + ImGui::GetWindowDrawList()->AddRectFilled( + groupMin, + ImVec2(groupMin.x + availW, groupMin.y + lineH), + IM_COL32(255, 200, 50, 45)); // soft golden tint + } ImGui::BeginGroup(); - renderTextWithLinks(fullMsg, color); + renderTextWithLinks(fullMsg, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : color); ImGui::EndGroup(); // Right-click context menu (only for player messages with a sender) From d817e4144c438fc5c1dc5640778b85521f48f507 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:55:16 -0700 Subject: [PATCH 035/105] feat: debuff dispel-type border coloring in buff bar Read DispelType from Spell.dbc (new field in all expansion DBC layouts) and use it to color debuff icon borders: magic=blue, curse=purple, disease=brown, poison=green, other=red. Buffs remain green-bordered. Adds getSpellDispelType() to GameHandler for lazy cache lookup. --- Data/expansions/classic/dbc_layouts.json | 3 ++- Data/expansions/tbc/dbc_layouts.json | 3 ++- Data/expansions/turtle/dbc_layouts.json | 3 ++- Data/expansions/wotlk/dbc_layouts.json | 3 ++- include/game/game_handler.hpp | 4 +++- src/game/game_handler.cpp | 19 ++++++++++++++++++- src/ui/game_screen.cpp | 17 ++++++++++++++++- 7 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index ca8c8a50..5c550c81 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, - "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, + "DispelType": 4 }, "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index fdc9e07d..b99159a6 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 5, "IconID": 124, "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215, - "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40 + "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40, + "DispelType": 3 }, "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index a2482e0d..3b06971d 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, - "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, + "DispelType": 4 }, "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 0d1667a1..94f7df4d 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 4, "IconID": 133, "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225, - "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49 + "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49, + "DispelType": 2 }, "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8dbebe1d..e3840fe2 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1586,6 +1586,8 @@ public: const std::string& getSpellName(uint32_t spellId) const; const std::string& getSpellRank(uint32_t spellId) const; const std::string& getSkillLineName(uint32_t spellId) const; + /// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other) + uint8_t getSpellDispelType(uint32_t spellId) const; struct TrainerTab { std::string name; @@ -2486,7 +2488,7 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; }; + struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; uint8_t dispelType = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a0f274a7..cd373518 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16983,6 +16983,14 @@ void GameHandler::loadSpellNameCache() { if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; } } + // DispelType field (0=none,1=magic,2=curse,3=disease,4=poison,5=stealth,…) + uint32_t dispelField = 0xFFFFFFFF; + bool hasDispelField = false; + if (spellL) { + uint32_t f = spellL->field("DispelType"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } + } + uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); @@ -16990,7 +16998,7 @@ void GameHandler::loadSpellNameCache() { std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136); std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153); if (!name.empty()) { - SpellNameEntry entry{std::move(name), std::move(rank), 0}; + SpellNameEntry entry{std::move(name), std::move(rank), 0, 0}; if (hasSchoolMask) { entry.schoolMask = dbc->getUInt32(i, schoolMaskField); } else if (hasSchoolEnum) { @@ -16999,6 +17007,9 @@ void GameHandler::loadSpellNameCache() { uint32_t e = dbc->getUInt32(i, schoolEnumField); entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0; } + if (hasDispelField) { + entry.dispelType = static_cast(dbc->getUInt32(i, dispelField)); + } spellNameCache_[id] = std::move(entry); } } @@ -17192,6 +17203,12 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING; } +uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.dispelType : 0; +} + const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { auto slIt = spellToSkillLine_.find(spellId); if (slIt == spellToSkillLine_.end()) return EMPTY_STRING; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bd1bcaed..8f2bd977 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9503,7 +9503,22 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PushID(static_cast(i) + (pass * 256)); - ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); + // Determine border color: buffs = green; debuffs use WoW dispel-type colors + ImVec4 borderColor; + if (isBuff) { + borderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); // green + } else { + // Debuff: color by dispel type (0=none/red, 1=magic/blue, 2=curse/purple, + // 3=disease/brown, 4=poison/green, other=dark-red) + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: borderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: borderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: borderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: borderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } // Try to get spell icon VkDescriptorSet iconTex = VK_NULL_HANDLE; From 92361c37df07095816269017990cdb6b974596b5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 06:58:42 -0700 Subject: [PATCH 036/105] feat: out-of-range indicator on party and raid frames Party members beyond 40 yards show a gray desaturated health bar with 'OOR' text instead of HP values. Raid frame cells get a dark overlay and gray health bar when a member is out of range. Range is computed from the server-reported posX/posY in SMSG_PARTY_MEMBER_STATS vs the local player entity position. --- src/ui/game_screen.cpp | 63 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8f2bd977..2b219faf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7250,6 +7250,20 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { bool isDead = (m.onlineStatus & 0x0020) != 0; bool isGhost = (m.onlineStatus & 0x0010) != 0; + // Out-of-range check (40 yard threshold) + bool isOOR = false; + if (m.hasPartyStats && isOnline && !isDead && !isGhost && m.zoneId != 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt) { + float dx = playerEnt->getX() - static_cast(m.posX); + float dy = playerEnt->getY() - static_cast(m.posY); + isOOR = (dx * dx + dy * dy) > (40.0f * 40.0f); + } + } + // Dim cell overlay when out of range + if (isOOR) + draw->AddRectFilled(cellMin, cellMax, IM_COL32(0, 0, 0, 80), 3.0f); + // Name text (truncated); leader name is gold char truncName[16]; snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); @@ -7282,13 +7296,17 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f); ImVec2 barFill(barBg.x, barBg.y); ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); - ImU32 hpCol = pct > 0.5f ? IM_COL32(60, 180, 60, 255) : - pct > 0.2f ? IM_COL32(200, 180, 50, 255) : - IM_COL32(200, 60, 60, 255); + ImU32 hpCol = isOOR ? IM_COL32(100, 100, 100, 160) : + pct > 0.5f ? IM_COL32(60, 180, 60, 255) : + 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 + // HP percentage or OOR text centered on bar char hpPct[8]; - snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(pct * 100.0f + 0.5f)); + if (isOOR) + snprintf(hpPct, sizeof(hpPct), "OOR"); + else + 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; @@ -7457,6 +7475,21 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { memberOffline = !isOnline2; } + // Out-of-range check: compare player position to member's reported position + // Range threshold: 40 yards (standard heal/spell range) + bool memberOutOfRange = false; + if (member.hasPartyStats && !memberOffline && !memberDead && + member.zoneId != 0) { + // Same map: use 2D Euclidean distance in WoW coordinates (yards) + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEntity) { + float dx = playerEntity->getX() - static_cast(member.posX); + float dy = playerEntity->getY() - static_cast(member.posY); + float distSq = dx * dx + dy * dy; + memberOutOfRange = (distSq > 40.0f * 40.0f); + } + } + if (memberDead) { // Gray "Dead" bar for fallen party members ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.35f, 0.35f, 0.35f, 1.0f)); @@ -7471,21 +7504,27 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(2); } else if (maxHp > 0) { float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - 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)); + // Out-of-range: desaturate health bar to gray + ImVec4 hpBarColor = memberOutOfRange + ? ImVec4(0.45f, 0.45f, 0.45f, 0.7f) + : (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::PushStyleColor(ImGuiCol_PlotHistogram, hpBarColor); char hpText[32]; - if (maxHp >= 10000) + if (memberOutOfRange) { + snprintf(hpText, sizeof(hpText), "OOR"); + } else if (maxHp >= 10000) { snprintf(hpText, sizeof(hpText), "%dk/%dk", (int)hp / 1000, (int)maxHp / 1000); - else + } else { snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + } ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); ImGui::PopStyleColor(); } - // Power bar (mana/rage/energy) from party stats — hidden for dead/offline + // Power bar (mana/rage/energy) from party stats — hidden for dead/offline/OOR if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) { float powerPct = static_cast(member.curPower) / static_cast(member.maxPower); ImVec4 powerColor; From b8e7fee9e7ceb839b4323f298d592406a94cc6fd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:04:45 -0700 Subject: [PATCH 037/105] feat: add quest kill objective markers on minimap Live NPCs that match active tracked quest kill objectives are now shown on the minimap as gold circles with an 'x' mark, making it easier to spot remaining quest targets at a glance without needing to open the map. Only shows targets for incomplete objectives in tracked quests. --- src/ui/game_screen.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2b219faf..bfbbd245 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12878,6 +12878,46 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { IM_COL32(0, 0, 0, 255), marker); } + // Quest kill objective markers — highlight live NPCs matching active quest kill objectives + { + // Collect NPC entry IDs needed for incomplete kill objectives in tracked quests + std::unordered_set killTargetEntries; + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& quest : gameHandler.getQuestLog()) { + if (quest.complete) continue; + if (!trackedIds.empty() && !trackedIds.count(quest.questId)) continue; + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId <= 0 || obj.required == 0) continue; + uint32_t npcEntry = static_cast(obj.npcOrGoId); + auto it = quest.killCounts.find(npcEntry); + uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0; + if (current < obj.required) killTargetEntries.insert(npcEntry); + } + } + + if (!killTargetEntries.empty()) { + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit || unit->getHealth() == 0) continue; + if (!killTargetEntries.count(unit->getEntry())) continue; + + glm::vec3 unitRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(unitRender, sx, sy)) continue; + + // Gold circle with a dark "x" mark — indicates a quest kill target + drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, IM_COL32(255, 185, 0, 240)); + drawList->AddCircle(ImVec2(sx, sy), 5.5f, IM_COL32(0, 0, 0, 180), 12, 1.0f); + drawList->AddLine(ImVec2(sx - 2.5f, sy - 2.5f), ImVec2(sx + 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + drawList->AddLine(ImVec2(sx + 2.5f, sy - 2.5f), ImVec2(sx - 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + } + } + } + // Gossip POI markers (quest / NPC navigation targets) for (const auto& poi : gameHandler.getGossipPois()) { // Convert WoW canonical coords to render coords for minimap projection From 17022b9b40ab50d374cbd5d942cae6e87e2d3e8e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:07:46 -0700 Subject: [PATCH 038/105] feat: add persistent low-health vignette when HP below 20% Screen edges pulse red (at ~1.5 Hz) whenever the player is alive but below 20% HP, with intensity scaling inversely with remaining health. Complements the existing on-hit damage flash by providing continuous danger awareness during sustained low-HP situations. --- 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 bfbbd245..014e719e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -773,6 +773,45 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Persistent low-health vignette — pulsing red edges when HP < 20% + { + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + bool isDead = gameHandler.isPlayerDead(); + float hpPct = 1.0f; + if (!isDead && playerEntity && + (playerEntity->getType() == game::ObjectType::PLAYER || + playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) + hpPct = static_cast(unit->getHealth()) / static_cast(unit->getMaxHealth()); + } + + // Only show when alive and below 20% HP; intensity increases as HP drops + if (!isDead && hpPct < 0.20f && hpPct > 0.0f) { + // Base intensity from HP deficit (0 at 20%, 1 at 0%); pulse at ~1.5 Hz + float danger = (0.20f - hpPct) / 0.20f; + float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 9.4f); + int alpha = static_cast(danger * pulse * 90.0f); // max ~90 alpha, subtle + if (alpha > 0) { + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const float thickness = std::min(W, H) * 0.15f; + const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); + const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + edgeCol, edgeCol, fadeCol, fadeCol); + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + fadeCol, fadeCol, edgeCol, edgeCol); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + edgeCol, fadeCol, fadeCol, edgeCol); + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + fadeCol, edgeCol, edgeCol, fadeCol); + } + } + } + // Level-up golden burst overlay if (levelUpFlashAlpha_ > 0.0f) { levelUpFlashAlpha_ -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second From 8858edde054b62581ceec697b89724317c3f3454 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:10:45 -0700 Subject: [PATCH 039/105] fix: correct chat bubble Y-coordinate projection The camera bakes the Vulkan Y-flip into the projection matrix, so no extra Y-inversion is needed when converting NDC to screen pixels. This matches the convention used by the nameplate and minimap marker code. The old formula double-flipped Y, causing chat bubbles to appear at mirrored positions (e.g. below characters instead of above their heads). --- src/ui/game_screen.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 014e719e..9295f71a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13748,7 +13748,9 @@ void GameScreen::renderChatBubbles(game::GameHandler& gameHandler) { glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); float screenX = (ndc.x * 0.5f + 0.5f) * screenW; - float screenY = (1.0f - (ndc.y * 0.5f + 0.5f)) * screenH; // Flip Y + // Camera bakes the Vulkan Y-flip into the projection matrix: + // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. + float screenY = (ndc.y * 0.5f + 0.5f) * screenH; // Skip if off-screen if (screenX < -200.0f || screenX > screenW + 200.0f || From 8cba8033baeede08531b37eb4f5941bbde84bbcb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:12:02 -0700 Subject: [PATCH 040/105] feat: add tooltip to XP bar showing XP to level and rested details Hovering the XP bar now shows a breakdown: current XP, XP remaining to the next level, rested bonus amount in XP and as a percentage of a full level, and whether the player is currently resting. --- src/ui/game_screen.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9295f71a..0d5ce1eb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6147,6 +6147,26 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay); ImGui::Dummy(barSize); + + // Tooltip with XP-to-level and rested details + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + uint32_t xpToLevel = (currentXp < nextLevelXp) ? (nextLevelXp - currentXp) : 0; + ImGui::TextColored(ImVec4(0.9f, 0.85f, 1.0f, 1.0f), "Experience"); + ImGui::Separator(); + ImGui::Text("Current: %u / %u XP", currentXp, nextLevelXp); + ImGui::Text("To next level: %u XP", xpToLevel); + if (restedXp > 0) { + float restedLevels = static_cast(restedXp) / static_cast(nextLevelXp); + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.78f, 0.60f, 1.0f, 1.0f), + "Rested: +%u XP (%.1f%% of a level)", restedXp, restedLevels * 100.0f); + if (isResting) + ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), + "Resting — accumulating bonus XP"); + } + ImGui::EndTooltip(); + } } ImGui::End(); From 9a08edae093698b95aadcdfdea80cacb85382729 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:15:08 -0700 Subject: [PATCH 041/105] feat: add Low Health Vignette toggle in Settings > Interface The persistent red-edge vignette (below 20% HP) now has an on/off checkbox under Settings > Interface > Screen Effects, alongside the existing Damage Flash toggle. The preference is persisted to settings.cfg. --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index ba1602d1..4b166783 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -83,6 +83,7 @@ private: 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; + bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP 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/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0d5ce1eb..379ed039 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -787,7 +787,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { } // Only show when alive and below 20% HP; intensity increases as HP drops - if (!isDead && hpPct < 0.20f && hpPct > 0.0f) { + if (lowHealthVignetteEnabled_ && !isDead && hpPct < 0.20f && hpPct > 0.0f) { // Base intensity from HP deficit (0 at 20%, 1 at 0%); pulse at ~1.5 Hz float danger = (0.20f - hpPct) / 0.20f; float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 9.4f); @@ -12021,6 +12021,12 @@ void GameScreen::renderSettingsWindow() { ImGui::SameLine(); ImGui::TextDisabled("(red vignette on taking damage)"); + if (ImGui::Checkbox("Low Health Vignette", &lowHealthVignetteEnabled_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(pulsing red edges below 20%% HP)"); + ImGui::EndChild(); ImGui::EndTabItem(); } @@ -13841,6 +13847,7 @@ void GameScreen::saveSettings() { out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n"; out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n"; out << "damage_flash=" << (damageFlashEnabled_ ? 1 : 0) << "\n"; + out << "low_health_vignette=" << (lowHealthVignetteEnabled_ ? 1 : 0) << "\n"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -13962,6 +13969,8 @@ void GameScreen::loadSettings() { pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } else if (key == "damage_flash") { damageFlashEnabled_ = (std::stoi(val) != 0); + } else if (key == "low_health_vignette") { + lowHealthVignetteEnabled_ = (std::stoi(val) != 0); } // Audio else if (key == "sound_muted") { From 381fc54c89429ad9174d4f0e8876c95505bd06eb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:18:11 -0700 Subject: [PATCH 042/105] feat: add hover tooltips to quest kill objective minimap markers --- src/ui/game_screen.cpp | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 379ed039..30ff4cba 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12945,8 +12945,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { // Quest kill objective markers — highlight live NPCs matching active quest kill objectives { - // Collect NPC entry IDs needed for incomplete kill objectives in tracked quests - std::unordered_set killTargetEntries; + // Build map of NPC entry → (quest title, current, required) for tooltips + struct KillInfo { std::string questTitle; uint32_t current = 0; uint32_t required = 0; }; + std::unordered_map killInfoMap; const auto& trackedIds = gameHandler.getTrackedQuestIds(); for (const auto& quest : gameHandler.getQuestLog()) { if (quest.complete) continue; @@ -12956,16 +12957,20 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { uint32_t npcEntry = static_cast(obj.npcOrGoId); auto it = quest.killCounts.find(npcEntry); uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0; - if (current < obj.required) killTargetEntries.insert(npcEntry); + if (current < obj.required) { + killInfoMap[npcEntry] = { quest.title, current, obj.required }; + } } } - if (!killTargetEntries.empty()) { + if (!killInfoMap.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::UNIT) continue; auto unit = std::static_pointer_cast(entity); if (!unit || unit->getHealth() == 0) continue; - if (!killTargetEntries.count(unit->getEntry())) continue; + auto infoIt = killInfoMap.find(unit->getEntry()); + if (infoIt == killInfoMap.end()) continue; glm::vec3 unitRender = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); @@ -12979,6 +12984,23 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { IM_COL32(20, 20, 20, 230), 1.2f); drawList->AddLine(ImVec2(sx + 2.5f, sy - 2.5f), ImVec2(sx - 2.5f, sy + 2.5f), IM_COL32(20, 20, 20, 230), 1.2f); + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const auto& ki = infoIt->second; + const std::string& npcName = unit->getName(); + if (!npcName.empty()) { + ImGui::SetTooltip("%s\n%s: %u/%u", + npcName.c_str(), + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } else { + ImGui::SetTooltip("%s: %u/%u", + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } + } } } } From c25f7b0e524a41cd57ed5c2d5b40aa0ccee52eac Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:22:36 -0700 Subject: [PATCH 043/105] feat: store and display achievement earn dates in achievement window tooltip --- include/game/game_handler.hpp | 7 +++++++ src/game/game_handler.cpp | 7 +++++-- src/ui/game_screen.cpp | 17 ++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e3840fe2..97a023ab 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1340,6 +1340,11 @@ public: 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 WoW PackedTime earn date for an achievement, or 0 if unknown. + uint32_t getAchievementDate(uint32_t id) const { + auto it = achievementDates_.find(id); + return (it != achievementDates_.end()) ? it->second : 0u; + } /// 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); @@ -2498,6 +2503,8 @@ private: void loadAchievementNameCache(); // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) std::unordered_set earnedAchievements_; + // Earn dates: achievementId → WoW PackedTime (from SMSG_ACHIEVEMENT_EARNED / SMSG_ALL_ACHIEVEMENT_DATA) + std::unordered_map achievementDates_; // Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE) std::unordered_map criteriaProgress_; void handleAllAchievementData(network::Packet& packet); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index cd373518..b8205e8a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20127,7 +20127,7 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { uint64_t guid = packet.readUInt64(); uint32_t achievementId = packet.readUInt32(); - /*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed + uint32_t earnDate = packet.readUInt32(); // WoW PackedTime bitfield loadAchievementNameCache(); auto nameIt = achievementNameCache_.find(achievementId); @@ -20146,6 +20146,7 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { addSystemChatMessage(buf); earnedAchievements_.insert(achievementId); + achievementDates_[achievementId] = earnDate; if (achievementEarnedCallback_) { achievementEarnedCallback_(achievementId, achName); } @@ -20186,14 +20187,16 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { void GameHandler::handleAllAchievementData(network::Packet& packet) { loadAchievementNameCache(); earnedAchievements_.clear(); + achievementDates_.clear(); // Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF) while (packet.getSize() - packet.getReadPos() >= 4) { uint32_t id = packet.readUInt32(); if (id == 0xFFFFFFFF) break; if (packet.getSize() - packet.getReadPos() < 4) break; - /*uint32_t date =*/ packet.readUInt32(); + uint32_t date = packet.readUInt32(); earnedAchievements_.insert(id); + achievementDates_[id] = date; } // Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 30ff4cba..415fab37 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16038,7 +16038,22 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { ImGui::TextUnformatted(display.c_str()); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); - ImGui::Text("ID: %u", id); + ImGui::Text("Achievement ID: %u", id); + uint32_t packed = gameHandler.getAchievementDate(id); + if (packed != 0) { + // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] + int minute = (packed >> 3) & 0x3F; + int hour = (packed >> 9) & 0x1F; + int day = (packed >> 17) & 0x1F; + int month = (packed >> 21) & 0x0F; + int year = ((packed >> 25) & 0x7F) + 2000; + static const char* kMonths[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + const char* mname = (month >= 1 && month <= 12) ? kMonths[month - 1] : "?"; + ImGui::Text("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); + } ImGui::EndTooltip(); } ImGui::PopID(); From 344556c63927adfe7f8faee21d8c474b8ecc907e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:25:56 -0700 Subject: [PATCH 044/105] feat: show class name with class color and zone tooltip in social frame friends list --- src/ui/game_screen.cpp | 53 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 415fab37..ba1612af 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9354,6 +9354,33 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { + // Class color helper shared by Friends tab + auto getClassColor = [](uint32_t classId, bool online) -> ImVec4 { + if (!online) return ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + switch (classId) { + case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior + case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin + case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter + case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue + case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest + case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // Death Knight + case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman + case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage + case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock + case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid + default: return ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + } + }; + static const char* kClassNames[] = { + "Unknown","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid" + }; + + // Get zone manager for area name lookups + game::ZoneManager* socialZoneMgr = nullptr; + if (auto* rend = core::Application::getInstance().getRenderer()) + socialZoneMgr = rend->getZoneManager(); + if (ImGui::BeginTabBar("##SocialTabs")) { // ---- Friends tab ---- if (ImGui::BeginTabItem("Friends")) { @@ -9391,7 +9418,31 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { if (c.isOnline() && c.level > 0) { ImGui::SameLine(); - ImGui::TextDisabled("Lv%u", c.level); + // Show level and class name in class color + ImVec4 cc = getClassColor(c.classId, true); + const char* cname = (c.classId < 12) ? kClassNames[c.classId] : "?"; + ImGui::TextColored(cc, "Lv%u %s", c.level, cname); + } + + // Tooltip: zone info and note + if (ImGui::IsItemHovered() || (c.isOnline() && ImGui::IsItemHovered())) { + if (c.isOnline() && (c.areaId != 0 || !c.note.empty())) { + ImGui::BeginTooltip(); + if (c.areaId != 0) { + const char* zoneName = nullptr; + if (socialZoneMgr) { + const auto* zi = socialZoneMgr->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) zoneName = zi->name.c_str(); + } + if (zoneName) + ImGui::Text("Zone: %s", zoneName); + else + ImGui::Text("Area ID: %u", c.areaId); + } + if (!c.note.empty()) + ImGui::TextDisabled("Note: %s", c.note.c_str()); + ImGui::EndTooltip(); + } } // Right-click context menu From bab66cfa35424d66f6756c1e61daebecd87a7d2a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:28:18 -0700 Subject: [PATCH 045/105] feat: display mail expiry date and urgency warnings in mailbox --- src/ui/game_screen.cpp | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ba1612af..22a58f81 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14200,6 +14200,21 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), " [A]"); } + // Expiry warning if within 3 days + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + if (secsLeft < 3.0f * 86400.0f && secsLeft > 0.0f) { + ImGui::SameLine(); + int daysLeft = static_cast(secsLeft / 86400.0f); + if (daysLeft == 0) { + ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), " [expires today!]"); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), + " [expires in %dd]", daysLeft); + } + } + } ImGui::PopID(); } @@ -14220,6 +14235,35 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { if (mail.messageType == 2) { ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.2f, 1.0f), "[Auction House]"); } + + // Show expiry date in the detail panel + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + // Format absolute expiry as a date using struct tm + time_t expT = static_cast(mail.expirationTime); + struct tm* tmExp = std::localtime(&expT); + if (tmExp) { + static const char* kMon[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + const char* mname = kMon[tmExp->tm_mon]; + int daysLeft = static_cast(secsLeft / 86400.0f); + if (secsLeft <= 0.0f) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + "Expired: %s %d, %d", mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } else if (secsLeft < 3.0f * 86400.0f) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Expires: %s %d, %d (%d day%s!)", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year, + daysLeft, daysLeft == 1 ? "" : "s"); + } else { + ImGui::TextDisabled("Expires: %s %d, %d", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } + } + } ImGui::Separator(); // Body text From 7cb4887011128e48143da7a77b22154f605ebb32 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:32:28 -0700 Subject: [PATCH 046/105] feat: add threat status bar and pulling-aggro alert to threat window --- src/ui/game_screen.cpp | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 22a58f81..6ce06e12 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16256,10 +16256,38 @@ void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { uint32_t maxThreat = list->front().threat; + // Pre-scan to find the player's rank and threat percentage + uint64_t playerGuid = gameHandler.getPlayerGuid(); + int playerRank = 0; + float playerPct = 0.0f; + { + int scan = 0; + for (const auto& e : *list) { + ++scan; + if (e.victimGuid == playerGuid) { + playerRank = scan; + playerPct = (maxThreat > 0) ? static_cast(e.threat) / static_cast(maxThreat) : 0.0f; + break; + } + if (scan >= 10) break; + } + } + + // Status bar: aggro alert or rank summary + if (playerRank == 1) { + // Player has aggro — persistent red warning + ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.25f, 1.0f), "!! YOU HAVE AGGRO !!"); + } else if (playerRank > 1 && playerPct >= 0.8f) { + // Close to pulling — pulsing warning + float pulse = 0.55f + 0.45f * sinf(static_cast(ImGui::GetTime()) * 5.0f); + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.1f, pulse), "! PULLING AGGRO (%.0f%%) !", playerPct * 100.0f); + } else if (playerRank > 0) { + ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "You: #%d %.0f%% threat", playerRank, playerPct * 100.0f); + } + ImGui::TextDisabled("%-19s Threat", "Player"); ImGui::Separator(); - uint64_t playerGuid = gameHandler.getPlayerGuid(); int rank = 0; for (const auto& entry : *list) { ++rank; From d26bac0221e861a149c0803338c7a5e444e73284 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:37:29 -0700 Subject: [PATCH 047/105] feat: store and display enchant indicators in inspect window gear list --- include/game/game_handler.hpp | 3 ++- src/game/game_handler.cpp | 4 +++- src/ui/game_screen.cpp | 8 ++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 97a023ab..81257c6f 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -339,7 +339,8 @@ public: uint32_t unspentTalents = 0; uint8_t talentGroups = 0; uint8_t activeTalentGroup = 0; - std::array itemEntries{}; // 0=head…18=ranged + std::array itemEntries{}; // 0=head…18=ranged + std::array enchantIds{}; // permanent enchant per slot (0 = none) }; const InspectResult* getInspectResult() const { return inspectResult_.guid ? &inspectResult_ : nullptr; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b8205e8a..3b643674 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11339,6 +11339,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { } // Parse enchantment slot mask + enchant IDs + std::array enchantIds{}; bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft >= 4) { uint32_t slotMask = packet.readUInt32(); @@ -11346,7 +11347,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { if (slotMask & (1u << slot)) { bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft < 2) break; - packet.readUInt16(); // enchantId + enchantIds[slot] = packet.readUInt16(); } } } @@ -11358,6 +11359,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { inspectResult_.unspentTalents = unspentTalents; inspectResult_.talentGroups = talentGroupCount; inspectResult_.activeTalentGroup = activeTalentGroup; + inspectResult_.enchantIds = enchantIds; // Merge any gear we already have from a prior inspect request auto gearIt = inspectedPlayerItemEntries_.find(guid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6ce06e12..548ed9ef 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16508,6 +16508,7 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::PushID(s); auto qColor = InventoryScreen::getQualityColor( static_cast(info->quality)); + uint16_t enchantId = result->enchantIds[s]; // Item icon VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); @@ -16530,6 +16531,13 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::BeginGroup(); ImGui::TextDisabled("%s", kSlotNames[s]); ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Enchant indicator on the same row as the name + if (enchantId != 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); // UTF-8 ✦ + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); + } ImGui::EndGroup(); hovered = hovered || ImGui::IsItemHovered(); From ddb8f06c3aae4c1b7156e1a4c8bbdc48b0ceefc0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:39:11 -0700 Subject: [PATCH 048/105] feat: add class-color and zone tooltip to friends tab in guild roster --- src/ui/game_screen.cpp | 48 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 548ed9ef..bc5fabbc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9232,17 +9232,59 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndTooltip(); } - // Level and status + // Level, class, and status if (c.isOnline()) { - ImGui::SameLine(160.0f); + ImGui::SameLine(150.0f); const char* statusLabel = (c.status == 2) ? " (AFK)" : (c.status == 3) ? " (DND)" : ""; - if (c.level > 0) { + // Class color for the level/class display + ImVec4 friendClassCol = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + const char* friendClassName = ""; + if (c.classId > 0 && c.classId < 12) { + static const ImVec4 kFriendClassColors[] = { + {0,0,0,0}, + {0.78f,0.61f,0.43f,1}, // Warrior + {0.96f,0.55f,0.73f,1}, // Paladin + {0.67f,0.83f,0.45f,1}, // Hunter + {1.00f,0.96f,0.41f,1}, // Rogue + {1.00f,1.00f,1.00f,1}, // Priest + {0.77f,0.12f,0.23f,1}, // Death Knight + {0.00f,0.44f,0.87f,1}, // Shaman + {0.41f,0.80f,0.94f,1}, // Mage + {0.58f,0.51f,0.79f,1}, // Warlock + {0,0,0,0}, // (unused slot 10) + {1.00f,0.49f,0.04f,1}, // Druid + }; + static const char* kFriendClassNames[] = { + "","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid" + }; + friendClassCol = kFriendClassColors[c.classId]; + friendClassName = kFriendClassNames[c.classId]; + } + if (c.level > 0 && *friendClassName) { + ImGui::TextColored(friendClassCol, "Lv%u %s%s", c.level, friendClassName, statusLabel); + } else if (c.level > 0) { ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); } else if (*statusLabel) { ImGui::TextDisabled("%s", statusLabel + 1); } + + // Tooltip: zone info + if (ImGui::IsItemHovered() && c.areaId != 0) { + ImGui::BeginTooltip(); + if (zoneManager) { + const auto* zi = zoneManager->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) + ImGui::Text("Zone: %s", zi->name.c_str()); + else + ImGui::TextDisabled("Area ID: %u", c.areaId); + } else { + ImGui::TextDisabled("Area ID: %u", c.areaId); + } + ImGui::EndTooltip(); + } } ImGui::PopID(); From dd8e09c2d91923fa4282181ce33b829a3db0317f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:41:22 -0700 Subject: [PATCH 049/105] feat: show hovered world coordinates in minimap tooltip --- src/ui/game_screen.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bc5fabbc..0b6831b7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13313,7 +13313,18 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { if (overMinimap) { ImGui::BeginTooltip(); - ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.5f, 1.0f), "Ctrl+click to ping"); + // Compute the world coordinate under the mouse cursor + // Inverse of projectToMinimap: pixel offset → world offset in render space → canonical + float rxW = mdx / mapRadius * viewRadius; + float ryW = mdy / mapRadius * viewRadius; + // Un-rotate: [dx, dy] = R^-1 * [rxW, ryW] + // where R applied: rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + float hoverDx = -cosB * rxW + sinB * ryW; + float hoverDy = -sinB * rxW - cosB * ryW; + glm::vec3 hoverRender(playerRender.x + hoverDx, playerRender.y + hoverDy, playerRender.z); + glm::vec3 hoverCanon = core::coords::renderToCanonical(hoverRender); + ImGui::TextColored(ImVec4(0.9f, 0.85f, 0.5f, 1.0f), "%.1f, %.1f", hoverCanon.x, hoverCanon.y); + ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "Ctrl+click to ping"); ImGui::EndTooltip(); if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { From d9ce056e1307b59bca2531e66290c0424fe7fd3f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:45:51 -0700 Subject: [PATCH 050/105] feat: add zone name labels and hover coordinates to world map - Draw zone name text centered in each zone rect on the continent view; only rendered when the rect is large enough to fit the label without crowding (explored zones get gold text, unexplored get dim grey) - Show WoW coordinates under the cursor when hovering the map image in continent or zone view, bottom-right corner of the map panel --- src/rendering/world_map.cpp | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 9c30a3b5..701c5148 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -12,6 +12,7 @@ #include "core/logger.hpp" #include #include +#include #include #include @@ -1016,6 +1017,40 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } + // Hover coordinate display — show WoW coordinates under cursor + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + auto& io = ImGui::GetIO(); + ImVec2 mp = io.MousePos; + if (mp.x >= imgMin.x && mp.x <= imgMin.x + displayW && + mp.y >= imgMin.y && mp.y <= imgMin.y + displayH) { + float mu = (mp.x - imgMin.x) / displayW; + float mv = (mp.y - imgMin.y) / displayH; + + const auto& zone = zones[currentIdx]; + float left = zone.locLeft, right = zone.locRight; + float top = zone.locTop, bottom = zone.locBottom; + if (zone.areaID == 0) { + float l, r, t, b; + getContinentProjectionBounds(currentIdx, l, r, t, b); + left = l; right = r; top = t; bottom = b; + // Undo the kVOffset applied during renderPosToMapUV for continent + constexpr float kVOffset = -0.15f; + mv -= kVOffset; + } + + float hWowX = left - mu * (left - right); + float hWowY = top - mv * (top - bottom); + + char coordBuf[32]; + snprintf(coordBuf, sizeof(coordBuf), "%.0f, %.0f", hWowX, hWowY); + ImVec2 coordSz = ImGui::CalcTextSize(coordBuf); + float cx = imgMin.x + displayW - coordSz.x - 8.0f; + float cy = imgMin.y + displayH - coordSz.y - 8.0f; + drawList->AddText(ImVec2(cx + 1.0f, cy + 1.0f), IM_COL32(0, 0, 0, 180), coordBuf); + drawList->AddText(ImVec2(cx, cy), IM_COL32(220, 210, 150, 230), coordBuf); + } + } + // Continent view: clickable zone overlays if (viewLevel == ViewLevel::CONTINENT && continentIdx >= 0) { const auto& cont = zones[continentIdx]; @@ -1080,6 +1115,23 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1), IM_COL32(255, 255, 255, 30), 0.0f, 0, 1.0f); } + + // Zone name label — only if the rect is large enough to fit it + if (!z.areaName.empty()) { + ImVec2 textSz = ImGui::CalcTextSize(z.areaName.c_str()); + float rectW = sx1 - sx0; + float rectH = sy1 - sy0; + if (rectW > textSz.x + 4.0f && rectH > textSz.y + 2.0f) { + float tx = (sx0 + sx1) * 0.5f - textSz.x * 0.5f; + float ty = (sy0 + sy1) * 0.5f - textSz.y * 0.5f; + ImU32 labelCol = explored + ? IM_COL32(255, 230, 150, 210) + : IM_COL32(160, 160, 160, 80); + drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, 130), z.areaName.c_str()); + drawList->AddText(ImVec2(tx, ty), labelCol, z.areaName.c_str()); + } + } } } From 6cd8dc0d9dd9913b8d25daeaa14dd4352eed93a2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:52:47 -0700 Subject: [PATCH 051/105] feat: show spell icon in cast bar alongside progress bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display the casting spell's icon (20×20) to the left of the progress bar using the existing getSpellIcon DBC lookup. Falls back gracefully to the icon-less layout when no icon is available (e.g. before DBC load or for unknown spells). --- src/ui/game_screen.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0b6831b7..bc0b54e3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6301,10 +6301,16 @@ void GameScreen::renderRepBar(game::GameHandler& gameHandler) { void GameScreen::renderCastBar(game::GameHandler& gameHandler) { if (!gameHandler.isCasting()) return; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); + VkDescriptorSet iconTex = (currentSpellId != 0 && assetMgr) + ? getSpellIcon(currentSpellId, assetMgr) : VK_NULL_HANDLE; + float barW = 300.0f; float barX = (screenW - barW) / 2.0f; float barY = screenH - 120.0f; @@ -6332,7 +6338,6 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); char overlay[64]; - uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); if (currentSpellId == 0) { snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining()); } else { @@ -6343,7 +6348,15 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { else snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); } - ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + + if (iconTex) { + // Spell icon to the left of the progress bar + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(20, 20)); + ImGui::SameLine(0, 4); + ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + } else { + ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + } ImGui::PopStyleColor(); } ImGui::End(); From e41788c63f857422f6e0f6be21c3d622ad4d6b11 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:56:59 -0700 Subject: [PATCH 052/105] feat: display current zone name inside minimap top edge Shows the player's current zone name (from server zone ID via ZoneManager) as a golden label at the top of the minimap circle. Gracefully absent when zone ID is 0 (loading screens, undetected zones). --- src/ui/game_screen.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index bc0b54e3..533e3458 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13317,6 +13317,29 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); } + // Zone name display — drawn inside the top edge of the minimap circle + { + auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; + uint32_t zoneId = gameHandler.getWorldStateZoneId(); + const game::ZoneInfo* zi = (zmRenderer && zoneId != 0) ? zmRenderer->getZoneInfo(zoneId) : nullptr; + if (zi && !zi->name.empty()) { + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, zi->name.c_str()); + float tx = centerX - ts.x * 0.5f; + float ty = centerY - mapRadius + 4.0f; // just inside top edge of the circle + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + IM_COL32(0, 0, 0, 160), 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, 180), zi->name.c_str()); + drawList->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 230, 150, 220), zi->name.c_str()); + } + } + // Hover tooltip and right-click context menu { ImVec2 mouse = ImGui::GetMousePos(); From 0674dc9ec24b45397d15ee258ffed6be23cbe0b8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 07:58:36 -0700 Subject: [PATCH 053/105] feat: add spell icons to boss and party member cast bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent with the player cast bar, show the spell icon (12×12 for boss, 10×10 for party) to the left of each cast bar progress widget. Falls back gracefully to the icon-less layout when no icon is found. --- src/ui/game_screen.cpp | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 533e3458..a165cbba 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7247,6 +7247,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (!gameHandler.isInGroup()) return; + auto* assetMgr = core::Application::getInstance().getAssetManager(); const auto& partyData = gameHandler.getPartyData(); const bool isRaid = (partyData.groupType == 1); float frameY = 120.0f; @@ -7626,7 +7627,17 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining); else snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + { + VkDescriptorSet pIcon = (cs->spellId != 0 && assetMgr) + ? getSpellIcon(cs->spellId, assetMgr) : VK_NULL_HANDLE; + if (pIcon) { + ImGui::Image((ImTextureID)(uintptr_t)pIcon, ImVec2(10, 10)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } + } ImGui::PopStyleColor(); } @@ -7974,6 +7985,8 @@ void GameScreen::renderZoneToasts(float deltaTime) { // ============================================================ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { + auto* assetMgr = core::Application::getInstance().getAssetManager(); + // Collect active boss unit slots struct BossSlot { uint32_t slot; uint64_t guid; }; std::vector active; @@ -8054,7 +8067,17 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { bcastName.c_str(), cs->timeRemaining); else snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + { + VkDescriptorSet bIcon = (bspell != 0 && assetMgr) + ? getSpellIcon(bspell, assetMgr) : VK_NULL_HANDLE; + if (bIcon) { + ImGui::Image((ImTextureID)(uintptr_t)bIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } + } ImGui::PopStyleColor(); } From 844a0002dc8278fa4616bac78d3d3d4e543713b2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:00:27 -0700 Subject: [PATCH 054/105] feat: improve combat text with drop shadows and larger crit size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch combat float text from ImGui::TextColored to draw list rendering for drop shadows on all entries (readability over complex backgrounds). Critical hit/heal events render at 1.35× normal font size for visual impact, matching the WoW combat feedback convention. --- src/ui/game_screen.cpp | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a165cbba..9f8fdff7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6815,8 +6815,29 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { float baseX = outgoing ? outgoingX : incomingX; float xOffset = baseX + (idx % 3 - 1) * 60.0f; ++idx; + + // Crits render at 1.35× normal font size for visual impact + bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || + entry.type == game::CombatTextEntry::CRIT_HEAL); + ImFont* font = ImGui::GetFont(); + float baseFontSize = ImGui::GetFontSize(); + float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; + + // Advance cursor so layout accounting is correct, then read screen pos ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); - ImGui::TextColored(color, "%s", text); + ImVec2 screenPos = ImGui::GetCursorScreenPos(); + + // Drop shadow for readability over complex backgrounds + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); + ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), + shadowCol, text); + dl->AddText(font, renderFontSize, screenPos, textCol, text); + + // Reserve space so ImGui doesn't clip the window prematurely + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + ImGui::Dummy(ts); } } ImGui::End(); From 8a9248be7927555d882752d20298195e8bf75528 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:03:43 -0700 Subject: [PATCH 055/105] feat: add spell icons to target and focus frame cast bars --- src/ui/game_screen.cpp | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9f8fdff7..1db48391 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3143,7 +3143,18 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); else snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft); - ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + { + auto* tcastAsset = core::Application::getInstance().getAssetManager(); + VkDescriptorSet tIcon = (tspell != 0 && tcastAsset) + ? getSpellIcon(tspell, tcastAsset) : VK_NULL_HANDLE; + if (tIcon) { + ImGui::Image((ImTextureID)(uintptr_t)tIcon, ImVec2(14, 14)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } + } ImGui::PopStyleColor(); } @@ -3558,7 +3569,18 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); else snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem); - ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + { + auto* fcAsset = core::Application::getInstance().getAssetManager(); + VkDescriptorSet fcIcon = (focusCast->spellId != 0 && fcAsset) + ? getSpellIcon(focusCast->spellId, fcAsset) : VK_NULL_HANDLE; + if (fcIcon) { + ImGui::Image((ImTextureID)(uintptr_t)fcIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } else { + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } + } ImGui::PopStyleColor(); } } From b5567b362fe560b24a859715243851929bf26726 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:10:17 -0700 Subject: [PATCH 056/105] feat: add colored gold/silver/copper text in vendor, trainer, and mail windows --- src/ui/game_screen.cpp | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1db48391..8853a7b5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -71,6 +71,23 @@ namespace { return s; } + // Render gold/silver/copper amounts in WoW-canonical colors on the current ImGui line. + // Skips zero-value denominations (except copper, which is always shown when gold=silver=0). + void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); + } + bool isPortBotTarget(const std::string& target) { std::string t = toLower(trim(target)); return t == "portbot" || t == "gmbot" || t == "telebot"; @@ -10701,7 +10718,8 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); if (vendor.canRepair) { ImGui::SameLine(); @@ -10911,9 +10929,11 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { uint32_t s = (item.buyPrice / 100) % 100; uint32_t c = item.buyPrice % 100; bool canAfford = money >= item.buyPrice; - if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); - ImGui::Text("%ug %us %uc", g, s, c); - if (!canAfford) ImGui::PopStyleColor(); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } } ImGui::TableSetColumnIndex(3); @@ -10991,7 +11011,8 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); // Filter controls static bool showUnavailable = false; @@ -11146,8 +11167,11 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t s = (spell->spellCost / 100) % 100; uint32_t c = spell->spellCost % 100; bool canAfford = money >= spell->spellCost; - ImVec4 costColor = canAfford ? color : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); - ImGui::TextColored(costColor, "%ug %us %uc", g, s, c); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } } else { ImGui::TextColored(color, "Free"); } @@ -14300,7 +14324,8 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); ImGui::SameLine(ImGui::GetWindowWidth() - 100); if (ImGui::Button("Compose")) { mailRecipientBuffer_[0] = '\0'; From 6649f1583a3a7623beae35a7b8b38820db3b7a58 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:13:03 -0700 Subject: [PATCH 057/105] feat: use colored coin display in loot window gold amount --- 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 8853a7b5..31b6ba24 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9953,10 +9953,11 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { const auto& loot = gameHandler.getCurrentLoot(); - // Gold + // Gold (auto-looted on open; shown for feedback) if (loot.gold > 0) { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%ug %us %uc", - loot.getGold(), loot.getSilver(), loot.getCopper()); + ImGui::TextDisabled("Gold:"); + ImGui::SameLine(0, 4); + renderCoinsText(loot.getGold(), loot.getSilver(), loot.getCopper()); ImGui::Separator(); } From 06c8c26b8a3dc17d7f0b2f305b3da760fdc70302 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:15:46 -0700 Subject: [PATCH 058/105] feat: extend colored coin display to item tooltip, quests, AH, guild bank, buyback, taxi --- src/ui/game_screen.cpp | 57 ++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 31b6ba24..f2136819 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1230,7 +1230,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { uint32_t g = info->sellPrice / 10000; uint32_t s = (info->sellPrice / 100) % 100; uint32_t c = info->sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { @@ -8857,7 +8858,8 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { uint32_t gold = cost / 10000; uint32_t silver = (cost % 10000) / 100; uint32_t copper = cost % 100; - ImGui::Text("Cost: %ug %us %uc", gold, silver, copper); + ImGui::TextDisabled("Cost:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); ImGui::Spacing(); ImGui::Text("Guild Name:"); ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_)); @@ -10363,9 +10365,8 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { uint32_t gold = quest.rewardMoney / 10000; uint32_t silver = (quest.rewardMoney % 10000) / 100; uint32_t copper = quest.rewardMoney % 100; - if (gold > 0) ImGui::Text(" %ug %us %uc", gold, silver, copper); - else if (silver > 0) ImGui::Text(" %us %uc", silver, copper); - else ImGui::Text(" %uc", copper); + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); } } @@ -10479,7 +10480,8 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { uint32_t g = quest.requiredMoney / 10000; uint32_t s = (quest.requiredMoney % 10000) / 100; uint32_t c = quest.requiredMoney % 100; - ImGui::Text("Required money: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Required money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } // Complete / Cancel buttons @@ -10657,9 +10659,8 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { uint32_t g = quest.rewardMoney / 10000; uint32_t s = (quest.rewardMoney % 10000) / 100; uint32_t c = quest.rewardMoney % 100; - if (g > 0) ImGui::Text(" %ug %us %uc", g, s, c); - else if (s > 0) ImGui::Text(" %us %uc", s, c); - else ImGui::Text(" %uc", c); + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } } @@ -10821,9 +10822,11 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { 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(); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } ImGui::TableSetColumnIndex(3); if (!canAfford) ImGui::BeginDisabled(); char bbLabel[32]; @@ -11452,13 +11455,7 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { } ImGui::TableSetColumnIndex(1); - if (gold > 0) { - ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper); - } else if (silver > 0) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), "%us %uc", silver, copper); - } else { - ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.2f, 1.0f), "%uc", copper); - } + renderCoinsText(gold, silver, copper); ImGui::TableSetColumnIndex(2); if (ImGui::SmallButton("Fly")) { @@ -14458,7 +14455,8 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { uint32_t g = mail.money / 10000; uint32_t s = (mail.money / 100) % 100; uint32_t c = mail.money % 100; - ImGui::Text("Money: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); ImGui::SameLine(); if (ImGui::SmallButton("Take Money")) { gameHandler.mailTakeMoney(mail.messageId); @@ -14954,9 +14952,8 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { uint32_t gold = static_cast(data.money / 10000); uint32_t silver = static_cast((data.money / 100) % 100); uint32_t copper = static_cast(data.money % 100); - ImGui::Text("Guild Bank Money: "); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper); + ImGui::TextDisabled("Guild Bank Money:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); // Tab bar if (!data.tabs.empty()) { @@ -15339,13 +15336,13 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(3); { uint32_t bid = auction.currentBid > 0 ? auction.currentBid : auction.startBid; - ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100); + renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(4); if (auction.buyoutPrice > 0) { - ImGui::Text("%ug%us%uc", auction.buyoutPrice / 10000, - (auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100); + renderCoinsText(auction.buyoutPrice / 10000, + (auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100); } else { ImGui::TextDisabled("--"); } @@ -15519,10 +15516,10 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); - ImGui::Text("%ug%us%uc", a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100); + renderCoinsText(a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100); ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) - ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); + renderCoinsText(a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); else ImGui::TextDisabled("--"); ImGui::TableSetColumnIndex(4); @@ -15595,11 +15592,11 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(2); { uint32_t bid = a.currentBid > 0 ? a.currentBid : a.startBid; - ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100); + renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) - ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); + renderCoinsText(a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); else ImGui::TextDisabled("--"); ImGui::TableSetColumnIndex(4); From dedafdf4439ac2cbc02dfc6535448c2767777c9a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:19:07 -0700 Subject: [PATCH 059/105] feat: use colored coin display for sell price in item tooltips --- src/ui/inventory_screen.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8b80be85..e2075f09 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -72,6 +72,21 @@ const game::ItemSlot* findComparableEquipped(const game::Inventory& inventory, u default: return nullptr; } } + +void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); +} } // namespace InventoryScreen::~InventoryScreen() { @@ -2197,7 +2212,8 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I uint32_t g = item.sellPrice / 10000; uint32_t s = (item.sellPrice / 100) % 100; uint32_t c = item.sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } // Shift-hover comparison with currently equipped equivalent. @@ -2477,7 +2493,8 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, uint32_t g = info.sellPrice / 10000; uint32_t s = (info.sellPrice / 100) % 100; uint32_t c = info.sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } // Shift-hover: compare with currently equipped item From 92ce7459bb0b1db1649aba36995ef8aa160d3c36 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:20:14 -0700 Subject: [PATCH 060/105] feat: add dispel-type border colors to target frame debuffs (magic/curse/disease/poison) --- src/ui/game_screen.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f2136819..cb795a4f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3239,7 +3239,20 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PushID(static_cast(10000 + i)); bool isBuff = (aura.flags & 0x80) == 0; - ImVec4 auraBorderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); + ImVec4 auraBorderColor; + if (isBuff) { + auraBorderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + // Debuff: color by dispel type, matching player buff bar convention + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: auraBorderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: auraBorderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: auraBorderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: auraBorderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: auraBorderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } VkDescriptorSet iconTex = VK_NULL_HANDLE; if (assetMgr) { From 2fbf3f28e66d3ea526b8cccbe7bcb5d5b946158a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:21:52 -0700 Subject: [PATCH 061/105] feat: use colored coin display for quest reward money in quest log --- src/ui/quest_log_screen.cpp | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 81f8657d..d89f6ab4 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -205,6 +205,21 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { if (s.size() > 72) s = s.substr(0, 72) + "..."; return s; } + +void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); +} } // anonymous namespace void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) { @@ -488,12 +503,7 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv 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); + renderCoinsText(rg, rs, rc); } // Guaranteed reward items From 25e90acf27d268184ad7a74910e91eedaa8dfafb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:28:08 -0700 Subject: [PATCH 062/105] feat: color party frame member names by WoW class Uses UNIT_FIELD_BYTES_0 (byte 1) from the entity's update fields to determine each party member's class when they are loaded in the world, and applies canonical WoW class colors to their name in the 5-man party frame. Falls back to gold (leader) or light gray (others) when the entity is not currently loaded. All 10 classes (Warrior, Paladin, Hunter, Rogue, Priest, Death Knight, Shaman, Mage, Warlock, Druid) use the standard Blizzard-matching hex values. --- src/ui/game_screen.cpp | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cb795a4f..16b5d919 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7583,12 +7583,37 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { else if (isDead || isGhost) label += " (dead)"; } - // Clickable name to target; leader name is gold - if (isLeader) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + // Clickable name to target — use WoW class colors when entity is loaded, + // fall back to gold for leader / light gray for others + ImVec4 nameColor = isLeader + ? ImVec4(1.0f, 0.85f, 0.0f, 1.0f) + : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + { + auto memberEntity = gameHandler.getEntityManager().getEntity(member.guid); + if (memberEntity) { + uint32_t bytes0 = memberEntity->getField( + game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); + uint8_t classId = static_cast((bytes0 >> 8) & 0xFF); + switch (classId) { + case 1: nameColor = ImVec4(0.78f, 0.61f, 0.43f, 1.0f); break; // Warrior + case 2: nameColor = ImVec4(0.96f, 0.55f, 0.73f, 1.0f); break; // Paladin + case 3: nameColor = ImVec4(0.67f, 0.83f, 0.45f, 1.0f); break; // Hunter + case 4: nameColor = ImVec4(1.00f, 0.96f, 0.41f, 1.0f); break; // Rogue + case 5: nameColor = ImVec4(1.00f, 1.00f, 1.00f, 1.0f); break; // Priest + case 6: nameColor = ImVec4(0.77f, 0.12f, 0.23f, 1.0f); break; // Death Knight + case 7: nameColor = ImVec4(0.00f, 0.44f, 0.87f, 1.0f); break; // Shaman + case 8: nameColor = ImVec4(0.41f, 0.80f, 0.94f, 1.0f); break; // Mage + case 9: nameColor = ImVec4(0.58f, 0.51f, 0.79f, 1.0f); break; // Warlock + case 11: nameColor = ImVec4(1.00f, 0.49f, 0.04f, 1.0f); break; // Druid + default: break; // keep fallback + } + } + } + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } - if (isLeader) ImGui::PopStyleColor(); + ImGui::PopStyleColor(); // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set if (member.roles != 0) { From d6d70f62c7bd927fe908d5250104b07590df1644 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:29:17 -0700 Subject: [PATCH 063/105] feat: apply WoW class colors to raid frame member names Same class color logic as the party frame: read UNIT_FIELD_BYTES_0 byte 1 from the entity manager to determine each member's class, then draw their name in the canonical Blizzard class color. Dead/offline members keep the gray color since their status is more important than their class identity. --- src/ui/game_screen.cpp | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 16b5d919..ca5b2b50 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7411,13 +7411,36 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (isOOR) draw->AddRectFilled(cellMin, cellMax, IM_COL32(0, 0, 0, 80), 3.0f); - // Name text (truncated); leader name is gold + // Name text (truncated) — class color when alive+online, gray when dead/offline char truncName[16]; snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); bool isMemberLeader = (m.guid == partyData.leaderGuid); - ImU32 nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : - (!isOnline || isDead || isGhost) - ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); + ImU32 nameCol; + if (!isOnline || isDead || isGhost) { + nameCol = IM_COL32(140, 140, 140, 200); // gray for dead/offline + } else { + // Default: gold for leader, light gray for others + nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : IM_COL32(220, 220, 220, 255); + // Override with WoW class color if entity is loaded + auto mEnt = gameHandler.getEntityManager().getEntity(m.guid); + if (mEnt) { + uint8_t cid = static_cast( + (mEnt->getField(game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)) >> 8) & 0xFF); + switch (cid) { + case 1: nameCol = IM_COL32(199, 156, 110, 255); break; // Warrior + case 2: nameCol = IM_COL32(245, 140, 186, 255); break; // Paladin + case 3: nameCol = IM_COL32(171, 212, 115, 255); break; // Hunter + case 4: nameCol = IM_COL32(255, 245, 105, 255); break; // Rogue + case 5: nameCol = IM_COL32(255, 255, 255, 255); break; // Priest + case 6: nameCol = IM_COL32(196, 31, 59, 255); break; // Death Knight + case 7: nameCol = IM_COL32( 0, 112, 222, 255); break; // Shaman + case 8: nameCol = IM_COL32(105, 204, 240, 255); break; // Mage + case 9: nameCol = IM_COL32(148, 130, 201, 255); break; // Warlock + case 11: nameCol = IM_COL32(255, 125, 10, 255); break; // Druid + default: break; + } + } + } draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); // Leader crown star in top-right of cell From 8f68d1efb9e041672253bcf1123e666ff956423f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:30:27 -0700 Subject: [PATCH 064/105] feat: show WoW class colors on player nameplates Player nameplates previously used a flat cyan for all players. Now they display the canonical Blizzard class color (Warrior=#C79C6E, Paladin=#F58CBA, Hunter=#ABD473, etc.) read from UNIT_FIELD_BYTES_0. This makes it easy to identify player classes at a glance in the world, especially useful in PvP and group content. NPC nameplates keep the existing red (hostile) / yellow (friendly) coloring. --- src/ui/game_screen.cpp | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ca5b2b50..c843be56 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7213,12 +7213,31 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(labelBuf); float nameX = sx - textSize.x * 0.5f; float nameY = sy - barH - 12.0f; - // Name color: other player=cyan, hostile=red, non-hostile=yellow (WoW convention) - ImU32 nameColor = isPlayer - ? IM_COL32( 80, 200, 255, A(230)) // cyan — other players - : unit->isHostile() + // Name color: players get WoW class colors; NPCs use hostility (red/yellow) + ImU32 nameColor; + if (isPlayer) { + // Determine class from UNIT_FIELD_BYTES_0 byte 1; cyan fallback for unknown class + nameColor = IM_COL32(80, 200, 255, A(230)); + uint8_t cid = static_cast( + (unit->getField(game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)) >> 8) & 0xFF); + switch (cid) { + case 1: nameColor = IM_COL32(199, 156, 110, A(230)); break; // Warrior + case 2: nameColor = IM_COL32(245, 140, 186, A(230)); break; // Paladin + case 3: nameColor = IM_COL32(171, 212, 115, A(230)); break; // Hunter + case 4: nameColor = IM_COL32(255, 245, 105, A(230)); break; // Rogue + case 5: nameColor = IM_COL32(255, 255, 255, A(230)); break; // Priest + case 6: nameColor = IM_COL32(196, 31, 59, A(230)); break; // Death Knight + case 7: nameColor = IM_COL32( 0, 112, 222, A(230)); break; // Shaman + case 8: nameColor = IM_COL32(105, 204, 240, A(230)); break; // Mage + case 9: nameColor = IM_COL32(148, 130, 201, A(230)); break; // Warlock + case 11: nameColor = IM_COL32(255, 125, 10, A(230)); break; // Druid + default: break; + } + } else { + nameColor = unit->isHostile() ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC + } drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); From 41d121df1de84b4d27852119db4d6ff7d8ee01ce Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:33:34 -0700 Subject: [PATCH 065/105] refactor: consolidate class color lookups into shared helpers Add classColorVec4(), classColorU32(), and entityClassId() to the anonymous namespace so the canonical Blizzard class colors are defined in exactly one place. Refactor the three existing class color blocks (party frame, raid frame, nameplates) to use these helpers. Also apply class colors to player names in the target frame. --- src/ui/game_screen.cpp | 101 ++++++++++++++++++++--------------------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c843be56..618042bf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -88,6 +88,41 @@ namespace { ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); } + // Return the canonical Blizzard class color as ImVec4. + // classId is byte 1 of UNIT_FIELD_BYTES_0 (or CharacterData::classId). + // Returns a neutral light-gray for unknown / class 0. + ImVec4 classColorVec4(uint8_t classId) { + switch (classId) { + case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E + case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA + case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473 + case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569 + case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF + case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B + case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE + case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0 + case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9 + case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A + default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f); // unknown + } + } + + // ImU32 variant with alpha in [0,255]. + ImU32 classColorU32(uint8_t classId, int alpha = 255) { + ImVec4 c = classColorVec4(classId); + return IM_COL32(static_cast(c.x * 255), static_cast(c.y * 255), + static_cast(c.z * 255), alpha); + } + + // Extract class id from a unit's UNIT_FIELD_BYTES_0 update field. + // Returns 0 if the entity pointer is null or field is unset. + uint8_t entityClassId(const wowee::game::Entity* entity) { + if (!entity) return 0; + using UF = wowee::game::UF; + uint32_t bytes0 = entity->getField(wowee::game::fieldIndex(UF::UNIT_FIELD_BYTES_0)); + return static_cast((bytes0 >> 8) & 0xFF); + } + bool isPortBotTarget(const std::string& target) { std::string t = toLower(trim(target)); return t == "portbot" || t == "gmbot" || t == "telebot"; @@ -3006,7 +3041,12 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { // Entity name and type — Selectable so we can attach a right-click context menu std::string name = getEntityName(target); + // Player targets: use class color instead of the generic green ImVec4 nameColor = hostileColor; + if (target->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(target.get()); + if (cid != 0) nameColor = classColorVec4(cid); + } ImGui::SameLine(0.0f, 0.0f); ImGui::PushStyleColor(ImGuiCol_Text, nameColor); @@ -7216,23 +7256,11 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Name color: players get WoW class colors; NPCs use hostility (red/yellow) ImU32 nameColor; if (isPlayer) { - // Determine class from UNIT_FIELD_BYTES_0 byte 1; cyan fallback for unknown class - nameColor = IM_COL32(80, 200, 255, A(230)); - uint8_t cid = static_cast( - (unit->getField(game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)) >> 8) & 0xFF); - switch (cid) { - case 1: nameColor = IM_COL32(199, 156, 110, A(230)); break; // Warrior - case 2: nameColor = IM_COL32(245, 140, 186, A(230)); break; // Paladin - case 3: nameColor = IM_COL32(171, 212, 115, A(230)); break; // Hunter - case 4: nameColor = IM_COL32(255, 245, 105, A(230)); break; // Rogue - case 5: nameColor = IM_COL32(255, 255, 255, A(230)); break; // Priest - case 6: nameColor = IM_COL32(196, 31, 59, A(230)); break; // Death Knight - case 7: nameColor = IM_COL32( 0, 112, 222, A(230)); break; // Shaman - case 8: nameColor = IM_COL32(105, 204, 240, A(230)); break; // Mage - case 9: nameColor = IM_COL32(148, 130, 201, A(230)); break; // Warlock - case 11: nameColor = IM_COL32(255, 125, 10, A(230)); break; // Druid - default: break; - } + // Class color with cyan fallback for unknown class + uint8_t cid = entityClassId(unit); + ImVec4 cc = (cid != 0) ? classColorVec4(cid) : ImVec4(0.31f, 0.78f, 1.0f, 1.0f); + nameColor = IM_COL32(static_cast(cc.x*255), static_cast(cc.y*255), + static_cast(cc.z*255), A(230)); } else { nameColor = unit->isHostile() ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC @@ -7442,23 +7470,8 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : IM_COL32(220, 220, 220, 255); // Override with WoW class color if entity is loaded auto mEnt = gameHandler.getEntityManager().getEntity(m.guid); - if (mEnt) { - uint8_t cid = static_cast( - (mEnt->getField(game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)) >> 8) & 0xFF); - switch (cid) { - case 1: nameCol = IM_COL32(199, 156, 110, 255); break; // Warrior - case 2: nameCol = IM_COL32(245, 140, 186, 255); break; // Paladin - case 3: nameCol = IM_COL32(171, 212, 115, 255); break; // Hunter - case 4: nameCol = IM_COL32(255, 245, 105, 255); break; // Rogue - case 5: nameCol = IM_COL32(255, 255, 255, 255); break; // Priest - case 6: nameCol = IM_COL32(196, 31, 59, 255); break; // Death Knight - case 7: nameCol = IM_COL32( 0, 112, 222, 255); break; // Shaman - case 8: nameCol = IM_COL32(105, 204, 240, 255); break; // Mage - case 9: nameCol = IM_COL32(148, 130, 201, 255); break; // Warlock - case 11: nameCol = IM_COL32(255, 125, 10, 255); break; // Druid - default: break; - } - } + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) nameCol = classColorU32(cid); } draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); @@ -7632,24 +7645,8 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); { auto memberEntity = gameHandler.getEntityManager().getEntity(member.guid); - if (memberEntity) { - uint32_t bytes0 = memberEntity->getField( - game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); - uint8_t classId = static_cast((bytes0 >> 8) & 0xFF); - switch (classId) { - case 1: nameColor = ImVec4(0.78f, 0.61f, 0.43f, 1.0f); break; // Warrior - case 2: nameColor = ImVec4(0.96f, 0.55f, 0.73f, 1.0f); break; // Paladin - case 3: nameColor = ImVec4(0.67f, 0.83f, 0.45f, 1.0f); break; // Hunter - case 4: nameColor = ImVec4(1.00f, 0.96f, 0.41f, 1.0f); break; // Rogue - case 5: nameColor = ImVec4(1.00f, 1.00f, 1.00f, 1.0f); break; // Priest - case 6: nameColor = ImVec4(0.77f, 0.12f, 0.23f, 1.0f); break; // Death Knight - case 7: nameColor = ImVec4(0.00f, 0.44f, 0.87f, 1.0f); break; // Shaman - case 8: nameColor = ImVec4(0.41f, 0.80f, 0.94f, 1.0f); break; // Mage - case 9: nameColor = ImVec4(0.58f, 0.51f, 0.79f, 1.0f); break; // Warlock - case 11: nameColor = ImVec4(1.00f, 0.49f, 0.04f, 1.0f); break; // Druid - default: break; // keep fallback - } - } + uint8_t cid = entityClassId(memberEntity.get()); + if (cid != 0) nameColor = classColorVec4(cid); } ImGui::PushStyleColor(ImGuiCol_Text, nameColor); if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { From c73da1629bc8fa3b83d61c0b332d8b7046fd1556 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:35:47 -0700 Subject: [PATCH 066/105] refactor: use classColorVec4 helper in player frame Replace the duplicated 10-case switch in renderPlayerFrame with a call to the shared classColorVec4() helper, keeping the single source of truth for Blizzard class colors. --- src/ui/game_screen.cpp | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 618042bf..d11939a4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2441,22 +2441,10 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { playerHp = playerMaxHp; } - // Derive class color (WoW standard class colors) - ImVec4 classColor(0.3f, 1.0f, 0.3f, 1.0f); // default green - if (activeChar) { - switch (activeChar->characterClass) { - case game::Class::WARRIOR: classColor = ImVec4(0.78f, 0.61f, 0.43f, 1.0f); break; - case game::Class::PALADIN: classColor = ImVec4(0.96f, 0.55f, 0.73f, 1.0f); break; - case game::Class::HUNTER: classColor = ImVec4(0.67f, 0.83f, 0.45f, 1.0f); break; - case game::Class::ROGUE: classColor = ImVec4(1.00f, 0.96f, 0.41f, 1.0f); break; - case game::Class::PRIEST: classColor = ImVec4(1.00f, 1.00f, 1.00f, 1.0f); break; - case game::Class::DEATH_KNIGHT: classColor = ImVec4(0.77f, 0.12f, 0.23f, 1.0f); break; - case game::Class::SHAMAN: classColor = ImVec4(0.00f, 0.44f, 0.87f, 1.0f); break; - case game::Class::MAGE: classColor = ImVec4(0.41f, 0.80f, 0.94f, 1.0f); break; - case game::Class::WARLOCK: classColor = ImVec4(0.58f, 0.51f, 0.79f, 1.0f); break; - case game::Class::DRUID: classColor = ImVec4(1.00f, 0.49f, 0.04f, 1.0f); break; - } - } + // Derive class color via shared helper + ImVec4 classColor = activeChar + ? classColorVec4(static_cast(activeChar->characterClass)) + : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Name in class color — clickable for self-target, right-click for menu ImGui::PushStyleColor(ImGuiCol_Text, classColor); From f4a31fef2a0585b346eed4647c2bf4cf8377ed69 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:37:14 -0700 Subject: [PATCH 067/105] feat: apply class colors to focus frame player name Player entities shown in the focus frame now display their canonical WoW class color (from UNIT_FIELD_BYTES_0), consistent with how player names are now colored in the target frame, party frame, raid frame, and nameplates. --- src/ui/game_screen.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d11939a4..f8de0380 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3486,7 +3486,9 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { // Determine color based on relation (same logic as target frame) ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f); if (focus->getType() == game::ObjectType::PLAYER) { - focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + // Use class color for player focus targets + uint8_t cid = entityClassId(focus.get()); + focusColor = (cid != 0) ? classColorVec4(cid) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); } else if (focus->getType() == game::ObjectType::UNIT) { auto u = std::static_pointer_cast(focus); if (u->getHealth() == 0 && u->getMaxHealth() > 0) { From b5131b19a3b715399f50ebe547be7dee406a63c9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:40:54 -0700 Subject: [PATCH 068/105] feat: color minimap party dots by class instead of uniform blue --- 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 f8de0380..d8f4c328 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13329,9 +13329,16 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(memberRender, sx, sy)) continue; - ImU32 dotColor = (member.guid == leaderGuid) - ? IM_COL32(255, 210, 0, 235) - : IM_COL32(100, 180, 255, 235); + ImU32 dotColor; + { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + dotColor = (cid != 0) + ? classColorU32(cid, 235) + : (member.guid == leaderGuid) + ? IM_COL32(255, 210, 0, 235) + : IM_COL32(100, 180, 255, 235); + } drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor); drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f); From 339ee4dbba01b806514d7f5b328e4032b0bb9072 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:42:55 -0700 Subject: [PATCH 069/105] refactor: use classColorVec4 helper in guild roster, color member names by class --- src/ui/game_screen.cpp | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d8f4c328..68d6d4bc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9019,9 +9019,10 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::TableNextRow(); ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + ImVec4 nameColor = m.online ? classColorVec4(m.classId) : textColor; ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.name.c_str()); + ImGui::TextColored(nameColor, "%s", m.name.c_str()); // Right-click context menu if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { @@ -9042,22 +9043,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::TableNextColumn(); const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; - // Class-colored text for online members (gray for offline) - ImVec4 classCol = textColor; - if (m.online) { - switch (m.classId) { - case 1: classCol = ImVec4(0.78f, 0.61f, 0.43f, 1.0f); break; // Warrior - case 2: classCol = ImVec4(0.96f, 0.55f, 0.73f, 1.0f); break; // Paladin - case 3: classCol = ImVec4(0.67f, 0.83f, 0.45f, 1.0f); break; // Hunter - case 4: classCol = ImVec4(1.00f, 0.96f, 0.41f, 1.0f); break; // Rogue - case 5: classCol = ImVec4(1.00f, 1.00f, 1.00f, 1.0f); break; // Priest - case 6: classCol = ImVec4(0.77f, 0.12f, 0.23f, 1.0f); break; // Death Knight - case 7: classCol = ImVec4(0.00f, 0.44f, 0.87f, 1.0f); break; // Shaman - case 8: classCol = ImVec4(0.41f, 0.80f, 0.94f, 1.0f); break; // Mage - case 9: classCol = ImVec4(0.58f, 0.51f, 0.79f, 1.0f); break; // Warlock - case 11: classCol = ImVec4(1.00f, 0.49f, 0.04f, 1.0f); break; // Druid - } - } + ImVec4 classCol = m.online ? classColorVec4(m.classId) : textColor; ImGui::TextColored(classCol, "%s", className); ImGui::TableNextColumn(); From 30b821f7baf6b2c329c98f8e0622bc10801d3cca Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:44:08 -0700 Subject: [PATCH 070/105] refactor: replace duplicate class color switch in social frame with classColorVec4 helper, color friend names by class --- src/ui/game_screen.cpp | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 68d6d4bc..d5669c03 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9547,22 +9547,8 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { - // Class color helper shared by Friends tab auto getClassColor = [](uint32_t classId, bool online) -> ImVec4 { - if (!online) return ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - switch (classId) { - case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior - case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin - case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter - case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue - case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest - case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // Death Knight - case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman - case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage - case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock - case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid - default: return ImVec4(0.75f, 0.75f, 0.75f, 1.0f); - } + return online ? classColorVec4(static_cast(classId)) : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); }; static const char* kClassNames[] = { "Unknown","Warrior","Paladin","Hunter","Rogue","Priest", @@ -9605,7 +9591,7 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); ImVec4 nameCol = c.isOnline() - ? ImVec4(0.9f, 0.9f, 0.9f, 1.0f) + ? classColorVec4(static_cast(c.classId)) : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); ImGui::TextColored(nameCol, "%s", displayName); From 0b14141e97d874a2b5edaec02599e44ba2e649d1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:46:26 -0700 Subject: [PATCH 071/105] refactor: consolidate all class name/color lookups to shared helpers, remove 4 duplicate class tables --- src/ui/game_screen.cpp | 58 +++++++++++------------------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d5669c03..d54e54db 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -123,6 +123,15 @@ namespace { return static_cast((bytes0 >> 8) & 0xFF); } + // Return the English class name for a class ID (1-11), or "Unknown". + const char* classNameStr(uint8_t classId) { + static const char* kNames[] = { + "Unknown","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid" + }; + return (classId < 12) ? kNames[classId] : "Unknown"; + } + bool isPortBotTarget(const std::string& target) { std::string t = toLower(trim(target)); return t == "portbot" || t == "gmbot" || t == "telebot"; @@ -9009,12 +9018,6 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { return a.name < b.name; }); - static const char* classNames[] = { - "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", - "Priest", "Death Knight", "Shaman", "Mage", "Warlock", - "", "Druid" - }; - for (const auto& m : sortedMembers) { ImGui::TableNextRow(); ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) @@ -9042,7 +9045,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::TextColored(textColor, "%u", m.level); ImGui::TableNextColumn(); - const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; + const char* className = classNameStr(m.classId); ImVec4 classCol = m.online ? classColorVec4(m.classId) : textColor; ImGui::TextColored(classCol, "%s", className); @@ -9390,31 +9393,9 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { (c.status == 2) ? " (AFK)" : (c.status == 3) ? " (DND)" : ""; // Class color for the level/class display - ImVec4 friendClassCol = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); - const char* friendClassName = ""; - if (c.classId > 0 && c.classId < 12) { - static const ImVec4 kFriendClassColors[] = { - {0,0,0,0}, - {0.78f,0.61f,0.43f,1}, // Warrior - {0.96f,0.55f,0.73f,1}, // Paladin - {0.67f,0.83f,0.45f,1}, // Hunter - {1.00f,0.96f,0.41f,1}, // Rogue - {1.00f,1.00f,1.00f,1}, // Priest - {0.77f,0.12f,0.23f,1}, // Death Knight - {0.00f,0.44f,0.87f,1}, // Shaman - {0.41f,0.80f,0.94f,1}, // Mage - {0.58f,0.51f,0.79f,1}, // Warlock - {0,0,0,0}, // (unused slot 10) - {1.00f,0.49f,0.04f,1}, // Druid - }; - static const char* kFriendClassNames[] = { - "","Warrior","Paladin","Hunter","Rogue","Priest", - "Death Knight","Shaman","Mage","Warlock","","Druid" - }; - friendClassCol = kFriendClassColors[c.classId]; - friendClassName = kFriendClassNames[c.classId]; - } - if (c.level > 0 && *friendClassName) { + ImVec4 friendClassCol = classColorVec4(static_cast(c.classId)); + const char* friendClassName = classNameStr(static_cast(c.classId)); + if (c.level > 0 && c.classId > 0) { ImGui::TextColored(friendClassCol, "Lv%u %s%s", c.level, friendClassName, statusLabel); } else if (c.level > 0) { ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); @@ -9547,14 +9528,6 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { - auto getClassColor = [](uint32_t classId, bool online) -> ImVec4 { - return online ? classColorVec4(static_cast(classId)) : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - }; - static const char* kClassNames[] = { - "Unknown","Warrior","Paladin","Hunter","Rogue","Priest", - "Death Knight","Shaman","Mage","Warlock","","Druid" - }; - // Get zone manager for area name lookups game::ZoneManager* socialZoneMgr = nullptr; if (auto* rend = core::Application::getInstance().getRenderer()) @@ -9598,9 +9571,8 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { if (c.isOnline() && c.level > 0) { ImGui::SameLine(); // Show level and class name in class color - ImVec4 cc = getClassColor(c.classId, true); - const char* cname = (c.classId < 12) ? kClassNames[c.classId] : "?"; - ImGui::TextColored(cc, "Lv%u %s", c.level, cname); + ImGui::TextColored(classColorVec4(static_cast(c.classId)), + "Lv%u %s", c.level, classNameStr(static_cast(c.classId))); } // Tooltip: zone info and note From 641f9432689fe3052f599ea0ee778bb05a54396f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:47:59 -0700 Subject: [PATCH 072/105] feat: inspect window shows class-colored name, class label, and average item level --- 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 d54e54db..1954f1ab 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16659,10 +16659,19 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { 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(); + // Player name — class-colored if entity is loaded, else gold + { + auto ent = gameHandler.getEntityManager().getEntity(result->guid); + uint8_t cid = entityClassId(ent.get()); + ImVec4 nameColor = (cid != 0) ? classColorVec4(cid) : ImVec4(1.0f, 0.82f, 0.0f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::Text("%s", result->playerName.c_str()); + ImGui::PopStyleColor(); + if (cid != 0) { + ImGui::SameLine(); + ImGui::TextColored(classColorVec4(cid), "(%s)", classNameStr(cid)); + } + } ImGui::SameLine(); ImGui::TextDisabled(" %u talent pts", result->totalTalents); if (result->unspentTalents > 0) { @@ -16686,6 +16695,27 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::TextDisabled("Equipment data not yet available."); ImGui::TextDisabled("(Gear loads after the player is inspected in-range)"); } else { + // Average item level (only slots that have loaded info and are not shirt/tabard) + // Shirt=slot3, Tabard=slot18 — excluded from gear score by WoW convention + uint32_t iLevelSum = 0; + int iLevelCount = 0; + for (int s = 0; s < 19; ++s) { + if (s == 3 || s == 18) continue; // shirt, tabard + uint32_t entry = result->itemEntries[s]; + if (entry == 0) continue; + const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); + if (info && info->valid && info->itemLevel > 0) { + iLevelSum += info->itemLevel; + ++iLevelCount; + } + } + if (iLevelCount > 0) { + float avgIlvl = static_cast(iLevelSum) / static_cast(iLevelCount); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Avg iLvl: %.1f", avgIlvl); + ImGui::SameLine(); + ImGui::TextDisabled("(%d/%d slots loaded)", iLevelCount, + [&]{ int c=0; for(int s=0;s<19;++s){if(s==3||s==18)continue;if(result->itemEntries[s])++c;} return c; }()); + } if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { constexpr float kIconSz = 28.0f; for (int s = 0; s < 19; ++s) { From ee7de739fc24d613d4c053a21ee021e8c4760845 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:49:18 -0700 Subject: [PATCH 073/105] fix: include class name in /who response chat messages --- src/game/game_handler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3b643674..773f9a91 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18324,10 +18324,12 @@ void GameHandler::handleWho(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) zoneId = packet.readUInt32(); + const char* className = getClassName(static_cast(classId)); + std::string msg = " " + playerName; if (!guildName.empty()) msg += " <" + guildName + ">"; - msg += " - Level " + std::to_string(level); + msg += " - Level " + std::to_string(level) + " " + className; if (zoneId != 0) { std::string zoneName = getAreaName(zoneId); if (!zoneName.empty()) From eaf60b4f797504500463601457f72beb5bbb81ba Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:50:14 -0700 Subject: [PATCH 074/105] feat: apply class color to target-of-target frame player names --- src/ui/game_screen.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1954f1ab..259b10ad 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3424,8 +3424,14 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { std::string totName = getEntityName(totEntity); + // Class color for players; gray for NPCs + ImVec4 totNameColor = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + if (totEntity->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(totEntity.get()); + if (cid != 0) totNameColor = classColorVec4(cid); + } // 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_Text, totNameColor); 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)); From 8a24638ced8d986f3bec153e1c72b841bb735813 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:54:29 -0700 Subject: [PATCH 075/105] fix: use uint64_t for chat tab typeMask to avoid UB for ChatType values >= 32, add missing boss/party chat types to Combat tab --- include/ui/game_screen.hpp | 2 +- src/ui/game_screen.cpp | 32 ++++++++++++++++++-------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 4b166783..fd30476e 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -66,7 +66,7 @@ private: int activeChatTab_ = 0; struct ChatTab { std::string name; - uint32_t typeMask; // bitmask of ChatType values to show + uint64_t typeMask; // bitmask of ChatType values to show (64-bit: types go up to 84) }; std::vector chatTabs_; void initChatTabs(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 259b10ad..33f3e02a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -204,29 +204,33 @@ GameScreen::GameScreen() { void GameScreen::initChatTabs() { chatTabs_.clear(); // General tab: shows everything - chatTabs_.push_back({"General", 0xFFFFFFFF}); + chatTabs_.push_back({"General", ~0ULL}); // 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::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))}); + chatTabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | + (1ULL << static_cast(game::ChatType::LOOT)) | + (1ULL << static_cast(game::ChatType::SKILL)) | + (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | + (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | + (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | + (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | + (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); // Whispers tab - chatTabs_.push_back({"Whispers", (1u << static_cast(game::ChatType::WHISPER)) | - (1u << static_cast(game::ChatType::WHISPER_INFORM))}); + chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | + (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); // Trade/LFG tab: channel messages - chatTabs_.push_back({"Trade/LFG", (1u << static_cast(game::ChatType::CHANNEL))}); + chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); } bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; const auto& tab = chatTabs_[tabIndex]; - if (tab.typeMask == 0xFFFFFFFF) return true; // General tab shows all + if (tab.typeMask == ~0ULL) return true; // General tab shows all - uint32_t typeBit = 1u << static_cast(msg.type); + uint64_t typeBit = 1ULL << static_cast(msg.type); // For Trade/LFG tab, also filter by channel name if (tabIndex == 3 && msg.type == game::ChatType::CHANNEL) { From 7acaa4d3010488547f1249855dd383bd67b06b98 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 08:59:38 -0700 Subject: [PATCH 076/105] feat: show live roll results from group members in loot roll popup Track each player's roll (need/greed/disenchant/pass + value) as SMSG_LOOT_ROLL packets arrive while our roll window is open. Display a color-coded table in the popup: green=need, blue=greed, purple=disenchant, gray=pass. Roll value hidden for pass. --- include/game/game_handler.hpp | 7 ++++++ src/game/game_handler.cpp | 23 +++++++++++++++++++ src/ui/game_screen.cpp | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 81257c6f..8b7a445a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1151,6 +1151,13 @@ public: uint8_t itemQuality = 0; uint32_t rollCountdownMs = 60000; // Duration of roll window in ms std::chrono::steady_clock::time_point rollStartedAt{}; + + struct PlayerRollResult { + std::string playerName; + uint8_t rollNum = 0; + uint8_t rollType = 0; // 0=need,1=greed,2=disenchant,96=pass + }; + std::vector playerRolls; // live roll results from group members }; bool hasPendingLootRoll() const { return pendingLootRollActive_; } const LootRollEntry& getPendingLootRoll() const { return pendingLootRoll_; } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 773f9a91..054d4865 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -19993,6 +19993,7 @@ void GameHandler::handleLootRoll(network::Packet& packet) { pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + pendingLootRoll_.playerRolls.clear(); // Ensure item info is in cache; query if not queryItemInfo(itemId, 0); // Look up item name from cache @@ -20017,6 +20018,28 @@ void GameHandler::handleLootRoll(network::Packet& packet) { } if (rollerName.empty()) rollerName = "Someone"; + // Track in the live roll list while our popup is open for the same item + if (pendingLootRollActive_ && + pendingLootRoll_.objectGuid == objectGuid && + pendingLootRoll_.slot == slot) { + bool found = false; + for (auto& r : pendingLootRoll_.playerRolls) { + if (r.playerName == rollerName) { + r.rollNum = rollNum; + r.rollType = rollType; + found = true; + break; + } + } + if (!found) { + LootRollEntry::PlayerRollResult prr; + prr.playerName = rollerName; + prr.rollNum = rollNum; + prr.rollType = rollType; + pendingLootRoll_.playerRolls.push_back(std::move(prr)); + } + } + auto* info = getItemInfo(itemId); std::string iName = info ? info->name : std::to_string(itemId); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 33f3e02a..493de4d3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8706,6 +8706,48 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { if (ImGui::Button("Pass", ImVec2(70, 30))) { gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); } + + // Live roll results from group members + if (!roll.playerRolls.empty()) { + ImGui::Separator(); + ImGui::TextDisabled("Rolls so far:"); + // Roll-type label + color + static const char* kRollLabels[] = {"Need", "Greed", "Disenchant", "Pass"}; + static const ImVec4 kRollColors[] = { + ImVec4(0.2f, 0.9f, 0.2f, 1.0f), // Need — green + ImVec4(0.3f, 0.6f, 1.0f, 1.0f), // Greed — blue + ImVec4(0.7f, 0.3f, 0.9f, 1.0f), // Disenchant — purple + ImVec4(0.5f, 0.5f, 0.5f, 1.0f), // Pass — gray + }; + auto rollTypeIndex = [](uint8_t t) -> int { + if (t == 0) return 0; + if (t == 1) return 1; + if (t == 2) return 2; + return 3; // pass (96 or unknown) + }; + + if (ImGui::BeginTable("##lootrolls", 3, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Roll", ImGuiTableColumnFlags_WidthFixed, 32.0f); + for (const auto& r : roll.playerRolls) { + int ri = rollTypeIndex(r.rollType); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.playerName.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(kRollColors[ri], "%s", kRollLabels[ri]); + ImGui::TableSetColumnIndex(2); + if (r.rollType != 96) { + ImGui::TextColored(kRollColors[ri], "%d", static_cast(r.rollNum)); + } else { + ImGui::TextDisabled("—"); + } + } + ImGui::EndTable(); + } + } } ImGui::End(); } From fb8377f3cae3857e96a02c7624c9d6ce31b6469b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:00:31 -0700 Subject: [PATCH 077/105] fix: dismiss loot roll popup when any player wins, not only when we win SMSG_LOOT_ROLL_WON signals the roll contest is over for this slot; clear pendingLootRollActive_ unconditionally so the popup does not linger if a different group member wins while we have not yet voted. --- src/game/game_handler.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 054d4865..25400302 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -20094,10 +20094,8 @@ void GameHandler::handleLootRollWon(network::Packet& packet) { winnerName.c_str(), iName.c_str(), rollName, static_cast(rollNum)); addSystemChatMessage(buf); - // Clear pending roll if it was ours - if (pendingLootRollActive_ && winnerGuid == playerGuid) { - pendingLootRollActive_ = false; - } + // Dismiss roll popup — roll contest is over regardless of who won + pendingLootRollActive_ = false; LOG_INFO("SMSG_LOOT_ROLL_WON: winner=", winnerName, " item=", itemId, " roll=", rollName, "(", rollNum, ")"); } From 09d4a6ab410f96062b52f3e7dbbebe8bccca3438 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:05:28 -0700 Subject: [PATCH 078/105] feat: add pet stance indicator and Passive/Defensive/Aggressive buttons to pet frame Show Psv/Def/Agg stance buttons (color-coded blue/green/red) above the pet action bar. Active stance is highlighted; clicking sends CMSG_PET_ACTION with the server-provided slot value for correct packet format, falling back to the wire-protocol action ID if the slot is not in the action bar. Also label stance slots 1/4/6 in the action bar as Psv/Def/Agg with proper full-name tooltips. --- src/ui/game_screen.cpp | 78 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 493de4d3..78e201ff 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2860,10 +2860,61 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - // Dismiss button (compact, right-aligned) - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); - if (ImGui::SmallButton("Dismiss")) { - gameHandler.dismissPet(); + // Stance row: Passive / Defensive / Aggressive — with Dismiss right-aligned + { + static const char* kReactLabels[] = { "Psv", "Def", "Agg" }; + static const char* kReactTooltips[] = { "Passive", "Defensive", "Aggressive" }; + static const ImVec4 kReactColors[] = { + ImVec4(0.4f, 0.6f, 1.0f, 1.0f), // passive — blue + ImVec4(0.3f, 0.85f, 0.3f, 1.0f), // defensive — green + ImVec4(1.0f, 0.35f, 0.35f, 1.0f),// aggressive — red + }; + static const ImVec4 kReactDimColors[] = { + ImVec4(0.15f, 0.2f, 0.4f, 0.8f), + ImVec4(0.1f, 0.3f, 0.1f, 0.8f), + ImVec4(0.4f, 0.1f, 0.1f, 0.8f), + }; + uint8_t curReact = gameHandler.getPetReact(); // 0=passive,1=defensive,2=aggressive + + // Find each react-type slot in the action bar by known built-in IDs: + // 1=Passive, 4=Defensive, 6=Aggressive (WoW wire protocol) + static const uint32_t kReactActionIds[] = { 1u, 4u, 6u }; + uint32_t reactSlotVals[3] = { 0, 0, 0 }; + const int slotTotal = game::GameHandler::PET_ACTION_BAR_SLOTS; + for (int i = 0; i < slotTotal; ++i) { + uint32_t sv = gameHandler.getPetActionSlot(i); + uint32_t aid = sv & 0x00FFFFFFu; + for (int r = 0; r < 3; ++r) { + if (aid == kReactActionIds[r]) { reactSlotVals[r] = sv; break; } + } + } + + for (int r = 0; r < 3; ++r) { + if (r > 0) ImGui::SameLine(0.0f, 3.0f); + bool active = (curReact == static_cast(r)); + ImVec4 btnCol = active ? kReactColors[r] : kReactDimColors[r]; + ImGui::PushID(r + 1000); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kReactColors[r]); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, kReactColors[r]); + if (ImGui::Button(kReactLabels[r], ImVec2(34.0f, 16.0f))) { + // Use server-provided slot value if available; fall back to raw ID + uint32_t action = (reactSlotVals[r] != 0) + ? reactSlotVals[r] + : kReactActionIds[r]; + gameHandler.sendPetAction(action, 0); + } + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", kReactTooltips[r]); + ImGui::PopID(); + } + + // Dismiss button right-aligned on the same row + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 58.0f); + if (ImGui::SmallButton("Dismiss")) { + gameHandler.dismissPet(); + } } // Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS @@ -2893,9 +2944,12 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { // Try to show spell icon; fall back to abbreviated text label. VkDescriptorSet iconTex = VK_NULL_HANDLE; const char* builtinLabel = nullptr; - if (actionId == 2) builtinLabel = "Fol"; + if (actionId == 1) builtinLabel = "Psv"; + else if (actionId == 2) builtinLabel = "Fol"; else if (actionId == 3) builtinLabel = "Sty"; + else if (actionId == 4) builtinLabel = "Def"; else if (actionId == 5) builtinLabel = "Atk"; + else if (actionId == 6) builtinLabel = "Agg"; else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); // Tint green when autocast is on. @@ -2933,11 +2987,17 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { // Tooltip: show spell name or built-in command name. if (ImGui::IsItemHovered()) { - const char* tip = builtinLabel - ? (actionId == 5 ? "Attack" : actionId == 2 ? "Follow" : "Stay") - : nullptr; + const char* tip = nullptr; + if (builtinLabel) { + if (actionId == 1) tip = "Passive"; + else if (actionId == 2) tip = "Follow"; + else if (actionId == 3) tip = "Stay"; + else if (actionId == 4) tip = "Defensive"; + else if (actionId == 5) tip = "Attack"; + else if (actionId == 6) tip = "Aggressive"; + } std::string spellNm; - if (!tip && actionId > 5) { + if (!tip && actionId > 6) { spellNm = gameHandler.getSpellName(actionId); if (!spellNm.empty()) tip = spellNm.c_str(); } From d8d59dcdc894d948c7715bfbc26b047aab9d9fb2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:07:37 -0700 Subject: [PATCH 079/105] feat: show live per-player responses in ready check popup Track each player's ready/not-ready response as MSG_RAID_READY_CHECK_CONFIRM packets arrive. Display a color-coded table (green=Ready, red=Not Ready) in the ready check popup so the raid leader can see who has responded in real time. Results clear when a new check starts or finishes. --- include/game/game_handler.hpp | 6 ++++++ src/game/game_handler.cpp | 9 +++++++++ src/ui/game_screen.cpp | 23 +++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8b7a445a..1eafa7c7 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -458,11 +458,16 @@ public: uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; } // Ready check + struct ReadyCheckResult { + std::string name; + bool ready = false; + }; void initiateReadyCheck(); void respondToReadyCheck(bool ready); bool hasPendingReadyCheck() const { return pendingReadyCheck_; } void dismissReadyCheck() { pendingReadyCheck_ = false; } const std::string& getReadyCheckInitiator() const { return readyCheckInitiator_; } + const std::vector& getReadyCheckResults() const { return readyCheckResults_; } // Duel void forfeitDuel(); @@ -2258,6 +2263,7 @@ private: uint32_t readyCheckReadyCount_ = 0; uint32_t readyCheckNotReadyCount_ = 0; std::string readyCheckInitiator_; + std::vector readyCheckResults_; // per-player status live during check // Faction standings (factionId → absolute standing value) std::unordered_map factionStandings_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 25400302..b2fd7619 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3149,6 +3149,7 @@ void GameHandler::handlePacket(network::Packet& packet) { readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; readyCheckInitiator_.clear(); + readyCheckResults_.clear(); if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t initiatorGuid = packet.readUInt64(); auto entity = entityManager.getEntity(initiatorGuid); @@ -3182,7 +3183,14 @@ void GameHandler::handlePacket(network::Packet& packet) { auto ent = entityManager.getEntity(respGuid); if (ent) rname = std::static_pointer_cast(ent)->getName(); } + // Track per-player result for live popup display if (!rname.empty()) { + bool found = false; + for (auto& r : readyCheckResults_) { + if (r.name == rname) { r.ready = (isReady != 0); found = true; break; } + } + if (!found) readyCheckResults_.push_back({ rname, isReady != 0 }); + char rbuf[128]; std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); addSystemChatMessage(rbuf); @@ -3198,6 +3206,7 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingReadyCheck_ = false; readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; + readyCheckResults_.clear(); break; } case Opcode::SMSG_RAID_INSTANCE_INFO: diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 78e201ff..5008cf58 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8866,6 +8866,29 @@ void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) { gameHandler.respondToReadyCheck(false); gameHandler.dismissReadyCheck(); } + + // Live player responses + const auto& results = gameHandler.getReadyCheckResults(); + if (!results.empty()) { + ImGui::Separator(); + if (ImGui::BeginTable("##rcresults", 2, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 72.0f); + for (const auto& r : results) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.name.c_str()); + ImGui::TableSetColumnIndex(1); + if (r.ready) { + ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "Ready"); + } else { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "Not Ready"); + } + } + ImGui::EndTable(); + } + } } ImGui::End(); } From c1a090a17c97e895ecd006a00ae032ec5417661f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:09:41 -0700 Subject: [PATCH 080/105] feat: show kick target name and reason in LFG vote-kick UI Parse the optional reason and target name strings from SMSG_LFG_BOOT_PROPOSAL_UPDATE and display them in the Dungeon Finder vote-kick section. Strings are cleared when the vote ends. --- include/game/game_handler.hpp | 12 ++++++++---- src/game/game_handler.cpp | 11 ++++++++++- src/ui/game_screen.cpp | 12 ++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1eafa7c7..bd108497 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1120,10 +1120,12 @@ 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_; } + uint32_t getLfgBootVotes() const { return lfgBootVotes_; } + uint32_t getLfgBootTotal() const { return lfgBootTotal_; } + uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; } + uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; } + const std::string& getLfgBootTargetName() const { return lfgBootTargetName_; } + const std::string& getLfgBootReason() const { return lfgBootReason_; } // ---- Arena Team Stats ---- struct ArenaTeamStats { @@ -2257,6 +2259,8 @@ private: uint32_t lfgBootTotal_ = 0; // total votes cast uint32_t lfgBootTimeLeft_ = 0; // seconds remaining uint32_t lfgBootNeeded_ = 0; // votes needed to kick + std::string lfgBootTargetName_; // name of player being voted on + std::string lfgBootReason_; // reason given for kick // Ready check state bool pendingReadyCheck_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b2fd7619..49293f79 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13111,11 +13111,19 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { lfgBootTimeLeft_ = timeLeft; lfgBootNeeded_ = votesNeeded; + // Optional: reason string and target name (null-terminated) follow the fixed fields + if (packet.getSize() - packet.getReadPos() > 0) + lfgBootReason_ = packet.readString(); + if (packet.getSize() - packet.getReadPos() > 0) + lfgBootTargetName_ = packet.readString(); + if (inProgress) { lfgState_ = LfgState::Boot; } else { // Boot vote ended — return to InDungeon state regardless of outcome lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; + lfgBootTargetName_.clear(); + lfgBootReason_.clear(); lfgState_ = LfgState::InDungeon; if (myAnswer) { addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); @@ -13125,7 +13133,8 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { } LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress, - " bootVotes=", bootVotes, "/", totalVotes); + " bootVotes=", bootVotes, "/", totalVotes, + " target=", lfgBootTargetName_, " reason=", lfgBootReason_); } void GameHandler::handleLfgTeleportDenied(network::Packet& packet) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5008cf58..098322bb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16059,6 +16059,18 @@ 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:"); + const std::string& bootTarget = gameHandler.getLfgBootTargetName(); + const std::string& bootReason = gameHandler.getLfgBootReason(); + if (!bootTarget.empty()) { + ImGui::Text("Player: "); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", bootTarget.c_str()); + } + if (!bootReason.empty()) { + ImGui::Text("Reason: "); + ImGui::SameLine(); + ImGui::TextWrapped("%s", bootReason.c_str()); + } uint32_t bootVotes = gameHandler.getLfgBootVotes(); uint32_t bootTotal = gameHandler.getLfgBootTotal(); uint32_t bootNeeded = gameHandler.getLfgBootNeeded(); From aaa3649975d9c10b067faca7a6d1dfc862f90df9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:30:59 -0700 Subject: [PATCH 081/105] feat: implement /cast slash command with rank support --- src/ui/game_screen.cpp | 73 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 098322bb..c213d907 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4451,6 +4451,79 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "cast" && spacePos != std::string::npos) { + std::string spellArg = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + + // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" + int requestedRank = -1; // -1 = highest rank + std::string spellName = spellArg; + { + auto rankPos = spellArg.find('('); + if (rankPos != std::string::npos) { + std::string rankStr = spellArg.substr(rankPos + 1); + // Strip closing paren and whitespace + auto closePos = rankStr.find(')'); + if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); + for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); + // Expect "rank N" + if (rankStr.rfind("rank ", 0) == 0) { + try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} + } + spellName = spellArg.substr(0, rankPos); + while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); + } + } + + std::string spellNameLower = spellName; + for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); + + // Search known spells for a name match; pick highest rank (or specific rank) + uint32_t bestSpellId = 0; + int bestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sName = gameHandler.getSpellName(sid); + if (sName.empty()) continue; + std::string sNameLower = sName; + for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); + if (sNameLower != spellNameLower) continue; + + // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) + int sRank = 0; + const std::string& rankStr = gameHandler.getSpellRank(sid); + if (!rankStr.empty()) { + std::string rLow = rankStr; + for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); + if (rLow.rfind("rank ", 0) == 0) { + try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} + } + } + + if (requestedRank >= 0) { + if (sRank == requestedRank) { bestSpellId = sid; break; } + } else { + if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } + } + } + + if (bestSpellId) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(bestSpellId, targetGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = requestedRank >= 0 + ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." + : "Unknown spell: '" + spellName + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // Targeting commands if (cmdLower == "cleartarget") { gameHandler.clearTarget(); From ec93981a9d12b5e5d130da63e9697380e0d61993 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:36:14 -0700 Subject: [PATCH 082/105] feat: add /stopfollow and /zone slash commands, update /help --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 9 +++++++++ src/ui/game_screen.cpp | 27 ++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index bd108497..30149c40 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -402,6 +402,7 @@ public: // Follow/Assist void followTarget(); + void cancelFollow(); // Stop following current target void assistTarget(); // PvP diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 49293f79..611319d9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10256,6 +10256,15 @@ void GameHandler::followTarget() { LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")"); } +void GameHandler::cancelFollow() { + if (followTargetGuid_ == 0) { + addSystemChatMessage("You are not following anyone."); + return; + } + followTargetGuid_ = 0; + addSystemChatMessage("You stop following."); +} + void GameHandler::assistTarget() { if (state != WorldState::IN_WORLD) { LOG_WARNING("Cannot assist: not in world"); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c213d907..d23d7305 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3809,6 +3809,20 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /zone command — print current zone name + if (cmdLower == "zone") { + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + // /played command if (cmdLower == "played") { gameHandler.requestPlayedTime(); @@ -3834,11 +3848,11 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { " /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", + "Combat: /startattack /stopattack /stopcasting /cast /duel /pvp", + " /forfeit /follow /stopfollow /assist", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", - "Misc: /played /time /afk [msg] /dnd [msg] /inspect", + "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", " /unstuck /logout /ticket /help", }; @@ -4083,6 +4097,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /stopfollow command + if (cmdLower == "stopfollow") { + gameHandler.cancelFollow(); + chatInputBuffer[0] = '\0'; + return; + } + // /assist command if (cmdLower == "assist") { gameHandler.assistTarget(); From aaae07e477edeff2ba4123df6a3537a3ac50646d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:38:29 -0700 Subject: [PATCH 083/105] feat: implement /wts and /wtb trade channel shortcuts --- src/ui/game_screen.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d23d7305..0713c116 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4775,6 +4775,31 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } chatInputBuffer[0] = '\0'; return; + } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { + // /wts and /wtb — send to Trade channel + // Prefix with [WTS] / [WTB] and route to the Trade channel + const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; + const std::string body = command.substr(spacePos + 1); + // Find the Trade channel among joined channels (case-insensitive prefix match) + std::string tradeChan; + for (const auto& ch : gameHandler.getJoinedChannels()) { + std::string chLow = ch; + for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); + if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } + } + if (tradeChan.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "You are not in the Trade channel."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer[0] = '\0'; + return; + } + message = tag + body; + type = game::ChatType::CHANNEL; + target = tradeChan; + isChannelCommand = true; } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { // /1 msg, /2 msg — channel shortcuts int channelIdx = cmdLower[0] - '0'; From 84b31d3bbdcef7d1660667f3247fcd4a13db76d4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:41:45 -0700 Subject: [PATCH 084/105] feat: show spell name in proc trigger combat text when spellId is known --- src/ui/game_screen.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0713c116..3967d9f4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7081,10 +7081,15 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Resisted"); color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist break; - case game::CombatTextEntry::PROC_TRIGGER: - snprintf(text, sizeof(text), "PROC!"); + case game::CombatTextEntry::PROC_TRIGGER: { + const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!procName.empty()) + snprintf(text, sizeof(text), "%s!", procName.c_str()); + else + snprintf(text, sizeof(text), "PROC!"); color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc break; + } default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); From 88c3cfe7ab985b1d6037108afc565074c53bfa88 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:43:23 -0700 Subject: [PATCH 085/105] feat: show pet happiness bar in pet frame for hunter pets --- include/game/entity.hpp | 3 +++ src/ui/game_screen.cpp | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 9f4dfde7..57147902 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -214,6 +214,9 @@ public: void setMaxPower(uint32_t p) { maxPowers[powerType < 7 ? powerType : 0] = p; } void setMaxPowerByType(uint8_t type, uint32_t p) { if (type < 7) maxPowers[type] = p; } + uint32_t getPowerByType(uint8_t type) const { return type < 7 ? powers[type] : 0; } + uint32_t getMaxPowerByType(uint8_t type) const { return type < 7 ? maxPowers[type] : 0; } + uint8_t getPowerType() const { return powerType; } void setPowerType(uint8_t t) { powerType = t; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3967d9f4..bd539149 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2844,6 +2844,23 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Happiness bar — hunter pets store happiness as power type 4 + { + uint32_t happiness = petUnit->getPowerByType(4); + uint32_t maxHappiness = petUnit->getMaxPowerByType(4); + if (maxHappiness > 0 && happiness > 0) { + float hapPct = static_cast(happiness) / static_cast(maxHappiness); + // Tier: < 33% = Unhappy (red), < 67% = Content (yellow), >= 67% = Happy (green) + ImVec4 hapColor = hapPct >= 0.667f ? ImVec4(0.2f, 0.85f, 0.2f, 1.0f) + : hapPct >= 0.333f ? ImVec4(0.9f, 0.75f, 0.1f, 1.0f) + : ImVec4(0.85f, 0.2f, 0.2f, 1.0f); + const char* hapLabel = hapPct >= 0.667f ? "Happy" : hapPct >= 0.333f ? "Content" : "Unhappy"; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hapColor); + ImGui::ProgressBar(hapPct, ImVec2(-1, 8), hapLabel); + ImGui::PopStyleColor(); + } + } + // Pet cast bar if (auto* pcs = gameHandler.getUnitCastState(petGuid)) { float castPct = (pcs->timeTotal > 0.0f) From e781ede5b25cd4cf30a5d40b777451dc93120818 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:45:03 -0700 Subject: [PATCH 086/105] feat: implement /chathelp command with channel-specific help text --- 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 bd539149..f6a1fa76 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3854,6 +3854,39 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /chathelp command — list chat-channel slash commands + if (cmdLower == "chathelp") { + static const char* kChatHelp[] = { + "--- Chat Channel Commands ---", + "/s [msg] Say to nearby players", + "/y [msg] Yell to a wider area", + "/w [msg] Whisper to player", + "/r [msg] Reply to last whisper", + "/p [msg] Party chat", + "/g [msg] Guild chat", + "/o [msg] Guild officer chat", + "/raid [msg] Raid chat", + "/rw [msg] Raid warning", + "/bg [msg] Battleground chat", + "/1 [msg] General channel", + "/2 [msg] Trade channel (also /wts /wtb)", + "/ [msg] Channel by number", + "/join Join a channel", + "/leave Leave a channel", + "/afk [msg] Set AFK status", + "/dnd [msg] Set Do Not Disturb", + }; + for (const char* line : kChatHelp) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // /help command — list available slash commands if (cmdLower == "help" || cmdLower == "?") { static const char* kHelpLines[] = { From 71b0a1823888b11bcd147f75026baeb601c3dc7e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 09:56:38 -0700 Subject: [PATCH 087/105] feat: implement quest sharing with party via CMSG_PUSHQUESTTOPARTY --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 22 ++++++++++++++++++++++ src/ui/game_screen.cpp | 5 +++++ src/ui/quest_log_screen.cpp | 14 +++++++++++++- 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 30149c40..bf2c3915 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1252,6 +1252,7 @@ public: }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); + void shareQuestWithParty(uint32_t questId); // CMSG_PUSHQUESTTOPARTY bool requestQuestQuery(uint32_t questId, bool force = false); bool isQuestTracked(uint32_t questId) const { return trackedQuestIds_.count(questId) > 0; } void setQuestTracked(uint32_t questId, bool tracked) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 611319d9..3431e927 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16096,6 +16096,28 @@ void GameHandler::abandonQuest(uint32_t questId) { gossipPois_.end()); } +void GameHandler::shareQuestWithParty(uint32_t questId) { + if (state != WorldState::IN_WORLD || !socket) { + addSystemChatMessage("Cannot share quest: not in world."); + return; + } + if (!isInGroup()) { + addSystemChatMessage("You must be in a group to share a quest."); + return; + } + network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY)); + pkt.writeUInt32(questId); + socket->send(pkt); + // Local feedback: find quest title + for (const auto& q : questLog_) { + if (q.questId == questId && !q.title.empty()) { + addSystemChatMessage("Sharing quest: " + q.title); + return; + } + } + addSystemChatMessage("Quest shared."); +} + void GameHandler::handleQuestRequestItems(network::Packet& packet) { QuestRequestItemsData data; if (!QuestRequestItemsParser::parse(packet, data)) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f6a1fa76..511977d0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6833,6 +6833,11 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { gameHandler.setQuestTracked(q.questId, true); } } + if (gameHandler.isInGroup() && !q.complete) { + if (ImGui::MenuItem("Share Quest")) { + gameHandler.shareQuestWithParty(q.questId); + } + } if (!q.complete) { ImGui::Separator(); if (ImGui::MenuItem("Abandon Quest")) { diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index d89f6ab4..c343baa5 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -377,6 +377,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) { gameHandler.setQuestTracked(q.questId, !tracked); } + if (gameHandler.isInGroup() && !q.complete) { + if (ImGui::MenuItem("Share Quest")) { + gameHandler.shareQuestWithParty(q.questId); + } + } if (!q.complete) { ImGui::Separator(); if (ImGui::MenuItem("Abandon Quest")) { @@ -559,12 +564,19 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv } } - // Track / Abandon buttons + // Track / Share / Abandon buttons ImGui::Separator(); bool isTracked = gameHandler.isQuestTracked(sel.questId); if (ImGui::Button(isTracked ? "Untrack" : "Track", ImVec2(100.0f, 0.0f))) { gameHandler.setQuestTracked(sel.questId, !isTracked); } + if (gameHandler.isInGroup() && !sel.complete) { + ImGui::SameLine(); + if (ImGui::Button("Share", ImVec2(80.0f, 0.0f))) { + gameHandler.shareQuestWithParty(sel.questId); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Share this quest with your party"); + } if (!sel.complete) { ImGui::SameLine(); if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) { From 611946bd3e3c12445fd1319be4bc82ed3c008cf4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 10:01:35 -0700 Subject: [PATCH 088/105] feat: add Trade and Set Note to social frame friends context menu - "Trade" option initiates a trade via existing initiateTrade(guid) API (only shown for online friends with a known GUID) - "Set Note" option opens an inline popup with InputText pre-filled with the current note; Enter or OK saves via setFriendNote(), Esc/Cancel discards --- src/ui/game_screen.cpp | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 511977d0..c650ecbe 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9835,6 +9835,10 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f)); + // State for "Set Note" inline editing + static int noteEditContactIdx = -1; + static char noteEditBuf[128] = {}; + bool open = showSocialFrame_; char socialTitle[32]; snprintf(socialTitle, sizeof(socialTitle), "Social (%d online)##SocialFrame", onlineCount); @@ -9924,6 +9928,14 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(c.name); + if (c.guid != 0 && ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(c.guid); + } + if (ImGui::MenuItem("Set Note")) { + noteEditContactIdx = static_cast(ci); + strncpy(noteEditBuf, c.note.c_str(), sizeof(noteEditBuf) - 1); + noteEditBuf[sizeof(noteEditBuf) - 1] = '\0'; + ImGui::OpenPopup("##SetFriendNote"); } if (ImGui::MenuItem("Remove Friend")) gameHandler.removeFriend(c.name); @@ -9944,6 +9956,31 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { } ImGui::EndChild(); + + // "Set Note" modal popup + if (ImGui::BeginPopup("##SetFriendNote")) { + const std::string& noteName = (noteEditContactIdx >= 0 && + noteEditContactIdx < static_cast(contacts.size())) + ? contacts[noteEditContactIdx].name : ""; + ImGui::TextDisabled("Note for %s:", noteName.c_str()); + ImGui::SetNextItemWidth(180.0f); + bool confirm = ImGui::InputText("##noteinput", noteEditBuf, sizeof(noteEditBuf), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (confirm || ImGui::Button("OK")) { + if (!noteName.empty()) + gameHandler.setFriendNote(noteName, noteEditBuf); + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + ImGui::Separator(); // Add friend From ef08366efcc24f99184a64b0af535087e138f48a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 10:06:11 -0700 Subject: [PATCH 089/105] feat: add /use and /equip slash commands for inventory items by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /use — searches backpack then bags (case-insensitive), calls useItemBySlot() / useItemInBag() for the first match - /equip — same search, calls autoEquipItemBySlot() / autoEquipItemInBag() for the first match - Both commands print an error if the item is not found - Added both to tab-autocomplete list and /help output --- src/ui/game_screen.cpp | 103 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c650ecbe..35907642 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1944,7 +1944,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { static const std::vector kCmds = { "/afk", "/away", "/cast", "/chathelp", "/clear", "/dance", "/do", "/dnd", "/e", "/emote", - "/follow", "/g", "/guild", "/guildinfo", + "/equip", "/follow", "/g", "/guild", "/guildinfo", "/gmticket", "/grouploot", "/i", "/instance", "/invite", "/j", "/join", "/kick", "/l", "/leave", "/local", "/me", @@ -1952,7 +1952,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { "/raidwarning", "/random", "/reply", "/roll", "/s", "/say", "/setloot", "/shout", "/stopattack", "/stopfollow", "/t", "/time", - "/trade", "/uninvite", "/w", "/whisper", + "/trade", "/uninvite", "/use", "/w", "/whisper", "/who", "/wts", "/wtb", "/y", "/yell", "/zone" }; @@ -3900,6 +3900,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { " /gleader /groster /ginfo /gcreate /gdisband", "Combat: /startattack /stopattack /stopcasting /cast /duel /pvp", " /forfeit /follow /stopfollow /assist", + "Items: /use /equip ", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", @@ -4595,6 +4596,104 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /use — use an item from backpack/bags by name + if (cmdLower == "use" && spacePos != std::string::npos) { + std::string useArg = command.substr(spacePos + 1); + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + std::string useArgLower = useArg; + for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + useArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /equip — auto-equip an item from backpack/bags by name + if (cmdLower == "equip" && spacePos != std::string::npos) { + std::string equipArg = command.substr(spacePos + 1); + while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); + while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); + std::string equipArgLower = equipArg; + for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + equipArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // Targeting commands if (cmdLower == "cleartarget") { gameHandler.clearTarget(); From 1f7f1076ca17665b2b8447cbae314e7e8df74618 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 10:14:44 -0700 Subject: [PATCH 090/105] feat: add Shaman totem frame with element-colored duration bars - Renders below pet frame, visible only for Shaman (class 7) - Shows each active totem (Earth/Fire/Water/Air) with its spell name and a colored countdown progress bar - Colored element dot (brown/red/blue/light-blue) identifies element - Only rendered when at least one totem is active --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 86 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index fd30476e..17327216 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -283,6 +283,7 @@ private: * Render pet frame (below player frame when player has an active pet) */ void renderPetFrame(game::GameHandler& gameHandler); + void renderTotemFrame(game::GameHandler& gameHandler); /** * Process targeting input (Tab, Escape, click) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 35907642..be4fe73e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -516,6 +516,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPetFrame(gameHandler); } + // Totem frame (Shaman only, when any totem is active) + if (gameHandler.getPlayerClass() == 7) { + renderTotemFrame(gameHandler); + } + // Target frame (only when we have a target) if (gameHandler.hasTarget()) { renderTargetFrame(gameHandler); @@ -3032,6 +3037,87 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Totem Frame (Shaman — below pet frame / player frame) +// ============================================================ + +void GameScreen::renderTotemFrame(game::GameHandler& gameHandler) { + // Only show if at least one totem is active + bool anyActive = false; + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + if (gameHandler.getTotemSlot(i).active()) { anyActive = true; break; } + } + if (!anyActive) return; + + static const struct { const char* name; ImU32 color; } kTotemInfo[4] = { + { "Earth", IM_COL32(139, 90, 43, 255) }, // brown + { "Fire", IM_COL32(220, 80, 30, 255) }, // red-orange + { "Water", IM_COL32( 30,120, 220, 255) }, // blue + { "Air", IM_COL32(180,220, 255, 255) }, // light blue + }; + + // Position: below pet frame / player frame, left side + // Pet frame is at ~y=200 if active, player frame is at y=20; totem frame near y=300 + // We anchor relative to screen left edge like pet frame + ImGui::SetNextWindowPos(ImVec2(8.0f, 300.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(130.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoTitleBar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.08f, 0.06f, 0.88f)); + + if (ImGui::Begin("##TotemFrame", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.3f, 1.0f), "Totems"); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + const auto& slot = gameHandler.getTotemSlot(i); + if (!slot.active()) continue; + + ImGui::PushID(i); + + // Colored element dot + ImVec2 dotPos = ImGui::GetCursorScreenPos(); + dotPos.x += 4.0f; dotPos.y += 6.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotPos.x + 4.0f, dotPos.y + 4.0f), 4.0f, kTotemInfo[i].color); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + + // Totem name or spell name + const std::string& spellName = gameHandler.getSpellName(slot.spellId); + const char* displayName = spellName.empty() ? kTotemInfo[i].name : spellName.c_str(); + ImGui::Text("%s", displayName); + + // Duration countdown bar + float remMs = slot.remainingMs(); + float totMs = static_cast(slot.durationMs); + float frac = (totMs > 0.0f) ? std::min(remMs / totMs, 1.0f) : 0.0f; + float remSec = remMs / 1000.0f; + + // Color bar with totem element tint + ImVec4 barCol( + static_cast((kTotemInfo[i].color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f, + 0.9f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barCol); + char timeBuf[16]; + snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remSec); + ImGui::ProgressBar(frac, ImVec2(-1, 8), timeBuf); + ImGui::PopStyleColor(); + + ImGui::PopID(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { auto target = gameHandler.getTarget(); if (!target) return; From 2f0fe302bc53f6cf3796261deb1b5a0cf1a9122b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 10:27:51 -0700 Subject: [PATCH 091/105] feat: add Trade, Duel, and Inspect to nameplate player context menu --- src/ui/game_screen.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index be4fe73e..86ae4257 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7780,6 +7780,16 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(ctxName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(nameplateCtxGuid_); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(nameplateCtxGuid_); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(nameplateCtxGuid_); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) gameHandler.addFriend(ctxName); if (ImGui::MenuItem("Ignore")) From 367390a852643f7b8217e56beab560e68f9a887a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 10:41:18 -0700 Subject: [PATCH 092/105] feat: add /who results window with sortable player table Store structured WhoEntry data from SMSG_WHO responses and show them in a dedicated popup window with Name/Guild/Level/Class/Zone columns. Right-click on any row to Whisper, Invite, Add Friend, or Ignore. Window auto-opens when /who or /whois is typed; shows online count in the title bar. Results persist until the next /who query. --- include/game/game_handler.hpp | 17 ++++++ include/ui/game_screen.hpp | 4 ++ src/game/game_handler.cpp | 27 +++++---- src/ui/game_screen.cpp | 103 ++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 14 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index bf2c3915..3981f199 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -353,6 +353,19 @@ public: uint32_t getTotalTimePlayed() const { return totalTimePlayed_; } uint32_t getLevelTimePlayed() const { return levelTimePlayed_; } + // Who results (structured, from last SMSG_WHO response) + struct WhoEntry { + std::string name; + std::string guildName; + uint32_t level = 0; + uint32_t classId = 0; + uint32_t raceId = 0; + uint32_t zoneId = 0; + }; + const std::vector& getWhoResults() const { return whoResults_; } + uint32_t getWhoOnlineCount() const { return whoOnlineCount_; } + std::string getWhoAreaName(uint32_t zoneId) const { return getAreaName(zoneId); } + // Social commands void addFriend(const std::string& playerName, const std::string& note = ""); void removeFriend(const std::string& playerName); @@ -2303,6 +2316,10 @@ private: uint32_t totalTimePlayed_ = 0; uint32_t levelTimePlayed_ = 0; + // Who results (last SMSG_WHO response) + std::vector whoResults_; + uint32_t whoOnlineCount_ = 0; + // Trade state TradeStatus tradeStatus_ = TradeStatus::None; uint64_t tradePeerGuid_= 0; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 17327216..492f224e 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -396,6 +396,10 @@ private: int bagBarPickedSlot_ = -1; // Visual drag in progress (-1 = none) int bagBarDragSource_ = -1; // Mouse pressed on this slot, waiting for drag or click (-1 = none) + // Who Results window + bool showWhoWindow_ = false; + void renderWhoWindow(game::GameHandler& gameHandler); + // Instance Lockouts window bool showInstanceLockouts_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3431e927..1b61649c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18352,13 +18352,15 @@ void GameHandler::handleWho(network::Packet& packet) { LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online"); + // Store structured results for the who-results window + whoResults_.clear(); + whoOnlineCount_ = onlineCount; + if (displayCount == 0) { addSystemChatMessage("No players found."); return; } - addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:"); - for (uint32_t i = 0; i < displayCount; ++i) { if (packet.getReadPos() >= packet.getSize()) break; std::string playerName = packet.readString(); @@ -18373,19 +18375,16 @@ void GameHandler::handleWho(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) zoneId = packet.readUInt32(); - const char* className = getClassName(static_cast(classId)); + // Store structured entry + WhoEntry entry; + entry.name = playerName; + entry.guildName = guildName; + entry.level = level; + entry.classId = classId; + entry.raceId = raceId; + entry.zoneId = zoneId; + whoResults_.push_back(std::move(entry)); - std::string msg = " " + playerName; - if (!guildName.empty()) - msg += " <" + guildName + ">"; - msg += " - Level " + std::to_string(level) + " " + className; - if (zoneId != 0) { - std::string zoneName = getAreaName(zoneId); - if (!zoneName.empty()) - msg += " [" + zoneName + "]"; - } - - addSystemChatMessage(msg); LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId, " Zone:", zoneId); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 86ae4257..29838f3d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -31,6 +31,7 @@ #include "pipeline/dbc_layout.hpp" #include "game/expansion_profile.hpp" +#include "game/character.hpp" #include "core/logger.hpp" #include #include @@ -596,6 +597,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAuctionHouseWindow(gameHandler); renderDungeonFinderWindow(gameHandler); renderInstanceLockouts(gameHandler); + renderWhoWindow(gameHandler); renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); @@ -4040,6 +4042,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } gameHandler.queryWho(query); + showWhoWindow_ = true; chatInputBuffer[0] = '\0'; return; } @@ -16838,6 +16841,106 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ─── Who Results Window ─────────────────────────────────────────────────────── +void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { + if (!showWhoWindow_) return; + + const auto& results = gameHandler.getWhoResults(); + + ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(200, 180), ImGuiCond_FirstUseEver); + + char title[64]; + uint32_t onlineCount = gameHandler.getWhoOnlineCount(); + if (onlineCount > 0) + snprintf(title, sizeof(title), "Players Online: %u###WhoWindow", onlineCount); + else + snprintf(title, sizeof(title), "Who###WhoWindow"); + + if (!ImGui::Begin(title, &showWhoWindow_)) { + ImGui::End(); + return; + } + + if (results.empty()) { + ImGui::TextDisabled("No results. Use /who [filter] to search."); + ImGui::End(); + return; + } + + // Table: Name | Guild | Level | Class | Zone + if (ImGui::BeginTable("##WhoTable", 5, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, + ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.22f); + ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 0.28f); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < results.size(); ++i) { + const auto& e = results[i]; + ImGui::TableNextRow(); + ImGui::PushID(static_cast(i)); + + // Name (class-colored if class is known) + ImGui::TableSetColumnIndex(0); + uint8_t cid = static_cast(e.classId); + ImVec4 nameCol = classColorVec4(cid); + ImGui::TextColored(nameCol, "%s", e.name.c_str()); + + // Right-click context menu on the name + if (ImGui::BeginPopupContextItem("##WhoCtx")) { + ImGui::TextDisabled("%s", e.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, e.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(e.name); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(e.name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(e.name); + ImGui::EndPopup(); + } + + // Guild + ImGui::TableSetColumnIndex(1); + if (!e.guildName.empty()) + ImGui::TextDisabled("<%s>", e.guildName.c_str()); + + // Level + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", e.level); + + // Class + ImGui::TableSetColumnIndex(3); + const char* className = game::getClassName(static_cast(e.classId)); + ImGui::TextColored(nameCol, "%s", className); + + // Zone + ImGui::TableSetColumnIndex(4); + if (e.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(e.zoneId); + ImGui::TextUnformatted(zoneName.empty() ? "Unknown" : zoneName.c_str()); + } + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + + ImGui::End(); +} + // ─── Achievement Window ─────────────────────────────────────────────────────── void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { if (!showAchievementWindow_) return; From 36d40905e1ed857ad43ee7ef8c5f554f4926c458 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 10:45:31 -0700 Subject: [PATCH 093/105] feat: add in-window search bar to who results window Add a search field with "Search" button directly in the who results window so players can query without using the chat box. Pressing Enter in the search field also triggers a new /who query. --- src/ui/game_screen.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 29838f3d..85fc7824 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16862,8 +16862,23 @@ void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { return; } + // Search bar with Send button + static char whoSearchBuf[64] = {}; + bool doSearch = false; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 60.0f); + if (ImGui::InputTextWithHint("##whosearch", "Search players...", whoSearchBuf, sizeof(whoSearchBuf), + ImGuiInputTextFlags_EnterReturnsTrue)) + doSearch = true; + ImGui::SameLine(); + if (ImGui::Button("Search", ImVec2(-1, 0))) + doSearch = true; + if (doSearch) { + gameHandler.queryWho(std::string(whoSearchBuf)); + } + ImGui::Separator(); + if (results.empty()) { - ImGui::TextDisabled("No results. Use /who [filter] to search."); + ImGui::TextDisabled("No results. Type a filter above or use /who [filter]."); ImGui::End(); return; } From 661f7e3e8d9b8f8f38c6935e431f669c33950242 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:00:10 -0700 Subject: [PATCH 094/105] feat: add persistent combat log window (/combatlog or /cl) Stores up to 500 combat events in a rolling deque alongside the existing floating combat text. Events are populated via the existing addCombatText() call site, resolving attacker/target names from the entity manager and player name cache at event time. - CombatLogEntry struct in spell_defines.hpp (type, amount, spellId, isPlayerSource, timestamp, sourceName, targetName) - getCombatLog() / clearCombatLog() accessors on GameHandler - renderCombatLog() in GameScreen: scrollable two-column table (Time + Event), color-coded by event category, with Damage/Healing/Misc filter checkboxes, auto-scroll toggle, and Clear button - /combatlog (/cl) chat command toggles the window --- include/game/game_handler.hpp | 6 + include/game/spell_defines.hpp | 14 +++ include/ui/game_screen.hpp | 4 + src/game/game_handler.cpp | 15 +++ src/ui/game_screen.cpp | 210 ++++++++++++++++++++++++++++++++- 5 files changed, 248 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 3981f199..e43d0a5e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -536,6 +536,10 @@ public: const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); + // Combat log (persistent rolling history, max MAX_COMBAT_LOG entries) + const std::deque& getCombatLog() const { return combatLog_; } + void clearCombatLog() { combatLog_.clear(); } + // Threat struct ThreatEntry { uint64_t victimGuid = 0; @@ -2149,6 +2153,8 @@ private: float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing std::unordered_set hostileAttackers_; std::vector combatText; + static constexpr size_t MAX_COMBAT_LOG = 500; + std::deque combatLog_; // unitGuid → sorted threat list (descending by threat value) std::unordered_map> threatLists_; diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index e0f070aa..dc38f813 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -63,6 +64,19 @@ struct CombatTextEntry { bool isExpired() const { return age >= LIFETIME; } }; +/** + * Persistent combat log entry (stored in a rolling deque, survives beyond floating-text lifetime) + */ +struct CombatLogEntry { + CombatTextEntry::Type type = CombatTextEntry::MELEE_DAMAGE; + int32_t amount = 0; + uint32_t spellId = 0; + bool isPlayerSource = false; + time_t timestamp = 0; // Wall-clock time (std::time(nullptr)) + std::string sourceName; // Resolved display name of attacker/caster + std::string targetName; // Resolved display name of victim/target +}; + /** * Spell cooldown entry received from server */ diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 492f224e..2b931b5f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -400,6 +400,10 @@ private: bool showWhoWindow_ = false; void renderWhoWindow(game::GameHandler& gameHandler); + // Combat Log window + bool showCombatLog_ = false; + void renderCombatLog(game::GameHandler& gameHandler); + // Instance Lockouts window bool showInstanceLockouts_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1b61649c..c2fde19d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12160,6 +12160,21 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; combatText.push_back(entry); + + // Persistent combat log + CombatLogEntry log; + log.type = type; + log.amount = amount; + log.spellId = spellId; + log.isPlayerSource = isPlayerSource; + log.timestamp = std::time(nullptr); + std::string pname(lookupName(playerGuid)); + std::string tname((targetGuid != 0) ? lookupName(targetGuid) : std::string()); + log.sourceName = isPlayerSource ? pname : tname; + log.targetName = isPlayerSource ? tname : pname; + if (combatLog_.size() >= MAX_COMBAT_LOG) + combatLog_.pop_front(); + combatLog_.push_back(std::move(log)); } void GameHandler::updateCombatText(float deltaTime) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 85fc7824..fa847a79 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -598,6 +598,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDungeonFinderWindow(gameHandler); renderInstanceLockouts(gameHandler); renderWhoWindow(gameHandler); + renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); @@ -1951,7 +1952,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { static const std::vector kCmds = { "/afk", "/away", "/cast", "/chathelp", "/clear", "/dance", "/do", "/dnd", "/e", "/emote", - "/equip", "/follow", "/g", "/guild", "/guildinfo", + "/cl", "/combatlog", "/equip", "/follow", "/g", "/guild", "/guildinfo", "/gmticket", "/grouploot", "/i", "/instance", "/invite", "/j", "/join", "/kick", "/l", "/leave", "/local", "/me", @@ -4047,6 +4048,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /combatlog command + if (cmdLower == "combatlog" || cmdLower == "cl") { + showCombatLog_ = !showCombatLog_; + chatInputBuffer[0] = '\0'; + return; + } + // /roll command if (cmdLower == "roll" || cmdLower == "random" || cmdLower == "rnd") { uint32_t minRoll = 1; @@ -16956,6 +16964,206 @@ void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── Combat Log Window ──────────────────────────────────────────────────────── +void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { + if (!showCombatLog_) return; + + const auto& log = gameHandler.getCombatLog(); + + ImGui::SetNextWindowSize(ImVec2(520, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(160, 200), ImGuiCond_FirstUseEver); + + char title[64]; + snprintf(title, sizeof(title), "Combat Log (%zu)###CombatLog", log.size()); + if (!ImGui::Begin(title, &showCombatLog_)) { + ImGui::End(); + return; + } + + // Filter toggles + static bool filterDamage = true; + static bool filterHeal = true; + static bool filterMisc = true; + static bool autoScroll = true; + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 2)); + ImGui::Checkbox("Damage", &filterDamage); ImGui::SameLine(); + ImGui::Checkbox("Healing", &filterHeal); ImGui::SameLine(); + ImGui::Checkbox("Misc", &filterMisc); ImGui::SameLine(); + ImGui::Checkbox("Auto-scroll", &autoScroll); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 40.0f); + if (ImGui::SmallButton("Clear")) + gameHandler.clearCombatLog(); + ImGui::PopStyleVar(); + ImGui::Separator(); + + // Helper: categorize entry + auto isDamageType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || + t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || + t == T::ENVIRONMENTAL; + }; + auto isHealType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::HEAL || t == T::CRIT_HEAL || t == T::PERIODIC_HEAL; + }; + + // Two-column table: Time | Event description + ImGuiTableFlags tableFlags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingFixedFit; + float availH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##CombatLogTable", 2, tableFlags, ImVec2(0.0f, availH))) { + ImGui::TableSetupScrollFreeze(0, 0); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("Event", ImGuiTableColumnFlags_WidthStretch); + + for (const auto& e : log) { + // Apply filters + bool isDmg = isDamageType(e.type); + bool isHeal = isHealType(e.type); + bool isMisc = !isDmg && !isHeal; + if (isDmg && !filterDamage) continue; + if (isHeal && !filterHeal) continue; + if (isMisc && !filterMisc) continue; + + // Format timestamp as HH:MM:SS + char timeBuf[10]; + { + struct tm* tm_info = std::localtime(&e.timestamp); + if (tm_info) + snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", + tm_info->tm_hour, tm_info->tm_min, tm_info->tm_sec); + else + snprintf(timeBuf, sizeof(timeBuf), "--:--:--"); + } + + // Build event description and choose color + char desc[256]; + ImVec4 color; + using T = game::CombatTextEntry; + const char* src = e.sourceName.empty() ? (e.isPlayerSource ? "You" : "?") : e.sourceName.c_str(); + const char* tgt = e.targetName.empty() ? "?" : e.targetName.c_str(); + const std::string& spellName = (e.spellId != 0) ? gameHandler.getSpellName(e.spellId) : std::string(); + const char* spell = spellName.empty() ? nullptr : spellName.c_str(); + + switch (e.type) { + case T::MELEE_DAMAGE: + snprintf(desc, sizeof(desc), "%s hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + break; + case T::CRIT_DAMAGE: + snprintf(desc, sizeof(desc), "%s crits %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 1.0f, 0.0f, 1.0f) : ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + break; + case T::SPELL_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s hits %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's spell hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + break; + case T::PERIODIC_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s ticks %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's DoT ticks %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.9f, 0.7f, 0.3f, 1.0f) : ImVec4(0.9f, 0.3f, 0.3f, 1.0f); + break; + case T::HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s heals %s for %d (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s heals %s for %d", src, tgt, e.amount); + color = ImVec4(0.4f, 1.0f, 0.4f, 1.0f); + break; + case T::CRIT_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s critically heals %s for %d! (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s critically heals %s for %d!", src, tgt, e.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + break; + case T::PERIODIC_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s heals %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's HoT heals %s for %d", src, tgt, e.amount); + color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); + break; + case T::MISS: + snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::DODGE: + snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::PARRY: + snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::BLOCK: + snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); + color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); + break; + case T::IMMUNE: + snprintf(desc, sizeof(desc), "%s is immune", tgt); + color = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + break; + case T::ABSORB: + snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); + break; + case T::RESIST: + snprintf(desc, sizeof(desc), "%d resisted", e.amount); + color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); + break; + case T::ENVIRONMENTAL: + snprintf(desc, sizeof(desc), "Environmental damage: %d", e.amount); + color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); + break; + case T::ENERGIZE: + if (spell) + snprintf(desc, sizeof(desc), "%s gains %d power (%s)", tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s gains %d power", tgt, e.amount); + color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); + break; + case T::XP_GAIN: + snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); + color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); + break; + case T::PROC_TRIGGER: + if (spell) + snprintf(desc, sizeof(desc), "%s procs!", spell); + else + snprintf(desc, sizeof(desc), "Proc triggered"); + color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); + break; + default: + snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); + color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + break; + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("%s", timeBuf); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(color, "%s", desc); + } + + // Auto-scroll to bottom + if (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) + ImGui::SetScrollHereY(1.0f); + + ImGui::EndTable(); + } + + ImGui::End(); +} + // ─── Achievement Window ─────────────────────────────────────────────────────── void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { if (!showAchievementWindow_) return; From 9fe7bbf826673c4d802aab6ba5d3801997857c2d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:04:10 -0700 Subject: [PATCH 095/105] feat: show lootable corpse diamonds on minimap Dead units with UNIT_DYNFLAG_LOOTABLE (0x0001) set are rendered as small yellow-green diamonds on the minimap, distinct from live NPC dots. A hover tooltip shows the unit name. Uses the dynamic flags already tracked by the update-object parser, so no new server data is needed. --- src/ui/game_screen.cpp | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fa847a79..777f5705 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13667,6 +13667,43 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Lootable corpse dots: small yellow-green diamonds on dead, lootable units. + // Shown whenever NPC dots are enabled (or always, since they're always useful). + { + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit) continue; + // Must be dead (health == 0) and marked lootable + if (unit->getHealth() != 0) continue; + if (!(unit->getDynamicFlags() & UNIT_DYNFLAG_LOOTABLE)) continue; + + glm::vec3 npcRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(npcRender, sx, sy)) continue; + + // Draw a small diamond (rotated square) in light yellow-green + const float dr = 3.5f; + ImVec2 top (sx, sy - dr); + ImVec2 right(sx + dr, sy ); + ImVec2 bot (sx, sy + dr); + ImVec2 left (sx - dr, sy ); + drawList->AddQuadFilled(top, right, bot, left, IM_COL32(180, 230, 80, 230)); + drawList->AddQuad (top, right, bot, left, IM_COL32(60, 80, 20, 200), 1.0f); + + // Tooltip on hover + if (ImGui::IsMouseHoveringRect(ImVec2(sx - dr, sy - dr), ImVec2(sx + dr, sy + dr))) { + const std::string& nm = unit->getName(); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.3f, 1.0f), "%s", + nm.empty() ? "Lootable corpse" : nm.c_str()); + ImGui::EndTooltip(); + } + } + } + for (const auto& [guid, status] : statuses) { ImU32 dotColor; const char* marker = nullptr; From 20fef40b7bdb9175e6b42ae1a6699edfab470067 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:06:40 -0700 Subject: [PATCH 096/105] feat: show area trigger messages as screen banners SMSG_AREA_TRIGGER_MESSAGE events (dungeon enter messages, objective triggers, etc.) were previously only appended to chat. Now they also appear as animated slide-up toasts in the lower-center of the screen: blue-bordered dark panel with light-blue text, 4.5s lifetime with 35ms slide-in/out animation. Up to 4 simultaneous toasts stack vertically. Messages still go to chat as before. --- include/game/game_handler.hpp | 10 ++++++ include/ui/game_screen.hpp | 4 +++ src/game/game_handler.cpp | 5 ++- src/ui/game_screen.cpp | 67 +++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e43d0a5e..17b66b75 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -540,6 +540,15 @@ public: const std::deque& getCombatLog() const { return combatLog_; } void clearCombatLog() { combatLog_.clear(); } + // Area trigger messages (SMSG_AREA_TRIGGER_MESSAGE) — drained by UI each frame + bool hasAreaTriggerMsg() const { return !areaTriggerMsgs_.empty(); } + std::string popAreaTriggerMsg() { + if (areaTriggerMsgs_.empty()) return {}; + std::string msg = areaTriggerMsgs_.front(); + areaTriggerMsgs_.pop_front(); + return msg; + } + // Threat struct ThreatEntry { uint64_t victimGuid = 0; @@ -2155,6 +2164,7 @@ private: std::vector combatText; static constexpr size_t MAX_COMBAT_LOG = 500; std::deque combatLog_; + std::deque areaTriggerMsgs_; // unitGuid → sorted threat list (descending by threat value) std::unordered_map> threatLists_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 2b931b5f..e37fe6d7 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -119,6 +119,10 @@ private: // Zone entry toast: brief banner when entering a new zone struct ZoneToastEntry { std::string zoneName; float age = 0.0f; }; std::vector zoneToasts_; + + struct AreaTriggerToast { std::string text; float age = 0.0f; }; + std::vector areaTriggerToasts_; + void renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler); std::string lastKnownZone_; static constexpr float kZoneToastLifetime = 3.0f; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c2fde19d..ccc620fc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3877,7 +3877,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) { /*uint32_t len =*/ packet.readUInt32(); std::string msg = packet.readString(); - if (!msg.empty()) addSystemChatMessage(msg); + if (!msg.empty()) { + addSystemChatMessage(msg); + areaTriggerMsgs_.push_back(msg); + } } break; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 777f5705..87526741 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -562,6 +562,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderRepToasts(ImGui::GetIO().DeltaTime); renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); renderZoneToasts(ImGui::GetIO().DeltaTime); + renderAreaTriggerToasts(ImGui::GetIO().DeltaTime, gameHandler); if (showRaidFrames_) { renderPartyFrames(gameHandler); } @@ -8572,6 +8573,72 @@ void GameScreen::renderZoneToasts(float deltaTime) { } } +// ─── Area Trigger Message Toasts ───────────────────────────────────────────── +void GameScreen::renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler) { + // Drain any pending messages from GameHandler + while (gameHandler.hasAreaTriggerMsg()) { + AreaTriggerToast t; + t.text = gameHandler.popAreaTriggerMsg(); + t.age = 0.0f; + areaTriggerToasts_.push_back(std::move(t)); + if (areaTriggerToasts_.size() > 4) + areaTriggerToasts_.erase(areaTriggerToasts_.begin()); + } + + // Age and prune + constexpr float kLifetime = 4.5f; + for (auto& t : areaTriggerToasts_) t.age += deltaTime; + areaTriggerToasts_.erase( + std::remove_if(areaTriggerToasts_.begin(), areaTriggerToasts_.end(), + [](const AreaTriggerToast& t) { return t.age >= kLifetime; }), + areaTriggerToasts_.end()); + if (areaTriggerToasts_.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; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + constexpr float kSlideDur = 0.35f; + + for (int i = 0; i < static_cast(areaTriggerToasts_.size()); ++i) { + const auto& t = areaTriggerToasts_[i]; + + float slideIn = std::min(t.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kLifetime - t.age), kSlideDur) / kSlideDur; + float alpha = std::clamp(std::min(slideIn, slideOut), 0.0f, 1.0f); + + // Measure text + ImVec2 txtSz = font->CalcTextSizeA(13.0f, FLT_MAX, 0.0f, t.text.c_str()); + float toastW = txtSz.x + 30.0f; + float toastH = 30.0f; + + // Center horizontally, place below zone text (center of lower-third) + float toastX = (screenW - toastW) * 0.5f; + float toastY = screenH * 0.62f + i * (toastH + 3.0f); + // Slide up from below + float offY = (1.0f - std::min(slideIn, slideOut)) * (toastH + 12.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(8, 12, 22, (int)(alpha * 190)), 5.0f); + draw->AddRect(tl, br, IM_COL32(100, 160, 220, (int)(alpha * 200)), 5.0f, 0, 1.0f); + + float cx = tl.x + toastW * 0.5f; + // Shadow + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f + 1, tl.y + (toastH - txtSz.y) * 0.5f + 1), + IM_COL32(0, 0, 0, (int)(alpha * 180)), t.text.c_str()); + // Text in light blue + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f, tl.y + (toastH - txtSz.y) * 0.5f), + IM_COL32(180, 220, 255, (int)(alpha * 240)), t.text.c_str()); + } +} + // ============================================================ // Boss Encounter Frames // ============================================================ From afcd6f2db320a157f8fc5062f854d0f47e5d758e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:16:42 -0700 Subject: [PATCH 097/105] feat: add CHANNEL option to chat type dropdown with channel picker Adds an 11th chat type "CHANNEL" to the dropdown, displaying a secondary combo box populated from the player's joined channels. Typing /1, /2 etc. in the input now also auto-switches the dropdown to CHANNEL mode and selects the corresponding channel. Input text is colored cyan for channel messages to visually distinguish them from other chat types. --- include/ui/game_screen.hpp | 3 ++- src/ui/game_screen.cpp | 55 +++++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e37fe6d7..947f4b4a 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -46,8 +46,9 @@ private: char chatInputBuffer[512] = ""; char whisperTargetBuffer[256] = ""; bool chatInputActive = false; - int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER + int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER, ..., 10=CHANNEL int lastChatType = 0; // Track chat type changes + int selectedChannelIdx = 0; // Index into joinedChannels_ when selectedChatType==10 bool chatInputMoveCursorToEnd = false; // Chat sent-message history (Up/Down arrow recall) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 87526741..1cdb4084 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1823,8 +1823,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::Text("Type:"); ImGui::SameLine(); ImGui::SetNextItemWidth(100); - const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE" }; - ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 10); + const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE", "CHANNEL" }; + ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 11); // Auto-fill whisper target when switching to WHISPER mode if (selectedChatType == 4 && lastChatType != 4) { @@ -1851,6 +1851,27 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::InputText("##WhisperTarget", whisperTargetBuffer, sizeof(whisperTargetBuffer)); } + // Show channel picker if CHANNEL is selected + if (selectedChatType == 10) { + const auto& channels = gameHandler.getJoinedChannels(); + if (channels.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(no channels joined)"); + } else { + ImGui::SameLine(); + if (selectedChannelIdx >= static_cast(channels.size())) selectedChannelIdx = 0; + ImGui::SetNextItemWidth(140); + if (ImGui::BeginCombo("##ChannelPicker", channels[selectedChannelIdx].c_str())) { + for (int ci = 0; ci < static_cast(channels.size()); ++ci) { + bool selected = (ci == selectedChannelIdx); + if (ImGui::Selectable(channels[ci].c_str(), selected)) selectedChannelIdx = ci; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + } + ImGui::SameLine(); ImGui::Text("Message:"); ImGui::SameLine(); @@ -1881,7 +1902,16 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { else if (cmd == "bg" || cmd == "battleground") detected = 7; else if (cmd == "rw" || cmd == "raidwarning") detected = 8; else if (cmd == "i" || cmd == "instance") detected = 9; - if (detected >= 0 && selectedChatType != detected) { + else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. + if (detected >= 0 && (selectedChatType != detected || detected == 10)) { + // For channel shortcuts, also update selectedChannelIdx + if (detected == 10) { + int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. + const auto& chans = gameHandler.getJoinedChannels(); + if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { + selectedChannelIdx = chanIdx; + } + } selectedChatType = detected; // Strip the prefix, keep only the message part std::string remaining = buf.substr(sp + 1); @@ -1919,7 +1949,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { case 6: inputColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); break; // OFFICER - green case 7: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // BG - orange case 8: inputColor = ImVec4(1.0f, 0.3f, 0.0f, 1.0f); break; // RAID WARNING - red-orange - case 9: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // INSTANCE - blue + case 9: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // INSTANCE - blue + case 10: inputColor = ImVec4(0.3f, 0.9f, 0.9f, 1.0f); break; // CHANNEL - cyan default: inputColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // SAY - white } ImGui::PushStyleColor(ImGuiCol_Text, inputColor); @@ -5153,6 +5184,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { case 7: type = game::ChatType::BATTLEGROUND; break; case 8: type = game::ChatType::RAID_WARNING; break; case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx]; + } else { type = game::ChatType::SAY; } + break; + } default: type = game::ChatType::SAY; break; } } @@ -5169,6 +5208,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { case 7: type = game::ChatType::BATTLEGROUND; break; case 8: type = game::ChatType::RAID_WARNING; break; case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx]; + } else { type = game::ChatType::SAY; } + break; + } default: type = game::ChatType::SAY; break; } } From db1f111054778a084f16aa723dd748a7f7816e0b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:21:12 -0700 Subject: [PATCH 098/105] feat: add Guild chat tab and fix Trade/LFG tab index after insertion Inserts a dedicated "Guild" tab between Whispers and Trade/LFG that shows guild, officer, and guild achievement messages. Updates the Trade/LFG channel-name filter from hardcoded index 3 to 4 to match the new tab order. --- src/ui/game_screen.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1cdb4084..7a3bba73 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -222,6 +222,10 @@ void GameScreen::initChatTabs() { // Whispers tab chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); + // Guild tab: guild and officer chat + chatTabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | + (1ULL << static_cast(game::ChatType::OFFICER)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); // Trade/LFG tab: channel messages chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); } @@ -233,8 +237,8 @@ bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabInde uint64_t typeBit = 1ULL << static_cast(msg.type); - // For Trade/LFG tab, also filter by channel name - if (tabIndex == 3 && msg.type == game::ChatType::CHANNEL) { + // For Trade/LFG tab (now index 4), also filter by channel name + if (tabIndex == 4 && msg.type == game::ChatType::CHANNEL) { const std::string& ch = msg.channelName; if (ch.find("Trade") == std::string::npos && ch.find("General") == std::string::npos && From f3e399e0ffebb1c2badb9ca59e56e216fe4ba445 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:23:01 -0700 Subject: [PATCH 099/105] feat: show unread message count on chat tabs Each non-General chat tab now shows an unread count in parentheses (e.g. "Whispers (3)") when messages arrive while that tab is inactive. The counter clears when the tab is selected. The General tab is excluded since it shows all messages anyway. --- include/ui/game_screen.hpp | 2 ++ src/ui/game_screen.cpp | 39 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 947f4b4a..cd102fa9 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -70,6 +70,8 @@ private: uint64_t typeMask; // bitmask of ChatType values to show (64-bit: types go up to 84) }; std::vector chatTabs_; + std::vector chatTabUnread_; // unread message count per tab (0 = none) + size_t chatTabSeenCount_ = 0; // how many history messages have been processed void initChatTabs(); bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7a3bba73..b54eb321 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -228,6 +228,9 @@ void GameScreen::initChatTabs() { (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); // Trade/LFG tab: channel messages chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); + // Reset unread counts to match new tab list + chatTabUnread_.assign(chatTabs_.size(), 0); + chatTabSeenCount_ = 0; } bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { @@ -1107,11 +1110,43 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { chatWindowPos_ = ImGui::GetWindowPos(); } + // Update unread counts: scan any new messages since last frame + { + const auto& history = gameHandler.getChatHistory(); + // Ensure unread array is sized correctly (guards against late init) + if (chatTabUnread_.size() != chatTabs_.size()) + chatTabUnread_.assign(chatTabs_.size(), 0); + // If history shrank (e.g. cleared), reset + if (chatTabSeenCount_ > history.size()) chatTabSeenCount_ = 0; + for (size_t mi = chatTabSeenCount_; mi < history.size(); ++mi) { + const auto& msg = history[mi]; + // For each non-General (non-0) tab that isn't currently active, check visibility + for (int ti = 1; ti < static_cast(chatTabs_.size()); ++ti) { + if (ti == activeChatTab_) continue; + if (shouldShowMessage(msg, ti)) { + chatTabUnread_[ti]++; + } + } + } + chatTabSeenCount_ = history.size(); + } + // Chat tabs if (ImGui::BeginTabBar("ChatTabs")) { for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { - if (ImGui::BeginTabItem(chatTabs_[i].name.c_str())) { - activeChatTab_ = i; + // Build label with unread count suffix for non-General tabs + std::string tabLabel = chatTabs_[i].name; + if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { + tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; + } + // Use ImGuiTabItemFlags_NoPushId so label changes don't break tab identity + if (ImGui::BeginTabItem(tabLabel.c_str())) { + if (activeChatTab_ != i) { + activeChatTab_ = i; + // Clear unread count when tab becomes active + if (i < static_cast(chatTabUnread_.size())) + chatTabUnread_[i] = 0; + } ImGui::EndTabItem(); } } From a336bebbe51eed46c6c7f44b753bfc7636ead8b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:31:45 -0700 Subject: [PATCH 100/105] feat: add debuff dot indicators on hostile target nameplate Show small colored squares below the health bar of the current hostile target indicating player-applied auras. Colors map to dispel types from Spell.dbc: blue=Magic, purple=Curse, yellow=Disease, green=Poison, grey=other/physical. Dots are positioned below the cast bar if one is active, otherwise directly below the health bar. They are clipped to the nameplate width and only rendered for the targeted hostile unit to keep the display readable. --- src/ui/game_screen.cpp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b54eb321..45845e41 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7709,6 +7709,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Cast bar below health bar when unit is casting float castBarBaseY = sy + barH + 2.0f; + float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots { const auto* cs = gameHandler.getUnitCastState(guid); if (cs && cs->casting && cs->timeTotal > 0.0f) { @@ -7751,6 +7752,37 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { 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); + nameplateBottom = castBarBaseY + cbH + 2.0f; + } + } + + // Debuff dot indicators: small colored squares below the nameplate showing + // player-applied auras on the current hostile target. + // Colors: Magic=blue, Curse=purple, Disease=yellow, Poison=green, Other=grey + if (isTarget && unit->isHostile() && !isCorpse) { + const auto& auras = gameHandler.getTargetAuras(); + const uint64_t pguid = gameHandler.getPlayerGuid(); + const float dotSize = 6.0f * nameplateScale_; + const float dotGap = 2.0f; + float dotX = barX; + for (const auto& aura : auras) { + if (aura.isEmpty() || aura.casterGuid != pguid) continue; + uint8_t dispelType = gameHandler.getSpellDispelType(aura.spellId); + ImU32 dotCol; + switch (dispelType) { + case 1: dotCol = IM_COL32( 64, 128, 255, A(210)); break; // Magic - blue + case 2: dotCol = IM_COL32(160, 32, 240, A(210)); break; // Curse - purple + case 3: dotCol = IM_COL32(180, 140, 40, A(210)); break; // Disease - yellow-brown + case 4: dotCol = IM_COL32( 50, 200, 50, A(210)); break; // Poison - green + default: dotCol = IM_COL32(170, 170, 170, A(170)); break; // Other - grey + } + drawList->AddRectFilled(ImVec2(dotX, nameplateBottom), + ImVec2(dotX + dotSize, nameplateBottom + dotSize), dotCol, 1.0f); + drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f), + ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), + IM_COL32(0, 0, 0, A(150)), 1.0f); + dotX += dotSize + dotGap; + if (dotX + dotSize > barX + barW) break; } } From 9c276d10725bebc042c12aca013263838b59d098 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:40:31 -0700 Subject: [PATCH 101/105] feat: add encounter-level DPS/HPS tracking to DPS meter Track cumulative player damage/healing for the full combat encounter using the persistent CombatLog, shown alongside the existing 2.5s rolling window. The encounter row appears after 3s of combat and persists post-combat until the next engagement, giving a stable full-fight average rather than the spiky per-window reading. --- include/ui/game_screen.hpp | 3 ++ src/ui/game_screen.cpp | 62 +++++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index cd102fa9..a21caf68 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -524,6 +524,9 @@ private: bool showDPSMeter_ = false; float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) bool dpsWasInCombat_ = false; + float dpsEncounterDamage_ = 0.0f; // total player damage this combat + float dpsEncounterHeal_ = 0.0f; // total player healing this combat + size_t dpsLogSeenCount_ = 0; // log entries already scanned public: void triggerDing(uint32_t newLevel); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 45845e41..c841cd0d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7479,11 +7479,37 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { // Track combat duration for accurate DPS denominator in short fights bool inCombat = gameHandler.isInCombat(); + if (inCombat && !dpsWasInCombat_) { + // Just entered combat — reset encounter accumulators + dpsEncounterDamage_ = 0.0f; + dpsEncounterHeal_ = 0.0f; + dpsLogSeenCount_ = gameHandler.getCombatLog().size(); + dpsCombatAge_ = 0.0f; + } if (inCombat) { dpsCombatAge_ += dt; + // Scan any new log entries since last frame + const auto& log = gameHandler.getCombatLog(); + while (dpsLogSeenCount_ < log.size()) { + const auto& e = log[dpsLogSeenCount_++]; + 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: + dpsEncounterDamage_ += static_cast(e.amount); + break; + case game::CombatTextEntry::HEAL: + case game::CombatTextEntry::CRIT_HEAL: + case game::CombatTextEntry::PERIODIC_HEAL: + dpsEncounterHeal_ += static_cast(e.amount); + break; + default: break; + } + } } else if (dpsWasInCombat_) { - // Just left combat — let meter show last reading for LIFETIME then reset - dpsCombatAge_ = 0.0f; + // Just left combat — keep encounter totals but stop accumulating } dpsWasInCombat_ = inCombat; @@ -7507,8 +7533,9 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { } } - // Only show if there's something to report - if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat) return; + // Only show if there's something to report (rolling window or lingering encounter data) + if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat && + dpsEncounterDamage_ < 1.0f && dpsEncounterHeal_ < 1.0f) 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. @@ -7534,8 +7561,22 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { float screenW = appWin ? static_cast(appWin->getWidth()) : 1280.0f; float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f; + // Show encounter row when fight has been going long enough (> 3s) + bool showEnc = (dpsCombatAge_ > 3.0f || (!inCombat && dpsEncounterDamage_ > 0.0f)); + float encDPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterDamage_ / dpsCombatAge_ : 0.0f; + float encHPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterHeal_ / dpsCombatAge_ : 0.0f; + + char encDpsBuf[16], encHpsBuf[16]; + fmtNum(encDPS, encDpsBuf, sizeof(encDpsBuf)); + fmtNum(encHPS, encHpsBuf, sizeof(encHpsBuf)); + constexpr float WIN_W = 90.0f; - constexpr float WIN_H = 36.0f; + // Extra rows for encounter DPS/HPS if active + int extraRows = 0; + if (showEnc && encDPS > 0.5f) ++extraRows; + if (showEnc && encHPS > 0.5f) ++extraRows; + float WIN_H = 18.0f + extraRows * 14.0f; + if (dps > 0.5f || hps > 0.5f) WIN_H = std::max(WIN_H, 36.0f); float wx = screenW * 0.5f + 160.0f; // right of cast bar float wy = screenH - 130.0f; // above action bar area @@ -7562,6 +7603,17 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { ImGui::SameLine(0, 2); ImGui::TextDisabled("hps"); } + // Encounter totals (full-fight average, shown when fight > 3s) + if (showEnc && encDPS > 0.5f) { + ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 0.80f), "%s", encDpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); + } + if (showEnc && encHPS > 0.5f) { + ImGui::TextColored(ImVec4(0.50f, 1.0f, 0.50f, 0.80f), "%s", encHpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); + } } ImGui::End(); From a4c23b7fa20e050267a6448d7a1b63af6cf69479 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 11:44:30 -0700 Subject: [PATCH 102/105] feat: add per-unit aura cache and dispellable debuff indicators on party frames Extend the aura tracking system to cache auras for any unit (not just player and current target), so healers can see dispellable debuffs on party members. Colored 8px dots appear below the power bar: Magic=blue, Curse=purple, Disease=brown, Poison=green One dot per dispel type; non-dispellable auras are suppressed. Cache is populated via existing SMSG_AURA_UPDATE/SMSG_AURA_UPDATE_ALL handling and cleared on world exit. --- include/game/game_handler.hpp | 6 +++++ src/game/game_handler.cpp | 5 ++++ src/ui/game_screen.cpp | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 17b66b75..227a27d2 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -705,6 +705,11 @@ public: // Auras const std::vector& getPlayerAuras() const { return playerAuras; } const std::vector& getTargetAuras() const { return targetAuras; } + // Per-unit aura cache (populated for party members and any unit we receive updates for) + const std::vector* getUnitAuras(uint64_t guid) const { + auto it = unitAurasCache_.find(guid); + return (it != unitAurasCache_.end()) ? &it->second : nullptr; + } // Completed quests (populated from SMSG_QUERY_QUESTS_COMPLETED_RESPONSE) bool isQuestCompleted(uint32_t questId) const { return completedQuests_.count(questId) > 0; } @@ -2247,6 +2252,7 @@ private: std::array actionBar{}; std::vector playerAuras; std::vector targetAuras; + std::unordered_map> unitAurasCache_; // per-unit aura cache uint64_t petGuid_ = 0; uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots) uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ccc620fc..6f59d00a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6708,6 +6708,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { actionBar = {}; playerAuras.clear(); targetAuras.clear(); + unitAurasCache_.clear(); unitCastStates_.clear(); petGuid_ = 0; playerXp_ = 0; @@ -14595,6 +14596,10 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { } else if (data.guid == targetGuid) { auraList = &targetAuras; } + // Also maintain a per-unit cache for any unit (party members, etc.) + if (data.guid != 0 && data.guid != playerGuid && data.guid != targetGuid) { + auraList = &unitAurasCache_[data.guid]; + } if (auraList) { if (isAll) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c841cd0d..1e106f35 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8379,6 +8379,57 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Dispellable debuff indicators — small colored dots for party member debuffs + // Only show magic/curse/disease/poison (types 1-4); skip non-dispellable + if (!memberDead && !memberOffline) { + const std::vector* unitAuras = nullptr; + if (member.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (member.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(member.guid); + + if (unitAuras) { + bool anyDebuff = false; + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // only debuffs + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0) continue; // skip non-dispellable + anyDebuff = true; + break; + } + if (anyDebuff) { + // Render one dot per unique dispel type present + bool shown[5] = {}; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 1.0f)); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dotCol; + switch (dt) { + case 1: dotCol = ImVec4(0.25f, 0.50f, 1.00f, 1.0f); break; // Magic: blue + case 2: dotCol = ImVec4(0.70f, 0.15f, 0.90f, 1.0f); break; // Curse: purple + case 3: dotCol = ImVec4(0.65f, 0.45f, 0.10f, 1.0f); break; // Disease: brown + case 4: dotCol = ImVec4(0.10f, 0.75f, 0.10f, 1.0f); break; // Poison: green + default: break; + } + ImGui::PushStyleColor(ImGuiCol_Button, dotCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); + ImGui::Button("##d", ImVec2(8.0f, 8.0f)); + ImGui::PopStyleColor(2); + ImGui::SameLine(); + } + ImGui::NewLine(); + ImGui::PopStyleVar(); + } + } + } + // Party member cast bar — shows when the party member is casting if (auto* cs = gameHandler.getUnitCastState(member.guid)) { float castPct = (cs->timeTotal > 0.0f) From 79c0887db26fb8f8c8bb3a6af00bcb535af3ca76 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:02:59 -0700 Subject: [PATCH 103/105] feat: add BG scoreboard (MSG_PVP_LOG_DATA) and fix TBC aura cache for party frames - Parse MSG_PVP_LOG_DATA to populate BgScoreboardData (players, KB, deaths, HKs, honor, BG-specific stats, winner) - Add /score command to request the scorecard while in a battleground - Render sortable per-player table with team color-coding and self-highlight - Refresh button re-requests live data from server - Fix TBC SMSG_INIT/SET_EXTRA_AURA_INFO_OBSOLETE to populate unitAurasCache_ for all GUIDs (not just player/target), mirroring WotLK aura update behavior so party frame debuff dots work on TBC servers --- include/game/game_handler.hpp | 26 ++++++ include/ui/game_screen.hpp | 4 + src/game/game_handler.cpp | 77 +++++++++++++++++- src/ui/game_screen.cpp | 144 +++++++++++++++++++++++++++++++++- 4 files changed, 249 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 227a27d2..7a11d97b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -393,6 +393,28 @@ public: void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); const std::array& getBgQueues() const { return bgQueues_; } + // BG scoreboard (MSG_PVP_LOG_DATA) + struct BgPlayerScore { + uint64_t guid = 0; + std::string name; + uint8_t team = 0; // 0=Horde, 1=Alliance + uint32_t killingBlows = 0; + uint32_t deaths = 0; + uint32_t honorableKills = 0; + uint32_t bonusHonor = 0; + std::vector> bgStats; // BG-specific fields + }; + struct BgScoreboardData { + std::vector players; + bool hasWinner = false; + uint8_t winner = 0; // 0=Horde, 1=Alliance + bool isArena = false; + }; + void requestPvpLog(); + const BgScoreboardData* getBgScoreboard() const { + return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_; + } + // Network latency (milliseconds, updated each PONG response) uint32_t getLatencyMs() const { return lastLatency; } @@ -1921,6 +1943,7 @@ private: void handleArenaTeamEvent(network::Packet& packet); void handleArenaTeamStats(network::Packet& packet); void handleArenaError(network::Packet& packet); + void handlePvpLogData(network::Packet& packet); // ---- Bank handlers ---- void handleShowBank(network::Packet& packet); @@ -2283,6 +2306,9 @@ private: // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) std::vector arenaTeamStats_; + // BG scoreboard (MSG_PVP_LOG_DATA) + BgScoreboardData bgScoreboard_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index a21caf68..c2320681 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -434,6 +434,10 @@ private: // Threat window bool showThreatWindow_ = false; void renderThreatWindow(game::GameHandler& gameHandler); + + // BG scoreboard window + bool showBgScoreboard_ = false; + void renderBgScoreboard(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 6f59d00a..4e0b8ef2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4988,7 +4988,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaError(packet); break; case Opcode::MSG_PVP_LOG_DATA: - LOG_INFO("Received MSG_PVP_LOG_DATA"); + handlePvpLogData(packet); break; case Opcode::MSG_INSPECT_ARENA_TEAMS: LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); @@ -5207,6 +5207,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::vector* auraList = nullptr; if (auraTargetGuid == playerGuid) auraList = &playerAuras; else if (auraTargetGuid == targetGuid) auraList = &targetAuras; + else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid]; if (auraList && isInit) auraList->clear(); @@ -13467,6 +13468,80 @@ void GameHandler::handleArenaError(network::Packet& packet) { LOG_INFO("Arena error: ", error, " - ", msg); } +void GameHandler::requestPvpLog() { + if (state != WorldState::IN_WORLD || !socket) return; + // MSG_PVP_LOG_DATA is bidirectional: client sends an empty packet to request + network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA)); + socket->send(pkt); + LOG_INFO("Requested PvP log data"); +} + +void GameHandler::handlePvpLogData(network::Packet& packet) { + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < 1) return; + + bgScoreboard_ = BgScoreboardData{}; + bgScoreboard_.isArena = (packet.readUInt8() != 0); + + if (bgScoreboard_.isArena) { + // Skip arena-specific header (two teams × (rating change uint32 + name string + 5×uint32)) + // Rather than hardcoding arena parse we skip gracefully up to playerCount + // Each arena team block: uint32 + string + uint32*5 — variable length due to string. + // Skip by scanning for the uint32 playerCount heuristically; simply consume rest. + packet.setReadPos(packet.getSize()); + return; + } + + if (remaining() < 4) return; + uint32_t playerCount = packet.readUInt32(); + bgScoreboard_.players.reserve(playerCount); + + for (uint32_t i = 0; i < playerCount && remaining() >= 13; ++i) { + BgPlayerScore ps; + ps.guid = packet.readUInt64(); + ps.team = packet.readUInt8(); + ps.killingBlows = packet.readUInt32(); + ps.honorableKills = packet.readUInt32(); + ps.deaths = packet.readUInt32(); + ps.bonusHonor = packet.readUInt32(); + + // Resolve player name from entity manager + { + auto ent = entityManager.getEntity(ps.guid); + if (ent && (ent->getType() == game::ObjectType::PLAYER || + ent->getType() == game::ObjectType::UNIT)) { + auto u = std::static_pointer_cast(ent); + if (!u->getName().empty()) ps.name = u->getName(); + } + } + + // BG-specific stat blocks: uint32 count + N × (string fieldName + uint32 value) + if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; } + uint32_t statCount = packet.readUInt32(); + for (uint32_t s = 0; s < statCount && remaining() >= 5; ++s) { + std::string fieldName; + while (remaining() > 0) { + char c = static_cast(packet.readUInt8()); + if (c == '\0') break; + fieldName += c; + } + uint32_t val = (remaining() >= 4) ? packet.readUInt32() : 0; + ps.bgStats.emplace_back(std::move(fieldName), val); + } + + bgScoreboard_.players.push_back(std::move(ps)); + } + + if (remaining() >= 1) { + bgScoreboard_.hasWinner = (packet.readUInt8() != 0); + if (bgScoreboard_.hasWinner && remaining() >= 1) + bgScoreboard_.winner = packet.readUInt8(); + } + + LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); +} + void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1e106f35..a31d8fa1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -611,6 +611,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderThreatWindow(gameHandler); + renderBgScoreboard(gameHandler); renderObjectiveTracker(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { @@ -3979,6 +3980,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /score command — BG scoreboard + if (cmdLower == "score") { + gameHandler.requestPvpLog(); + showBgScoreboard_ = true; + chatInputBuffer[0] = '\0'; + return; + } + // /time command if (cmdLower == "time") { gameHandler.queryServerTime(); @@ -4065,7 +4074,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", - " /unstuck /logout /ticket /help", + " /score /unstuck /logout /ticket /help", }; for (const char* line : kHelpLines) { game::MessageChatData helpMsg; @@ -17738,6 +17747,139 @@ void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── BG Scoreboard ──────────────────────────────────────────────────────────── +void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { + if (!showBgScoreboard_) return; + + const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard(); + + ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); + + const char* title = "Battleground Score###BgScore"; + if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!data) { + ImGui::TextDisabled("No score data yet."); + ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground."); + ImGui::End(); + return; + } + + // Winner banner + if (data->hasWinner) { + const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; + ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f) + : ImVec4(1.0f, 0.35f, 0.35f, 1.0f); + float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); + ImGui::TextColored(winnerColor, "%s", winnerStr); + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Victory!"); + ImGui::Separator(); + } + + // Refresh button + if (ImGui::SmallButton("Refresh")) { + gameHandler.requestPvpLog(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu players", data->players.size()); + + // Score table + constexpr ImGuiTableFlags kTableFlags = + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV | + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable; + + // Build dynamic column count based on what BG-specific stats are present + int numBgCols = 0; + std::vector bgColNames; + for (const auto& ps : data->players) { + for (const auto& [fieldName, val] : ps.bgStats) { + // Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps") + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + bool found = false; + for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } } + if (!found) bgColNames.push_back(shortName); + } + } + numBgCols = static_cast(bgColNames.size()); + + // Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific + int totalCols = 6 + numBgCols; + float tableH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f); + for (const auto& col : bgColNames) + ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableHeadersRow(); + + // Sort: Alliance first, then Horde; within each team by KB desc + std::vector sorted; + sorted.reserve(data->players.size()); + for (const auto& ps : data->players) sorted.push_back(&ps); + std::stable_sort(sorted.begin(), sorted.end(), + [](const game::GameHandler::BgPlayerScore* a, + const game::GameHandler::BgPlayerScore* b) { + if (a->team != b->team) return a->team > b->team; // Alliance(1) first + return a->killingBlows > b->killingBlows; + }); + + uint64_t playerGuid = gameHandler.getPlayerGuid(); + for (const auto* ps : sorted) { + ImGui::TableNextRow(); + + // Team + ImGui::TableNextColumn(); + if (ps->team == 1) + ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "Alliance"); + else + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Horde"); + + // Name (highlight player's own row) + ImGui::TableNextColumn(); + bool isSelf = (ps->guid == playerGuid); + if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str(); + ImGui::TextUnformatted(nameStr); + if (isSelf) ImGui::PopStyleColor(); + + ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor); + + for (const auto& col : bgColNames) { + ImGui::TableNextColumn(); + uint32_t val = 0; + for (const auto& [fieldName, fval] : ps->bgStats) { + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + if (shortName == col) { val = fval; break; } + } + if (val > 0) ImGui::Text("%u", val); + else ImGui::TextDisabled("-"); + } + } + ImGui::EndTable(); + } + + ImGui::End(); +} + // ─── Quest Objective Tracker ────────────────────────────────────────────────── void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { if (gameHandler.getState() != game::WorldState::IN_WORLD) return; From 562fc13a6a0f48e7490808b8328a8d2e61746693 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:05:05 -0700 Subject: [PATCH 104/105] fix: wire TOGGLE_CHARACTER_SCREEN (C) and TOGGLE_BAGS (B) keybindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both actions were defined in KeybindingManager but never wired to the input handling block — C had no effect and B did nothing. Connect them: - C toggles inventoryScreen's character panel (equipment slots view) - B opens all separate bags, or falls back to toggling the unified view --- src/ui/game_screen.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a31d8fa1..33e78f25 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2157,11 +2157,23 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } - // Toggle nameplates (customizable keybinding, default V) + // Toggle character screen (C) and inventory/bags (I) + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { + inventoryScreen.toggleCharacter(); + } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { inventoryScreen.toggle(); } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { + if (inventoryScreen.isSeparateBags()) { + inventoryScreen.openAllBags(); + } else { + inventoryScreen.toggle(); + } + } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { showNameplates_ = !showNameplates_; } From a90f2acd267e0cec3d2a19989d2a4979b23beca7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 12:07:46 -0700 Subject: [PATCH 105/105] feat: populate unitAurasCache_ from SMSG_PARTY_MEMBER_STATS aura block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_PARTY_MEMBER_STATS includes a 64-bit aura presence mask + per-slot spellId/flags that was previously read and discarded. Now populate unitAurasCache_[memberGuid] from this data so party frame debuff dots show even when no dedicated SMSG_AURA_UPDATE has been received for that unit. For Classic/TBC (no flags byte), infer debuff status from dispel type — any spell with a non-zero dispel type is treated as a debuff. --- src/game/game_handler.cpp | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4e0b8ef2..df1da0df 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15093,20 +15093,34 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { if (updateFlags & 0x0200) { // AURAS if (remaining() >= 8) { uint64_t auraMask = packet.readUInt64(); + // Collect aura updates for this member and store in unitAurasCache_ + // so party frame debuff dots can use them. + std::vector newAuras; for (int i = 0; i < 64; ++i) { if (auraMask & (uint64_t(1) << i)) { + AuraSlot a; + a.level = static_cast(i); // use slot index if (isWotLK) { // WotLK: uint32 spellId + uint8 auraFlags if (remaining() < 5) break; - packet.readUInt32(); - packet.readUInt8(); + a.spellId = packet.readUInt32(); + a.flags = packet.readUInt8(); } else { - // Classic/TBC: uint16 spellId only + // Classic/TBC: uint16 spellId only; negative auras not indicated here if (remaining() < 2) break; - packet.readUInt16(); + a.spellId = packet.readUInt16(); + // Infer negative/positive from dispel type: non-zero dispel → debuff + uint8_t dt = getSpellDispelType(a.spellId); + if (dt > 0) a.flags = 0x80; // mark as debuff } + if (a.spellId != 0) newAuras.push_back(a); } } + // Populate unitAurasCache_ for this member (merge: keep existing per-GUID data + // only if we already have a richer source; otherwise replace with stats data) + if (memberGuid != 0 && memberGuid != playerGuid && memberGuid != targetGuid) { + unitAurasCache_[memberGuid] = std::move(newAuras); + } } } if (updateFlags & 0x0400) { // PET_GUID