Kelsidavis-WoWee/src/ui/combat_ui.cpp
Paul 5af9f7aa4b chore(renderer): extract AnimationController and remove audio pass-throughs
Extract ~1,500 lines of character animation state from Renderer into a dedicated
AnimationController class, and complete the AudioCoordinator migration by removing
all 10 audio pass-through getters from Renderer.

AnimationController:
- New: include/rendering/animation_controller.hpp (182 lines)
- New: src/rendering/animation_controller.cpp (1,703 lines)
- Moves: locomotion state machine (50+ members), mount animation (40+ members),
  emote system, footstep triggering, surface detection, melee combat animations
- Renderer holds std::unique_ptr<AnimationController> and delegates completely
- AnimationController accesses audio via renderer_->getAudioCoordinator()

Audio caller migration:
- Migrate ~60 external callers from renderer->getXManager() to AudioCoordinator
  directly, grouped by access pattern:
  - UIServices: settings_panel, game_screen, toast_manager, chat_panel,
    combat_ui, window_manager
  - GameServices: game_handler, spell_handler, inventory_handler, quest_handler,
    social_handler, combat_handler
  - Application singleton: application.cpp, auth_screen.cpp, lua_engine.cpp
- Remove 10 pass-through getter definitions from renderer.cpp
- Remove 10 pass-through getter declarations from renderer.hpp
- Remove individual audio manager forward declarations from renderer.hpp
- Redirect 69 internal renderer.cpp audio calls to audioCoordinator_ directly
- game_handler.cpp: withSoundManager template uses services_.audioCoordinator;
  MFP changed from &Renderer::getUiSoundManager to &AudioCoordinator::getUiSoundManager
- GameServices struct: add AudioCoordinator* audioCoordinator member
- settings_panel: applyAudioVolumes(Renderer*) -> applyAudioVolumes(AudioCoordinator*)
2026-04-02 13:06:31 +03:00

1893 lines
86 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// CombatUI — extracted from GameScreen
// Owns all combat-related UI rendering: cast bar, cooldown tracker,
// raid warning overlay, combat text, DPS meter, buff bar,
// battleground score HUD, combat log, threat window, BG scoreboard.
// ============================================================
#include "ui/combat_ui.hpp"
#include "ui/settings_panel.hpp"
#include "ui/spellbook_screen.hpp"
#include "ui/ui_colors.hpp"
#include "core/application.hpp"
#include "core/logger.hpp"
#include "core/coordinates.hpp"
#include "rendering/renderer.hpp"
#include "rendering/camera.hpp"
#include "game/game_handler.hpp"
#include "pipeline/asset_manager.hpp"
#include "audio/audio_coordinator.hpp"
#include "audio/audio_engine.hpp"
#include "audio/ui_sound_manager.hpp"
#include <imgui.h>
#include <imgui_internal.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
namespace {
using namespace wowee::ui::colors;
constexpr auto& kColorRed = kRed;
constexpr auto& kColorGreen = kGreen;
constexpr auto& kColorBrightGreen = kBrightGreen;
constexpr auto& kColorYellow = kYellow;
// Format a duration in seconds as compact text: "2h", "3:05", "42"
void fmtDurationCompact(char* buf, size_t sz, int secs) {
if (secs >= 3600) snprintf(buf, sz, "%dh", secs / 3600);
else if (secs >= 60) snprintf(buf, sz, "%d:%02d", secs / 60, secs % 60);
else snprintf(buf, sz, "%d", secs);
}
// Render "Remaining: Xs" or "Remaining: Xm Ys" in a tooltip (light gray)
void renderAuraRemaining(int remainMs) {
if (remainMs <= 0) return;
int s = remainMs / 1000;
char buf[32];
if (s < 60) snprintf(buf, sizeof(buf), "Remaining: %ds", s);
else snprintf(buf, sizeof(buf), "Remaining: %dm %ds", s / 60, s % 60);
ImGui::TextColored(kLightGray, "%s", buf);
}
} // anonymous namespace
namespace wowee {
namespace ui {
// ============================================================
// Cast Bar (Phase 3)
// ============================================================
void CombatUI::renderCastBar(game::GameHandler& gameHandler, SpellIconFn getSpellIcon) {
if (!gameHandler.isCasting()) return;
auto* assetMgr = services_.assetManager;
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;
ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, 40), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoFocusOnAppearing;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f));
if (ImGui::Begin("##CastBar", nullptr, flags)) {
const bool channeling = gameHandler.isChanneling();
// Channels drain right-to-left; regular casts fill left-to-right
float progress = channeling
? (1.0f - gameHandler.getCastProgress())
: gameHandler.getCastProgress();
// Color by spell school for cast identification; channels always blue
ImVec4 barColor;
if (channeling) {
barColor = ImVec4(0.3f, 0.6f, 0.9f, 1.0f); // blue for channels
} else {
uint32_t school = (currentSpellId != 0) ? gameHandler.getSpellSchoolMask(currentSpellId) : 0;
if (school & 0x04) barColor = ImVec4(0.95f, 0.40f, 0.10f, 1.0f); // Fire: orange-red
else if (school & 0x10) barColor = ImVec4(0.30f, 0.65f, 0.95f, 1.0f); // Frost: icy blue
else if (school & 0x20) barColor = ImVec4(0.55f, 0.15f, 0.70f, 1.0f); // Shadow: purple
else if (school & 0x40) barColor = ImVec4(0.65f, 0.30f, 0.85f, 1.0f); // Arcane: violet
else if (school & 0x08) barColor = ImVec4(0.20f, 0.75f, 0.25f, 1.0f); // Nature: green
else if (school & 0x02) barColor = ImVec4(0.90f, 0.80f, 0.30f, 1.0f); // Holy: golden
else barColor = ImVec4(0.80f, 0.60f, 0.20f, 1.0f); // Physical/default: gold
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor);
char overlay[96];
if (currentSpellId == 0) {
snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining());
} else {
const std::string& spellName = gameHandler.getSpellName(currentSpellId);
const char* verb = channeling ? "Channeling" : "Casting";
int queueLeft = gameHandler.getCraftQueueRemaining();
if (!spellName.empty()) {
if (queueLeft > 0)
snprintf(overlay, sizeof(overlay), "%s (%.1fs) [%d left]", spellName.c_str(), gameHandler.getCastTimeRemaining(), queueLeft);
else
snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining());
} else {
snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining());
}
}
// Queued spell icon (right edge): the next spell queued to fire within 400ms.
uint32_t queuedId = gameHandler.getQueuedSpellId();
VkDescriptorSet queuedTex = (queuedId != 0 && assetMgr)
? getSpellIcon(queuedId, assetMgr) : VK_NULL_HANDLE;
const float iconSz = 20.0f;
const float reservedRight = (queuedTex) ? (iconSz + 4.0f) : 0.0f;
if (iconTex) {
// Spell icon to the left of the progress bar
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(iconSz, iconSz));
ImGui::SameLine(0, 4);
ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay);
} else {
ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay);
}
// Draw queued-spell icon on the right with a ">" arrow prefix tooltip.
if (queuedTex) {
ImGui::SameLine(0, 4);
ImGui::Image((ImTextureID)(uintptr_t)queuedTex, ImVec2(iconSz, iconSz),
ImVec2(0,0), ImVec2(1,1),
ImVec4(1,1,1,0.8f), ImVec4(0,0,0,0)); // slightly dimmed
if (ImGui::IsItemHovered()) {
const std::string& qn = gameHandler.getSpellName(queuedId);
ImGui::SetTooltip("Queued: %s", qn.empty() ? "Unknown" : qn.c_str());
}
}
ImGui::PopStyleColor();
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Cooldown Tracker — floating panel showing all active spell CDs
// ============================================================
void CombatUI::renderCooldownTracker(game::GameHandler& gameHandler,
const SettingsPanel& settings,
SpellIconFn getSpellIcon) {
if (!settings.showCooldownTracker_) return;
const auto& cooldowns = gameHandler.getSpellCooldowns();
if (cooldowns.empty()) return;
// Collect spells with remaining cooldown > 0.5s (skip GCD noise)
struct CDEntry { uint32_t spellId; float remaining; };
std::vector<CDEntry> active;
active.reserve(16);
for (const auto& [sid, rem] : cooldowns) {
if (rem > 0.5f) active.push_back({sid, rem});
}
if (active.empty()) return;
// Sort: longest remaining first
std::sort(active.begin(), active.end(), [](const CDEntry& a, const CDEntry& b) {
return a.remaining > b.remaining;
});
auto* assetMgr = services_.assetManager;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
constexpr float TRACKER_W = 200.0f;
constexpr int MAX_SHOWN = 12;
float posX = screenW - TRACKER_W - 10.0f;
float posY = screenH - 220.0f; // above the action bar area
ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always, ImVec2(1.0f, 1.0f));
ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0.0f), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.75f);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoBringToFrontOnFocus;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f));
if (ImGui::Begin("##CooldownTracker", nullptr, flags)) {
ImGui::TextDisabled("Cooldowns");
ImGui::Separator();
int shown = 0;
for (const auto& cd : active) {
if (shown >= MAX_SHOWN) break;
const std::string& name = gameHandler.getSpellName(cd.spellId);
if (name.empty()) continue; // skip unnamed spells (internal/passive)
// Small icon if available
VkDescriptorSet icon = assetMgr ? getSpellIcon(cd.spellId, assetMgr) : VK_NULL_HANDLE;
if (icon) {
ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(14, 14));
ImGui::SameLine(0, 3);
}
// Name (truncated) + remaining time
char timeStr[16];
if (cd.remaining >= 60.0f)
snprintf(timeStr, sizeof(timeStr), "%dm%ds", static_cast<int>(cd.remaining) / 60, static_cast<int>(cd.remaining) % 60);
else
snprintf(timeStr, sizeof(timeStr), "%.0fs", cd.remaining);
// Color: red > 30s, orange > 10s, yellow > 5s, green otherwise
ImVec4 cdColor = cd.remaining > 30.0f ? kColorRed :
cd.remaining > 10.0f ? ImVec4(1.0f, 0.6f, 0.2f, 1.0f) :
cd.remaining > 5.0f ? kColorYellow :
colors::kActiveGreen;
// Truncate name to fit
std::string displayName = name;
if (displayName.size() > 16) displayName = displayName.substr(0, 15) + "\xe2\x80\xa6"; // ellipsis
ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", displayName.c_str());
ImGui::SameLine(TRACKER_W - 48.0f);
ImGui::TextColored(cdColor, "%s", timeStr);
++shown;
}
}
ImGui::End();
ImGui::PopStyleVar(3);
}
// ============================================================
// Raid Warning / Boss Emote Center-Screen Overlay
// ============================================================
void CombatUI::renderRaidWarningOverlay(game::GameHandler& gameHandler) {
// Scan chat history for new RAID_WARNING / RAID_BOSS_EMOTE messages
const auto& chatHistory = gameHandler.getChatHistory();
size_t newCount = chatHistory.size();
if (newCount > raidWarnChatSeenCount_) {
// Walk only the new messages (deque — iterate from back by skipping old ones)
size_t toScan = newCount - raidWarnChatSeenCount_;
size_t startIdx = newCount > toScan ? newCount - toScan : 0;
auto* renderer = services_.renderer;
for (size_t i = startIdx; i < newCount; ++i) {
const auto& msg = chatHistory[i];
if (msg.type == game::ChatType::RAID_WARNING ||
msg.type == game::ChatType::RAID_BOSS_EMOTE ||
msg.type == game::ChatType::MONSTER_EMOTE) {
bool isBoss = (msg.type != game::ChatType::RAID_WARNING);
// Limit display text length to avoid giant overlay
std::string text = msg.message;
if (text.size() > 200) text = text.substr(0, 200) + "...";
raidWarnEntries_.push_back({text, 0.0f, isBoss});
if (raidWarnEntries_.size() > 3)
raidWarnEntries_.erase(raidWarnEntries_.begin());
}
// Whisper audio notification
if (msg.type == game::ChatType::WHISPER) {
if (auto* ac = services_.audioCoordinator) {
if (auto* ui = ac->getUiSoundManager())
ui->playWhisperReceived();
}
}
}
raidWarnChatSeenCount_ = newCount;
}
// Age and remove expired entries
float dt = ImGui::GetIO().DeltaTime;
for (auto& e : raidWarnEntries_) e.age += dt;
raidWarnEntries_.erase(
std::remove_if(raidWarnEntries_.begin(), raidWarnEntries_.end(),
[](const RaidWarnEntry& e){ return e.age >= RaidWarnEntry::LIFETIME; }),
raidWarnEntries_.end());
if (raidWarnEntries_.empty()) return;
ImGuiIO& io = ImGui::GetIO();
float screenW = io.DisplaySize.x;
float screenH = io.DisplaySize.y;
ImDrawList* fg = ImGui::GetForegroundDrawList();
// Stack entries vertically near upper-center (below target frame area)
float baseY = screenH * 0.28f;
for (const auto& e : raidWarnEntries_) {
float alpha = std::clamp(1.0f - (e.age / RaidWarnEntry::LIFETIME), 0.0f, 1.0f);
// Fade in quickly, hold, then fade out last 20%
if (e.age < 0.3f) alpha = e.age / 0.3f;
// Truncate to fit screen width reasonably
const char* txt = e.text.c_str();
const float fontSize = 22.0f;
ImFont* font = ImGui::GetFont();
// Word-wrap manually: compute text size, center horizontally
float maxW = screenW * 0.7f;
ImVec2 textSz = font->CalcTextSizeA(fontSize, maxW, maxW, txt);
float tx = (screenW - textSz.x) * 0.5f;
ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast<int>(alpha * 200));
ImU32 mainCol;
if (e.isBossEmote) {
mainCol = IM_COL32(255, 185, 60, static_cast<int>(alpha * 255)); // amber
} else {
// Raid warning: alternating red/yellow flash during first second
float flashT = std::fmod(e.age * 4.0f, 1.0f);
if (flashT < 0.5f)
mainCol = IM_COL32(255, 50, 50, static_cast<int>(alpha * 255));
else
mainCol = IM_COL32(255, 220, 50, static_cast<int>(alpha * 255));
}
// Background dim box for readability
float pad = 8.0f;
fg->AddRectFilled(ImVec2(tx - pad, baseY - pad),
ImVec2(tx + textSz.x + pad, baseY + textSz.y + pad),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 120)), 4.0f);
// Shadow + main text
fg->AddText(font, fontSize, ImVec2(tx + 2.0f, baseY + 2.0f), shadowCol, txt,
nullptr, maxW);
fg->AddText(font, fontSize, ImVec2(tx, baseY), mainCol, txt,
nullptr, maxW);
baseY += textSz.y + 6.0f;
}
}
// ============================================================
// Floating Combat Text (Phase 2)
// ============================================================
void CombatUI::renderCombatText(game::GameHandler& gameHandler) {
const auto& entries = gameHandler.getCombatText();
if (entries.empty()) return;
auto* window = services_.window;
if (!window) return;
const float screenW = static_cast<float>(window->getWidth());
const float screenH = static_cast<float>(window->getHeight());
// Camera for world-space projection
auto* appRenderer = services_.renderer;
rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr;
glm::mat4 viewProj;
if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix();
ImDrawList* drawList = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
const float baseFontSize = ImGui::GetFontSize();
// HUD fallback: entries without world-space anchor use classic screen-position layout.
// We still need an ImGui window for those.
const float hudIncomingX = screenW * 0.40f;
const float hudOutgoingX = screenW * 0.68f;
int hudInIdx = 0, hudOutIdx = 0;
bool needsHudWindow = false;
for (const auto& entry : entries) {
const float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME);
const bool outgoing = entry.isPlayerSource;
// --- Format text and color (identical logic for both world and HUD paths) ---
ImVec4 color;
char text[128];
switch (entry.type) {
case game::CombatTextEntry::MELEE_DAMAGE:
case game::CombatTextEntry::SPELL_DAMAGE:
snprintf(text, sizeof(text), "-%d", entry.amount);
color = outgoing ?
ImVec4(1.0f, 1.0f, 0.3f, alpha) :
ImVec4(1.0f, 0.3f, 0.3f, alpha);
break;
case game::CombatTextEntry::CRIT_DAMAGE:
snprintf(text, sizeof(text), "-%d!", entry.amount);
color = outgoing ?
ImVec4(1.0f, 0.8f, 0.0f, alpha) :
ImVec4(1.0f, 0.5f, 0.0f, alpha);
break;
case game::CombatTextEntry::HEAL:
snprintf(text, sizeof(text), "+%d", entry.amount);
color = ImVec4(0.3f, 1.0f, 0.3f, alpha);
break;
case game::CombatTextEntry::CRIT_HEAL:
snprintf(text, sizeof(text), "+%d!", entry.amount);
color = ImVec4(0.3f, 1.0f, 0.3f, alpha);
break;
case game::CombatTextEntry::MISS:
snprintf(text, sizeof(text), "Miss");
color = ImVec4(0.7f, 0.7f, 0.7f, alpha);
break;
case game::CombatTextEntry::DODGE:
snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge");
color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha)
: ImVec4(0.4f, 0.9f, 1.0f, alpha);
break;
case game::CombatTextEntry::PARRY:
snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry");
color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha)
: ImVec4(0.4f, 0.9f, 1.0f, alpha);
break;
case game::CombatTextEntry::BLOCK:
if (entry.amount > 0)
snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount);
else
snprintf(text, sizeof(text), outgoing ? "Block" : "You Block");
color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha)
: ImVec4(0.4f, 0.9f, 1.0f, alpha);
break;
case game::CombatTextEntry::EVADE:
snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade");
color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha)
: ImVec4(0.4f, 0.9f, 1.0f, alpha);
break;
case game::CombatTextEntry::PERIODIC_DAMAGE:
snprintf(text, sizeof(text), "-%d", entry.amount);
color = outgoing ?
ImVec4(1.0f, 0.9f, 0.3f, alpha) :
ImVec4(1.0f, 0.4f, 0.4f, alpha);
break;
case game::CombatTextEntry::PERIODIC_HEAL:
snprintf(text, sizeof(text), "+%d", entry.amount);
color = ImVec4(0.4f, 1.0f, 0.5f, alpha);
break;
case game::CombatTextEntry::ENVIRONMENTAL: {
const char* envLabel = "";
switch (entry.powerType) {
case 0: envLabel = "Fatigue "; break;
case 1: envLabel = "Drowning "; break;
case 2: envLabel = ""; break;
case 3: envLabel = "Lava "; break;
case 4: envLabel = "Slime "; break;
case 5: envLabel = "Fire "; break;
default: envLabel = ""; break;
}
snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount);
color = ImVec4(0.9f, 0.5f, 0.2f, alpha);
break;
}
case game::CombatTextEntry::ENERGIZE:
snprintf(text, sizeof(text), "+%d", entry.amount);
switch (entry.powerType) {
case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break;
case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break;
case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break;
case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break;
default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break;
}
break;
case game::CombatTextEntry::POWER_DRAIN:
snprintf(text, sizeof(text), "-%d", entry.amount);
switch (entry.powerType) {
case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break;
case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break;
case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break;
case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break;
default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break;
}
break;
case game::CombatTextEntry::XP_GAIN:
snprintf(text, sizeof(text), "+%d XP", entry.amount);
color = ImVec4(0.7f, 0.3f, 1.0f, alpha);
break;
case game::CombatTextEntry::IMMUNE:
snprintf(text, sizeof(text), "Immune!");
color = ImVec4(0.9f, 0.9f, 0.9f, alpha);
break;
case game::CombatTextEntry::ABSORB:
if (entry.amount > 0)
snprintf(text, sizeof(text), "Absorbed %d", entry.amount);
else
snprintf(text, sizeof(text), "Absorbed");
color = ImVec4(0.5f, 0.8f, 1.0f, alpha);
break;
case game::CombatTextEntry::RESIST:
if (entry.amount > 0)
snprintf(text, sizeof(text), "Resisted %d", entry.amount);
else
snprintf(text, sizeof(text), "Resisted");
color = ImVec4(0.7f, 0.7f, 0.7f, alpha);
break;
case game::CombatTextEntry::DEFLECT:
snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect");
color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha)
: ImVec4(0.5f, 0.9f, 1.0f, alpha);
break;
case game::CombatTextEntry::REFLECT: {
const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : "";
if (!reflectName.empty())
snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str());
else
snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect");
color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha)
: ImVec4(0.75f, 0.85f, 1.0f, alpha);
break;
}
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);
break;
}
case game::CombatTextEntry::DISPEL:
if (entry.spellId != 0) {
const std::string& dispelledName = gameHandler.getSpellName(entry.spellId);
if (!dispelledName.empty())
snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str());
else
snprintf(text, sizeof(text), "Dispel");
} else {
snprintf(text, sizeof(text), "Dispel");
}
color = ImVec4(0.6f, 0.9f, 1.0f, alpha);
break;
case game::CombatTextEntry::STEAL:
if (entry.spellId != 0) {
const std::string& stolenName = gameHandler.getSpellName(entry.spellId);
if (!stolenName.empty())
snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str());
else
snprintf(text, sizeof(text), "Spellsteal");
} else {
snprintf(text, sizeof(text), "Spellsteal");
}
color = ImVec4(0.8f, 0.7f, 1.0f, alpha);
break;
case game::CombatTextEntry::INTERRUPT: {
const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : "";
if (!interruptedName.empty())
snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str());
else
snprintf(text, sizeof(text), "Interrupt");
color = ImVec4(1.0f, 0.6f, 0.9f, alpha);
break;
}
case game::CombatTextEntry::INSTAKILL:
snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!");
color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha)
: ImVec4(1.0f, 0.1f, 0.1f, alpha);
break;
case game::CombatTextEntry::HONOR_GAIN:
snprintf(text, sizeof(text), "+%d Honor", entry.amount);
color = ImVec4(1.0f, 0.85f, 0.0f, alpha);
break;
case game::CombatTextEntry::GLANCING:
snprintf(text, sizeof(text), "~%d", entry.amount);
color = outgoing ?
ImVec4(0.75f, 0.75f, 0.5f, alpha) :
ImVec4(0.75f, 0.35f, 0.35f, alpha);
break;
case game::CombatTextEntry::CRUSHING:
snprintf(text, sizeof(text), "%d!", entry.amount);
color = outgoing ?
ImVec4(1.0f, 0.55f, 0.1f, alpha) :
ImVec4(1.0f, 0.15f, 0.15f, alpha);
break;
default:
snprintf(text, sizeof(text), "%d", entry.amount);
color = ImVec4(1.0f, 1.0f, 1.0f, alpha);
break;
}
// --- Rendering style ---
bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE ||
entry.type == game::CombatTextEntry::CRIT_HEAL);
float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize;
ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast<int>(alpha * 180));
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color);
// --- Try world-space anchor if we have a destination entity ---
// Types that should always stay as HUD elements (no world anchor)
bool isHudOnly = (entry.type == game::CombatTextEntry::XP_GAIN ||
entry.type == game::CombatTextEntry::HONOR_GAIN ||
entry.type == game::CombatTextEntry::PROC_TRIGGER);
bool rendered = false;
if (!isHudOnly && camera && entry.dstGuid != 0) {
// Look up the destination entity's render position
glm::vec3 renderPos;
bool havePos = core::Application::getInstance().getRenderPositionForGuid(entry.dstGuid, renderPos);
if (!havePos) {
// Fallback to entity canonical position
auto entity = gameHandler.getEntityManager().getEntity(entry.dstGuid);
if (entity) {
auto* unit = entity->isUnit() ? static_cast<game::Unit*>(entity.get()) : nullptr;
if (unit) {
renderPos = core::coords::canonicalToRender(
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
havePos = true;
}
}
}
if (havePos) {
// Float upward from above the entity's head
renderPos.z += 2.5f + entry.age * 1.2f;
// Project to screen
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
if (clipPos.w > 0.01f) {
glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w;
if (ndc.x >= -1.5f && ndc.x <= 1.5f && ndc.y >= -1.5f && ndc.y <= 1.5f) {
float sx = (ndc.x * 0.5f + 0.5f) * screenW;
float sy = (ndc.y * 0.5f + 0.5f) * screenH;
// Horizontal stagger using the random seed
sx += entry.xSeed * 40.0f;
// Center the text horizontally on the projected point
ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text);
sx -= ts.x * 0.5f;
// Clamp to screen bounds
sx = std::max(2.0f, std::min(sx, screenW - ts.x - 2.0f));
drawList->AddText(font, renderFontSize,
ImVec2(sx + 1.0f, sy + 1.0f), shadowCol, text);
drawList->AddText(font, renderFontSize,
ImVec2(sx, sy), textCol, text);
rendered = true;
}
}
}
}
// --- HUD fallback for entries without world anchor or HUD-only types ---
if (!rendered) {
if (!needsHudWindow) {
needsHudWindow = true;
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(screenW, 400));
ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
ImGui::Begin("##CombatText", nullptr, flags);
}
float yOffset = 200.0f - entry.age * 60.0f;
int& idx = outgoing ? hudOutIdx : hudInIdx;
float baseX = outgoing ? hudOutgoingX : hudIncomingX;
float xOffset = baseX + (idx % 3 - 1) * 60.0f;
++idx;
ImGui::SetCursorPos(ImVec2(xOffset, yOffset));
ImVec2 screenPos = ImGui::GetCursorScreenPos();
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);
ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text);
ImGui::Dummy(ts);
}
}
if (needsHudWindow) {
ImGui::End();
}
}
// ============================================================
// DPS / HPS Meter
// ============================================================
void CombatUI::renderDPSMeter(game::GameHandler& gameHandler,
const SettingsPanel& settings) {
if (!settings.showDPSMeter_) return;
if (gameHandler.getState() != game::WorldState::IN_WORLD) return;
const float dt = ImGui::GetIO().DeltaTime;
// Track combat duration for accurate DPS denominator in short fights
bool inCombat = gameHandler.isInCombat();
if (inCombat && !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:
case game::CombatTextEntry::GLANCING:
case game::CombatTextEntry::CRUSHING:
dpsEncounterDamage_ += static_cast<float>(e.amount);
break;
case game::CombatTextEntry::HEAL:
case game::CombatTextEntry::CRIT_HEAL:
case game::CombatTextEntry::PERIODIC_HEAL:
dpsEncounterHeal_ += static_cast<float>(e.amount);
break;
default: break;
}
}
} else if (dpsWasInCombat_) {
// Just left combat — keep encounter totals but stop accumulating
}
dpsWasInCombat_ = inCombat;
// Sum all player-source damage and healing in the current combat-text window
float totalDamage = 0.0f, totalHeal = 0.0f;
for (const auto& e : gameHandler.getCombatText()) {
if (!e.isPlayerSource) continue;
switch (e.type) {
case game::CombatTextEntry::MELEE_DAMAGE:
case game::CombatTextEntry::SPELL_DAMAGE:
case game::CombatTextEntry::CRIT_DAMAGE:
case game::CombatTextEntry::PERIODIC_DAMAGE:
case game::CombatTextEntry::GLANCING:
case game::CombatTextEntry::CRUSHING:
totalDamage += static_cast<float>(e.amount);
break;
case game::CombatTextEntry::HEAL:
case game::CombatTextEntry::CRIT_HEAL:
case game::CombatTextEntry::PERIODIC_HEAL:
totalHeal += static_cast<float>(e.amount);
break;
default: break;
}
}
// 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.
float window = std::min(dpsCombatAge_, game::CombatTextEntry::LIFETIME);
if (window < 0.1f) window = 0.1f;
float dps = totalDamage / window;
float hps = totalHeal / window;
// Format numbers with K/M suffix for readability
auto fmtNum = [](float v, char* buf, int bufSz) {
if (v >= 1e6f) snprintf(buf, bufSz, "%.1fM", v / 1e6f);
else if (v >= 1000.f) snprintf(buf, bufSz, "%.1fK", v / 1000.f);
else snprintf(buf, bufSz, "%.0f", v);
};
char dpsBuf[16], hpsBuf[16];
fmtNum(dps, dpsBuf, sizeof(dpsBuf));
fmtNum(hps, hpsBuf, sizeof(hpsBuf));
// Position: small floating label just above the action bar, right of center
auto* appWin = services_.window;
float screenW = appWin ? static_cast<float>(appWin->getWidth()) : 1280.0f;
float screenH = appWin ? static_cast<float>(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;
// 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
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoInputs;
ImGui::SetNextWindowPos(ImVec2(wx, wy), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(WIN_W, WIN_H), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.55f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 3));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.7f));
if (ImGui::Begin("##DPSMeter", nullptr, flags)) {
if (dps > 0.5f) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "%s", dpsBuf);
ImGui::SameLine(0, 2);
ImGui::TextDisabled("dps");
}
if (hps > 0.5f) {
ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), "%s", hpsBuf);
ImGui::SameLine(0, 2);
ImGui::TextDisabled("hps");
}
// 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();
ImGui::PopStyleColor();
ImGui::PopStyleVar(2);
}
// ============================================================
// Buff/Debuff Bar (Phase 3)
// ============================================================
void CombatUI::renderBuffBar(game::GameHandler& gameHandler,
SpellbookScreen& spellbookScreen,
SpellIconFn getSpellIcon) {
const auto& auras = gameHandler.getPlayerAuras();
// Count non-empty auras
int activeCount = 0;
for (const auto& a : auras) {
if (!a.isEmpty()) activeCount++;
}
if (activeCount == 0 && !gameHandler.hasPet()) return;
auto* assetMgr = services_.assetManager;
// Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210)
// Anchored to the right side to stay away from party frames on the left
constexpr float ICON_SIZE = 32.0f;
constexpr int ICONS_PER_ROW = 8;
float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
// Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210)
ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f));
if (ImGui::Begin("##BuffBar", nullptr, flags)) {
// Pre-sort auras: buffs first, then debuffs; within each group, shorter remaining first
uint64_t buffNowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
std::vector<size_t> 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 si = 0; si < buffSortedIdx.size() && shown < 40; ++si) {
size_t i = buffSortedIdx[si];
const auto& aura = auras[i];
if (aura.isEmpty()) continue;
bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag
if (isBuff != wantBuff) continue; // only render matching pass
if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(i) + (pass * 256));
// 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;
if (assetMgr) {
iconTex = getSpellIcon(aura.spellId, assetMgr);
}
if (iconTex) {
ImGui::PushStyleColor(ImGuiCol_Button, borderColor);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
ImGui::ImageButton("##aura",
(ImTextureID)(uintptr_t)iconTex,
ImVec2(ICON_SIZE - 4, ICON_SIZE - 4));
ImGui::PopStyleVar();
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Button, borderColor);
const std::string& pAuraName = gameHandler.getSpellName(aura.spellId);
char label[32];
if (!pAuraName.empty())
snprintf(label, sizeof(label), "%.6s", pAuraName.c_str());
else
snprintf(label, sizeof(label), "%u", aura.spellId);
ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE));
ImGui::PopStyleColor();
}
// Compute remaining duration once (shared by overlay and tooltip)
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
int32_t remainMs = aura.getRemainingMs(nowMs);
// Clock-sweep overlay: dark fan shows elapsed time (WoW style)
if (remainMs > 0 && aura.maxDurationMs > 0) {
ImVec2 iconMin2 = ImGui::GetItemRectMin();
ImVec2 iconMax2 = ImGui::GetItemRectMax();
float cx2 = (iconMin2.x + iconMax2.x) * 0.5f;
float cy2 = (iconMin2.y + iconMax2.y) * 0.5f;
float fanR2 = (iconMax2.x - iconMin2.x) * 0.5f;
float total2 = static_cast<float>(aura.maxDurationMs);
float elapsedFrac2 = std::clamp(
1.0f - static_cast<float>(remainMs) / total2, 0.0f, 1.0f);
if (elapsedFrac2 > 0.005f) {
constexpr int SWEEP_SEGS = 24;
float sa = -IM_PI * 0.5f;
float ea = sa + elapsedFrac2 * 2.0f * IM_PI;
ImVec2 pts[SWEEP_SEGS + 2];
pts[0] = ImVec2(cx2, cy2);
for (int s = 0; s <= SWEEP_SEGS; ++s) {
float a = sa + (ea - sa) * s / static_cast<float>(SWEEP_SEGS);
pts[s + 1] = ImVec2(cx2 + std::cos(a) * fanR2,
cy2 + std::sin(a) * fanR2);
}
ImGui::GetWindowDrawList()->AddConvexPolyFilled(
pts, SWEEP_SEGS + 2, IM_COL32(0, 0, 0, 145));
}
}
// Duration countdown overlay — always visible on the icon bottom
if (remainMs > 0) {
ImVec2 iconMin = ImGui::GetItemRectMin();
ImVec2 iconMax = ImGui::GetItemRectMax();
char timeStr[12];
int secs = (remainMs + 999) / 1000; // ceiling seconds
if (secs >= 3600)
snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600);
else if (secs >= 60)
snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60);
else
snprintf(timeStr, sizeof(timeStr), "%d", secs);
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<float>(ImGui::GetTime()) * 6.0f);
timerColor = IM_COL32(
static_cast<int>(255 * pulse),
static_cast<int>(80 * pulse),
static_cast<int>(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),
timerColor, timeStr);
}
// Stack / charge count overlay — upper-left corner of the icon
if (aura.charges > 1) {
ImVec2 iconMin = ImGui::GetItemRectMin();
char chargeStr[8];
snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast<unsigned>(aura.charges));
// Drop shadow then bright yellow text
ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3),
IM_COL32(0, 0, 0, 200), chargeStr);
ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2),
IM_COL32(255, 220, 50, 255), chargeStr);
}
// Right-click to cancel buffs / dismount
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
if (gameHandler.isMounted()) {
gameHandler.dismount();
} else if (isBuff) {
gameHandler.cancelAura(aura.spellId);
}
}
// Tooltip: rich spell info + remaining duration
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr);
if (!richOk) {
std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr);
if (name.empty()) name = "Spell #" + std::to_string(aura.spellId);
ImGui::Text("%s", name.c_str());
}
renderAuraRemaining(remainMs);
ImGui::EndTooltip();
}
ImGui::PopID();
shown++;
} // end aura loop
// Add visual gap between buffs and debuffs
if (pass == 0 && shown > 0) ImGui::Spacing();
} // end pass loop
// Dismiss Pet button
if (gameHandler.hasPet()) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f));
if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) {
gameHandler.dismissPet();
}
ImGui::PopStyleColor(2);
}
// Temporary weapon enchant timers (Shaman imbues, Rogue poisons, whetstones, etc.)
{
const auto& timers = gameHandler.getTempEnchantTimers();
if (!timers.empty()) {
ImGui::Spacing();
ImGui::Separator();
static constexpr ImVec4 kEnchantSlotColors[] = {
colors::kOrange, // main-hand: gold
ImVec4(0.5f, 0.8f, 0.9f, 1.0f), // off-hand: teal
ImVec4(0.7f, 0.5f, 0.9f, 1.0f), // ranged: purple
};
uint64_t enchNowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
for (const auto& t : timers) {
if (t.slot > 2) continue;
uint64_t remMs = (t.expireMs > enchNowMs) ? (t.expireMs - enchNowMs) : 0;
if (remMs == 0) continue;
ImVec4 col = kEnchantSlotColors[t.slot];
// Flash red when < 60s remaining
if (remMs < 60000) {
float pulse = 0.6f + 0.4f * std::sin(
static_cast<float>(ImGui::GetTime()) * 4.0f);
col = ImVec4(pulse, 0.2f, 0.1f, 1.0f);
}
// Format remaining time
uint32_t secs = static_cast<uint32_t>((remMs + 999) / 1000);
char timeStr[16];
if (secs >= 3600)
snprintf(timeStr, sizeof(timeStr), "%dh%02dm", secs / 3600, (secs % 3600) / 60);
else if (secs >= 60)
snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60);
else
snprintf(timeStr, sizeof(timeStr), "%ds", secs);
ImGui::PushID(static_cast<int>(t.slot) + 5000);
ImGui::PushStyleColor(ImGuiCol_Button, col);
char label[40];
snprintf(label, sizeof(label), "~%s %s",
game::GameHandler::kTempEnchantSlotNames[t.slot], timeStr);
ImGui::Button(label, ImVec2(-1, 16));
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Temporary weapon enchant: %s\nRemaining: %s",
game::GameHandler::kTempEnchantSlotNames[t.slot],
timeStr);
ImGui::PopStyleColor();
ImGui::PopID();
}
}
}
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor();
}
// WSG 489 Alliance / Horde flag captures (max 3)
// AB 529 Alliance / Horde resource scores (max 1600)
// AV 30 Alliance / Horde reinforcements
// EotS 566 Alliance / Horde resource scores (max 1600)
// ============================================================================
void CombatUI::renderBattlegroundScore(game::GameHandler& gameHandler) {
// Only show when in a recognised battleground map
uint32_t mapId = gameHandler.getWorldStateMapId();
// World state key sets per battleground
// Keys from the WoW 3.3.5a WorldState.dbc / client source
struct BgScoreDef {
uint32_t mapId;
const char* name;
uint32_t allianceKey; // world state key for Alliance value
uint32_t hordeKey; // world state key for Horde value
uint32_t maxKey; // max score world state key (0 = use hardcoded)
uint32_t hardcodedMax; // used when maxKey == 0
const char* unit; // suffix label (e.g. "flags", "resources")
};
static constexpr BgScoreDef kBgDefs[] = {
// Warsong Gulch: 3 flag captures wins
{ 489, "Warsong Gulch", 1581, 1582, 0, 3, "flags" },
// Arathi Basin: 1600 resources wins
{ 529, "Arathi Basin", 1218, 1219, 0, 1600, "resources" },
// Alterac Valley: reinforcements count down from 600 / 800 etc.
{ 30, "Alterac Valley", 1322, 1323, 0, 600, "reinforcements" },
// Eye of the Storm: 1600 resources wins
{ 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" },
// Strand of the Ancients (WotLK)
{ 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" },
// Isle of Conquest (WotLK): reinforcements (300 default)
{ 628, "Isle of Conquest", 4221, 4222, 0, 300, "reinforcements" },
};
const BgScoreDef* def = nullptr;
for (const auto& d : kBgDefs) {
if (d.mapId == mapId) { def = &d; break; }
}
if (!def) return;
auto allianceOpt = gameHandler.getWorldState(def->allianceKey);
auto hordeOpt = gameHandler.getWorldState(def->hordeKey);
if (!allianceOpt && !hordeOpt) return;
uint32_t allianceScore = allianceOpt.value_or(0);
uint32_t hordeScore = hordeOpt.value_or(0);
uint32_t maxScore = def->hardcodedMax;
if (def->maxKey != 0) {
if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv;
}
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Width scales with screen but stays reasonable
float frameW = 260.0f;
float frameH = 60.0f;
float posX = screenW / 2.0f - frameW / 2.0f;
float posY = 4.0f;
ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, frameH), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.75f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f));
if (ImGui::Begin("##BGScore", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_NoSavedSettings)) {
// BG name centred at top
float nameW = ImGui::CalcTextSize(def->name).x;
ImGui::SetCursorPosX((frameW - nameW) / 2.0f);
ImGui::TextColored(colors::kBrightGold, "%s", def->name);
// Alliance score | separator | Horde score
float innerW = frameW - 12.0f;
float halfW = innerW / 2.0f - 4.0f;
ImGui::SetCursorPosX(6.0f);
ImGui::BeginGroup();
{
// Alliance (blue)
char aBuf[32];
if (maxScore > 0 && strlen(def->unit) > 0)
snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u / %u", allianceScore, maxScore);
else
snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u", allianceScore);
ImGui::TextColored(colors::kLightBlue, "%s", aBuf);
}
ImGui::EndGroup();
ImGui::SameLine(halfW + 16.0f);
ImGui::BeginGroup();
{
// Horde (red)
char hBuf[32];
if (maxScore > 0 && strlen(def->unit) > 0)
snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u / %u", hordeScore, maxScore);
else
snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u", hordeScore);
ImGui::TextColored(colors::kHostileRed, "%s", hBuf);
}
ImGui::EndGroup();
}
ImGui::End();
ImGui::PopStyleVar(2);
}
// ─── Combat Log Window ────────────────────────────────────────────────────────
void CombatUI::renderCombatLog(game::GameHandler& gameHandler,
SpellbookScreen& spellbookScreen) {
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 || t == T::GLANCING || t == T::CRUSHING;
};
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) : colors::kSoftRed;
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) : colors::kBrightRed;
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) : colors::kSoftRed;
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 = kColorGreen;
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 = kColorBrightGreen;
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:
if (spell)
snprintf(desc, sizeof(desc), "%s's %s misses %s", src, spell, tgt);
else
snprintf(desc, sizeof(desc), "%s misses %s", src, tgt);
color = colors::kMediumGray;
break;
case T::DODGE:
if (spell)
snprintf(desc, sizeof(desc), "%s dodges %s's %s", tgt, src, spell);
else
snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src);
color = colors::kMediumGray;
break;
case T::PARRY:
if (spell)
snprintf(desc, sizeof(desc), "%s parries %s's %s", tgt, src, spell);
else
snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src);
color = colors::kMediumGray;
break;
case T::BLOCK:
if (spell)
snprintf(desc, sizeof(desc), "%s blocks %s's %s (%d blocked)", tgt, src, spell, e.amount);
else
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::EVADE:
if (spell)
snprintf(desc, sizeof(desc), "%s evades %s's %s", tgt, src, spell);
else
snprintf(desc, sizeof(desc), "%s evades %s's attack", tgt, src);
color = colors::kMediumGray;
break;
case T::IMMUNE:
if (spell)
snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell);
else
snprintf(desc, sizeof(desc), "%s is immune", tgt);
color = colors::kSilver;
break;
case T::ABSORB:
if (spell && e.amount > 0)
snprintf(desc, sizeof(desc), "%s's %s absorbs %d", src, spell, e.amount);
else if (spell)
snprintf(desc, sizeof(desc), "%s absorbs %s", tgt, spell);
else if (e.amount > 0)
snprintf(desc, sizeof(desc), "%d absorbed", e.amount);
else
snprintf(desc, sizeof(desc), "Absorbed");
color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f);
break;
case T::RESIST:
if (spell && e.amount > 0)
snprintf(desc, sizeof(desc), "%s resists %s's %s (%d resisted)", tgt, src, spell, e.amount);
else if (spell)
snprintf(desc, sizeof(desc), "%s resists %s's %s", tgt, src, spell);
else if (e.amount > 0)
snprintf(desc, sizeof(desc), "%d resisted", e.amount);
else
snprintf(desc, sizeof(desc), "Resisted");
color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f);
break;
case T::DEFLECT:
if (spell)
snprintf(desc, sizeof(desc), "%s deflects %s's %s", tgt, src, spell);
else
snprintf(desc, sizeof(desc), "%s deflects %s's attack", tgt, src);
color = ImVec4(0.65f, 0.8f, 0.95f, 1.0f);
break;
case T::REFLECT:
if (spell)
snprintf(desc, sizeof(desc), "%s reflects %s's %s", tgt, src, spell);
else
snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src);
color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f);
break;
case T::ENVIRONMENTAL: {
const char* envName = "Environmental";
switch (e.powerType) {
case 0: envName = "Fatigue"; break;
case 1: envName = "Drowning"; break;
case 2: envName = "Falling"; break;
case 3: envName = "Lava"; break;
case 4: envName = "Slime"; break;
case 5: envName = "Fire"; break;
}
snprintf(desc, sizeof(desc), "%s damage: %d", envName, e.amount);
color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f);
break;
}
case T::ENERGIZE: {
const char* pwrName = "power";
switch (e.powerType) {
case 0: pwrName = "Mana"; break;
case 1: pwrName = "Rage"; break;
case 2: pwrName = "Focus"; break;
case 3: pwrName = "Energy"; break;
case 4: pwrName = "Happiness"; break;
case 6: pwrName = "Runic Power"; break;
}
if (spell)
snprintf(desc, sizeof(desc), "%s gains %d %s (%s)", tgt, e.amount, pwrName, spell);
else
snprintf(desc, sizeof(desc), "%s gains %d %s", tgt, e.amount, pwrName);
color = colors::kLightBlue;
break;
}
case T::POWER_DRAIN: {
const char* drainName = "power";
switch (e.powerType) {
case 0: drainName = "Mana"; break;
case 1: drainName = "Rage"; break;
case 2: drainName = "Focus"; break;
case 3: drainName = "Energy"; break;
case 4: drainName = "Happiness"; break;
case 6: drainName = "Runic Power"; break;
}
if (spell)
snprintf(desc, sizeof(desc), "%s loses %d %s to %s's %s", tgt, e.amount, drainName, src, spell);
else
snprintf(desc, sizeof(desc), "%s loses %d %s", tgt, e.amount, drainName);
color = ImVec4(0.45f, 0.75f, 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;
case T::DISPEL:
if (spell && e.isPlayerSource)
snprintf(desc, sizeof(desc), "You dispel %s from %s", spell, tgt);
else if (spell)
snprintf(desc, sizeof(desc), "%s dispels %s from %s", src, spell, tgt);
else if (e.isPlayerSource)
snprintf(desc, sizeof(desc), "You dispel from %s", tgt);
else
snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt);
color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f);
break;
case T::STEAL:
if (spell && e.isPlayerSource)
snprintf(desc, sizeof(desc), "You steal %s from %s", spell, tgt);
else if (spell)
snprintf(desc, sizeof(desc), "%s steals %s from %s", src, spell, tgt);
else if (e.isPlayerSource)
snprintf(desc, sizeof(desc), "You steal from %s", tgt);
else
snprintf(desc, sizeof(desc), "%s steals from %s", src, tgt);
color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f);
break;
case T::INTERRUPT:
if (spell && e.isPlayerSource)
snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell);
else if (spell)
snprintf(desc, sizeof(desc), "%s interrupts %s's %s", src, tgt, spell);
else if (e.isPlayerSource)
snprintf(desc, sizeof(desc), "You interrupt %s", tgt);
else
snprintf(desc, sizeof(desc), "%s interrupted", tgt);
color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f);
break;
case T::INSTAKILL:
if (spell && e.isPlayerSource)
snprintf(desc, sizeof(desc), "You instantly kill %s with %s", tgt, spell);
else if (spell)
snprintf(desc, sizeof(desc), "%s instantly kills %s with %s", src, tgt, spell);
else if (e.isPlayerSource)
snprintf(desc, sizeof(desc), "You instantly kill %s", tgt);
else
snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt);
color = colors::kBrightRed;
break;
case T::HONOR_GAIN:
snprintf(desc, sizeof(desc), "You gain %d honor", e.amount);
color = colors::kBrightGold;
break;
case T::GLANCING:
snprintf(desc, sizeof(desc), "%s glances %s for %d", src, tgt, e.amount);
color = e.isPlayerSource ? ImVec4(0.75f, 0.75f, 0.5f, 1.0f)
: ImVec4(0.75f, 0.4f, 0.4f, 1.0f);
break;
case T::CRUSHING:
snprintf(desc, sizeof(desc), "%s crushes %s for %d!", src, tgt, e.amount);
color = e.isPlayerSource ? ImVec4(1.0f, 0.55f, 0.1f, 1.0f)
: ImVec4(1.0f, 0.15f, 0.15f, 1.0f);
break;
default:
snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", static_cast<int>(e.type), e.amount);
color = ui::colors::kLightGray;
break;
}
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextDisabled("%s", timeBuf);
ImGui::TableSetColumnIndex(1);
ImGui::TextColored(color, "%s", desc);
// Hover tooltip: show rich spell info for entries with a known spell
if (e.spellId != 0 && ImGui::IsItemHovered()) {
auto* assetMgrLog = services_.assetManager;
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog);
if (!richOk) {
ImGui::Text("%s", spellName.c_str());
}
ImGui::EndTooltip();
}
}
// Auto-scroll to bottom
if (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY())
ImGui::SetScrollHereY(1.0f);
ImGui::EndTable();
}
ImGui::End();
}
// ─── Threat Window ────────────────────────────────────────────────────────────
void CombatUI::renderThreatWindow(game::GameHandler& gameHandler) {
if (!showThreatWindow_) return;
const auto* list = gameHandler.getTargetThreatList();
ImGui::SetNextWindowSize(ImVec2(280, 220), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(10, 300), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowBgAlpha(0.85f);
if (!ImGui::Begin("Threat###ThreatWin", &showThreatWindow_,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::End();
return;
}
if (!list || list->empty()) {
ImGui::TextDisabled("No threat data for current target.");
ImGui::End();
return;
}
uint32_t maxThreat = list->front().threat;
// 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<float>(e.threat) / static_cast<float>(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<float>(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();
int rank = 0;
for (const auto& entry : *list) {
++rank;
bool isPlayer = (entry.victimGuid == playerGuid);
// Resolve name
std::string victimName;
auto entity = gameHandler.getEntityManager().getEntity(entry.victimGuid);
if (entity) {
if (entity->getType() == game::ObjectType::PLAYER) {
auto p = std::static_pointer_cast<game::Player>(entity);
victimName = p->getName().empty() ? "Player" : p->getName();
} else if (entity->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(entity);
victimName = u->getName().empty() ? "NPC" : u->getName();
}
}
if (victimName.empty())
victimName = "0x" + [&](){
char buf[20]; snprintf(buf, sizeof(buf), "%llX",
static_cast<unsigned long long>(entry.victimGuid)); return std::string(buf); }();
// Colour: gold for #1 (tank), red if player is highest, white otherwise
ImVec4 col = ui::colors::kWhite;
if (rank == 1) col = ui::colors::kTooltipGold; // gold
if (isPlayer && rank == 1) col = kColorRed; // red — you have aggro
// Threat bar
float pct = (maxThreat > 0) ? static_cast<float>(entry.threat) / static_cast<float>(maxThreat) : 0.0f;
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f));
char barLabel[48];
snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f);
ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel);
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat);
if (rank >= 10) break; // cap display at 10 entries
}
ImGui::End();
}
// ─── BG Scoreboard ────────────────────────────────────────────────────────────
void CombatUI::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 = data && data->isArena ? "Arena Score###BgScore"
: "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 or arena.");
ImGui::End();
return;
}
// Arena team rating banner (shown only for arenas)
if (data->isArena) {
for (int t = 0; t < 2; ++t) {
const auto& at = data->arenaTeams[t];
if (at.teamName.empty()) continue;
int32_t ratingDelta = static_cast<int32_t>(at.ratingChange);
ImVec4 teamCol = (t == 0) ? colors::kHostileRed // team 0: red
: colors::kLightBlue; // team 1: blue
ImGui::TextColored(teamCol, "%s", at.teamName.c_str());
ImGui::SameLine();
char ratingBuf[32];
if (ratingDelta >= 0)
std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta);
else
std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta);
ImGui::TextDisabled("%s", ratingBuf);
}
ImGui::Separator();
}
// Winner banner
if (data->hasWinner) {
const char* winnerStr;
ImVec4 winnerColor;
if (data->isArena) {
// For arenas, winner byte 0/1 refers to team index in arenaTeams[]
const auto& winTeam = data->arenaTeams[data->winner & 1];
winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str();
winnerColor = (data->winner == 0) ? colors::kHostileRed
: colors::kLightBlue;
} else {
winnerStr = (data->winner == 1) ? "Alliance" : "Horde";
winnerColor = (data->winner == 1) ? colors::kLightBlue
: colors::kHostileRed;
}
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(colors::kBrightGold, "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<std::string> 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<int>(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<const game::GameHandler::BgPlayerScore*> 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(colors::kLightBlue, "Alliance");
else
ImGui::TextColored(colors::kHostileRed, "Horde");
// Name (highlight player's own row)
ImGui::TableNextColumn();
bool isSelf = (ps->guid == playerGuid);
if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold);
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();
}
} // namespace ui
} // namespace wowee