Kelsidavis-WoWee/src/ui/game_screen.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

7460 lines
361 KiB
C++

#include "ui/game_screen.hpp"
#include "ui/ui_colors.hpp"
#include "rendering/vk_context.hpp"
#include "core/application.hpp"
#include "core/appearance_composer.hpp"
#include "addons/addon_manager.hpp"
#include "core/coordinates.hpp"
#include "core/input.hpp"
#include "rendering/renderer.hpp"
#include "rendering/wmo_renderer.hpp"
#include "rendering/terrain_manager.hpp"
#include "rendering/minimap.hpp"
#include "rendering/world_map.hpp"
#include "rendering/character_renderer.hpp"
#include "rendering/camera.hpp"
#include "rendering/camera_controller.hpp"
#include "audio/audio_coordinator.hpp"
#include "audio/audio_engine.hpp"
#include "audio/music_manager.hpp"
#include "game/zone_manager.hpp"
#include "audio/footstep_manager.hpp"
#include "audio/activity_sound_manager.hpp"
#include "audio/mount_sound_manager.hpp"
#include "audio/npc_voice_manager.hpp"
#include "audio/ambient_sound_manager.hpp"
#include "audio/ui_sound_manager.hpp"
#include "audio/combat_sound_manager.hpp"
#include "audio/spell_sound_manager.hpp"
#include "audio/movement_sound_manager.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_loader.hpp"
#include "pipeline/dbc_layout.hpp"
#include "game/expansion_profile.hpp"
#include "game/character.hpp"
#include "core/logger.hpp"
#include <imgui.h>
#include <imgui_internal.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <sstream>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <cctype>
#include <chrono>
#include <ctime>
#include <unordered_set>
namespace {
// Common ImGui colors (aliases into local namespace for brevity)
using namespace wowee::ui::colors;
constexpr auto& kColorRed = kRed;
constexpr auto& kColorGreen = kGreen;
constexpr auto& kColorBrightGreen= kBrightGreen;
constexpr auto& kColorYellow = kYellow;
constexpr auto& kColorGray = kGray;
constexpr auto& kColorDarkGray = kDarkGray;
// Aura dispel-type names (indexed by dispelType 0-4)
constexpr const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" };
// Abbreviated month names (indexed 0-11)
constexpr const char* kMonthAbbrev[12] = {
"Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"
};
// Raid mark names with symbol prefixes (indexed 0-7: Star..Skull)
constexpr const char* kRaidMarkNames[] = {
"{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle",
"{)} Moon", "{ } Square", "{x} Cross", "{8} Skull"
};
// Common ImGui window flags for popup dialogs
const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize;
// Build a WoW-format item link string for chat insertion.
// Format: |cff<qualHex>|Hitem:<itemId>:0:0:0:0:0:0:0:0|h[<name>]|h|r
std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) {
static constexpr const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"};
uint8_t qi = quality < 8 ? quality : 1;
char buf[512];
snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r",
kQualHex[qi], itemId, name.c_str());
return buf;
}
std::string trim(const std::string& s) {
size_t first = s.find_first_not_of(" \t\r\n");
if (first == std::string::npos) return "";
size_t last = s.find_last_not_of(" \t\r\n");
return s.substr(first, last - first + 1);
}
// 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);
}
std::string toLower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
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).
// Aliases for shared class color helpers (wowee::ui namespace)
inline ImVec4 classColorVec4(uint8_t classId) { return wowee::ui::getClassColor(classId); }
inline ImU32 classColorU32(uint8_t classId, int alpha = 255) { return wowee::ui::getClassColorU32(classId, 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<uint8_t>((bytes0 >> 8) & 0xFF);
}
// Alias for shared class name helper
const char* classNameStr(uint8_t classId) {
return wowee::game::getClassName(static_cast<wowee::game::Class>(classId));
}
bool isPortBotTarget(const std::string& target) {
std::string t = toLower(trim(target));
return t == "portbot" || t == "gmbot" || t == "telebot";
}
std::string buildPortBotCommand(const std::string& rawInput) {
std::string input = trim(rawInput);
if (input.empty()) return "";
std::string lower = toLower(input);
if (lower == "help" || lower == "?") {
return "__help__";
}
if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) {
return input;
}
if (lower.rfind("xyz ", 0) == 0) {
return ".go " + input;
}
if (lower == "sw" || lower == "stormwind") return ".tele stormwind";
if (lower == "if" || lower == "ironforge") return ".tele ironforge";
if (lower == "darn" || lower == "darnassus") return ".tele darnassus";
if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar";
if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff";
if (lower == "uc" || lower == "undercity") return ".tele undercity";
if (lower == "shatt" || lower == "shattrath") return ".tele shattrath";
if (lower == "dal" || lower == "dalaran") return ".tele dalaran";
return ".tele " + input;
}
bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) {
glm::vec3 oc = ray.origin - center;
float b = glm::dot(oc, ray.direction);
float c = glm::dot(oc, oc) - radius * radius;
float discriminant = b * b - c;
if (discriminant < 0.0f) return false;
float t = -b - std::sqrt(discriminant);
if (t < 0.0f) t = -b + std::sqrt(discriminant);
if (t < 0.0f) return false;
tOut = t;
return true;
}
std::string getEntityName(const std::shared_ptr<wowee::game::Entity>& entity) {
if (entity->getType() == wowee::game::ObjectType::PLAYER) {
auto player = std::static_pointer_cast<wowee::game::Player>(entity);
if (!player->getName().empty()) return player->getName();
} else if (entity->getType() == wowee::game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<wowee::game::Unit>(entity);
if (!unit->getName().empty()) return unit->getName();
} else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<wowee::game::GameObject>(entity);
if (!go->getName().empty()) return go->getName();
}
return "Unknown";
}
// Collect all non-comment, non-empty lines from a macro body.
std::vector<std::string> allMacroCommands(const std::string& macroText) {
std::vector<std::string> cmds;
size_t pos = 0;
while (pos <= macroText.size()) {
size_t nl = macroText.find('\n', pos);
std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos);
if (!line.empty() && line.back() == '\r') line.pop_back();
size_t start = line.find_first_not_of(" \t");
if (start != std::string::npos) line = line.substr(start);
if (!line.empty() && line.front() != '#')
cmds.push_back(std::move(line));
if (nl == std::string::npos) break;
pos = nl + 1;
}
return cmds;
}
// Returns the #showtooltip argument from a macro body.
std::string getMacroShowtooltipArg(const std::string& macroText) {
size_t pos = 0;
while (pos <= macroText.size()) {
size_t nl = macroText.find('\n', pos);
std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos);
if (!line.empty() && line.back() == '\r') line.pop_back();
size_t fs = line.find_first_not_of(" \t");
if (fs != std::string::npos) line = line.substr(fs);
if (line.rfind("#showtooltip", 0) == 0 || line.rfind("#show", 0) == 0) {
size_t sp = line.find(' ');
if (sp != std::string::npos) {
std::string arg = line.substr(sp + 1);
size_t as = arg.find_first_not_of(" \t");
if (as != std::string::npos) arg = arg.substr(as);
size_t ae = arg.find_last_not_of(" \t");
if (ae != std::string::npos) arg.resize(ae + 1);
if (!arg.empty()) return arg;
}
return "__auto__";
}
if (nl == std::string::npos) break;
pos = nl + 1;
}
return {};
}
}
namespace wowee { namespace ui {
GameScreen::GameScreen() {
loadSettings();
}
// Section 3.5: Set UI services and propagate to child components
void GameScreen::setServices(const UIServices& services) {
services_ = services;
// Update legacy pointer for Phase A compatibility
appearanceComposer_ = services.appearanceComposer;
// Propagate to child panels
chatPanel_.setServices(services);
toastManager_.setServices(services);
dialogManager_.setServices(services);
settingsPanel_.setServices(services);
combatUI_.setServices(services);
socialPanel_.setServices(services);
actionBarPanel_.setServices(services);
windowManager_.setServices(services);
}
void GameScreen::render(game::GameHandler& gameHandler) {
// Set up chat bubble callback (once) and cache game handler in ChatPanel
chatPanel_.setupCallbacks(gameHandler);
toastManager_.setupCallbacks(gameHandler);
// Set up appearance-changed callback to refresh inventory preview (barber shop, etc.)
if (!appearanceCallbackSet_) {
gameHandler.setAppearanceChangedCallback([this]() {
inventoryScreenCharGuid_ = 0; // force preview re-sync on next frame
});
appearanceCallbackSet_ = true;
}
// Set up UI error frame callback (once)
if (!uiErrorCallbackSet_) {
gameHandler.setUIErrorCallback([this](const std::string& msg) {
uiErrors_.push_back({msg, 0.0f});
if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin());
// Play error sound for each new error (rate-limited by deque cap of 5)
if (auto* ac = services_.audioCoordinator) {
if (auto* sfx = ac->getUiSoundManager()) sfx->playError();
}
});
uiErrorCallbackSet_ = true;
}
// Flash the action bar button whose spell just failed (0.5 s red overlay).
if (!castFailedCallbackSet_) {
gameHandler.setSpellCastFailedCallback([this](uint32_t spellId) {
if (spellId == 0) return;
float now = static_cast<float>(ImGui::GetTime());
actionBarPanel_.actionFlashEndTimes_[spellId] = now + actionBarPanel_.kActionFlashDuration;
});
castFailedCallbackSet_ = true;
}
// Apply UI transparency setting
float prevAlpha = ImGui::GetStyle().Alpha;
ImGui::GetStyle().Alpha = settingsPanel_.uiOpacity_;
// Sync minimap opacity with UI opacity
{
auto* renderer = services_.renderer;
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->setOpacity(settingsPanel_.uiOpacity_);
}
}
}
// Apply initial settings when renderer becomes available
if (!settingsPanel_.minimapSettingsApplied_) {
auto* renderer = services_.renderer;
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
settingsPanel_.minimapRotate_ = false;
settingsPanel_.pendingMinimapRotate = false;
minimap->setRotateWithCamera(false);
minimap->setSquareShape(settingsPanel_.minimapSquare_);
settingsPanel_.minimapSettingsApplied_ = true;
}
if (auto* zm = renderer->getZoneManager()) {
zm->setUseOriginalSoundtrack(settingsPanel_.pendingUseOriginalSoundtrack);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(static_cast<float>(settingsPanel_.pendingGroundClutterDensity) / 100.0f);
}
// Restore mute state: save actual master volume first, then apply mute
if (settingsPanel_.soundMuted_) {
float actual = audio::AudioEngine::instance().getMasterVolume();
settingsPanel_.preMuteVolume_ = (actual > 0.0f) ? actual
: static_cast<float>(settingsPanel_.pendingMasterVolume) / 100.0f;
audio::AudioEngine::instance().setMasterVolume(0.0f);
}
}
}
// Apply saved volume settings once when audio managers first become available
if (!settingsPanel_.volumeSettingsApplied_) {
auto* ac = services_.audioCoordinator;
if (ac && ac->getUiSoundManager()) {
settingsPanel_.applyAudioVolumes(ac);
settingsPanel_.volumeSettingsApplied_ = true;
}
}
// Apply saved MSAA setting once when renderer is available
if (!settingsPanel_.msaaSettingsApplied_ && settingsPanel_.pendingAntiAliasing > 0) {
auto* renderer = services_.renderer;
if (renderer) {
static const VkSampleCountFlagBits aaSamples[] = {
VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT,
VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT
};
renderer->setMsaaSamples(aaSamples[settingsPanel_.pendingAntiAliasing]);
settingsPanel_.msaaSettingsApplied_ = true;
}
} else {
settingsPanel_.msaaSettingsApplied_ = true;
}
// Apply saved FXAA setting once when renderer is available
if (!settingsPanel_.fxaaSettingsApplied_) {
auto* renderer = services_.renderer;
if (renderer) {
renderer->setFXAAEnabled(settingsPanel_.pendingFXAA);
settingsPanel_.fxaaSettingsApplied_ = true;
}
}
// Apply saved water refraction setting once when renderer is available
if (!settingsPanel_.waterRefractionApplied_) {
auto* renderer = services_.renderer;
if (renderer) {
renderer->setWaterRefractionEnabled(settingsPanel_.pendingWaterRefraction);
settingsPanel_.waterRefractionApplied_ = true;
}
}
// Apply saved normal mapping / POM settings once when WMO renderer is available
if (!settingsPanel_.normalMapSettingsApplied_) {
auto* renderer = services_.renderer;
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(settingsPanel_.pendingNormalMapping);
wr->setNormalMapStrength(settingsPanel_.pendingNormalMapStrength);
wr->setPOMEnabled(settingsPanel_.pendingPOM);
wr->setPOMQuality(settingsPanel_.pendingPOMQuality);
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(settingsPanel_.pendingNormalMapping);
cr->setNormalMapStrength(settingsPanel_.pendingNormalMapStrength);
cr->setPOMEnabled(settingsPanel_.pendingPOM);
cr->setPOMQuality(settingsPanel_.pendingPOMQuality);
}
settingsPanel_.normalMapSettingsApplied_ = true;
}
}
}
// Apply saved upscaling setting once when renderer is available
if (!settingsPanel_.fsrSettingsApplied_) {
auto* renderer = services_.renderer;
if (renderer) {
static constexpr float fsrScales[] = { 0.77f, 0.67f, 0.59f, 1.00f };
settingsPanel_.pendingFSRQuality = std::clamp(settingsPanel_.pendingFSRQuality, 0, 3);
renderer->setFSRQuality(fsrScales[settingsPanel_.pendingFSRQuality]);
renderer->setFSRSharpness(settingsPanel_.pendingFSRSharpness);
renderer->setFSR2DebugTuning(settingsPanel_.pendingFSR2JitterSign, settingsPanel_.pendingFSR2MotionVecScaleX, settingsPanel_.pendingFSR2MotionVecScaleY);
renderer->setAmdFsr3FramegenEnabled(settingsPanel_.pendingAMDFramegen);
int effectiveMode = settingsPanel_.pendingUpscalingMode;
// Defer FSR2/FSR3 activation until fully in-world to avoid
// init issues during login/character selection screens.
if (effectiveMode == 2 && gameHandler.getState() != game::WorldState::IN_WORLD) {
renderer->setFSREnabled(false);
renderer->setFSR2Enabled(false);
} else {
renderer->setFSREnabled(effectiveMode == 1);
renderer->setFSR2Enabled(effectiveMode == 2);
settingsPanel_.fsrSettingsApplied_ = true;
}
}
}
// Apply auto-loot / auto-sell settings to GameHandler every frame (cheap bool sync)
gameHandler.setAutoLoot(settingsPanel_.pendingAutoLoot);
gameHandler.setAutoSellGrey(settingsPanel_.pendingAutoSellGrey);
gameHandler.setAutoRepair(settingsPanel_.pendingAutoRepair);
// Sync chat auto-join settings to GameHandler
gameHandler.chatAutoJoin.general = chatPanel_.chatAutoJoinGeneral;
gameHandler.chatAutoJoin.trade = chatPanel_.chatAutoJoinTrade;
gameHandler.chatAutoJoin.localDefense = chatPanel_.chatAutoJoinLocalDefense;
gameHandler.chatAutoJoin.lfg = chatPanel_.chatAutoJoinLFG;
gameHandler.chatAutoJoin.local = chatPanel_.chatAutoJoinLocal;
// Process targeting input before UI windows
processTargetInput(gameHandler);
// Player unit frame (top-left)
renderPlayerFrame(gameHandler);
// Pet frame (below player frame, only when player has an active pet)
if (gameHandler.hasPet()) {
renderPetFrame(gameHandler);
}
// Auto-open pet rename modal when server signals the pet is renameable (first tame)
if (gameHandler.consumePetRenameablePending()) {
petRenameOpen_ = true;
petRenameBuf_[0] = '\0';
}
// 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);
}
// Focus target frame (only when we have a focus)
if (gameHandler.hasFocus()) {
renderFocusFrame(gameHandler);
}
// Render windows
if (showPlayerInfo) {
renderPlayerInfo(gameHandler);
}
if (showEntityWindow) {
renderEntityList(gameHandler);
}
if (showChatWindow) {
chatPanel_.getSpellIcon = [this](uint32_t id, pipeline::AssetManager* am) {
return getSpellIcon(id, am);
};
chatPanel_.render(gameHandler, inventoryScreen, spellbookScreen, questLogScreen);
// Process slash commands that affect GameScreen state
auto cmds = chatPanel_.consumeSlashCommands();
if (cmds.showInspect) socialPanel_.showInspectWindow_ = true;
if (cmds.toggleThreat) combatUI_.showThreatWindow_ = !combatUI_.showThreatWindow_;
if (cmds.showBgScore) combatUI_.showBgScoreboard_ = !combatUI_.showBgScoreboard_;
if (cmds.showGmTicket) windowManager_.showGmTicketWindow_ = true;
if (cmds.showWho) socialPanel_.showWhoWindow_ = true;
if (cmds.toggleCombatLog) combatUI_.showCombatLog_ = !combatUI_.showCombatLog_;
if (cmds.takeScreenshot) takeScreenshot(gameHandler);
}
// ---- New UI elements ----
actionBarPanel_.renderActionBar(gameHandler, settingsPanel_, chatPanel_,
inventoryScreen, spellbookScreen, questLogScreen,
[this](uint32_t id, pipeline::AssetManager* am) { return getSpellIcon(id, am); });
actionBarPanel_.renderStanceBar(gameHandler, settingsPanel_, spellbookScreen,
[this](uint32_t id, pipeline::AssetManager* am) { return getSpellIcon(id, am); });
actionBarPanel_.renderBagBar(gameHandler, settingsPanel_, inventoryScreen);
actionBarPanel_.renderXpBar(gameHandler, settingsPanel_);
actionBarPanel_.renderRepBar(gameHandler, settingsPanel_);
auto spellIconFn = [this](uint32_t id, pipeline::AssetManager* am) { return getSpellIcon(id, am); };
combatUI_.renderCastBar(gameHandler, spellIconFn);
renderMirrorTimers(gameHandler);
combatUI_.renderCooldownTracker(gameHandler, settingsPanel_, spellIconFn);
renderQuestObjectiveTracker(gameHandler);
renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_
combatUI_.renderBattlegroundScore(gameHandler);
combatUI_.renderRaidWarningOverlay(gameHandler);
combatUI_.renderCombatText(gameHandler);
combatUI_.renderDPSMeter(gameHandler, settingsPanel_);
renderDurabilityWarning(gameHandler);
renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime);
toastManager_.renderEarlyToasts(ImGui::GetIO().DeltaTime, gameHandler);
if (socialPanel_.showRaidFrames_) {
socialPanel_.renderPartyFrames(gameHandler, chatPanel_, spellIconFn);
}
socialPanel_.renderBossFrames(gameHandler, spellbookScreen, spellIconFn);
dialogManager_.renderDialogs(gameHandler, inventoryScreen, chatPanel_);
socialPanel_.renderGuildRoster(gameHandler, chatPanel_);
socialPanel_.renderSocialFrame(gameHandler, chatPanel_);
combatUI_.renderBuffBar(gameHandler, spellbookScreen, spellIconFn);
windowManager_.renderLootWindow(gameHandler, inventoryScreen, chatPanel_);
windowManager_.renderGossipWindow(gameHandler, chatPanel_);
windowManager_.renderQuestDetailsWindow(gameHandler, chatPanel_, inventoryScreen);
windowManager_.renderQuestRequestItemsWindow(gameHandler, chatPanel_, inventoryScreen);
windowManager_.renderQuestOfferRewardWindow(gameHandler, chatPanel_, inventoryScreen);
windowManager_.renderVendorWindow(gameHandler, inventoryScreen, chatPanel_);
windowManager_.renderTrainerWindow(gameHandler,
[this](uint32_t id, pipeline::AssetManager* am) { return getSpellIcon(id, am); });
windowManager_.renderBarberShopWindow(gameHandler);
windowManager_.renderStableWindow(gameHandler);
windowManager_.renderTaxiWindow(gameHandler);
windowManager_.renderMailWindow(gameHandler, inventoryScreen, chatPanel_);
windowManager_.renderMailComposeWindow(gameHandler, inventoryScreen);
windowManager_.renderBankWindow(gameHandler, inventoryScreen, chatPanel_);
windowManager_.renderGuildBankWindow(gameHandler, inventoryScreen, chatPanel_);
windowManager_.renderAuctionHouseWindow(gameHandler, inventoryScreen, chatPanel_);
socialPanel_.renderDungeonFinderWindow(gameHandler, chatPanel_);
windowManager_.renderInstanceLockouts(gameHandler);
socialPanel_.renderWhoWindow(gameHandler, chatPanel_);
combatUI_.renderCombatLog(gameHandler, spellbookScreen);
windowManager_.renderAchievementWindow(gameHandler);
windowManager_.renderSkillsWindow(gameHandler);
windowManager_.renderTitlesWindow(gameHandler);
windowManager_.renderEquipSetWindow(gameHandler);
windowManager_.renderGmTicketWindow(gameHandler);
socialPanel_.renderInspectWindow(gameHandler, inventoryScreen);
windowManager_.renderBookWindow(gameHandler);
combatUI_.renderThreatWindow(gameHandler);
combatUI_.renderBgScoreboard(gameHandler);
if (showMinimap_) {
renderMinimapMarkers(gameHandler);
}
windowManager_.renderLogoutCountdown(gameHandler);
windowManager_.renderDeathScreen(gameHandler);
windowManager_.renderReclaimCorpseButton(gameHandler);
dialogManager_.renderLateDialogs(gameHandler);
chatPanel_.renderBubbles(gameHandler);
windowManager_.renderEscapeMenu(settingsPanel_);
settingsPanel_.renderSettingsWindow(inventoryScreen, chatPanel_, [this]() { saveSettings(); });
toastManager_.renderLateToasts(gameHandler);
renderWeatherOverlay(gameHandler);
// World map (M key toggle handled inside)
renderWorldMap(gameHandler);
// Quest Log (L key toggle handled inside)
questLogScreen.render(gameHandler, inventoryScreen);
// Spellbook (P key toggle handled inside)
spellbookScreen.render(gameHandler, services_.assetManager);
// Insert spell link into chat if player shift-clicked a spellbook entry
{
std::string pendingSpellLink = spellbookScreen.getAndClearPendingChatLink();
if (!pendingSpellLink.empty()) {
chatPanel_.insertChatLink(pendingSpellLink);
}
}
// Talents (N key toggle handled inside)
talentScreen.render(gameHandler);
// Set up inventory screen asset manager + player appearance (re-init on character switch)
{
uint64_t activeGuid = gameHandler.getActiveCharacterGuid();
if (activeGuid != 0 && activeGuid != inventoryScreenCharGuid_) {
auto* am = services_.assetManager;
if (am) {
inventoryScreen.setAssetManager(am);
const auto* ch = gameHandler.getActiveCharacter();
if (ch) {
uint8_t skin = ch->appearanceBytes & 0xFF;
uint8_t face = (ch->appearanceBytes >> 8) & 0xFF;
uint8_t hairStyle = (ch->appearanceBytes >> 16) & 0xFF;
uint8_t hairColor = (ch->appearanceBytes >> 24) & 0xFF;
inventoryScreen.setPlayerAppearance(
ch->race, ch->gender, skin, face,
hairStyle, hairColor, ch->facialFeatures);
inventoryScreenCharGuid_ = activeGuid;
}
}
}
}
// Set vendor mode before rendering inventory
inventoryScreen.setVendorMode(gameHandler.isVendorWindowOpen(), &gameHandler);
// Auto-open bags once when vendor window first opens
if (gameHandler.isVendorWindowOpen()) {
if (!windowManager_.vendorBagsOpened_) {
windowManager_.vendorBagsOpened_ = true;
if (inventoryScreen.isSeparateBags()) {
inventoryScreen.openAllBags();
} else if (!inventoryScreen.isOpen()) {
inventoryScreen.setOpen(true);
}
}
} else {
windowManager_.vendorBagsOpened_ = false;
}
// Bags (B key toggle handled inside)
inventoryScreen.setGameHandler(&gameHandler);
inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper());
// Character screen (C key toggle handled inside render())
inventoryScreen.renderCharacterScreen(gameHandler);
// Insert item link into chat if player shift-clicked any inventory/equipment slot
{
std::string pendingLink = inventoryScreen.getAndClearPendingChatLink();
if (!pendingLink.empty()) {
chatPanel_.insertChatLink(pendingLink);
}
}
if (inventoryScreen.consumeEquipmentDirty() || gameHandler.consumeOnlineEquipmentDirty()) {
updateCharacterGeosets(gameHandler.getInventory());
updateCharacterTextures(gameHandler.getInventory());
if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
inventoryScreen.markPreviewDirty();
// Update renderer weapon type for animation selection
auto* r = services_.renderer;
if (r) {
const auto& mh = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND);
r->setEquippedWeaponType(mh.empty() ? 0 : mh.item.inventoryType);
}
}
// Update renderer face-target position and selection circle
auto* renderer = services_.renderer;
if (renderer) {
renderer->setInCombat(gameHandler.isInCombat() &&
!gameHandler.isPlayerDead() &&
!gameHandler.isPlayerGhost());
if (auto* cr = renderer->getCharacterRenderer()) {
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId != 0) {
const bool isGhost = gameHandler.isPlayerGhost();
if (!ghostOpacityStateKnown_ ||
ghostOpacityLastState_ != isGhost ||
ghostOpacityLastInstanceId_ != charInstId) {
cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f);
ghostOpacityStateKnown_ = true;
ghostOpacityLastState_ = isGhost;
ghostOpacityLastInstanceId_ = charInstId;
}
}
}
static glm::vec3 targetGLPos;
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target) {
// Prefer the renderer's actual instance position so the selection
// circle tracks the rendered model (not a parallel entity-space
// interpolator that can drift from the visual position).
glm::vec3 instPos;
if (core::Application::getInstance().getRenderPositionForGuid(target->getGuid(), instPos)) {
targetGLPos = instPos;
// Override Z with foot position to sit the circle on the ground.
float footZ = 0.0f;
if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) {
targetGLPos.z = footZ;
}
} else {
// Fallback: entity game-logic position (no CharacterRenderer instance yet)
targetGLPos = core::coords::canonicalToRender(
glm::vec3(target->getX(), target->getY(), target->getZ()));
float footZ = 0.0f;
if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) {
targetGLPos.z = footZ;
}
}
renderer->setTargetPosition(&targetGLPos);
// Selection circle color: WoW-canonical level-based colors
bool showSelectionCircle = false;
glm::vec3 circleColor(1.0f, 1.0f, 0.3f); // default yellow
float circleRadius = 1.5f;
{
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
if (core::Application::getInstance().getRenderBoundsForGuid(target->getGuid(), boundsCenter, boundsRadius)) {
float r = boundsRadius * 1.1f;
circleRadius = std::min(std::max(r, 0.8f), 8.0f);
}
}
if (target->getType() == game::ObjectType::UNIT) {
showSelectionCircle = true;
auto unit = std::static_pointer_cast<game::Unit>(target);
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
circleColor = glm::vec3(0.5f, 0.5f, 0.5f); // gray (dead)
} else if (unit->isHostile() || gameHandler.isAggressiveTowardPlayer(target->getGuid())) {
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = unit->getLevel();
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
if (game::GameHandler::killXp(playerLv, mobLv) == 0) {
circleColor = glm::vec3(0.6f, 0.6f, 0.6f); // grey
} else if (diff >= 10) {
circleColor = glm::vec3(1.0f, 0.1f, 0.1f); // red
} else if (diff >= 5) {
circleColor = glm::vec3(1.0f, 0.5f, 0.1f); // orange
} else if (diff >= -2) {
circleColor = glm::vec3(1.0f, 1.0f, 0.1f); // yellow
} else {
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green
}
} else {
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (friendly)
}
} else if (target->getType() == game::ObjectType::PLAYER) {
showSelectionCircle = true;
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (player)
}
if (showSelectionCircle) {
renderer->setSelectionCircle(targetGLPos, circleRadius, circleColor);
} else {
renderer->clearSelectionCircle();
}
} else {
renderer->setTargetPosition(nullptr);
renderer->clearSelectionCircle();
}
} else {
renderer->setTargetPosition(nullptr);
renderer->clearSelectionCircle();
}
}
// Screen edge damage flash — red vignette that fires on HP decrease
{
auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
uint32_t currentHp = 0;
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER ||
playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
if (unit->getMaxHealth() > 0)
currentHp = unit->getHealth();
}
// Detect HP drop (ignore transitions from 0 — entity just spawned or uninitialized)
if (settingsPanel_.damageFlashEnabled_ && lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0)
damageFlashAlpha_ = 1.0f;
lastPlayerHp_ = currentHp;
// Fade out over ~0.5 seconds
if (damageFlashAlpha_ > 0.0f) {
damageFlashAlpha_ -= ImGui::GetIO().DeltaTime * 2.0f;
if (damageFlashAlpha_ < 0.0f) damageFlashAlpha_ = 0.0f;
// Draw four red gradient rectangles along each screen edge (vignette style)
ImDrawList* fg = ImGui::GetForegroundDrawList();
ImGuiIO& io = ImGui::GetIO();
const float W = io.DisplaySize.x;
const float H = io.DisplaySize.y;
const int alpha = static_cast<int>(damageFlashAlpha_ * 100.0f);
const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha);
const ImU32 fadeCol = IM_COL32(200, 0, 0, 0);
const float thickness = std::min(W, H) * 0.12f;
// Top
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness),
edgeCol, edgeCol, fadeCol, fadeCol);
// Bottom
fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H),
fadeCol, fadeCol, edgeCol, edgeCol);
// Left
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H),
edgeCol, fadeCol, fadeCol, edgeCol);
// Right
fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H),
fadeCol, edgeCol, edgeCol, fadeCol);
}
}
// 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<game::Unit>(playerEntity);
if (unit->getMaxHealth() > 0)
hpPct = static_cast<float>(unit->getHealth()) / static_cast<float>(unit->getMaxHealth());
}
// Only show when alive and below 20% HP; intensity increases as HP drops
if (settingsPanel_.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<float>(ImGui::GetTime()) * 9.4f);
int alpha = static_cast<int>(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 (toastManager_.levelUpFlashAlpha > 0.0f) {
toastManager_.levelUpFlashAlpha -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second
if (toastManager_.levelUpFlashAlpha < 0.0f) toastManager_.levelUpFlashAlpha = 0.0f;
ImDrawList* fg = ImGui::GetForegroundDrawList();
ImGuiIO& io = ImGui::GetIO();
const float W = io.DisplaySize.x;
const float H = io.DisplaySize.y;
const int alpha = static_cast<int>(toastManager_.levelUpFlashAlpha * 160.0f);
const ImU32 goldEdge = IM_COL32(255, 210, 50, alpha);
const ImU32 goldFade = IM_COL32(255, 210, 50, 0);
const float thickness = std::min(W, H) * 0.18f;
// Four golden gradient edges
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness),
goldEdge, goldEdge, goldFade, goldFade);
fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H),
goldFade, goldFade, goldEdge, goldEdge);
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H),
goldEdge, goldFade, goldFade, goldEdge);
fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H),
goldFade, goldEdge, goldEdge, goldFade);
// "Level X!" text in the center during the first half of the animation
if (toastManager_.levelUpFlashAlpha > 0.5f && toastManager_.levelUpDisplayLevel > 0) {
char lvlText[32];
snprintf(lvlText, sizeof(lvlText), "Level %u!", toastManager_.levelUpDisplayLevel);
ImVec2 ts = ImGui::CalcTextSize(lvlText);
float tx = (W - ts.x) * 0.5f;
float ty = H * 0.35f;
// Large shadow + bright gold text
fg->AddText(nullptr, 28.0f, ImVec2(tx + 2, ty + 2), IM_COL32(0, 0, 0, alpha), lvlText);
fg->AddText(nullptr, 28.0f, ImVec2(tx, ty), IM_COL32(255, 230, 80, alpha), lvlText);
}
}
// Restore previous alpha
ImGui::GetStyle().Alpha = prevAlpha;
}
void GameScreen::renderPlayerInfo(game::GameHandler& gameHandler) {
ImGui::SetNextWindowSize(ImVec2(350, 250), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(10, 30), ImGuiCond_FirstUseEver);
ImGui::Begin("Player Info", &showPlayerInfo);
const auto& movement = gameHandler.getMovementInfo();
ImGui::Text("Position & Movement");
ImGui::Separator();
ImGui::Spacing();
// Position
ImGui::Text("Position:");
ImGui::Indent();
ImGui::Text("X: %.2f", movement.x);
ImGui::Text("Y: %.2f", movement.y);
ImGui::Text("Z: %.2f", movement.z);
ImGui::Text("Orientation: %.2f rad (%.1f deg)", movement.orientation, movement.orientation * 180.0f / 3.14159f);
ImGui::Unindent();
ImGui::Spacing();
// Movement flags
ImGui::Text("Movement Flags: 0x%08X", movement.flags);
ImGui::Text("Time: %u ms", movement.time);
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Connection state
ImGui::Text("Connection State:");
ImGui::Indent();
auto state = gameHandler.getState();
switch (state) {
case game::WorldState::IN_WORLD:
ImGui::TextColored(kColorBrightGreen, "In World");
break;
case game::WorldState::AUTHENTICATED:
ImGui::TextColored(kColorYellow, "Authenticated");
break;
case game::WorldState::ENTERING_WORLD:
ImGui::TextColored(kColorYellow, "Entering World...");
break;
default:
ImGui::TextColored(kColorRed, "State: %d", static_cast<int>(state));
break;
}
ImGui::Unindent();
ImGui::End();
}
void GameScreen::renderEntityList(game::GameHandler& gameHandler) {
ImGui::SetNextWindowSize(ImVec2(500, 400), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(10, 290), ImGuiCond_FirstUseEver);
ImGui::Begin("Entities", &showEntityWindow);
const auto& entityManager = gameHandler.getEntityManager();
const auto& entities = entityManager.getEntities();
ImGui::Text("Entities in View: %zu", entities.size());
ImGui::Separator();
ImGui::Spacing();
if (entities.empty()) {
ImGui::TextDisabled("No entities in view");
} else {
// Entity table
if (ImGui::BeginTable("EntitiesTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("GUID", ImGuiTableColumnFlags_WidthFixed, 140.0f);
ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 100.0f);
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Position", ImGuiTableColumnFlags_WidthFixed, 150.0f);
ImGui::TableSetupColumn("Distance", ImGuiTableColumnFlags_WidthFixed, 80.0f);
ImGui::TableHeadersRow();
const auto& playerMovement = gameHandler.getMovementInfo();
float playerX = playerMovement.x;
float playerY = playerMovement.y;
float playerZ = playerMovement.z;
for (const auto& [guid, entity] : entities) {
ImGui::TableNextRow();
// GUID
ImGui::TableSetColumnIndex(0);
char guidStr[24];
snprintf(guidStr, sizeof(guidStr), "0x%016llX", (unsigned long long)guid);
ImGui::Text("%s", guidStr);
// Type
ImGui::TableSetColumnIndex(1);
switch (entity->getType()) {
case game::ObjectType::PLAYER:
ImGui::TextColored(kColorBrightGreen, "Player");
break;
case game::ObjectType::UNIT:
ImGui::TextColored(kColorYellow, "Unit");
break;
case game::ObjectType::GAMEOBJECT:
ImGui::TextColored(ImVec4(0.3f, 0.8f, 1.0f, 1.0f), "GameObject");
break;
default:
ImGui::Text("Object");
break;
}
// Name (for players and units)
ImGui::TableSetColumnIndex(2);
if (entity->getType() == game::ObjectType::PLAYER) {
auto player = std::static_pointer_cast<game::Player>(entity);
ImGui::Text("%s", player->getName().c_str());
} else if (entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (!unit->getName().empty()) {
ImGui::Text("%s", unit->getName().c_str());
} else {
ImGui::TextDisabled("--");
}
} else {
ImGui::TextDisabled("--");
}
// Position
ImGui::TableSetColumnIndex(3);
ImGui::Text("%.1f, %.1f, %.1f", entity->getX(), entity->getY(), entity->getZ());
// Distance from player
ImGui::TableSetColumnIndex(4);
float dx = entity->getX() - playerX;
float dy = entity->getY() - playerY;
float dz = entity->getZ() - playerZ;
float distance = std::sqrt(dx*dx + dy*dy + dz*dz);
ImGui::Text("%.1f", distance);
}
ImGui::EndTable();
}
}
ImGui::End();
}
void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
auto& io = ImGui::GetIO();
auto& input = core::Input::getInstance();
// If the user is typing (or about to focus chat this frame), do not allow
// A-Z or 1-0 shortcuts to fire.
if (!io.WantTextInput && !chatPanel_.isChatInputActive() && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) {
chatPanel_.activateSlashInput();
}
if (!io.WantTextInput && !chatPanel_.isChatInputActive() &&
KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) {
chatPanel_.activateInput();
}
const bool textFocus = chatPanel_.isChatInputActive() || io.WantTextInput;
// Tab targeting (when keyboard not captured by UI)
if (!io.WantCaptureKeyboard) {
// When typing in chat (or any text input), never treat keys as gameplay/UI shortcuts.
if (!textFocus && input.isKeyJustPressed(SDL_SCANCODE_TAB)) {
const auto& movement = gameHandler.getMovementInfo();
gameHandler.tabTarget(movement.x, movement.y, movement.z);
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SETTINGS, true)) {
if (settingsPanel_.showSettingsWindow) {
settingsPanel_.showSettingsWindow = false;
} else if (windowManager_.showEscapeMenu) {
windowManager_.showEscapeMenu = false;
settingsPanel_.showEscapeSettingsNotice = false;
} else if (gameHandler.isCasting()) {
gameHandler.cancelCast();
} else if (gameHandler.isLootWindowOpen()) {
gameHandler.closeLoot();
} else if (gameHandler.isGossipWindowOpen()) {
gameHandler.closeGossip();
} else if (gameHandler.isVendorWindowOpen()) {
gameHandler.closeVendor();
} else if (gameHandler.isBarberShopOpen()) {
gameHandler.closeBarberShop();
} else if (gameHandler.isBankOpen()) {
gameHandler.closeBank();
} else if (gameHandler.isTrainerWindowOpen()) {
gameHandler.closeTrainer();
} else if (gameHandler.isMailboxOpen()) {
gameHandler.closeMailbox();
} else if (gameHandler.isAuctionHouseOpen()) {
gameHandler.closeAuctionHouse();
} else if (gameHandler.isQuestDetailsOpen()) {
gameHandler.declineQuest();
} else if (gameHandler.isQuestOfferRewardOpen()) {
gameHandler.closeQuestOfferReward();
} else if (gameHandler.isQuestRequestItemsOpen()) {
gameHandler.closeQuestRequestItems();
} else if (gameHandler.isTradeOpen()) {
gameHandler.cancelTrade();
} else if (socialPanel_.showWhoWindow_) {
socialPanel_.showWhoWindow_ = false;
} else if (combatUI_.showCombatLog_) {
combatUI_.showCombatLog_ = false;
} else if (socialPanel_.showSocialFrame_) {
socialPanel_.showSocialFrame_ = false;
} else if (talentScreen.isOpen()) {
talentScreen.setOpen(false);
} else if (spellbookScreen.isOpen()) {
spellbookScreen.setOpen(false);
} else if (questLogScreen.isOpen()) {
questLogScreen.setOpen(false);
} else if (inventoryScreen.isCharacterOpen()) {
inventoryScreen.toggleCharacter();
} else if (inventoryScreen.isOpen()) {
inventoryScreen.setOpen(false);
} else if (showWorldMap_) {
showWorldMap_ = false;
} else {
windowManager_.showEscapeMenu = true;
}
}
if (!textFocus) {
// Toggle character screen (C) and inventory/bags (I)
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) {
const bool wasOpen = inventoryScreen.isCharacterOpen();
inventoryScreen.toggleCharacter();
if (!wasOpen && gameHandler.isConnected()) {
gameHandler.requestPlayedTime();
}
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) {
inventoryScreen.toggle();
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) {
if (ImGui::GetIO().KeyShift)
settingsPanel_.showFriendlyNameplates_ = !settingsPanel_.showFriendlyNameplates_;
else
showNameplates_ = !showNameplates_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) {
showWorldMap_ = !showWorldMap_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) {
showMinimap_ = !showMinimap_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) {
socialPanel_.showRaidFrames_ = !socialPanel_.showRaidFrames_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) {
windowManager_.showAchievementWindow_ = !windowManager_.showAchievementWindow_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SKILLS)) {
windowManager_.showSkillsWindow_ = !windowManager_.showSkillsWindow_;
}
// Toggle Titles window with H (hero/title screen — no conflicting keybinding)
if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) {
windowManager_.showTitlesWindow_ = !windowManager_.showTitlesWindow_;
}
// Screenshot (PrintScreen key)
if (input.isKeyJustPressed(SDL_SCANCODE_PRINTSCREEN)) {
takeScreenshot(gameHandler);
}
// Action bar keys (1-9, 0, -, =)
static const SDL_Scancode actionBarKeys[] = {
SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4,
SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8,
SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS
};
const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT);
const bool ctrlDown = input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL);
const auto& bar = gameHandler.getActionBar();
// Ctrl+1..Ctrl+8 → switch stance/form/presence (WoW default bindings).
// Only fires for classes that use a stance bar; same slot ordering as
// renderStanceBar: Warrior, DK, Druid, Rogue, Priest.
if (ctrlDown) {
static const uint32_t warriorStances[] = { 2457, 71, 2458 };
static const uint32_t dkPresences[] = { 48266, 48263, 48265 };
static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 };
static const uint32_t rogueForms[] = { 1784 };
static const uint32_t priestForms[] = { 15473 };
const uint32_t* stArr = nullptr; int stCnt = 0;
switch (gameHandler.getPlayerClass()) {
case 1: stArr = warriorStances; stCnt = 3; break;
case 6: stArr = dkPresences; stCnt = 3; break;
case 11: stArr = druidForms; stCnt = 9; break;
case 4: stArr = rogueForms; stCnt = 1; break;
case 5: stArr = priestForms; stCnt = 1; break;
}
if (stArr) {
const auto& known = gameHandler.getKnownSpells();
// Build available list (same order as UI)
std::vector<uint32_t> avail;
avail.reserve(stCnt);
for (int i = 0; i < stCnt; ++i)
if (known.count(stArr[i])) avail.push_back(stArr[i]);
// Ctrl+1 = first stance, Ctrl+2 = second, …
for (int i = 0; i < static_cast<int>(avail.size()) && i < 8; ++i) {
if (input.isKeyJustPressed(actionBarKeys[i]))
gameHandler.castSpell(avail[i]);
}
}
}
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) {
if (!ctrlDown && input.isKeyJustPressed(actionBarKeys[i])) {
int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i;
if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) {
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(bar[slotIdx].id, target);
} else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) {
gameHandler.useItemById(bar[slotIdx].id);
} else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) {
chatPanel_.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(bar[slotIdx].id));
}
}
}
}
}
// Cursor affordance: show hand cursor over interactable entities.
if (!io.WantCaptureMouse) {
auto* renderer = services_.renderer;
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = services_.window;
if (camera && window) {
glm::vec2 mousePos = input.getMousePosition();
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH);
float closestT = 1e30f;
bool hoverInteractable = false;
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
bool isGo = (entity->getType() == game::ObjectType::GAMEOBJECT);
bool isUnit = (entity->getType() == game::ObjectType::UNIT);
bool isPlayer = (entity->getType() == game::ObjectType::PLAYER);
if (!isGo && !isUnit && !isPlayer) continue;
if (guid == gameHandler.getPlayerGuid()) continue; // skip self
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
hitRadius = isGo ? 2.5f : 1.8f;
hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += isGo ? 1.2f : 1.0f;
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.8f);
}
float hitT;
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT) && hitT < closestT) {
closestT = hitT;
hoverInteractable = true;
}
}
if (hoverInteractable) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
}
}
// Left-click targeting: only on mouse-up if the mouse didn't drag (camera rotate)
// Record press position on mouse-down
if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_LEFT) && !input.isMouseButtonPressed(SDL_BUTTON_RIGHT)) {
leftClickPressPos_ = input.getMousePosition();
leftClickWasPress_ = true;
}
// On mouse-up, check if it was a click (not a drag)
if (leftClickWasPress_ && input.isMouseButtonJustReleased(SDL_BUTTON_LEFT)) {
leftClickWasPress_ = false;
glm::vec2 releasePos = input.getMousePosition();
glm::vec2 dragDelta = releasePos - leftClickPressPos_;
float dragDistSq = glm::dot(dragDelta, dragDelta);
constexpr float CLICK_THRESHOLD = 5.0f; // pixels
if (dragDistSq < CLICK_THRESHOLD * CLICK_THRESHOLD) {
auto* renderer = services_.renderer;
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = services_.window;
if (camera && window) {
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
rendering::Ray ray = camera->screenToWorldRay(leftClickPressPos_.x, leftClickPressPos_.y, screenW, screenH);
float closestT = 1e30f;
uint64_t closestGuid = 0;
float closestHostileUnitT = 1e30f;
uint64_t closestHostileUnitGuid = 0;
const uint64_t myGuid = gameHandler.getPlayerGuid();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
auto t = entity->getType();
if (t != game::ObjectType::UNIT &&
t != game::ObjectType::PLAYER &&
t != game::ObjectType::GAMEOBJECT) continue;
if (guid == myGuid) continue; // Don't target self
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
// Fallback hitbox based on entity type
float heightOffset = 1.5f;
hitRadius = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
// Critters have very low max health (< 100)
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
hitRadius = 2.5f;
heightOffset = 1.2f;
}
hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.6f);
}
float hitT;
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) {
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
bool hostileUnit = unit->isHostile() || gameHandler.isAggressiveTowardPlayer(guid);
if (hostileUnit && hitT < closestHostileUnitT) {
closestHostileUnitT = hitT;
closestHostileUnitGuid = guid;
}
}
if (hitT < closestT) {
closestT = hitT;
closestGuid = guid;
}
}
}
// Prefer hostile monsters over nearby gameobjects/others when both are hittable.
if (closestHostileUnitGuid != 0) {
closestGuid = closestHostileUnitGuid;
}
if (closestGuid != 0) {
gameHandler.setTarget(closestGuid);
} else {
// Clicked empty space — deselect current target
gameHandler.clearTarget();
}
}
}
}
// Right-click: select NPC (if needed) then interact / loot / auto-attack
// Suppress when left button is held (both-button run)
if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_RIGHT) && !input.isMouseButtonPressed(SDL_BUTTON_LEFT)) {
// If a gameobject is already targeted, prioritize interacting with that target
// instead of re-picking under cursor (which can hit nearby decorative GOs).
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target && target->getType() == game::ObjectType::GAMEOBJECT) {
LOG_WARNING("[GO-DIAG] Right-click: re-interacting with targeted GO 0x",
std::hex, target->getGuid(), std::dec);
gameHandler.setTarget(target->getGuid());
gameHandler.interactWithGameObject(target->getGuid());
return;
}
}
// If no target or right-clicking in world, try to pick one under cursor
{
auto* renderer = services_.renderer;
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = services_.window;
if (camera && window) {
// If a quest objective gameobject is under the cursor, prefer it over
// hostile units so quest pickups (e.g. "Bundle of Wood") are reliable.
std::unordered_set<uint32_t> questObjectiveGoEntries;
{
const auto& ql = gameHandler.getQuestLog();
questObjectiveGoEntries.reserve(32);
for (const auto& q : ql) {
if (q.complete) continue;
for (const auto& obj : q.killObjectives) {
if (obj.npcOrGoId >= 0 || obj.required == 0) continue;
uint32_t entry = static_cast<uint32_t>(-obj.npcOrGoId);
uint32_t cur = 0;
auto it = q.killCounts.find(entry);
if (it != q.killCounts.end()) cur = it->second.first;
if (cur < obj.required) questObjectiveGoEntries.insert(entry);
}
}
}
glm::vec2 mousePos = input.getMousePosition();
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH);
float closestT = 1e30f;
uint64_t closestGuid = 0;
game::ObjectType closestType = game::ObjectType::OBJECT;
float closestHostileUnitT = 1e30f;
uint64_t closestHostileUnitGuid = 0;
float closestQuestGoT = 1e30f;
uint64_t closestQuestGoGuid = 0;
float closestGoT = 1e30f;
uint64_t closestGoGuid = 0;
const uint64_t myGuid = gameHandler.getPlayerGuid();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
auto t = entity->getType();
if (t != game::ObjectType::UNIT &&
t != game::ObjectType::PLAYER &&
t != game::ObjectType::GAMEOBJECT)
continue;
if (guid == myGuid) continue;
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
float heightOffset = 1.5f;
hitRadius = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
hitRadius = 2.5f;
heightOffset = 1.2f;
}
hitCenter = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
// Log each unique GO's raypick position once
if (t == game::ObjectType::GAMEOBJECT) {
static std::unordered_set<uint64_t> goPickLog;
if (goPickLog.insert(guid).second) {
auto go = std::static_pointer_cast<game::GameObject>(entity);
LOG_WARNING("[GO-DIAG] Raypick GO: guid=0x", std::hex, guid, std::dec,
" entry=", go->getEntry(), " name='", go->getName(),
"' pos=(", entity->getX(), ",", entity->getY(), ",", entity->getZ(),
") center=(", hitCenter.x, ",", hitCenter.y, ",", hitCenter.z,
") r=", hitRadius);
}
}
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.6f);
}
float hitT;
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) {
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
bool hostileUnit = unit->isHostile() || gameHandler.isAggressiveTowardPlayer(guid);
if (hostileUnit && hitT < closestHostileUnitT) {
closestHostileUnitT = hitT;
closestHostileUnitGuid = guid;
}
}
if (t == game::ObjectType::GAMEOBJECT) {
if (hitT < closestGoT) {
closestGoT = hitT;
closestGoGuid = guid;
}
if (!questObjectiveGoEntries.empty()) {
auto go = std::static_pointer_cast<game::GameObject>(entity);
if (questObjectiveGoEntries.count(go->getEntry())) {
if (hitT < closestQuestGoT) {
closestQuestGoT = hitT;
closestQuestGoGuid = guid;
}
}
}
}
if (hitT < closestT) {
closestT = hitT;
closestGuid = guid;
closestType = t;
}
}
}
// Priority: quest GO > closer of (GO, hostile unit) > closest anything.
if (closestQuestGoGuid != 0) {
closestGuid = closestQuestGoGuid;
closestType = game::ObjectType::GAMEOBJECT;
} else if (closestGoGuid != 0 && closestHostileUnitGuid != 0) {
// Both a GO and hostile unit were hit — prefer whichever is closer.
if (closestGoT <= closestHostileUnitT) {
closestGuid = closestGoGuid;
closestType = game::ObjectType::GAMEOBJECT;
} else {
closestGuid = closestHostileUnitGuid;
closestType = game::ObjectType::UNIT;
}
} else if (closestGoGuid != 0) {
closestGuid = closestGoGuid;
closestType = game::ObjectType::GAMEOBJECT;
} else if (closestHostileUnitGuid != 0) {
closestGuid = closestHostileUnitGuid;
closestType = game::ObjectType::UNIT;
}
if (closestGuid != 0) {
if (closestType == game::ObjectType::GAMEOBJECT) {
LOG_WARNING("[GO-DIAG] Right-click: raypick hit GO 0x",
std::hex, closestGuid, std::dec);
gameHandler.setTarget(closestGuid);
gameHandler.interactWithGameObject(closestGuid);
return;
}
gameHandler.setTarget(closestGuid);
}
}
}
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target) {
if (target->getType() == game::ObjectType::UNIT) {
// Check if unit is dead (health == 0) → loot, otherwise interact/attack
auto unit = std::static_pointer_cast<game::Unit>(target);
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
gameHandler.lootTarget(target->getGuid());
} else {
// Interact with service NPCs; otherwise treat non-interactable living units
// as attackable fallback (covers bad faction-template classification).
auto isSpiritNpc = [&]() -> bool {
constexpr uint32_t NPC_FLAG_SPIRIT_GUIDE = 0x00004000;
constexpr uint32_t NPC_FLAG_SPIRIT_HEALER = 0x00008000;
if (unit->getNpcFlags() & (NPC_FLAG_SPIRIT_GUIDE | NPC_FLAG_SPIRIT_HEALER)) {
return true;
}
std::string name = unit->getName();
std::transform(name.begin(), name.end(), name.begin(),
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
return (name.find("spirit healer") != std::string::npos) ||
(name.find("spirit guide") != std::string::npos);
};
bool allowSpiritInteract = (gameHandler.isPlayerDead() || gameHandler.isPlayerGhost()) && isSpiritNpc();
bool canInteractNpc = unit->isInteractable() || allowSpiritInteract;
bool shouldAttackByFallback = !canInteractNpc;
if (!unit->isHostile() && canInteractNpc) {
gameHandler.interactWithNpc(target->getGuid());
} else if (unit->isHostile() || shouldAttackByFallback) {
gameHandler.startAutoAttack(target->getGuid());
}
}
} else if (target->getType() == game::ObjectType::GAMEOBJECT) {
gameHandler.interactWithGameObject(target->getGuid());
} else if (target->getType() == game::ObjectType::PLAYER) {
// Right-click another player could start attack in PvP context
}
}
}
}
}
void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
bool isDead = gameHandler.isPlayerDead();
ImGui::SetNextWindowPos(ImVec2(10.0f, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(250.0f, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f));
const bool inCombatConfirmed = gameHandler.isInCombat();
const bool attackIntentOnly = gameHandler.hasAutoAttackIntent() && !inCombatConfirmed;
ImVec4 playerBorder = isDead
? kColorDarkGray
: (inCombatConfirmed
? colors::kBrightRed
: (attackIntentOnly
? ImVec4(1.0f, 0.7f, 0.2f, 1.0f)
: ImVec4(0.4f, 0.4f, 0.4f, 1.0f)));
ImGui::PushStyleColor(ImGuiCol_Border, playerBorder);
if (ImGui::Begin("##PlayerFrame", nullptr, flags)) {
// Use selected character info if available, otherwise defaults
std::string playerName = "Adventurer";
uint32_t playerLevel = 1;
uint32_t playerHp = 100;
uint32_t playerMaxHp = 100;
const auto& characters = gameHandler.getCharacters();
uint64_t activeGuid = gameHandler.getActiveCharacterGuid();
const game::Character* activeChar = nullptr;
for (const auto& c : characters) {
if (c.guid == activeGuid) { activeChar = &c; break; }
}
if (!activeChar && !characters.empty()) activeChar = &characters[0];
if (activeChar) {
const auto& ch = *activeChar;
playerName = ch.name;
// Use live server level if available, otherwise character struct
playerLevel = gameHandler.getPlayerLevel();
if (playerLevel == 0) playerLevel = ch.level;
playerMaxHp = 20 + playerLevel * 10;
playerHp = playerMaxHp;
}
// Derive class color via shared helper
ImVec4 classColor = activeChar
? classColorVec4(static_cast<uint8_t>(activeChar->characterClass))
: kColorBrightGreen;
// 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());
}
if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) {
ImGui::TextDisabled("%s", playerName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Open Character")) {
inventoryScreen.setCharacterOpen(true);
}
if (ImGui::MenuItem("Toggle PvP")) {
gameHandler.togglePvp();
}
ImGui::Separator();
bool afk = gameHandler.isAfk();
bool dnd = gameHandler.isDnd();
if (ImGui::MenuItem(afk ? "Cancel AFK" : "Set AFK")) {
gameHandler.toggleAfk();
}
if (ImGui::MenuItem(dnd ? "Cancel DND" : "Set DND")) {
gameHandler.toggleDnd();
}
if (gameHandler.isInGroup()) {
ImGui::Separator();
if (ImGui::MenuItem("Leave Group")) {
gameHandler.leaveGroup();
}
}
ImGui::EndPopup();
}
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", playerLevel);
if (isDead) {
ImGui::SameLine();
ImGui::TextColored(colors::kDarkRed, "DEAD");
}
// Group leader crown on self frame when you lead the party/raid
if (gameHandler.isInGroup() &&
gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()) {
ImGui::SameLine(0, 4);
ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are the group leader");
}
if (gameHandler.isAfk()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), "<AFK>");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Away from keyboard — /afk to cancel");
} else if (gameHandler.isDnd()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), "<DND>");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel");
}
if (auto* ren = services_.renderer) {
if (auto* cam = ren->getCameraController()) {
if (cam->isAutoRunning()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "[Auto-Run]");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Auto-running — press ` or NumLock to stop");
}
}
}
if (inCombatConfirmed && !isDead) {
float combatPulse = 0.75f + 0.25f * std::sin(static_cast<float>(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");
}
// Active title — shown in gold below the name/level line
{
int32_t titleBit = gameHandler.getChosenTitleBit();
if (titleBit >= 0) {
const std::string titleText = gameHandler.getFormattedTitle(
static_cast<uint32_t>(titleBit));
if (!titleText.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 0.9f), "%s", titleText.c_str());
}
}
}
// Try to get real HP/mana from the player entity
auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
if (unit->getMaxHealth() > 0) {
playerHp = unit->getHealth();
playerMaxHp = unit->getMaxHealth();
}
}
// Health bar — color transitions green→yellow→red as HP drops
float pct = static_cast<float>(playerHp) / static_cast<float>(playerMaxHp);
ImVec4 hpColor;
if (isDead) {
hpColor = kColorDarkGray;
} else if (pct > 0.5f) {
hpColor = colors::kHealthGreen; // green
} else if (pct > 0.2f) {
float t = (pct - 0.2f) / 0.3f; // 0 at 20%, 1 at 50%
hpColor = ImVec4(0.9f - 0.7f * t, 0.4f + 0.4f * t, 0.0f, 1.0f); // orange→yellow
} else {
// Critical — pulse red when < 20%
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.5f);
hpColor = ImVec4(0.9f * pulse, 0.05f, 0.05f, 1.0f); // pulsing red
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor);
char overlay[64];
snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay);
ImGui::PopStyleColor();
// Mana/Power bar
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
uint8_t powerType = unit->getPowerType();
uint32_t power = unit->getPower();
uint32_t maxPower = unit->getMaxPower();
// Rage (1), Focus (2), Energy (3), and Runic Power (6) always cap at 100.
// Show bar even if server hasn't sent UNIT_FIELD_MAXPOWER1 yet.
if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3 || powerType == 6)) maxPower = 100;
if (maxPower > 0) {
float mpPct = static_cast<float>(power) / static_cast<float>(maxPower);
ImVec4 powerColor;
switch (powerType) {
case 0: {
// Mana: pulse desaturated blue when critically low (< 20%)
if (mpPct < 0.2f) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f);
powerColor = ImVec4(0.1f, 0.1f, 0.8f * pulse, 1.0f);
} else {
powerColor = colors::kManaBlue;
}
break;
}
case 1: powerColor = colors::kDarkRed; break; // Rage (red)
case 2: powerColor = colors::kOrange; break; // Focus (orange)
case 3: powerColor = colors::kEnergyYellow; break; // Energy (yellow)
case 4: powerColor = colors::kHappinessGreen; break; // Happiness (green)
case 6: powerColor = colors::kRunicRed; break; // Runic Power (crimson)
case 7: powerColor = colors::kSoulShardPurple; break; // Soul Shards (purple)
default: powerColor = colors::kManaBlue; break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor);
char mpOverlay[64];
snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", power, maxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay);
ImGui::PopStyleColor();
}
}
// Death Knight rune bar (class 6) — 6 colored squares with fill fraction
if (gameHandler.getPlayerClass() == 6) {
const auto& runes = gameHandler.getPlayerRunes();
float dt = ImGui::GetIO().DeltaTime;
ImGui::Spacing();
ImVec2 cursor = ImGui::GetCursorScreenPos();
float totalW = ImGui::GetContentRegionAvail().x;
float spacing = 3.0f;
float squareW = (totalW - spacing * 5.0f) / 6.0f;
float squareH = 14.0f;
ImDrawList* dl = ImGui::GetWindowDrawList();
for (int i = 0; i < 6; i++) {
// Client-side prediction: advance fill over ~10s cooldown
runeClientFill_[i] = runes[i].ready ? 1.0f
: std::min(runeClientFill_[i] + dt / 10.0f, runes[i].readyFraction + 0.02f);
runeClientFill_[i] = std::clamp(runeClientFill_[i], 0.0f, runes[i].ready ? 1.0f : 0.97f);
float x0 = cursor.x + i * (squareW + spacing);
float y0 = cursor.y;
float x1 = x0 + squareW;
float y1 = y0 + squareH;
// Background (dark)
dl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1),
IM_COL32(30, 30, 30, 200), 2.0f);
// Fill color by rune type
ImVec4 fc;
switch (runes[i].type) {
case game::GameHandler::RuneType::Blood: fc = ImVec4(0.85f, 0.12f, 0.12f, 1.0f); break;
case game::GameHandler::RuneType::Unholy: fc = ImVec4(0.20f, 0.72f, 0.20f, 1.0f); break;
case game::GameHandler::RuneType::Frost: fc = ImVec4(0.30f, 0.55f, 0.90f, 1.0f); break;
case game::GameHandler::RuneType::Death: fc = ImVec4(0.55f, 0.20f, 0.70f, 1.0f); break;
default: fc = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); break;
}
float fillX = x0 + (x1 - x0) * runeClientFill_[i];
dl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1),
ImGui::ColorConvertFloat4ToU32(fc), 2.0f);
// Border
ImU32 borderCol = runes[i].ready
? IM_COL32(220, 220, 220, 180)
: IM_COL32(100, 100, 100, 160);
dl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f);
}
ImGui::Dummy(ImVec2(totalW, squareH));
}
// Combo point display — Rogue (4) and Druid (11) in Cat Form
{
uint8_t cls = gameHandler.getPlayerClass();
const bool isRogue = (cls == 4);
const bool isDruid = (cls == 11);
if (isRogue || isDruid) {
uint8_t cp = gameHandler.getComboPoints();
if (cp > 0 || isRogue) { // always show for rogue; only when non-zero for druid
ImGui::Spacing();
ImVec2 cursor = ImGui::GetCursorScreenPos();
float totalW = ImGui::GetContentRegionAvail().x;
constexpr int MAX_CP = 5;
constexpr float DOT_R = 7.0f;
constexpr float SPACING = 4.0f;
float totalDotsW = MAX_CP * (DOT_R * 2.0f) + (MAX_CP - 1) * SPACING;
float startX = cursor.x + (totalW - totalDotsW) * 0.5f;
float cy = cursor.y + DOT_R;
ImDrawList* dl = ImGui::GetWindowDrawList();
for (int i = 0; i < MAX_CP; ++i) {
float cx = startX + i * (DOT_R * 2.0f + SPACING) + DOT_R;
ImU32 col = (i < static_cast<int>(cp))
? IM_COL32(255, 210, 0, 240) // bright gold — active
: IM_COL32(60, 60, 60, 160); // dark — empty
dl->AddCircleFilled(ImVec2(cx, cy), DOT_R, col);
dl->AddCircle(ImVec2(cx, cy), DOT_R, IM_COL32(160, 140, 0, 180), 0, 1.5f);
}
ImGui::Dummy(ImVec2(totalW, DOT_R * 2.0f));
}
}
}
// Shaman totem bar (class 7) — 4 slots: Earth, Fire, Water, Air
if (gameHandler.getPlayerClass() == 7) {
static constexpr 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 constexpr 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<float>(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[16];
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));
char totemBtnId[16]; snprintf(totemBtnId, sizeof(totemBtnId), "##totem%d", i);
ImGui::InvisibleButton(totemBtnId, 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));
}
}
// Melee swing timer — shown when player is auto-attacking
if (gameHandler.isAutoAttacking()) {
const uint64_t lastSwingMs = gameHandler.getLastMeleeSwingMs();
if (lastSwingMs > 0) {
// Determine weapon speed from the equipped main-hand weapon
uint32_t weaponDelayMs = 2000; // Default: 2.0s unarmed
const auto& mainSlot = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND);
if (!mainSlot.empty() && mainSlot.item.itemId != 0) {
const auto* info = gameHandler.getItemInfo(mainSlot.item.itemId);
if (info && info->delayMs > 0) {
weaponDelayMs = info->delayMs;
}
}
// Compute elapsed since last swing
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count());
uint64_t elapsedMs = (nowMs >= lastSwingMs) ? (nowMs - lastSwingMs) : 0;
// Clamp to weapon delay (cap at 1.0 so the bar fills but doesn't exceed)
float pct = std::min(static_cast<float>(elapsedMs) / static_cast<float>(weaponDelayMs), 1.0f);
// Light silver-orange color indicating auto-attack readiness
ImVec4 swingColor = (pct >= 0.95f)
? ImVec4(1.0f, 0.75f, 0.15f, 1.0f) // gold when ready to swing
: ImVec4(0.65f, 0.55f, 0.40f, 1.0f); // muted brown-orange while filling
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, swingColor);
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.12f, 0.08f, 0.8f));
char swingLabel[24];
float remainSec = std::max(0.0f, (weaponDelayMs - static_cast<float>(elapsedMs)) / 1000.0f);
if (pct >= 0.98f)
snprintf(swingLabel, sizeof(swingLabel), "Swing!");
else
snprintf(swingLabel, sizeof(swingLabel), "%.1fs", remainSec);
ImGui::ProgressBar(pct, ImVec2(-1.0f, 8.0f), swingLabel);
ImGui::PopStyleColor(2);
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void GameScreen::renderPetFrame(game::GameHandler& gameHandler) {
uint64_t petGuid = gameHandler.getPetGuid();
if (petGuid == 0) return;
auto petEntity = gameHandler.getEntityManager().getEntity(petGuid);
if (!petEntity) return;
auto* petUnit = petEntity->isUnit() ? static_cast<game::Unit*>(petEntity.get()) : nullptr;
if (!petUnit) return;
// Position below player frame. If in a group, push below party frames
// (party frame at y=120, each member ~50px, up to 4 members → max ~320px + y=120 = ~440).
// When not grouped, the player frame ends at ~110px so y=125 is fine.
const int partyMemberCount = gameHandler.isInGroup()
? static_cast<int>(gameHandler.getPartyData().members.size()) : 0;
float petY = (partyMemberCount > 0)
? 120.0f + partyMemberCount * 52.0f + 8.0f
: 125.0f;
ImGui::SetNextWindowPos(ImVec2(10.0f, petY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.1f, 0.08f, 0.85f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.2f, 0.6f, 0.2f, 1.0f));
if (ImGui::Begin("##PetFrame", nullptr, flags)) {
const std::string& petName = petUnit->getName();
uint32_t petLevel = petUnit->getLevel();
// Name + level on one row — clicking the pet name targets it
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.9f, 0.4f, 1.0f));
char petLabel[96];
snprintf(petLabel, sizeof(petLabel), "%s",
petName.empty() ? "Pet" : petName.c_str());
if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) {
gameHandler.setTarget(petGuid);
}
// Right-click context menu on pet name
if (ImGui::BeginPopupContextItem("PetNameCtx")) {
ImGui::TextDisabled("%s", petLabel);
ImGui::Separator();
if (ImGui::MenuItem("Target Pet")) {
gameHandler.setTarget(petGuid);
}
if (ImGui::MenuItem("Rename Pet")) {
ImGui::CloseCurrentPopup();
petRenameOpen_ = true;
petRenameBuf_[0] = '\0';
}
if (ImGui::MenuItem("Dismiss Pet")) {
gameHandler.dismissPet();
}
ImGui::EndPopup();
}
// Pet rename modal (opened via context menu)
if (petRenameOpen_) {
ImGui::OpenPopup("Rename Pet###PetRename");
petRenameOpen_ = false;
}
if (ImGui::BeginPopupModal("Rename Pet###PetRename", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) {
ImGui::Text("Enter new pet name (max 12 characters):");
ImGui::SetNextItemWidth(180.0f);
bool submitted = ImGui::InputText("##PetRenameInput", petRenameBuf_, sizeof(petRenameBuf_),
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
if (ImGui::Button("OK") || submitted) {
std::string newName(petRenameBuf_);
if (!newName.empty() && newName.size() <= 12) {
gameHandler.renamePet(newName);
}
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
ImGui::PopStyleColor();
if (petLevel > 0) {
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", petLevel);
}
// Health bar
uint32_t hp = petUnit->getHealth();
uint32_t maxHp = petUnit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImVec4 petHpColor = pct > 0.5f ? colors::kHealthGreen
: pct > 0.2f ? ImVec4(0.9f, 0.6f, 0.0f, 1.0f)
: ImVec4(0.9f, 0.15f, 0.15f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, petHpColor);
char hpText[32];
snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText);
ImGui::PopStyleColor();
}
// Power/mana bar (hunters' pets use focus)
uint8_t powerType = petUnit->getPowerType();
uint32_t power = petUnit->getPower();
uint32_t maxPower = petUnit->getMaxPower();
if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3)) maxPower = 100;
if (maxPower > 0) {
float mpPct = static_cast<float>(power) / static_cast<float>(maxPower);
ImVec4 powerColor;
switch (powerType) {
case 0: powerColor = colors::kManaBlue; break; // Mana
case 1: powerColor = colors::kDarkRed; break; // Rage
case 2: powerColor = colors::kOrange; break; // Focus (hunter pets)
case 3: powerColor = colors::kEnergyYellow; break; // Energy
default: powerColor = colors::kManaBlue; break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor);
char mpText[32];
snprintf(mpText, sizeof(mpText), "%u/%u", power, maxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpText);
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<float>(happiness) / static_cast<float>(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)
? (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();
}
// Stance row: Passive / Defensive / Aggressive — with Dismiss right-aligned
{
static constexpr const char* kReactLabels[] = { "Psv", "Def", "Agg" };
static constexpr const char* kReactTooltips[] = { "Passive", "Defensive", "Aggressive" };
static constexpr ImVec4 kReactColors[] = {
colors::kLightBlue, // passive — blue
ImVec4(0.3f, 0.85f, 0.3f, 1.0f), // defensive — green
colors::kHostileRed,// aggressive — red
};
static constexpr 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<uint8_t>(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
{
const int slotCount = game::GameHandler::PET_ACTION_BAR_SLOTS;
// Filter to non-zero slots; lay them out as small icon/text buttons.
// Raw slot value layout (WotLK 3.3.5): low 24 bits = spell/action ID,
// high byte = flag (0x80=autocast on, 0x40=can-autocast, 0x0C=type).
// Built-in commands: id=2 follow, id=3 stay/move, id=5 attack.
auto* assetMgr = services_.assetManager;
const float iconSz = 20.0f;
const float spacing = 2.0f;
ImGui::Separator();
int rendered = 0;
for (int i = 0; i < slotCount; ++i) {
uint32_t slotVal = gameHandler.getPetActionSlot(i);
if (slotVal == 0) continue;
uint32_t actionId = slotVal & 0x00FFFFFFu;
// Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags.
bool autocastOn = gameHandler.isPetSpellAutocast(actionId);
// Cooldown tracking for pet spells (actionId > 6 are spell IDs)
float petCd = (actionId > 6) ? gameHandler.getSpellCooldown(actionId) : 0.0f;
bool petOnCd = (petCd > 0.0f);
ImGui::PushID(i);
if (rendered > 0) ImGui::SameLine(0.0f, spacing);
// Try to show spell icon; fall back to abbreviated text label.
VkDescriptorSet iconTex = VK_NULL_HANDLE;
const char* builtinLabel = nullptr;
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);
// Dim when on cooldown; tint green when autocast is on
ImVec4 tint = petOnCd
? ImVec4(0.35f, 0.35f, 0.35f, 0.7f)
: (autocastOn ? colors::kLightGreen : ui::colors::kWhite);
bool clicked = false;
if (iconTex) {
clicked = ImGui::ImageButton("##pa",
(ImTextureID)(uintptr_t)iconTex,
ImVec2(iconSz, iconSz),
ImVec2(0,0), ImVec2(1,1),
ImVec4(0.1f,0.1f,0.1f,0.9f), tint);
} else {
char label[8];
if (builtinLabel) {
snprintf(label, sizeof(label), "%s", builtinLabel);
} else {
// Show first 3 chars of spell name or spell ID.
std::string nm = gameHandler.getSpellName(actionId);
if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100);
else snprintf(label, sizeof(label), "%.3s", nm.c_str());
}
ImVec4 btnCol = petOnCd ? ImVec4(0.1f,0.1f,0.15f,0.9f)
: (autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f)
: ImVec4(0.2f,0.2f,0.3f,0.9f));
ImGui::PushStyleColor(ImGuiCol_Button, btnCol);
clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz));
ImGui::PopStyleColor();
}
// Cooldown overlay: dark fill + time text centered on the button
if (petOnCd && !builtinLabel) {
ImVec2 bMin = ImGui::GetItemRectMin();
ImVec2 bMax = ImGui::GetItemRectMax();
auto* cdDL = ImGui::GetWindowDrawList();
cdDL->AddRectFilled(bMin, bMax, IM_COL32(0, 0, 0, 140));
char cdTxt[8];
if (petCd >= 60.0f)
snprintf(cdTxt, sizeof(cdTxt), "%dm", static_cast<int>(petCd / 60.0f));
else if (petCd >= 1.0f)
snprintf(cdTxt, sizeof(cdTxt), "%d", static_cast<int>(petCd));
else
snprintf(cdTxt, sizeof(cdTxt), "%.1f", petCd);
ImVec2 tsz = ImGui::CalcTextSize(cdTxt);
float cx = (bMin.x + bMax.x) * 0.5f;
float cy = (bMin.y + bMax.y) * 0.5f;
cdDL->AddText(ImVec2(cx - tsz.x * 0.5f, cy - tsz.y * 0.5f),
IM_COL32(255, 255, 255, 230), cdTxt);
}
if (clicked && !petOnCd) {
// Send pet action; use current target for spells.
uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u;
gameHandler.sendPetAction(slotVal, targetGuid);
}
// Right-click toggles autocast for castable pet spells (actionId > 6)
if (actionId > 6 && ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
gameHandler.togglePetSpellAutocast(actionId);
}
// Tooltip: rich spell info for pet spells, simple label for built-in commands
if (ImGui::IsItemHovered()) {
if (builtinLabel) {
const char* tip = nullptr;
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";
if (tip) ImGui::SetTooltip("%s", tip);
} else if (actionId > 6) {
auto* spellAsset = services_.assetManager;
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(actionId, gameHandler, spellAsset);
if (!richOk) {
std::string nm = gameHandler.getSpellName(actionId);
if (nm.empty()) nm = "Spell #" + std::to_string(actionId);
ImGui::Text("%s", nm.c_str());
}
ImGui::TextColored(autocastOn
? kColorGreen
: kColorGray,
"Autocast: %s (right-click to toggle)", autocastOn ? "On" : "Off");
if (petOnCd) {
if (petCd >= 60.0f)
ImGui::TextColored(kColorRed,
"Cooldown: %d min %d sec",
static_cast<int>(petCd) / 60, static_cast<int>(petCd) % 60);
else
ImGui::TextColored(kColorRed,
"Cooldown: %.1f sec", petCd);
}
ImGui::EndTooltip();
}
}
ImGui::PopID();
++rendered;
}
}
}
ImGui::End();
ImGui::PopStyleColor(2);
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 constexpr 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<float>(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<float>((kTotemInfo[i].color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f,
static_cast<float>((kTotemInfo[i].color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f,
static_cast<float>((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;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float frameW = 250.0f;
float frameX = (screenW - frameW) / 2.0f;
ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
// Determine hostility/level color for border and name (WoW-canonical)
ImVec4 hostileColor(0.7f, 0.7f, 0.7f, 1.0f);
if (target->getType() == game::ObjectType::PLAYER) {
hostileColor = kColorBrightGreen;
} else if (target->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(target);
if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
hostileColor = kColorDarkGray;
} else if (u->isHostile()) {
// Check tapped-by-other: grey name for mobs tagged by someone else
uint32_t tgtDynFlags = u->getDynamicFlags();
bool tgtTapped = (tgtDynFlags & 0x0004) != 0 && (tgtDynFlags & 0x0008) == 0;
if (tgtTapped) {
hostileColor = kColorGray; // Grey — tapped by other
} else {
// WoW level-based color for hostile mobs
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = u->getLevel();
if (mobLv == 0) {
// Level 0 = unknown/?? (e.g. high-level raid bosses) — always skull red
hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f);
} else {
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
if (game::GameHandler::killXp(playerLv, mobLv) == 0) {
hostileColor = kColorGray; // Grey - no XP
} else if (diff >= 10) {
hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard
} else if (diff >= 5) {
hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard
} else if (diff >= -2) {
hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even
} else {
hostileColor = kColorBrightGreen; // Green - easy
}
}
} // end tapped else
} else {
hostileColor = kColorBrightGreen; // Friendly
}
}
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f));
const uint64_t targetGuid = target->getGuid();
const bool confirmedCombatWithTarget = gameHandler.isInCombatWith(targetGuid);
const bool intentTowardTarget =
gameHandler.hasAutoAttackIntent() &&
gameHandler.getAutoAttackTargetGuid() == targetGuid &&
!confirmedCombatWithTarget;
ImVec4 borderColor = ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f);
if (confirmedCombatWithTarget) {
float t = ImGui::GetTime();
float pulse = (std::fmod(t, 0.6f) < 0.3f) ? 1.0f : 0.0f;
borderColor = ImVec4(1.0f, 0.1f, 0.1f, pulse);
} else if (intentTowardTarget) {
borderColor = ImVec4(1.0f, 0.7f, 0.2f, 1.0f);
}
ImGui::PushStyleColor(ImGuiCol_Border, borderColor);
if (ImGui::Begin("##TargetFrame", nullptr, flags)) {
// Raid mark icon (Star/Circle/Diamond/Triangle/Moon/Square/Cross/Skull)
static constexpr struct { const char* sym; ImU32 col; } kRaidMarks[] = {
{ "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star (yellow)
{ "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle (orange)
{ "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple)
{ "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green)
{ "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue)
{ "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal)
{ "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red)
{ "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white)
};
uint8_t mark = gameHandler.getEntityRaidMark(target->getGuid());
if (mark < game::GameHandler::kRaidMarkCount) {
ImGui::GetWindowDrawList()->AddText(
ImGui::GetCursorScreenPos(),
kRaidMarks[mark].col, kRaidMarks[mark].sym);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f);
}
// 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);
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f));
ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_DontClosePopups,
ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0));
ImGui::PopStyleColor(4);
// Right-click context menu on target frame
if (ImGui::BeginPopupContextItem("##TargetFrameCtx")) {
const bool isPlayer = (target->getType() == game::ObjectType::PLAYER);
const uint64_t tGuid = target->getGuid();
ImGui::TextDisabled("%s", name.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Set Focus"))
gameHandler.setFocus(tGuid);
if (ImGui::MenuItem("Clear Target"))
gameHandler.clearTarget();
if (isPlayer) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
chatPanel_.setWhisperTarget(name);
}
if (ImGui::MenuItem("Follow"))
gameHandler.followTarget();
if (ImGui::MenuItem("Invite to Group"))
gameHandler.inviteToGroup(name);
if (ImGui::MenuItem("Trade"))
gameHandler.initiateTrade(tGuid);
if (ImGui::MenuItem("Duel"))
gameHandler.proposeDuel(tGuid);
if (ImGui::MenuItem("Inspect")) {
gameHandler.inspectTarget();
socialPanel_.showInspectWindow_ = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Add Friend"))
gameHandler.addFriend(name);
if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(name);
if (ImGui::MenuItem("Report Player"))
gameHandler.reportPlayer(tGuid, "Reported via UI");
}
ImGui::Separator();
if (ImGui::BeginMenu("Set Raid Mark")) {
for (int mi = 0; mi < 8; ++mi) {
if (ImGui::MenuItem(kRaidMarkNames[mi]))
gameHandler.setRaidMark(tGuid, static_cast<uint8_t>(mi));
}
ImGui::Separator();
if (ImGui::MenuItem("Clear Mark"))
gameHandler.setRaidMark(tGuid, 0xFF);
ImGui::EndMenu();
}
ImGui::EndPopup();
}
// Group leader crown — golden ♛ when the targeted player is the party/raid leader
if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) {
if (gameHandler.getPartyData().leaderGuid == target->getGuid()) {
ImGui::SameLine(0, 4);
ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader");
}
}
// Quest giver indicator — "!" for available quests, "?" for completable quests
{
using QGS = game::QuestGiverStatus;
QGS qgs = gameHandler.getQuestGiverStatus(target->getGuid());
if (qgs == QGS::AVAILABLE) {
ImGui::SameLine(0, 4);
ImGui::TextColored(colors::kBrightGold, "!");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a quest available");
} else if (qgs == QGS::AVAILABLE_LOW) {
ImGui::SameLine(0, 4);
ImGui::TextColored(kColorGray, "!");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a low-level quest available");
} else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) {
ImGui::SameLine(0, 4);
ImGui::TextColored(colors::kBrightGold, "?");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest ready to turn in");
} else if (qgs == QGS::INCOMPLETE) {
ImGui::SameLine(0, 4);
ImGui::TextColored(kColorGray, "?");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest incomplete");
}
}
// Creature subtitle (e.g. "<Warchief of the Horde>", "Captain of the Guard")
if (target->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(target);
const std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry());
if (!sub.empty()) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", sub.c_str());
}
}
// Player guild name (e.g. "<My Guild>") — mirrors NPC subtitle styling
if (target->getType() == game::ObjectType::PLAYER) {
uint32_t guildId = gameHandler.getEntityGuildId(target->getGuid());
if (guildId != 0) {
const std::string& gn = gameHandler.lookupGuildName(guildId);
if (!gn.empty()) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str());
}
}
}
// Right-click context menu on the target name
if (ImGui::BeginPopupContextItem("##TargetNameCtx")) {
const bool isPlayer = (target->getType() == game::ObjectType::PLAYER);
const uint64_t tGuid = target->getGuid();
ImGui::TextDisabled("%s", name.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Set Focus")) {
gameHandler.setFocus(tGuid);
}
if (ImGui::MenuItem("Clear Target")) {
gameHandler.clearTarget();
}
if (isPlayer) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
chatPanel_.setWhisperTarget(name);
}
if (ImGui::MenuItem("Follow")) {
gameHandler.followTarget();
}
if (ImGui::MenuItem("Invite to Group")) {
gameHandler.inviteToGroup(name);
}
if (ImGui::MenuItem("Trade")) {
gameHandler.initiateTrade(tGuid);
}
if (ImGui::MenuItem("Duel")) {
gameHandler.proposeDuel(tGuid);
}
if (ImGui::MenuItem("Inspect")) {
gameHandler.inspectTarget();
socialPanel_.showInspectWindow_ = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Add Friend")) {
gameHandler.addFriend(name);
}
if (ImGui::MenuItem("Ignore")) {
gameHandler.addIgnore(name);
}
}
ImGui::Separator();
if (ImGui::BeginMenu("Set Raid Mark")) {
for (int mi = 0; mi < 8; ++mi) {
if (ImGui::MenuItem(kRaidMarkNames[mi]))
gameHandler.setRaidMark(tGuid, static_cast<uint8_t>(mi));
}
ImGui::Separator();
if (ImGui::MenuItem("Clear Mark"))
gameHandler.setRaidMark(tGuid, 0xFF);
ImGui::EndMenu();
}
ImGui::EndPopup();
}
// Level (for units/players) — colored by difficulty
if (target->getType() == game::ObjectType::UNIT || target->getType() == game::ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<game::Unit>(target);
ImGui::SameLine();
// Level color matches the hostility/difficulty color
ImVec4 levelColor = hostileColor;
if (target->getType() == game::ObjectType::PLAYER) {
levelColor = ui::colors::kLightGray;
}
if (unit->getLevel() == 0)
ImGui::TextColored(levelColor, "Lv ??");
else
ImGui::TextColored(levelColor, "Lv %u", unit->getLevel());
// Classification badge: Elite / Rare Elite / Boss / Rare
if (target->getType() == game::ObjectType::UNIT) {
int rank = gameHandler.getCreatureRank(unit->getEntry());
if (rank == 1) {
ImGui::SameLine(0, 4);
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "[Elite]");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Elite — requires a group");
} else if (rank == 2) {
ImGui::SameLine(0, 4);
ImGui::TextColored(ImVec4(0.8f, 0.4f, 1.0f, 1.0f), "[Rare Elite]");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare Elite — uncommon spawn, group recommended");
} else if (rank == 3) {
ImGui::SameLine(0, 4);
ImGui::TextColored(kColorRed, "[Boss]");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Boss — raid / dungeon boss");
} else if (rank == 4) {
ImGui::SameLine(0, 4);
ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.0f, 1.0f), "[Rare]");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot");
}
}
// Creature type label (Beast, Humanoid, Demon, etc.)
if (target->getType() == game::ObjectType::UNIT) {
uint32_t ctype = gameHandler.getCreatureType(unit->getEntry());
const char* ctypeName = nullptr;
switch (ctype) {
case 1: ctypeName = "Beast"; break;
case 2: ctypeName = "Dragonkin"; break;
case 3: ctypeName = "Demon"; break;
case 4: ctypeName = "Elemental"; break;
case 5: ctypeName = "Giant"; break;
case 6: ctypeName = "Undead"; break;
case 7: ctypeName = "Humanoid"; break;
case 8: ctypeName = "Critter"; break;
case 9: ctypeName = "Mechanical"; break;
case 11: ctypeName = "Totem"; break;
case 12: ctypeName = "Non-combat Pet"; break;
case 13: ctypeName = "Gas Cloud"; break;
default: break;
}
if (ctypeName) {
ImGui::SameLine(0, 4);
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", ctypeName);
}
}
if (confirmedCombatWithTarget) {
float cPulse = 0.75f + 0.25f * std::sin(static_cast<float>(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();
uint32_t maxHp = unit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? colors::kHealthGreen :
pct > 0.2f ? colors::kMidHealthYellow :
colors::kLowHealthRed);
char overlay[64];
snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay);
ImGui::PopStyleColor();
// Target power bar (mana/rage/energy)
uint8_t targetPowerType = unit->getPowerType();
uint32_t targetPower = unit->getPower();
uint32_t targetMaxPower = unit->getMaxPower();
if (targetMaxPower == 0 && (targetPowerType == 1 || targetPowerType == 3)) targetMaxPower = 100;
if (targetMaxPower > 0) {
float mpPct = static_cast<float>(targetPower) / static_cast<float>(targetMaxPower);
ImVec4 targetPowerColor;
switch (targetPowerType) {
case 0: targetPowerColor = colors::kManaBlue; break; // Mana (blue)
case 1: targetPowerColor = colors::kDarkRed; break; // Rage (red)
case 2: targetPowerColor = colors::kOrange; break; // Focus (orange)
case 3: targetPowerColor = colors::kEnergyYellow; break; // Energy (yellow)
case 4: targetPowerColor = colors::kHappinessGreen; break; // Happiness (green)
case 6: targetPowerColor = colors::kRunicRed; break; // Runic Power (crimson)
case 7: targetPowerColor = colors::kSoulShardPurple; break; // Soul Shards (purple)
default: targetPowerColor = colors::kManaBlue; break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, targetPowerColor);
char mpOverlay[64];
snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", targetPower, targetMaxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay);
ImGui::PopStyleColor();
}
} else {
ImGui::TextDisabled("No health data");
}
}
// Combo points — shown when the player has combo points on this target
{
uint8_t cp = gameHandler.getComboPoints();
if (cp > 0 && gameHandler.getComboTarget() == target->getGuid()) {
const float dotSize = 12.0f;
const float dotSpacing = 4.0f;
const int maxCP = 5;
float totalW = maxCP * dotSize + (maxCP - 1) * dotSpacing;
float startX = (frameW - totalW) * 0.5f;
ImGui::SetCursorPosX(startX);
ImVec2 cursor = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
for (int ci = 0; ci < maxCP; ++ci) {
float cx = cursor.x + ci * (dotSize + dotSpacing) + dotSize * 0.5f;
float cy = cursor.y + dotSize * 0.5f;
if (ci < static_cast<int>(cp)) {
// Lit: yellow for 1-4, red glow for 5
ImU32 col = (cp >= 5)
? IM_COL32(255, 50, 30, 255)
: IM_COL32(255, 210, 30, 255);
dl->AddCircleFilled(ImVec2(cx, cy), dotSize * 0.45f, col);
// Subtle glow
dl->AddCircle(ImVec2(cx, cy), dotSize * 0.5f, IM_COL32(255, 255, 200, 80), 0, 1.5f);
} else {
// Unlit: dark outline
dl->AddCircle(ImVec2(cx, cy), dotSize * 0.4f, IM_COL32(80, 80, 80, 180), 0, 1.5f);
}
}
ImGui::Dummy(ImVec2(totalW, dotSize + 2.0f));
}
}
// Target cast bar — shown when the target is casting
if (gameHandler.isTargetCasting()) {
float castPct = gameHandler.getTargetCastProgress();
float castLeft = gameHandler.getTargetCastTimeRemaining();
uint32_t tspell = gameHandler.getTargetCastSpellId();
bool interruptible = gameHandler.isTargetCastInterruptible();
const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : "";
// Color: interruptible = green (can Kick/CS), not interruptible = red, both pulse when >80%
ImVec4 castBarColor;
if (castPct > 0.8f) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
if (interruptible)
castBarColor = ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f); // green pulse
else
castBarColor = ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); // red pulse
} else {
castBarColor = interruptible ? colors::kCastGreen // green = can interrupt
: ImVec4(0.85f, 0.15f, 0.15f, 1.0f); // red = uninterruptible
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor);
char castLabel[72];
if (!castName.empty())
snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft);
else if (tspell != 0)
snprintf(castLabel, sizeof(castLabel), "Spell #%u (%.1fs)", tspell, castLeft);
else
snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft);
{
auto* tcastAsset = services_.assetManager;
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();
}
// Target-of-Target (ToT): show who the current target is targeting
{
uint64_t totGuid = 0;
const auto& tFields = target->getFields();
auto itLo = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO));
if (itLo != tFields.end()) {
totGuid = itLo->second;
auto itHi = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI));
if (itHi != tFields.end())
totGuid |= (static_cast<uint64_t>(itHi->second) << 32);
}
if (totGuid != 0) {
auto totEnt = gameHandler.getEntityManager().getEntity(totGuid);
std::string totName;
ImVec4 totColor(0.7f, 0.7f, 0.7f, 1.0f);
if (totGuid == gameHandler.getPlayerGuid()) {
auto playerEnt = gameHandler.getEntityManager().getEntity(totGuid);
totName = playerEnt ? getEntityName(playerEnt) : "You";
totColor = kColorBrightGreen;
} else if (totEnt) {
totName = getEntityName(totEnt);
uint8_t cid = entityClassId(totEnt.get());
if (cid != 0) totColor = classColorVec4(cid);
}
if (!totName.empty()) {
ImGui::TextDisabled("");
ImGui::SameLine(0, 2);
ImGui::TextColored(totColor, "%s", totName.c_str());
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Target's target: %s\nClick to target", totName.c_str());
}
if (ImGui::IsItemClicked()) {
gameHandler.setTarget(totGuid);
}
// Compact health bar for the ToT — essential for healers tracking boss target
if (totEnt) {
auto totUnit = std::dynamic_pointer_cast<game::Unit>(totEnt);
if (totUnit && totUnit->getMaxHealth() > 0) {
uint32_t totHp = totUnit->getHealth();
uint32_t totMaxHp = totUnit->getMaxHealth();
float totPct = static_cast<float>(totHp) / static_cast<float>(totMaxHp);
ImVec4 totBarColor =
totPct > 0.5f ? colors::kCastGreen :
totPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) :
ImVec4(0.75f, 0.2f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, totBarColor);
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
char totOverlay[32];
snprintf(totOverlay, sizeof(totOverlay), "%u%%",
static_cast<unsigned>(totPct * 100.0f + 0.5f));
ImGui::ProgressBar(totPct, ImVec2(-1, 10), totOverlay);
ImGui::PopStyleColor(2);
}
}
}
}
}
// Distance
const auto& movement = gameHandler.getMovementInfo();
float dx = target->getX() - movement.x;
float dy = target->getY() - movement.y;
float dz = target->getZ() - movement.z;
float distance = std::sqrt(dx*dx + dy*dy + dz*dz);
ImGui::TextDisabled("%.1f yd", distance);
// Threat button (shown when in combat and threat data is available)
if (gameHandler.getTargetThreatList()) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f));
if (ImGui::SmallButton("Threat")) combatUI_.showThreatWindow_ = !combatUI_.showThreatWindow_;
ImGui::PopStyleColor(2);
}
// Target auras (buffs/debuffs)
const auto& targetAuras = gameHandler.getTargetAuras();
int activeAuras = 0;
for (const auto& a : targetAuras) {
if (!a.isEmpty()) activeAuras++;
}
if (activeAuras > 0) {
auto* assetMgr = services_.assetManager;
constexpr float ICON_SIZE = 24.0f;
constexpr int ICONS_PER_ROW = 8;
ImGui::Separator();
// Build sorted index list: debuffs before buffs, shorter duration first
uint64_t tNowSort = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
std::vector<size_t> 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 si = 0; si < sortedIdx.size() && shown < 16; ++si) {
size_t i = sortedIdx[si];
const auto& aura = targetAuras[i];
if (aura.isEmpty()) continue;
if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(10000 + i));
bool isBuff = (aura.flags & 0x80) == 0;
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) {
iconTex = getSpellIcon(aura.spellId, assetMgr);
}
if (iconTex) {
ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1));
ImGui::ImageButton("##taura",
(ImTextureID)(uintptr_t)iconTex,
ImVec2(ICON_SIZE - 2, ICON_SIZE - 2));
ImGui::PopStyleVar();
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor);
const std::string& tAuraName = gameHandler.getSpellName(aura.spellId);
char label[32];
if (!tAuraName.empty())
snprintf(label, sizeof(label), "%.6s", tAuraName.c_str());
else
snprintf(label, sizeof(label), "%u", aura.spellId);
ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE));
ImGui::PopStyleColor();
}
// Compute remaining once for overlay + tooltip
uint64_t tNowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
int32_t tRemainMs = aura.getRemainingMs(tNowMs);
// Clock-sweep overlay (elapsed = dark area, WoW style)
if (tRemainMs > 0 && aura.maxDurationMs > 0) {
ImVec2 tIconMin = ImGui::GetItemRectMin();
ImVec2 tIconMax = ImGui::GetItemRectMax();
float tcx = (tIconMin.x + tIconMax.x) * 0.5f;
float tcy = (tIconMin.y + tIconMax.y) * 0.5f;
float tR = (tIconMax.x - tIconMin.x) * 0.5f;
float tTot = static_cast<float>(aura.maxDurationMs);
float tFrac = std::clamp(
1.0f - static_cast<float>(tRemainMs) / tTot, 0.0f, 1.0f);
if (tFrac > 0.005f) {
constexpr int TSEGS = 24;
float tSa = -IM_PI * 0.5f;
float tEa = tSa + tFrac * 2.0f * IM_PI;
ImVec2 tPts[TSEGS + 2];
tPts[0] = ImVec2(tcx, tcy);
for (int s = 0; s <= TSEGS; ++s) {
float a = tSa + (tEa - tSa) * s / static_cast<float>(TSEGS);
tPts[s + 1] = ImVec2(tcx + std::cos(a) * tR,
tcy + std::sin(a) * tR);
}
ImGui::GetWindowDrawList()->AddConvexPolyFilled(
tPts, TSEGS + 2, IM_COL32(0, 0, 0, 145));
}
}
// Duration countdown overlay
if (tRemainMs > 0) {
ImVec2 iconMin = ImGui::GetItemRectMin();
ImVec2 iconMax = ImGui::GetItemRectMax();
char timeStr[12];
int secs = (tRemainMs + 999) / 1000;
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 - 1.0f;
// Color by urgency (matches player buff bar)
ImU32 tTimerColor;
if (tRemainMs < 10000) {
float pulse = 0.7f + 0.3f * std::sin(
static_cast<float>(ImGui::GetTime()) * 6.0f);
tTimerColor = IM_COL32(
static_cast<int>(255 * pulse),
static_cast<int>(80 * pulse),
static_cast<int>(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),
tTimerColor, timeStr);
}
// Stack / charge count — upper-left corner
if (aura.charges > 1) {
ImVec2 iconMin = ImGui::GetItemRectMin();
char chargeStr[8];
snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast<unsigned>(aura.charges));
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);
}
// 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(tRemainMs);
ImGui::EndTooltip();
}
ImGui::PopID();
shown++;
}
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
// ---- Target-of-Target (ToT) mini frame ----
// Read target's current target from UNIT_FIELD_TARGET_LO/HI update fields
if (target) {
const auto& fields = target->getFields();
uint64_t totGuid = 0;
auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO));
if (loIt != fields.end()) {
totGuid = loIt->second;
auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI));
if (hiIt != fields.end())
totGuid |= (static_cast<uint64_t>(hiIt->second) << 32);
}
if (totGuid != 0) {
auto totEntity = gameHandler.getEntityManager().getEntity(totGuid);
if (totEntity) {
// Position ToT frame just below and right-aligned with the target frame
float totW = 160.0f;
float totX = (screenW - totW) / 2.0f + (frameW - totW);
ImGui::SetNextWindowPos(ImVec2(totX, 30.0f + 130.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(totW, 0.0f), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.80f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 0.7f));
if (ImGui::Begin("##ToTFrame", nullptr,
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) {
std::string totName = getEntityName(totEntity);
// Class color for players; gray for NPCs
ImVec4 totNameColor = colors::kSilver;
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, 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));
if (ImGui::Selectable(totName.c_str(), false,
ImGuiSelectableFlags_DontClosePopups,
ImVec2(ImGui::CalcTextSize(totName.c_str()).x, 0))) {
gameHandler.setTarget(totGuid);
}
ImGui::PopStyleColor(4);
if (ImGui::BeginPopupContextItem("##ToTCtx")) {
ImGui::TextDisabled("%s", totName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(totGuid);
if (ImGui::MenuItem("Set Focus"))
gameHandler.setFocus(totGuid);
ImGui::EndPopup();
}
if (totEntity->getType() == game::ObjectType::UNIT ||
totEntity->getType() == game::ObjectType::PLAYER) {
auto totUnit = std::static_pointer_cast<game::Unit>(totEntity);
if (totUnit->getLevel() > 0) {
ImGui::SameLine();
ImGui::TextDisabled("Lv%u", totUnit->getLevel());
}
uint32_t hp = totUnit->getHealth();
uint32_t maxHp = totUnit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? colors::kFriendlyGreen :
pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) :
colors::kDangerRed);
ImGui::ProgressBar(pct, ImVec2(-1, 10), "");
ImGui::PopStyleColor();
}
// ToT cast bar — green if interruptible, red if not; pulses near completion
if (auto* totCs = gameHandler.getUnitCastState(totGuid)) {
float totCastPct = (totCs->timeTotal > 0.0f)
? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f;
ImVec4 tcColor;
if (totCastPct > 0.8f) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
tcColor = totCs->interruptible
? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f)
: ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f);
} else {
tcColor = totCs->interruptible
? colors::kCastGreen
: ImVec4(0.85f, 0.15f, 0.15f, 1.0f);
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor);
char tcLabel[48];
const std::string& tcName = gameHandler.getSpellName(totCs->spellId);
if (!tcName.empty())
snprintf(tcLabel, sizeof(tcLabel), "%s (%.1fs)", tcName.c_str(), totCs->timeRemaining);
else
snprintf(tcLabel, sizeof(tcLabel), "Casting... (%.1fs)", totCs->timeRemaining);
ImGui::ProgressBar(totCastPct, ImVec2(-1, 8), tcLabel);
ImGui::PopStyleColor();
}
// ToT aura row — compact icons, debuffs first
{
const std::vector<game::AuraSlot>* totAuras = nullptr;
if (totGuid == gameHandler.getPlayerGuid())
totAuras = &gameHandler.getPlayerAuras();
else if (totGuid == gameHandler.getTargetGuid())
totAuras = &gameHandler.getTargetAuras();
else
totAuras = gameHandler.getUnitAuras(totGuid);
if (totAuras) {
int totActive = 0;
for (const auto& a : *totAuras) if (!a.isEmpty()) totActive++;
if (totActive > 0) {
auto* totAsset = services_.assetManager;
constexpr float TA_ICON = 16.0f;
constexpr int TA_PER_ROW = 8;
ImGui::Separator();
uint64_t taNowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
std::vector<size_t> taIdx;
taIdx.reserve(totAuras->size());
for (size_t i = 0; i < totAuras->size(); ++i)
if (!(*totAuras)[i].isEmpty()) taIdx.push_back(i);
std::sort(taIdx.begin(), taIdx.end(), [&](size_t a, size_t b) {
bool aD = ((*totAuras)[a].flags & 0x80) != 0;
bool bD = ((*totAuras)[b].flags & 0x80) != 0;
if (aD != bD) return aD > bD;
int32_t ra = (*totAuras)[a].getRemainingMs(taNowMs);
int32_t rb = (*totAuras)[b].getRemainingMs(taNowMs);
if (ra < 0 && rb < 0) return false;
if (ra < 0) return false;
if (rb < 0) return true;
return ra < rb;
});
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f));
int taShown = 0;
for (size_t si = 0; si < taIdx.size() && taShown < 16; ++si) {
const auto& aura = (*totAuras)[taIdx[si]];
bool isBuff = (aura.flags & 0x80) == 0;
if (taShown > 0 && taShown % TA_PER_ROW != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(taIdx[si]) + 5000);
ImVec4 borderCol;
if (isBuff) {
borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f);
} else {
uint8_t dt = gameHandler.getSpellDispelType(aura.spellId);
switch (dt) {
case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break;
case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break;
case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break;
case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break;
default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break;
}
}
VkDescriptorSet taIcon = (totAsset)
? getSpellIcon(aura.spellId, totAsset) : VK_NULL_HANDLE;
if (taIcon) {
ImGui::PushStyleColor(ImGuiCol_Button, borderCol);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1));
ImGui::ImageButton("##taura",
(ImTextureID)(uintptr_t)taIcon,
ImVec2(TA_ICON - 2, TA_ICON - 2));
ImGui::PopStyleVar();
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Button, borderCol);
char lab[8];
snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000);
ImGui::Button(lab, ImVec2(TA_ICON, TA_ICON));
ImGui::PopStyleColor();
}
// Duration overlay
int32_t taRemain = aura.getRemainingMs(taNowMs);
if (taRemain > 0) {
ImVec2 imin = ImGui::GetItemRectMin();
ImVec2 imax = ImGui::GetItemRectMax();
char ts[12];
fmtDurationCompact(ts, sizeof(ts), (taRemain + 999) / 1000);
ImVec2 tsz = ImGui::CalcTextSize(ts);
float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f;
float cy = imax.y - tsz.y;
ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts);
ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts);
}
// Tooltip
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(
aura.spellId, gameHandler, totAsset);
if (!richOk) {
std::string nm = spellbookScreen.lookupSpellName(aura.spellId, totAsset);
if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId);
ImGui::Text("%s", nm.c_str());
}
renderAuraRemaining(taRemain);
ImGui::EndTooltip();
}
ImGui::PopID();
taShown++;
}
ImGui::PopStyleVar();
}
}
}
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
}
}
}
void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) {
auto focus = gameHandler.getFocus();
if (!focus) return;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Position: right side of screen, mirroring the target frame on the opposite side
float frameW = 200.0f;
float frameX = screenW - frameW - 10.0f;
ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
// 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) {
// Use class color for player focus targets
uint8_t cid = entityClassId(focus.get());
focusColor = (cid != 0) ? classColorVec4(cid) : kColorBrightGreen;
} else if (focus->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(focus);
if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
focusColor = kColorDarkGray;
} else if (u->isHostile()) {
// Tapped-by-other: grey focus frame name
uint32_t focDynFlags = u->getDynamicFlags();
bool focTapped = (focDynFlags & 0x0004) != 0 && (focDynFlags & 0x0008) == 0;
if (focTapped) {
focusColor = kColorGray;
} else {
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = u->getLevel();
if (mobLv == 0) {
focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // ?? level = skull red
} else {
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
if (game::GameHandler::killXp(playerLv, mobLv) == 0)
focusColor = kColorGray;
else if (diff >= 10)
focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f);
else if (diff >= 5)
focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f);
else if (diff >= -2)
focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f);
else
focusColor = kColorBrightGreen;
}
} // end tapped else
} else {
focusColor = kColorBrightGreen;
}
}
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus
if (ImGui::Begin("##FocusFrame", nullptr, flags)) {
// "Focus" label
ImGui::TextDisabled("[Focus]");
ImGui::SameLine();
// Raid mark icon (star, circle, diamond, …) preceding the name
{
static constexpr struct { const char* sym; ImU32 col; } kFocusMarks[] = {
{ "\xe2\x98\x85", IM_COL32(255, 204, 0, 255) }, // 0 Star (yellow)
{ "\xe2\x97\x8f", IM_COL32(255, 103, 0, 255) }, // 1 Circle (orange)
{ "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple)
{ "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green)
{ "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue)
{ "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal)
{ "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red)
{ "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white)
};
uint8_t fmark = gameHandler.getEntityRaidMark(focus->getGuid());
if (fmark < game::GameHandler::kRaidMarkCount) {
ImGui::GetWindowDrawList()->AddText(
ImGui::GetCursorScreenPos(),
kFocusMarks[fmark].col, kFocusMarks[fmark].sym);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f);
}
}
std::string focusName = getEntityName(focus);
ImGui::PushStyleColor(ImGuiCol_Text, focusColor);
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f));
ImGui::Selectable(focusName.c_str(), false, ImGuiSelectableFlags_DontClosePopups,
ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0));
ImGui::PopStyleColor(4);
// Right-click context menu on focus frame
if (ImGui::BeginPopupContextItem("##FocusFrameCtx")) {
ImGui::TextDisabled("%s", focusName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(focus->getGuid());
if (ImGui::MenuItem("Clear Focus"))
gameHandler.clearFocus();
if (focus->getType() == game::ObjectType::PLAYER) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
chatPanel_.setWhisperTarget(focusName);
}
if (ImGui::MenuItem("Invite to Group"))
gameHandler.inviteToGroup(focusName);
if (ImGui::MenuItem("Trade"))
gameHandler.initiateTrade(focus->getGuid());
if (ImGui::MenuItem("Duel"))
gameHandler.proposeDuel(focus->getGuid());
if (ImGui::MenuItem("Inspect")) {
gameHandler.setTarget(focus->getGuid());
gameHandler.inspectTarget();
socialPanel_.showInspectWindow_ = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Add Friend"))
gameHandler.addFriend(focusName);
if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(focusName);
}
ImGui::EndPopup();
}
// Group leader crown — golden ♛ when the focused player is the party/raid leader
if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) {
if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) {
ImGui::SameLine(0, 4);
ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader");
}
}
// Quest giver indicator and classification badge for NPC focus targets
if (focus->getType() == game::ObjectType::UNIT) {
auto focusUnit = std::static_pointer_cast<game::Unit>(focus);
// Quest indicator: ! / ?
{
using QGS = game::QuestGiverStatus;
QGS qgs = gameHandler.getQuestGiverStatus(focus->getGuid());
if (qgs == QGS::AVAILABLE) {
ImGui::SameLine(0, 4);
ImGui::TextColored(colors::kBrightGold, "!");
} else if (qgs == QGS::AVAILABLE_LOW) {
ImGui::SameLine(0, 4);
ImGui::TextColored(kColorGray, "!");
} else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) {
ImGui::SameLine(0, 4);
ImGui::TextColored(colors::kBrightGold, "?");
} else if (qgs == QGS::INCOMPLETE) {
ImGui::SameLine(0, 4);
ImGui::TextColored(kColorGray, "?");
}
}
// Classification badge
int fRank = gameHandler.getCreatureRank(focusUnit->getEntry());
if (fRank == 1) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.8f,0.2f,1.0f), "[Elite]"); }
else if (fRank == 2) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.8f,0.4f,1.0f,1.0f), "[Rare Elite]"); }
else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(colors::kRed, "[Boss]"); }
else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); }
// Creature type
{
uint32_t fctype = gameHandler.getCreatureType(focusUnit->getEntry());
const char* fctName = nullptr;
switch (fctype) {
case 1: fctName="Beast"; break; case 2: fctName="Dragonkin"; break;
case 3: fctName="Demon"; break; case 4: fctName="Elemental"; break;
case 5: fctName="Giant"; break; case 6: fctName="Undead"; break;
case 7: fctName="Humanoid"; break; case 8: fctName="Critter"; break;
case 9: fctName="Mechanical"; break; case 11: fctName="Totem"; break;
case 12: fctName="Non-combat Pet"; break; case 13: fctName="Gas Cloud"; break;
default: break;
}
if (fctName) {
ImGui::SameLine(0, 4);
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", fctName);
}
}
// Creature subtitle
const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry());
if (!fSub.empty())
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str());
}
// Player guild name on focus frame
if (focus->getType() == game::ObjectType::PLAYER) {
uint32_t guildId = gameHandler.getEntityGuildId(focus->getGuid());
if (guildId != 0) {
const std::string& gn = gameHandler.lookupGuildName(guildId);
if (!gn.empty()) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str());
}
}
}
if (ImGui::BeginPopupContextItem("##FocusNameCtx")) {
const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER);
const uint64_t fGuid = focus->getGuid();
ImGui::TextDisabled("%s", focusName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(fGuid);
if (ImGui::MenuItem("Clear Focus"))
gameHandler.clearFocus();
if (focusIsPlayer) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
chatPanel_.setWhisperTarget(focusName);
}
if (ImGui::MenuItem("Invite to Group"))
gameHandler.inviteToGroup(focusName);
if (ImGui::MenuItem("Trade"))
gameHandler.initiateTrade(fGuid);
if (ImGui::MenuItem("Duel"))
gameHandler.proposeDuel(fGuid);
if (ImGui::MenuItem("Inspect")) {
gameHandler.setTarget(fGuid);
gameHandler.inspectTarget();
socialPanel_.showInspectWindow_ = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Add Friend"))
gameHandler.addFriend(focusName);
if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(focusName);
}
ImGui::EndPopup();
}
if (focus->getType() == game::ObjectType::UNIT ||
focus->getType() == game::ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<game::Unit>(focus);
// Level + health on same row
ImGui::SameLine();
if (unit->getLevel() == 0)
ImGui::TextDisabled("Lv ??");
else
ImGui::TextDisabled("Lv %u", unit->getLevel());
uint32_t hp = unit->getHealth();
uint32_t maxHp = unit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? colors::kFriendlyGreen :
pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) :
colors::kDangerRed);
char overlay[32];
snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay);
ImGui::PopStyleColor();
// Power bar
uint8_t pType = unit->getPowerType();
uint32_t pwr = unit->getPower();
uint32_t maxPwr = unit->getMaxPower();
if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100;
if (maxPwr > 0) {
float mpPct = static_cast<float>(pwr) / static_cast<float>(maxPwr);
ImVec4 pwrColor;
switch (pType) {
case 0: pwrColor = colors::kManaBlue; break;
case 1: pwrColor = colors::kDarkRed; break;
case 3: pwrColor = colors::kEnergyYellow; break;
case 6: pwrColor = colors::kRunicRed; break;
default: pwrColor = colors::kManaBlue; break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor);
ImGui::ProgressBar(mpPct, ImVec2(-1, 10), "");
ImGui::PopStyleColor();
}
}
// Focus cast bar
const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid());
if (focusCast) {
float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f;
float rem = focusCast->timeRemaining;
float prog = std::clamp(1.0f - rem / total, 0.f, 1.f);
const std::string& spName = gameHandler.getSpellName(focusCast->spellId);
// Pulse orange when > 80% complete — interrupt window closing
ImVec4 focusCastColor;
if (prog > 0.8f) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(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);
else
snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem);
{
auto* fcAsset = services_.assetManager;
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();
}
}
// Focus auras — buffs first, then debuffs, up to 8 icons wide
{
const std::vector<game::AuraSlot>* focusAuras =
(focus->getGuid() == gameHandler.getTargetGuid())
? &gameHandler.getTargetAuras()
: gameHandler.getUnitAuras(focus->getGuid());
if (focusAuras) {
int activeCount = 0;
for (const auto& a : *focusAuras) if (!a.isEmpty()) activeCount++;
if (activeCount > 0) {
auto* focusAsset = services_.assetManager;
constexpr float FA_ICON = 20.0f;
constexpr int FA_PER_ROW = 10;
ImGui::Separator();
uint64_t faNowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
// Sort: debuffs first (so hostile-caster info is prominent), then buffs
std::vector<size_t> faIdx;
faIdx.reserve(focusAuras->size());
for (size_t i = 0; i < focusAuras->size(); ++i)
if (!(*focusAuras)[i].isEmpty()) faIdx.push_back(i);
std::sort(faIdx.begin(), faIdx.end(), [&](size_t a, size_t b) {
bool aD = ((*focusAuras)[a].flags & 0x80) != 0;
bool bD = ((*focusAuras)[b].flags & 0x80) != 0;
if (aD != bD) return aD > bD; // debuffs first
int32_t ra = (*focusAuras)[a].getRemainingMs(faNowMs);
int32_t rb = (*focusAuras)[b].getRemainingMs(faNowMs);
if (ra < 0 && rb < 0) return false;
if (ra < 0) return false;
if (rb < 0) return true;
return ra < rb;
});
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f));
int faShown = 0;
for (size_t si = 0; si < faIdx.size() && faShown < 20; ++si) {
const auto& aura = (*focusAuras)[faIdx[si]];
bool isBuff = (aura.flags & 0x80) == 0;
if (faShown > 0 && faShown % FA_PER_ROW != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(faIdx[si]) + 3000);
ImVec4 borderCol;
if (isBuff) {
borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f);
} else {
uint8_t dt = gameHandler.getSpellDispelType(aura.spellId);
switch (dt) {
case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break;
case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break;
case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break;
case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break;
default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break;
}
}
VkDescriptorSet faIcon = (focusAsset)
? getSpellIcon(aura.spellId, focusAsset) : VK_NULL_HANDLE;
if (faIcon) {
ImGui::PushStyleColor(ImGuiCol_Button, borderCol);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1));
ImGui::ImageButton("##faura",
(ImTextureID)(uintptr_t)faIcon,
ImVec2(FA_ICON - 2, FA_ICON - 2));
ImGui::PopStyleVar();
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Button, borderCol);
char lab[8];
snprintf(lab, sizeof(lab), "%u", aura.spellId);
ImGui::Button(lab, ImVec2(FA_ICON, FA_ICON));
ImGui::PopStyleColor();
}
// Duration overlay
int32_t faRemain = aura.getRemainingMs(faNowMs);
if (faRemain > 0) {
ImVec2 imin = ImGui::GetItemRectMin();
ImVec2 imax = ImGui::GetItemRectMax();
char ts[12];
fmtDurationCompact(ts, sizeof(ts), (faRemain + 999) / 1000);
ImVec2 tsz = ImGui::CalcTextSize(ts);
float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f;
float cy = imax.y - tsz.y - 1.0f;
ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts);
ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts);
}
// Stack / charge count — upper-left corner (parity with target frame)
if (aura.charges > 1) {
ImVec2 faMin = ImGui::GetItemRectMin();
char chargeStr[8];
snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast<unsigned>(aura.charges));
ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 3, faMin.y + 3),
IM_COL32(0, 0, 0, 200), chargeStr);
ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 2, faMin.y + 2),
IM_COL32(255, 220, 50, 255), chargeStr);
}
// Tooltip
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(
aura.spellId, gameHandler, focusAsset);
if (!richOk) {
std::string nm = spellbookScreen.lookupSpellName(aura.spellId, focusAsset);
if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId);
ImGui::Text("%s", nm.c_str());
}
renderAuraRemaining(faRemain);
ImGui::EndTooltip();
}
ImGui::PopID();
faShown++;
}
ImGui::PopStyleVar();
}
}
}
// Target-of-Focus: who the focus target is currently targeting
{
uint64_t fofGuid = 0;
const auto& fFields = focus->getFields();
auto fItLo = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO));
if (fItLo != fFields.end()) {
fofGuid = fItLo->second;
auto fItHi = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI));
if (fItHi != fFields.end())
fofGuid |= (static_cast<uint64_t>(fItHi->second) << 32);
}
if (fofGuid != 0) {
auto fofEnt = gameHandler.getEntityManager().getEntity(fofGuid);
std::string fofName;
ImVec4 fofColor(0.7f, 0.7f, 0.7f, 1.0f);
if (fofGuid == gameHandler.getPlayerGuid()) {
fofName = "You";
fofColor = kColorBrightGreen;
} else if (fofEnt) {
fofName = getEntityName(fofEnt);
uint8_t fcid = entityClassId(fofEnt.get());
if (fcid != 0) fofColor = classColorVec4(fcid);
}
if (!fofName.empty()) {
ImGui::TextDisabled("");
ImGui::SameLine(0, 2);
ImGui::TextColored(fofColor, "%s", fofName.c_str());
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Focus's target: %s\nClick to target", fofName.c_str());
if (ImGui::IsItemClicked())
gameHandler.setTarget(fofGuid);
// Compact health bar for target-of-focus
if (fofEnt) {
auto fofUnit = std::dynamic_pointer_cast<game::Unit>(fofEnt);
if (fofUnit && fofUnit->getMaxHealth() > 0) {
float fofPct = static_cast<float>(fofUnit->getHealth()) /
static_cast<float>(fofUnit->getMaxHealth());
ImVec4 fofBarColor =
fofPct > 0.5f ? colors::kCastGreen :
fofPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) :
ImVec4(0.75f, 0.2f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, fofBarColor);
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
char fofOverlay[32];
snprintf(fofOverlay, sizeof(fofOverlay), "%u%%",
static_cast<unsigned>(fofPct * 100.0f + 0.5f));
ImGui::ProgressBar(fofPct, ImVec2(-1, 10), fofOverlay);
ImGui::PopStyleColor(2);
}
}
}
}
}
// Distance to focus target
{
const auto& mv = gameHandler.getMovementInfo();
float fdx = focus->getX() - mv.x;
float fdy = focus->getY() - mv.y;
float fdz = focus->getZ() - mv.z;
float fdist = std::sqrt(fdx * fdx + fdy * fdy + fdz * fdz);
ImGui::TextDisabled("%.1f yd", fdist);
}
// Clicking the focus frame targets it
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) {
gameHandler.setTarget(focus->getGuid());
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void GameScreen::updateCharacterGeosets(game::Inventory& inventory) {
auto& app = core::Application::getInstance();
auto* renderer = app.getRenderer();
if (!renderer) return;
uint32_t instanceId = renderer->getCharacterInstanceId();
if (instanceId == 0) return;
auto* charRenderer = renderer->getCharacterRenderer();
if (!charRenderer) return;
auto* assetManager = app.getAssetManager();
// Load ItemDisplayInfo.dbc for geosetGroup lookup
std::shared_ptr<pipeline::DBCFile> displayInfoDbc;
if (assetManager) {
displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
}
// Helper: get geosetGroup field for an equipped item's displayInfoId
// DBC binary fields: 7=geosetGroup_1, 8=geosetGroup_2, 9=geosetGroup_3
auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t {
if (!displayInfoDbc || displayInfoId == 0) return 0;
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
if (recIdx < 0) return 0;
return displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
};
// Helper: find first equipped item matching inventoryType, return its displayInfoId
auto findEquippedDisplayId = [&](std::initializer_list<uint8_t> types) -> uint32_t {
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (!slot.empty()) {
for (uint8_t t : types) {
if (slot.item.inventoryType == t)
return slot.item.displayInfoId;
}
}
}
return 0;
};
// Helper: check if any equipment slot has the given inventoryType
auto hasEquippedType = [&](std::initializer_list<uint8_t> types) -> bool {
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (!slot.empty()) {
for (uint8_t t : types) {
if (slot.item.inventoryType == t) return true;
}
}
}
return false;
};
// Base geosets always present (group 0: IDs 0-99, some models use up to 27)
std::unordered_set<uint16_t> geosets;
for (uint16_t i = 0; i <= 99; i++) {
geosets.insert(i);
}
// Hair/facial geosets must match the active character's appearance, otherwise
// we end up forcing a default hair mesh (often perceived as "wrong hair").
{
uint8_t hairStyleId = 0;
uint8_t facialId = 0;
if (auto* gh = app.getGameHandler()) {
if (const auto* ch = gh->getActiveCharacter()) {
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
facialId = ch->facialFeatures;
}
}
geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1)); // Group 1 hair
geosets.insert(static_cast<uint16_t>(200 + facialId + 1)); // Group 2 facial
}
geosets.insert(702); // Ears: visible (default)
geosets.insert(2002); // Bare feet mesh (group 20 = CG_FEET, always on)
// CharGeosets mapping (verified via vertex bounding boxes):
// Group 4 (401+) = GLOVES (forearm area, Z~1.1-1.4)
// Group 5 (501+) = BOOTS (shin area, Z~0.1-0.6)
// Group 8 (801+) = WRISTBANDS/SLEEVES (controlled by chest armor)
// Group 9 (901+) = KNEEPADS
// Group 13 (1301+) = TROUSERS/PANTS
// Group 15 (1501+) = CAPE/CLOAK
// Group 20 (2002) = FEET
// Gloves: inventoryType 10 → group 4 (forearms)
// 401=bare forearms, 402+=glove styles covering forearm
{
uint32_t did = findEquippedDisplayId({10});
uint32_t gg = getGeosetGroup(did, 0);
geosets.insert(static_cast<uint16_t>(gg > 0 ? 401 + gg : 401));
}
// Boots: inventoryType 8 → group 5 (shins/lower legs)
// 501=narrow bare shin, 502=wider (matches thigh width better). Use 502 as bare default.
// When boots equipped, gg selects boot style: 501+gg (gg=1→502, gg=2→503, etc.)
{
uint32_t did = findEquippedDisplayId({8});
uint32_t gg = getGeosetGroup(did, 0);
geosets.insert(static_cast<uint16_t>(gg > 0 ? 501 + gg : 502));
}
// Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe)
// Controls group 8 (wristbands/sleeve length): 801=bare wrists, 802+=sleeve styles
// Also controls group 13 (trousers) via GeosetGroup[2] for robes
{
uint32_t did = findEquippedDisplayId({4, 5, 20});
uint32_t gg = getGeosetGroup(did, 0);
geosets.insert(static_cast<uint16_t>(gg > 0 ? 801 + gg : 801));
// Robe kilt: GeosetGroup[2] > 0 → show kilt legs (1302+)
uint32_t gg3 = getGeosetGroup(did, 2);
if (gg3 > 0) {
geosets.insert(static_cast<uint16_t>(1301 + gg3));
}
}
// Kneepads: group 9 (always default 902)
geosets.insert(902);
// Legs/Pants: inventoryType 7 → group 13 (trousers/thighs)
// 1301=bare legs, 1302+=pant/kilt styles
{
uint32_t did = findEquippedDisplayId({7});
uint32_t gg = getGeosetGroup(did, 0);
// Only add if robe hasn't already set a kilt geoset
if (geosets.count(1302) == 0 && geosets.count(1303) == 0) {
geosets.insert(static_cast<uint16_t>(gg > 0 ? 1301 + gg : 1301));
}
}
// Back/Cloak: inventoryType 16 → group 15
geosets.insert(hasEquippedType({16}) ? 1502 : 1501);
// Tabard: inventoryType 19 → group 12
if (hasEquippedType({19})) {
geosets.insert(1201);
}
charRenderer->setActiveGeosets(instanceId, geosets);
}
void GameScreen::updateCharacterTextures(game::Inventory& inventory) {
auto& app = core::Application::getInstance();
auto* renderer = app.getRenderer();
if (!renderer) return;
auto* charRenderer = renderer->getCharacterRenderer();
if (!charRenderer) return;
auto* assetManager = app.getAssetManager();
if (!assetManager) return;
const auto& bodySkinPath = app.getBodySkinPath();
const auto& underwearPaths = app.getUnderwearPaths();
uint32_t skinSlot = app.getSkinTextureSlotIndex();
if (bodySkinPath.empty()) return;
// Component directory names indexed by region
static constexpr const char* componentDirs[] = {
"ArmUpperTexture", // 0
"ArmLowerTexture", // 1
"HandTexture", // 2
"TorsoUpperTexture", // 3
"TorsoLowerTexture", // 4
"LegUpperTexture", // 5
"LegLowerTexture", // 6
"FootTexture", // 7
};
// Load ItemDisplayInfo.dbc
auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
if (!displayInfoDbc) return;
const auto* idiL = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
// Texture component region fields (8 regions: ArmUpper..Foot)
// Binary DBC (23 fields) has textures at 14+
const uint32_t texRegionFields[8] = {
idiL ? (*idiL)["TextureArmUpper"] : 14u,
idiL ? (*idiL)["TextureArmLower"] : 15u,
idiL ? (*idiL)["TextureHand"] : 16u,
idiL ? (*idiL)["TextureTorsoUpper"]: 17u,
idiL ? (*idiL)["TextureTorsoLower"]: 18u,
idiL ? (*idiL)["TextureLegUpper"] : 19u,
idiL ? (*idiL)["TextureLegLower"] : 20u,
idiL ? (*idiL)["TextureFoot"] : 21u,
};
// Collect equipment texture regions from all equipped items
std::vector<std::pair<int, std::string>> regionLayers;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (slot.empty() || slot.item.displayInfoId == 0) continue;
int32_t recIdx = displayInfoDbc->findRecordById(slot.item.displayInfoId);
if (recIdx < 0) continue;
for (int region = 0; region < 8; region++) {
std::string texName = displayInfoDbc->getString(
static_cast<uint32_t>(recIdx), texRegionFields[region]);
if (texName.empty()) continue;
// Actual MPQ files have a gender suffix: _M (male), _F (female), _U (unisex)
// Try gender-specific first, then unisex fallback
std::string base = "Item\\TextureComponents\\" +
std::string(componentDirs[region]) + "\\" + texName;
// Determine gender suffix from active character
bool isFemale = false;
if (auto* gh = app.getGameHandler()) {
if (auto* ch = gh->getActiveCharacter()) {
isFemale = (ch->gender == game::Gender::FEMALE) ||
(ch->gender == game::Gender::NONBINARY && ch->useFemaleModel);
}
}
std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp");
std::string unisexPath = base + "_U.blp";
std::string fullPath;
if (assetManager->fileExists(genderPath)) {
fullPath = genderPath;
} else if (assetManager->fileExists(unisexPath)) {
fullPath = unisexPath;
} else {
// Last resort: try without suffix
fullPath = base + ".blp";
}
regionLayers.emplace_back(region, fullPath);
}
}
// Re-composite: base skin + underwear + equipment regions
// Clear composite cache first to prevent stale textures from being reused
charRenderer->clearCompositeCache();
// Use per-instance texture override (not model-level) to avoid deleting cached composites.
uint32_t instanceId = renderer->getCharacterInstanceId();
auto* newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers);
if (newTex != nullptr && instanceId != 0) {
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(skinSlot), newTex);
}
// Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin)
uint32_t cloakSlot = app.getCloakTextureSlotIndex();
if (cloakSlot > 0 && instanceId != 0) {
// Find equipped cloak (inventoryType 16)
uint32_t cloakDisplayId = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (!slot.empty() && slot.item.inventoryType == 16 && slot.item.displayInfoId != 0) {
cloakDisplayId = slot.item.displayInfoId;
break;
}
}
if (cloakDisplayId > 0) {
int32_t recIdx = displayInfoDbc->findRecordById(cloakDisplayId);
if (recIdx >= 0) {
// DBC field 3 = modelTexture_1 (cape texture name)
const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
std::string capeName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), dispL ? (*dispL)["LeftModelTexture"] : 3);
if (!capeName.empty()) {
std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp";
auto* capeTex = charRenderer->loadTexture(capePath);
if (capeTex != nullptr) {
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot), capeTex);
LOG_INFO("Cloak texture applied: ", capePath);
}
}
}
} else {
// No cloak equipped — clear override so model's default (white) shows
charRenderer->clearTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot));
}
}
}
// ============================================================
// World Map
// ============================================================
void GameScreen::renderWorldMap(game::GameHandler& gameHandler) {
if (!showWorldMap_) return;
auto& app = core::Application::getInstance();
auto* renderer = app.getRenderer();
if (!renderer) return;
auto* wm = renderer->getWorldMap();
if (!wm) return;
// Keep map name in sync with minimap's map name
auto* minimap = renderer->getMinimap();
if (minimap) {
wm->setMapName(minimap->getMapName());
}
wm->setServerExplorationMask(
gameHandler.getPlayerExploredZoneMasks(),
gameHandler.hasPlayerExploredZoneMasks());
// Party member dots on world map
{
std::vector<rendering::WorldMapPartyDot> dots;
if (gameHandler.isInGroup()) {
const auto& partyData = gameHandler.getPartyData();
for (const auto& member : partyData.members) {
if (!member.isOnline || !member.hasPartyStats) continue;
if (member.posX == 0 && member.posY == 0) continue;
// posY → canonical X (north), posX → canonical Y (west)
float wowX = static_cast<float>(member.posY);
float wowY = static_cast<float>(member.posX);
glm::vec3 rpos = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f));
auto ent = gameHandler.getEntityManager().getEntity(member.guid);
uint8_t cid = entityClassId(ent.get());
ImU32 col = (cid != 0)
? classColorU32(cid, 230)
: (member.guid == partyData.leaderGuid
? IM_COL32(255, 210, 0, 230)
: IM_COL32(100, 180, 255, 230));
dots.push_back({ rpos, col, member.name });
}
}
wm->setPartyDots(std::move(dots));
}
// Taxi node markers on world map
{
std::vector<rendering::WorldMapTaxiNode> taxiNodes;
const auto& nodes = gameHandler.getTaxiNodes();
taxiNodes.reserve(nodes.size());
for (const auto& [id, node] : nodes) {
rendering::WorldMapTaxiNode wtn;
wtn.id = node.id;
wtn.mapId = node.mapId;
wtn.wowX = node.x;
wtn.wowY = node.y;
wtn.wowZ = node.z;
wtn.name = node.name;
wtn.known = gameHandler.isKnownTaxiNode(id);
taxiNodes.push_back(std::move(wtn));
}
wm->setTaxiNodes(std::move(taxiNodes));
}
// Quest POI markers on world map (from SMSG_QUEST_POI_QUERY_RESPONSE / gossip POIs)
{
std::vector<rendering::WorldMap::QuestPoi> qpois;
for (const auto& poi : gameHandler.getGossipPois()) {
rendering::WorldMap::QuestPoi qp;
qp.wowX = poi.x;
qp.wowY = poi.y;
qp.name = poi.name;
qpois.push_back(std::move(qp));
}
wm->setQuestPois(std::move(qpois));
}
// Corpse marker: show skull X on world map when ghost with unclaimed corpse
{
float corpseCanX = 0.0f, corpseCanY = 0.0f;
bool ghostWithCorpse = gameHandler.isPlayerGhost() &&
gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY);
glm::vec3 corpseRender = ghostWithCorpse
? core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f))
: glm::vec3{};
wm->setCorpsePos(ghostWithCorpse, corpseRender);
}
glm::vec3 playerPos = renderer->getCharacterPosition();
float playerYaw = renderer->getCharacterYaw();
auto* window = app.getWindow();
int screenW = window ? window->getWidth() : 1280;
int screenH = window ? window->getHeight() : 720;
wm->render(playerPos, screenW, screenH, playerYaw);
// Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay).
if (!wm->isOpen()) showWorldMap_ = false;
}
// ============================================================
// Action Bar (Phase 3)
// ============================================================
VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) {
if (spellId == 0 || !am) return VK_NULL_HANDLE;
// Check cache first
auto cit = spellIconCache_.find(spellId);
if (cit != spellIconCache_.end()) return cit->second;
// Lazy-load SpellIcon.dbc and Spell.dbc icon IDs
if (!spellIconDbLoaded_) {
spellIconDbLoaded_ = true;
// Load SpellIcon.dbc: field 0 = ID, field 1 = icon path
auto iconDbc = am->loadDBC("SpellIcon.dbc");
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
if (iconDbc && iconDbc->isLoaded()) {
for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) {
uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0);
std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1);
if (!path.empty() && id > 0) {
spellIconPaths_[id] = path;
}
}
}
// Load Spell.dbc: SpellIconID field
auto spellDbc = am->loadDBC("Spell.dbc");
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
if (spellDbc && spellDbc->isLoaded()) {
uint32_t fieldCount = spellDbc->getFieldCount();
// Helper to load icons for a given field layout
auto tryLoadIcons = [&](uint32_t idField, uint32_t iconField) {
spellIconIds_.clear();
if (iconField >= fieldCount) return;
for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) {
uint32_t id = spellDbc->getUInt32(i, idField);
uint32_t iconId = spellDbc->getUInt32(i, iconField);
if (id > 0 && iconId > 0) {
spellIconIds_[id] = iconId;
}
}
};
// Use expansion-aware layout if available AND the DBC field count
// matches the expansion's expected format. Classic=173, TBC=216,
// WotLK=234 fields. When Classic is active but the base WotLK DBC
// is loaded (234 fields), field 117 is NOT IconID — we must use
// the WotLK field 133 instead.
uint32_t iconField = 133; // WotLK default
uint32_t idField = 0;
if (spellL) {
uint32_t layoutIcon = (*spellL)["IconID"];
// Only trust the expansion layout if the DBC has a compatible
// field count (within ~20 of the layout's icon field).
if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) {
iconField = layoutIcon;
idField = (*spellL)["ID"];
}
}
tryLoadIcons(idField, iconField);
}
}
// Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached
// (e.g., first login, after loading screen, or many new auras appearing at once).
static int gsLoadsThisFrame = 0;
static int gsLastImGuiFrame = -1;
int gsCurFrame = ImGui::GetFrameCount();
if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; }
if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
// Look up spellId -> SpellIconID -> icon path
auto iit = spellIconIds_.find(spellId);
if (iit == spellIconIds_.end()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
auto pit = spellIconPaths_.find(iit->second);
if (pit == spellIconPaths_.end()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
// Path from DBC has no extension — append .blp
std::string iconPath = pit->second + ".blp";
auto blpData = am->readFile(iconPath);
if (blpData.empty()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
auto image = pipeline::BLPLoader::load(blpData);
if (!image.isValid()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
// Upload to Vulkan via VkContext
auto* window = services_.window;
auto* vkCtx = window ? window->getVkContext() : nullptr;
if (!vkCtx) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
++gsLoadsThisFrame;
VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
spellIconCache_[spellId] = ds;
return ds;
}
// ============================================================
// Stance / Form / Presence Bar
// Shown for Warriors (stances), Death Knights (presences),
// Druids (shapeshift forms), Rogues (stealth), Priests (Shadowform).
// Buttons display the player's known stance/form spells.
// Active form is detected by checking permanent player auras.
// ============================================================
// ============================================================
// Bag Bar
// ============================================================
// ============================================================
// XP Bar
// ============================================================
// ============================================================
// Reputation Bar
// ============================================================
// ============================================================
// Cast Bar (Phase 3)
// ============================================================
// ============================================================
// Mirror Timers (breath / fatigue / feign death)
// ============================================================
void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) {
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;
static constexpr struct { const char* label; ImVec4 color; } kTimerInfo[3] = {
{ "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) },
{ "Breath", ImVec4(0.2f, 0.5f, 1.0f, 1.0f) },
{ "Feign", kColorGray },
};
float barW = 280.0f;
float barH = 36.0f;
float barX = (screenW - barW) / 2.0f;
float baseY = screenH - 160.0f; // Just above the cast bar slot
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoInputs;
for (int i = 0; i < 3; ++i) {
const auto& t = gameHandler.getMirrorTimer(i);
if (!t.active || t.maxValue <= 0) continue;
float frac = static_cast<float>(t.value) / static_cast<float>(t.maxValue);
frac = std::max(0.0f, std::min(1.0f, frac));
char winId[32];
std::snprintf(winId, sizeof(winId), "##MirrorTimer%d", i);
ImGui::SetNextWindowPos(ImVec2(barX, baseY - i * (barH + 4.0f)), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.88f));
if (ImGui::Begin(winId, nullptr, flags)) {
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kTimerInfo[i].color);
char overlay[48];
float sec = static_cast<float>(t.value) / 1000.0f;
std::snprintf(overlay, sizeof(overlay), "%s %.0fs", kTimerInfo[i].label, sec);
ImGui::ProgressBar(frac, ImVec2(-1, 20), overlay);
ImGui::PopStyleColor();
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
}
// ============================================================
// Cooldown Tracker — floating panel showing all active spell CDs
// ============================================================
// ============================================================
// Quest Objective Tracker (right-side HUD)
// ============================================================
void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
const auto& questLog = gameHandler.getQuestLog();
if (questLog.empty()) return;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
constexpr float TRACKER_W = 220.0f;
constexpr float RIGHT_MARGIN = 10.0f;
constexpr int MAX_QUESTS = 5;
// Build display list: tracked quests only, or all quests if none tracked
const auto& trackedIds = gameHandler.getTrackedQuestIds();
std::vector<const game::GameHandler::QuestLogEntry*> toShow;
toShow.reserve(MAX_QUESTS);
if (!trackedIds.empty()) {
for (const auto& q : questLog) {
if (q.questId == 0) continue;
if (trackedIds.count(q.questId)) toShow.push_back(&q);
if (static_cast<int>(toShow.size()) >= MAX_QUESTS) break;
}
}
// Fallback: show all quests if nothing is tracked
if (toShow.empty()) {
for (const auto& q : questLog) {
if (q.questId == 0) continue;
toShow.push_back(&q);
if (static_cast<int>(toShow.size()) >= MAX_QUESTS) break;
}
}
if (toShow.empty()) return;
float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f;
// Default position: top-right, below minimap + buff bar space.
// questTrackerRightOffset_ stores pixels from the right edge so the tracker
// stays anchored to the right side when the window is resized.
if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) {
questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned
questTrackerPos_.y = 320.0f;
questTrackerPosInit_ = true;
}
// Recompute X from right offset every frame (handles window resize)
questTrackerPos_.x = screenW - questTrackerRightOffset_;
ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always);
ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoBringToFrontOnFocus;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f));
if (ImGui::Begin("##QuestTracker", nullptr, flags)) {
for (int i = 0; i < static_cast<int>(toShow.size()); ++i) {
const auto& q = *toShow[i];
// Clickable quest title — opens quest log
ImGui::PushID(q.questId);
ImVec4 titleCol = q.complete ? colors::kWarmGold
: ImVec4(1.0f, 1.0f, 0.85f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_Text, titleCol);
if (ImGui::Selectable(q.title.c_str(), false,
ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) {
questLogScreen.openAndSelectQuest(q.questId);
}
if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) {
ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options");
}
ImGui::PopStyleColor();
// Right-click context menu for quest tracker entry
if (ImGui::BeginPopupContextItem("##QTCtx")) {
ImGui::TextDisabled("%s", q.title.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Open in Quest Log")) {
questLogScreen.openAndSelectQuest(q.questId);
}
bool tracked = gameHandler.isQuestTracked(q.questId);
if (tracked) {
if (ImGui::MenuItem("Stop Tracking")) {
gameHandler.setQuestTracked(q.questId, false);
}
} else {
if (ImGui::MenuItem("Track")) {
gameHandler.setQuestTracked(q.questId, true);
}
}
if (gameHandler.isInGroup() && !q.complete) {
if (ImGui::MenuItem("Share Quest")) {
gameHandler.shareQuestWithParty(q.questId);
}
}
if (!q.complete) {
ImGui::Separator();
if (ImGui::MenuItem("Abandon Quest")) {
gameHandler.abandonQuest(q.questId);
gameHandler.setQuestTracked(q.questId, false);
}
}
ImGui::EndPopup();
}
ImGui::PopID();
// Objectives line (condensed)
if (q.complete) {
ImGui::TextColored(colors::kActiveGreen, " (Complete)");
} else {
// 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 ? kColorGreen
: ImVec4(0.75f, 0.75f, 0.75f, 1.0f);
std::string name = gameHandler.getCachedCreatureName(entry);
if (name.empty()) {
const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry);
if (goInfo && !goInfo->name.empty()) name = goInfo->name;
}
if (!name.empty()) {
ImGui::TextColored(objColor,
" %s: %u/%u", name.c_str(),
progress.first, progress.second);
} else {
ImGui::TextColored(objColor,
" %u/%u", progress.first, progress.second);
}
}
// 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 ? kColorGreen
: 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;
// Show small icon if available
uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0;
VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
if (iconTex) {
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12));
if (info && info->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
inventoryScreen.renderItemTooltip(*info);
ImGui::EndTooltip();
}
ImGui::SameLine(0, 3);
ImGui::TextColored(objColor,
"%s: %u/%u", itemName ? itemName : "Item", count, required);
if (info && info->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
inventoryScreen.renderItemTooltip(*info);
ImGui::EndTooltip();
}
} else if (itemName) {
ImGui::TextColored(objColor,
" %s: %u/%u", itemName, count, required);
if (info && info->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
inventoryScreen.renderItemTooltip(*info);
ImGui::EndTooltip();
}
} else {
ImGui::TextColored(objColor,
" Item: %u/%u", count, required);
}
}
if (q.killCounts.empty() && q.itemCounts.empty() && !q.objectives.empty()) {
const std::string& obj = q.objectives;
if (obj.size() > 40) {
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
" %.37s...", obj.c_str());
} else {
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
" %s", obj.c_str());
}
}
}
if (i < static_cast<int>(toShow.size()) - 1) {
ImGui::Spacing();
}
}
// Capture position and size after drag/resize
ImVec2 newPos = ImGui::GetWindowPos();
ImVec2 newSize = ImGui::GetWindowSize();
bool changed = false;
// Clamp within screen
newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x);
newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f);
if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f ||
std::abs(newPos.y - questTrackerPos_.y) > 0.5f) {
questTrackerPos_ = newPos;
// Update right offset so resizes keep the new position anchored
questTrackerRightOffset_ = screenW - newPos.x;
changed = true;
}
if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f ||
std::abs(newSize.y - questTrackerSize_.y) > 0.5f) {
questTrackerSize_ = newSize;
changed = true;
}
if (changed) saveSettings();
}
ImGui::End();
ImGui::PopStyleVar(2);
ImGui::PopStyleColor();
}
// ============================================================
// Raid Warning / Boss Emote Center-Screen Overlay
// ============================================================
// ============================================================
// Floating Combat Text (Phase 2)
// ============================================================
// ============================================================
// DPS / HPS Meter
// ============================================================
// ============================================================
// Nameplates — world-space health bars projected to screen
// ============================================================
void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
if (gameHandler.getState() != game::WorldState::IN_WORLD) return;
// Reset mouseover each frame; we'll set it below when the cursor is over a nameplate
gameHandler.setMouseoverGuid(0);
auto* appRenderer = services_.renderer;
if (!appRenderer) return;
rendering::Camera* camera = appRenderer->getCamera();
if (!camera) return;
auto* window = services_.window;
if (!window) return;
const float screenW = static_cast<float>(window->getWidth());
const float screenH = static_cast<float>(window->getHeight());
const glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix();
const glm::vec3 camPos = camera->getPosition();
const uint64_t playerGuid = gameHandler.getPlayerGuid();
const uint64_t targetGuid = gameHandler.getTargetGuid();
// Build set of creature entries that are kill objectives in active (incomplete) quests.
std::unordered_set<uint32_t> questKillEntries;
{
const auto& questLog = gameHandler.getQuestLog();
const auto& trackedIds = gameHandler.getTrackedQuestIds();
for (const auto& q : questLog) {
if (q.complete || q.questId == 0) continue;
// Only highlight for tracked quests (or all if nothing tracked).
if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue;
for (const auto& obj : q.killObjectives) {
if (obj.npcOrGoId > 0 && obj.required > 0) {
// Check if not already completed.
auto it = q.killCounts.find(static_cast<uint32_t>(obj.npcOrGoId));
if (it == q.killCounts.end() || it->second.first < it->second.second) {
questKillEntries.insert(static_cast<uint32_t>(obj.npcOrGoId));
}
}
}
}
}
ImDrawList* drawList = ImGui::GetBackgroundDrawList();
for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) {
if (!entityPtr || guid == playerGuid) continue;
if (!entityPtr->isUnit()) continue;
auto* unit = static_cast<game::Unit*>(entityPtr.get());
if (unit->getMaxHealth() == 0) continue;
bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER);
bool isTarget = (guid == targetGuid);
// Player nameplates use Shift+V toggle; NPC/enemy nameplates use V toggle
if (isPlayer && !settingsPanel_.showFriendlyNameplates_) continue;
if (!isPlayer && !showNameplates_) continue;
// For corpses (dead units), only show a minimal grey nameplate if selected
bool isCorpse = (unit->getHealth() == 0);
if (isCorpse && !isTarget) continue;
// Prefer the renderer's actual instance position so the nameplate tracks the
// rendered model exactly (avoids drift from the parallel entity interpolator).
glm::vec3 renderPos;
if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) {
renderPos = core::coords::canonicalToRender(
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
}
renderPos.z += 2.3f;
// Cull distance: target or other players up to 40 units; NPC others up to 20 units
glm::vec3 nameDelta = renderPos - camPos;
float distSq = glm::dot(nameDelta, nameDelta);
float cullDist = (isTarget || isPlayer) ? 40.0f : 20.0f;
if (distSq > cullDist * cullDist) continue;
// Project to clip space
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
if (clipPos.w <= 0.01f) continue; // Behind camera
glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w;
if (ndc.x < -1.2f || ndc.x > 1.2f || ndc.y < -1.2f || ndc.y > 1.2f) continue;
// NDC → screen pixels.
// The camera bakes the Vulkan Y-flip into the projection matrix, so
// NDC y = -1 is the top of the screen and y = 1 is the bottom.
// Map directly: sy = (ndc.y + 1) / 2 * screenH (no extra inversion).
float sx = (ndc.x * 0.5f + 0.5f) * screenW;
float sy = (ndc.y * 0.5f + 0.5f) * screenH;
// Fade out in the last 5 units of cull range
float fadeSq = (cullDist - 5.0f) * (cullDist - 5.0f);
float dist = std::sqrt(distSq);
float alpha = distSq < fadeSq ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f;
auto A = [&](int v) { return static_cast<int>(v * alpha); };
// Bar colour by hostility (grey for corpses)
ImU32 barColor, bgColor;
if (isCorpse) {
// Minimal grey bar for selected corpses (loot/skin targets)
barColor = IM_COL32(140, 140, 140, A(200));
bgColor = IM_COL32(70, 70, 70, A(160));
} else if (unit->isHostile()) {
// Check if mob is tapped by another player (grey nameplate)
uint32_t dynFlags = unit->getDynamicFlags();
bool tappedByOther = (dynFlags & 0x0004) != 0 && (dynFlags & 0x0008) == 0; // TAPPED but not TAPPED_BY_ALL_THREAT_LIST
if (tappedByOther) {
barColor = IM_COL32(160, 160, 160, A(200));
bgColor = IM_COL32(80, 80, 80, A(160));
} else {
barColor = IM_COL32(220, 60, 60, A(200));
bgColor = IM_COL32(100, 25, 25, A(160));
}
} else if (isPlayer) {
// Player nameplates: use class color for easy identification
uint8_t cid = entityClassId(unit);
if (cid != 0) {
ImVec4 cv = classColorVec4(cid);
barColor = IM_COL32(
static_cast<int>(cv.x * 255),
static_cast<int>(cv.y * 255),
static_cast<int>(cv.z * 255), A(210));
bgColor = IM_COL32(
static_cast<int>(cv.x * 80),
static_cast<int>(cv.y * 80),
static_cast<int>(cv.z * 80), A(160));
} else {
barColor = IM_COL32(60, 200, 80, A(200));
bgColor = IM_COL32(25, 100, 35, A(160));
}
} else {
barColor = IM_COL32(60, 200, 80, A(200));
bgColor = IM_COL32(25, 100, 35, A(160));
}
// Check if this unit is targeting the local player (threat indicator)
bool isTargetingPlayer = false;
if (unit->isHostile() && !isCorpse) {
const auto& fields = entityPtr->getFields();
auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO));
if (loIt != fields.end() && loIt->second != 0) {
uint64_t unitTarget = loIt->second;
auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI));
if (hiIt != fields.end())
unitTarget |= (static_cast<uint64_t>(hiIt->second) << 32);
isTargetingPlayer = (unitTarget == playerGuid);
}
}
// Creature rank for border styling (Elite=gold double border, Boss=red, Rare=silver)
int creatureRank = -1;
if (!isPlayer) creatureRank = gameHandler.getCreatureRank(unit->getEntry());
// Border: gold = currently selected, orange = targeting player, dark = default
ImU32 borderColor = isTarget
? IM_COL32(255, 215, 0, A(255))
: isTargetingPlayer
? IM_COL32(255, 140, 0, A(220)) // orange = this mob is targeting you
: IM_COL32(20, 20, 20, A(180));
// Bar geometry
const float barW = 80.0f * settingsPanel_.nameplateScale_;
const float barH = 8.0f * settingsPanel_.nameplateScale_;
const float barX = sx - barW * 0.5f;
// Guard against division by zero when maxHealth hasn't been populated yet
// (freshly spawned entity with default fields). 0/0 produces NaN which
// poisons all downstream geometry; +inf is clamped but still wasteful.
float healthPct = (unit->getMaxHealth() > 0)
? std::clamp(static_cast<float>(unit->getHealth()) / static_cast<float>(unit->getMaxHealth()), 0.0f, 1.0f)
: 0.0f;
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f);
// For corpses, don't fill health bar (just show grey background)
if (!isCorpse) {
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f);
}
drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f);
// Elite/Boss/Rare decoration: extra outer border with rank-specific color
if (creatureRank == 1 || creatureRank == 2) {
// Elite / Rare Elite: gold double border
drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f),
ImVec2(barX + barW + 3.0f, sy + barH + 3.0f),
IM_COL32(255, 200, 50, A(200)), 3.0f);
} else if (creatureRank == 3) {
// Boss: red double border
drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f),
ImVec2(barX + barW + 3.0f, sy + barH + 3.0f),
IM_COL32(255, 40, 40, A(200)), 3.0f);
} else if (creatureRank == 4) {
// Rare: silver double border
drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f),
ImVec2(barX + barW + 3.0f, sy + barH + 3.0f),
IM_COL32(170, 200, 230, A(200)), 3.0f);
}
// HP % text centered on health bar (non-corpse, non-full-health for readability)
if (!isCorpse && unit->getMaxHealth() > 0) {
int hpPct = static_cast<int>(healthPct * 100.0f + 0.5f);
char hpBuf[8];
snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct);
ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf);
float hpTx = sx - hpTextSz.x * 0.5f;
float hpTy = sy + (barH - hpTextSz.y) * 0.5f;
drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf);
drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf);
}
// Cast bar below health bar when unit is casting
float castBarBaseY = sy + barH + 2.0f;
float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots
{
const auto* cs = gameHandler.getUnitCastState(guid);
if (cs && cs->casting && cs->timeTotal > 0.0f) {
float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f);
const float cbH = 6.0f * settingsPanel_.nameplateScale_;
// Spell icon + name above the cast bar
const std::string& spellName = gameHandler.getSpellName(cs->spellId);
{
auto* castAm = services_.assetManager;
VkDescriptorSet castIcon = (cs->spellId && castAm)
? getSpellIcon(cs->spellId, castAm) : VK_NULL_HANDLE;
float iconSz = cbH + 8.0f;
if (castIcon) {
// Draw icon to the left of the cast bar
float iconX = barX - iconSz - 2.0f;
float iconY = castBarBaseY;
drawList->AddImage((ImTextureID)(uintptr_t)castIcon,
ImVec2(iconX, iconY),
ImVec2(iconX + iconSz, iconY + iconSz));
drawList->AddRect(ImVec2(iconX - 1.0f, iconY - 1.0f),
ImVec2(iconX + iconSz + 1.0f, iconY + iconSz + 1.0f),
IM_COL32(0, 0, 0, A(180)), 1.0f);
}
if (!spellName.empty()) {
ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str());
float snX = sx - snSz.x * 0.5f;
float snY = castBarBaseY;
drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str());
drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str());
castBarBaseY += snSz.y + 2.0f;
}
}
// Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete
ImU32 cbBg = IM_COL32(30, 25, 40, A(180));
ImU32 cbFill;
if (castPct > 0.8f && unit->isHostile()) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
cbFill = cs->interruptible
? IM_COL32(static_cast<int>(40 * pulse), static_cast<int>(220 * pulse), static_cast<int>(40 * pulse), A(220)) // green pulse
: IM_COL32(static_cast<int>(255 * pulse), static_cast<int>(30 * pulse), static_cast<int>(30 * pulse), A(220)); // red pulse
} else {
cbFill = cs->interruptible
? IM_COL32(50, 190, 50, A(200)) // green = interruptible
: IM_COL32(190, 40, 40, A(200)); // red = uninterruptible
}
drawList->AddRectFilled(ImVec2(barX, castBarBaseY),
ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f);
drawList->AddRectFilled(ImVec2(barX, castBarBaseY),
ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f);
drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f),
ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f),
IM_COL32(20, 10, 40, A(200)), 2.0f);
// Time remaining text
char timeBuf[12];
snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining);
ImVec2 timeSz = ImGui::CalcTextSize(timeBuf);
float timeX = sx - timeSz.x * 0.5f;
float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f;
drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf);
drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf);
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 * settingsPanel_.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);
// Duration clock-sweep overlay (like target frame auras)
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);
if (aura.maxDurationMs > 0 && remainMs > 0) {
float pct = 1.0f - static_cast<float>(remainMs) / static_cast<float>(aura.maxDurationMs);
pct = std::clamp(pct, 0.0f, 1.0f);
float cx = dotX + dotSize * 0.5f;
float cy = nameplateBottom + dotSize * 0.5f;
float r = dotSize * 0.5f;
float startAngle = -IM_PI * 0.5f;
float endAngle = startAngle + pct * IM_PI * 2.0f;
ImVec2 center(cx, cy);
const int segments = 12;
for (int seg = 0; seg < segments; seg++) {
float a0 = startAngle + (endAngle - startAngle) * seg / segments;
float a1 = startAngle + (endAngle - startAngle) * (seg + 1) / segments;
drawList->AddTriangleFilled(
center,
ImVec2(cx + r * std::cos(a0), cy + r * std::sin(a0)),
ImVec2(cx + r * std::cos(a1), cy + r * std::sin(a1)),
IM_COL32(0, 0, 0, A(100)));
}
}
// Stack count on dot (upper-left corner)
if (aura.charges > 1) {
char stackBuf[8];
snprintf(stackBuf, sizeof(stackBuf), "%d", aura.charges);
drawList->AddText(ImVec2(dotX + 1.0f, nameplateBottom), IM_COL32(0, 0, 0, A(200)), stackBuf);
drawList->AddText(ImVec2(dotX, nameplateBottom - 1.0f), IM_COL32(255, 255, 255, A(240)), stackBuf);
}
// Duration text below dot
if (remainMs > 0) {
char durBuf[8];
if (remainMs >= 60000)
snprintf(durBuf, sizeof(durBuf), "%dm", remainMs / 60000);
else
snprintf(durBuf, sizeof(durBuf), "%d", remainMs / 1000);
ImVec2 durSz = ImGui::CalcTextSize(durBuf);
float durX = dotX + (dotSize - durSz.x) * 0.5f;
float durY = nameplateBottom + dotSize + 1.0f;
drawList->AddText(ImVec2(durX + 1.0f, durY + 1.0f), IM_COL32(0, 0, 0, A(180)), durBuf);
// Color: red if < 5s, yellow if < 15s, white otherwise
ImU32 durCol = remainMs < 5000 ? IM_COL32(255, 60, 60, A(240))
: remainMs < 15000 ? IM_COL32(255, 200, 60, A(240))
: IM_COL32(230, 230, 230, A(220));
drawList->AddText(ImVec2(durX, durY), durCol, durBuf);
}
// Spell name + duration tooltip on hover
{
ImVec2 mouse = ImGui::GetMousePos();
if (mouse.x >= dotX && mouse.x < dotX + dotSize &&
mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) {
const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId);
if (!dotSpellName.empty()) {
if (remainMs > 0) {
int secs = remainMs / 1000;
int mins = secs / 60;
secs %= 60;
char tipBuf[128];
if (mins > 0)
snprintf(tipBuf, sizeof(tipBuf), "%s (%dm %ds)", dotSpellName.c_str(), mins, secs);
else
snprintf(tipBuf, sizeof(tipBuf), "%s (%ds)", dotSpellName.c_str(), secs);
ImGui::SetTooltip("%s", tipBuf);
} else {
ImGui::SetTooltip("%s", dotSpellName.c_str());
}
}
}
}
dotX += dotSize + dotGap;
if (dotX + dotSize > barX + barW) break;
}
}
// Name + level label above health bar
uint32_t level = unit->getLevel();
const std::string& unitName = unit->getName();
char labelBuf[96];
if (isPlayer) {
// Player nameplates: show name only (no level clutter).
// Fall back to level as placeholder while the name query is pending.
if (!unitName.empty())
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
else {
// Name query may be pending; request it now to ensure it gets resolved
gameHandler.queryPlayerName(unit->getGuid());
if (level > 0)
snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level);
else
snprintf(labelBuf, sizeof(labelBuf), "Player");
}
} else if (level > 0) {
uint32_t playerLevel = gameHandler.getPlayerLevel();
// Show skull for units more than 10 levels above the player
if (playerLevel > 0 && level > playerLevel + 10)
snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str());
else
snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str());
} else {
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
}
ImVec2 textSize = ImGui::CalcTextSize(labelBuf);
float nameX = sx - textSize.x * 0.5f;
float nameY = sy - barH - 12.0f;
// Name color: players get WoW class colors; NPCs use hostility (red/yellow)
ImU32 nameColor;
if (isPlayer) {
// 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<int>(cc.x*255), static_cast<int>(cc.y*255),
static_cast<int>(cc.z*255), A(230));
} else {
nameColor = unit->isHostile()
? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC
: IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC
}
// Sub-label below the name: guild tag for players, subtitle for NPCs
std::string subLabel;
if (isPlayer) {
uint32_t guildId = gameHandler.getEntityGuildId(guid);
if (guildId != 0) {
const std::string& gn = gameHandler.lookupGuildName(guildId);
if (!gn.empty()) subLabel = "<" + gn + ">";
}
} else {
// NPC subtitle (e.g. "<Reagent Vendor>", "<Innkeeper>")
std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry());
if (!sub.empty()) subLabel = "<" + sub + ">";
}
if (!subLabel.empty()) nameY -= 10.0f; // shift name up for sub-label line
drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf);
drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf);
// Sub-label below the name (WoW-style <Guild Name> or <NPC Title> in lighter color)
if (!subLabel.empty()) {
ImVec2 subSz = ImGui::CalcTextSize(subLabel.c_str());
float subX = sx - subSz.x * 0.5f;
float subY = nameY + textSize.y + 1.0f;
drawList->AddText(ImVec2(subX + 1.0f, subY + 1.0f), IM_COL32(0, 0, 0, A(120)), subLabel.c_str());
drawList->AddText(ImVec2(subX, subY), IM_COL32(180, 180, 180, A(200)), subLabel.c_str());
}
// Group leader crown to the right of the name on player nameplates
if (isPlayer && gameHandler.isInGroup() &&
gameHandler.getPartyData().leaderGuid == guid) {
float crownX = nameX + textSize.x + 3.0f;
const char* crownSym = "\xe2\x99\x9b"; // ♛
drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym);
drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym);
}
// Raid mark (if any) to the left of the name
{
static constexpr struct { const char* sym; ImU32 col; } kNPMarks[] = {
{ "\xe2\x98\x85", IM_COL32(255,220, 50,230) }, // Star
{ "\xe2\x97\x8f", IM_COL32(255,140, 0,230) }, // Circle
{ "\xe2\x97\x86", IM_COL32(160, 32,240,230) }, // Diamond
{ "\xe2\x96\xb2", IM_COL32( 50,200, 50,230) }, // Triangle
{ "\xe2\x97\x8c", IM_COL32( 80,160,255,230) }, // Moon
{ "\xe2\x96\xa0", IM_COL32( 50,200,220,230) }, // Square
{ "\xe2\x9c\x9d", IM_COL32(255, 80, 80,230) }, // Cross
{ "\xe2\x98\xa0", IM_COL32(255,255,255,230) }, // Skull
};
uint8_t raidMark = gameHandler.getEntityRaidMark(guid);
if (raidMark < game::GameHandler::kRaidMarkCount) {
float markX = nameX - 14.0f;
drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym);
drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym);
}
// Quest kill objective indicator: small yellow sword icon to the right of the name
float questIconX = nameX + textSize.x + 4.0f;
if (!isPlayer && questKillEntries.count(unit->getEntry())) {
const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8)
drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym);
drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym);
questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f;
}
// Quest giver indicator: "!" for available quests, "?" for completable/incomplete
if (!isPlayer) {
using QGS = game::QuestGiverStatus;
QGS qgs = gameHandler.getQuestGiverStatus(guid);
const char* qSym = nullptr;
ImU32 qCol = IM_COL32(255, 210, 0, A(255));
if (qgs == QGS::AVAILABLE) {
qSym = "!";
} else if (qgs == QGS::AVAILABLE_LOW) {
qSym = "!";
qCol = IM_COL32(160, 160, 160, A(220));
} else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) {
qSym = "?";
} else if (qgs == QGS::INCOMPLETE) {
qSym = "?";
qCol = IM_COL32(160, 160, 160, A(220));
}
if (qSym) {
drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym);
drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym);
}
}
}
// Click to target / right-click context: detect clicks inside the nameplate region.
// Use the wider of name text or health bar for the horizontal hit area so short
// names like "Wolf" don't produce a tiny clickable strip narrower than the bar.
if (!ImGui::GetIO().WantCaptureMouse) {
ImVec2 mouse = ImGui::GetIO().MousePos;
float hitLeft = std::min(nameX, barX) - 2.0f;
float hitRight = std::max(nameX + textSize.x, barX + barW) + 2.0f;
float ny0 = nameY - 1.0f;
float ny1 = sy + barH + 2.0f;
float nx0 = hitLeft;
float nx1 = hitRight;
if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) {
// Track mouseover for [target=mouseover] macro conditionals
gameHandler.setMouseoverGuid(guid);
// Hover tooltip: name, level/class, guild
ImGui::BeginTooltip();
ImGui::TextUnformatted(unitName.c_str());
if (isPlayer) {
uint8_t cid = entityClassId(unit);
ImGui::Text("Level %u %s", level, classNameStr(cid));
} else if (level > 0) {
ImGui::Text("Level %u", level);
}
if (!subLabel.empty()) ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", subLabel.c_str());
ImGui::EndTooltip();
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
gameHandler.setTarget(guid);
} else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
nameplateCtxGuid_ = guid;
nameplateCtxPos_ = mouse;
ImGui::OpenPopup("##NameplateCtx");
}
}
}
}
// Render nameplate context popup (uses a tiny overlay window as host)
if (nameplateCtxGuid_ != 0) {
ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always);
ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_AlwaysAutoResize;
if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) {
if (ImGui::BeginPopup("##NameplateCtx")) {
auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_);
std::string ctxName = entityPtr ? getEntityName(entityPtr) : "";
if (!ctxName.empty()) {
ImGui::TextDisabled("%s", ctxName.c_str());
ImGui::Separator();
}
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(nameplateCtxGuid_);
if (ImGui::MenuItem("Set Focus"))
gameHandler.setFocus(nameplateCtxGuid_);
bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER;
if (isPlayer && !ctxName.empty()) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
chatPanel_.setWhisperTarget(ctxName);
}
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();
socialPanel_.showInspectWindow_ = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Add Friend"))
gameHandler.addFriend(ctxName);
if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(ctxName);
}
ImGui::EndPopup();
} else {
nameplateCtxGuid_ = 0;
}
}
ImGui::End();
}
}
// ============================================================
// Party Frames (Phase 4)
// ============================================================
// ============================================================
// Durability Warning (equipment damage indicator)
// ============================================================
void GameScreen::takeScreenshot(game::GameHandler& /*gameHandler*/) {
auto* renderer = services_.renderer;
if (!renderer) return;
// Build path: ~/.wowee/screenshots/WoWee_YYYYMMDD_HHMMSS.png
const char* home = std::getenv("HOME");
if (!home) home = std::getenv("USERPROFILE");
if (!home) home = "/tmp";
std::string dir = std::string(home) + "/.wowee/screenshots";
auto now = std::chrono::system_clock::now();
auto tt = std::chrono::system_clock::to_time_t(now);
std::tm tm{};
#ifdef _WIN32
localtime_s(&tm, &tt);
#else
localtime_r(&tt, &tm);
#endif
char filename[128];
std::snprintf(filename, sizeof(filename),
"WoWee_%04d%02d%02d_%02d%02d%02d.png",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
std::string path = dir + "/" + filename;
if (renderer->captureScreenshot(path)) {
game::MessageChatData sysMsg;
sysMsg.type = game::ChatType::SYSTEM;
sysMsg.language = game::ChatLanguage::UNIVERSAL;
sysMsg.message = "Screenshot saved: " + path;
services_.gameHandler->addLocalChatMessage(sysMsg);
}
}
void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) {
if (gameHandler.getPlayerGuid() == 0) return;
const auto& inv = gameHandler.getInventory();
// Scan all equipment slots (skip bag slots which have no durability)
float minDurPct = 1.0f;
bool hasBroken = false;
for (int i = static_cast<int>(game::EquipSlot::HEAD);
i < static_cast<int>(game::EquipSlot::BAG1); ++i) {
const auto& slot = inv.getEquipSlot(static_cast<game::EquipSlot>(i));
if (slot.empty() || slot.item.maxDurability == 0) continue;
if (slot.item.curDurability == 0) {
hasBroken = true;
}
float pct = static_cast<float>(slot.item.curDurability) /
static_cast<float>(slot.item.maxDurability);
if (pct < minDurPct) minDurPct = pct;
}
// Only show warning below 20%
if (minDurPct >= 0.2f && !hasBroken) return;
ImGuiIO& io = ImGui::GetIO();
const float screenW = io.DisplaySize.x;
const float screenH = io.DisplaySize.y;
// Position: just above the XP bar / action bar area (bottom-center)
const float warningW = 220.0f;
const float warningH = 26.0f;
const float posX = (screenW - warningW) * 0.5f;
const float posY = screenH - 140.0f; // above action bar
ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.75f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0));
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoBringToFrontOnFocus;
if (ImGui::Begin("##durability_warn", nullptr, flags)) {
if (hasBroken) {
ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f),
"\xef\x94\x9b Gear broken! Visit a repair NPC");
} else {
int pctInt = static_cast<int>(minDurPct * 100.0f);
ImGui::TextColored(colors::kSymbolGold,
"\xef\x94\x9b Low durability: %d%%", pctInt);
}
if (ImGui::IsWindowHovered())
ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC.");
}
ImGui::End();
ImGui::PopStyleVar(3);
}
// ============================================================
// UI Error Frame (WoW-style center-bottom error overlay)
// ============================================================
void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) {
// Age out old entries
for (auto& e : uiErrors_) e.age += deltaTime;
uiErrors_.erase(
std::remove_if(uiErrors_.begin(), uiErrors_.end(),
[](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }),
uiErrors_.end());
if (uiErrors_.empty()) return;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Fixed invisible overlay
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(screenW, screenH));
ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
if (ImGui::Begin("##UIErrors", nullptr, flags)) {
// Render messages stacked above the action bar (~200px from bottom)
// The newest message is on top; older ones fade below it.
const float baseY = screenH - 200.0f;
const float lineH = 20.0f;
const int count = static_cast<int>(uiErrors_.size());
ImDrawList* draw = ImGui::GetWindowDrawList();
for (int i = count - 1; i >= 0; --i) {
const auto& e = uiErrors_[i];
float alpha = 1.0f - (e.age / kUIErrorLifetime);
alpha = std::max(0.0f, std::min(1.0f, alpha));
// Fade fast in the last 0.5 s
if (e.age > kUIErrorLifetime - 0.5f)
alpha *= (kUIErrorLifetime - e.age) / 0.5f;
uint8_t a8 = static_cast<uint8_t>(alpha * 255.0f);
ImU32 textCol = IM_COL32(255, 50, 50, a8);
ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast<uint8_t>(alpha * 180));
const char* txt = e.text.c_str();
ImVec2 sz = ImGui::CalcTextSize(txt);
float x = std::round((screenW - sz.x) * 0.5f);
float y = std::round(baseY - (count - 1 - i) * lineH);
// Drop shadow
draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt);
draw->AddText(ImVec2(x, y), textCol, txt);
}
}
ImGui::End();
ImGui::PopStyleVar();
}
// ============================================================
// Boss Encounter Frames
// ============================================================
// ============================================================
// Social Frame — compact online friends panel (toggled by socialPanel_.showSocialFrame_)
// ============================================================
// ============================================================
// Buff/Debuff Bar (Phase 3)
// ============================================================
// ============================================================
// Loot Window (Phase 5)
// ============================================================
// ============================================================
// Gossip Window (Phase 5)
// ============================================================
// ============================================================
// Quest Details Window
// ============================================================
// ============================================================
// Quest Request Items Window (turn-in progress check)
// ============================================================
// ============================================================
// Quest Offer Reward Window (choose reward)
// ============================================================
// ============================================================
// ItemExtendedCost.dbc loader
// ============================================================
// ============================================================
// Vendor Window (Phase 5)
// ============================================================
// ============================================================
// Trainer
// ============================================================
// ============================================================
// Teleporter Panel
// ============================================================
// ============================================================
// Escape Menu
// ============================================================
// ============================================================
// Barber Shop Window
// ============================================================
// ============================================================
// Pet Stable Window
// ============================================================
// ============================================================
// Taxi Window
// ============================================================
// ============================================================
// Logout Countdown
// ============================================================
// ============================================================
// Death Screen
// ============================================================
void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) {
const auto& statuses = gameHandler.getNpcQuestStatuses();
if (statuses.empty()) return;
auto* renderer = services_.renderer;
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = services_.window;
if (!camera || !window) return;
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
glm::mat4 viewProj = camera->getViewProjectionMatrix();
auto* drawList = ImGui::GetForegroundDrawList();
for (const auto& [guid, status] : statuses) {
// Only show markers for available (!) and reward/completable (?)
const char* marker = nullptr;
ImU32 color = IM_COL32(255, 210, 0, 255); // yellow
if (status == game::QuestGiverStatus::AVAILABLE) {
marker = "!";
} else if (status == game::QuestGiverStatus::AVAILABLE_LOW) {
marker = "!";
color = IM_COL32(160, 160, 160, 255); // gray
} else if (status == game::QuestGiverStatus::REWARD ||
status == game::QuestGiverStatus::REWARD_REP) {
marker = "?";
} else if (status == game::QuestGiverStatus::INCOMPLETE) {
marker = "?";
color = IM_COL32(160, 160, 160, 255); // gray
} else {
continue;
}
// Get entity position (canonical coords)
auto entity = gameHandler.getEntityManager().getEntity(guid);
if (!entity) continue;
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
// Get model height for offset
float heightOffset = 3.0f;
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
if (core::Application::getInstance().getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
heightOffset = boundsRadius * 2.0f + 1.0f;
}
renderPos.z += heightOffset;
// Project to screen
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
if (clipPos.w <= 0.0f) continue;
glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w);
float sx = (ndc.x + 1.0f) * 0.5f * screenW;
float sy = (1.0f - ndc.y) * 0.5f * screenH;
// Skip if off-screen
if (sx < -50 || sx > screenW + 50 || sy < -50 || sy > screenH + 50) continue;
// Scale text size based on distance
float dist = clipPos.w;
float fontSize = std::clamp(800.0f / dist, 14.0f, 48.0f);
// Draw outlined text: 4 shadow copies then main text
ImFont* font = ImGui::GetFont();
ImU32 outlineColor = IM_COL32(0, 0, 0, 220);
float off = std::max(1.0f, fontSize * 0.06f);
ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, marker);
float tx = sx - textSize.x * 0.5f;
float ty = sy - textSize.y * 0.5f;
drawList->AddText(font, fontSize, ImVec2(tx - off, ty), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx + off, ty), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx, ty - off), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx, ty + off), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx, ty), color, marker);
}
}
void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
const auto& statuses = gameHandler.getNpcQuestStatuses();
auto* renderer = services_.renderer;
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* minimap = renderer ? renderer->getMinimap() : nullptr;
auto* window = services_.window;
if (!camera || !minimap || !window) return;
float screenW = static_cast<float>(window->getWidth());
// Minimap parameters (matching minimap.cpp)
float mapSize = 200.0f;
float margin = 10.0f;
float mapRadius = mapSize * 0.5f;
float centerX = screenW - margin - mapRadius;
float centerY = margin + mapRadius;
float viewRadius = minimap->getViewRadius();
// Use the exact same minimap center as Renderer::renderWorld() to keep markers anchored.
glm::vec3 playerRender = camera->getPosition();
if (renderer->getCharacterInstanceId() != 0) {
playerRender = renderer->getCharacterPosition();
}
// Camera bearing for minimap rotation
float bearing = 0.0f;
float cosB = 1.0f;
float sinB = 0.0f;
if (minimap->isRotateWithCamera()) {
glm::vec3 fwd = camera->getForward();
// Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)).
// Clockwise bearing from North: atan2(fwd.y, -fwd.x).
bearing = std::atan2(fwd.y, -fwd.x);
cosB = std::cos(bearing);
sinB = std::sin(bearing);
}
auto* drawList = ImGui::GetForegroundDrawList();
auto projectToMinimap = [&](const glm::vec3& worldRenderPos, float& sx, float& sy) -> bool {
float dx = worldRenderPos.x - playerRender.x;
float dy = worldRenderPos.y - playerRender.y;
// Exact inverse of minimap display shader:
// shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2
// where rotated = R(bearing) * center, center in [-0.5, 0.5]
// Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2)
// With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south):
float rx = -(dx * cosB + dy * sinB);
float ry = dx * sinB - dy * cosB;
// Scale to minimap pixels
float px = rx / viewRadius * mapRadius;
float py = ry / viewRadius * mapRadius;
float distFromCenter = std::sqrt(px * px + py * py);
if (distFromCenter > mapRadius - 3.0f) {
return false;
}
sx = centerX + px;
sy = centerY + py;
return true;
};
// Build sets of entries that are incomplete objectives for tracked quests.
// minimapQuestEntries: NPC creature entries (npcOrGoId > 0)
// minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value)
std::unordered_set<uint32_t> minimapQuestEntries;
std::unordered_set<uint32_t> minimapQuestGoEntries;
{
const auto& ql = gameHandler.getQuestLog();
const auto& tq = gameHandler.getTrackedQuestIds();
for (const auto& q : ql) {
if (q.complete || q.questId == 0) continue;
if (!tq.empty() && !tq.count(q.questId)) continue;
for (const auto& obj : q.killObjectives) {
if (obj.required == 0) continue;
if (obj.npcOrGoId > 0) {
auto it = q.killCounts.find(static_cast<uint32_t>(obj.npcOrGoId));
if (it == q.killCounts.end() || it->second.first < it->second.second)
minimapQuestEntries.insert(static_cast<uint32_t>(obj.npcOrGoId));
} else if (obj.npcOrGoId < 0) {
uint32_t goEntry = static_cast<uint32_t>(-obj.npcOrGoId);
auto it = q.killCounts.find(goEntry);
if (it == q.killCounts.end() || it->second.first < it->second.second)
minimapQuestGoEntries.insert(goEntry);
}
}
}
}
// Optional base nearby NPC dots (independent of quest status packets).
if (settingsPanel_.minimapNpcDots_) {
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<game::Unit>(entity);
if (!unit || unit->getHealth() == 0) 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;
bool isQuestTarget = minimapQuestEntries.count(unit->getEntry()) != 0;
if (isQuestTarget) {
// Quest kill objective: larger gold dot with dark outline
drawList->AddCircleFilled(ImVec2(sx, sy), 3.5f, IM_COL32(255, 210, 30, 240));
drawList->AddCircle(ImVec2(sx, sy), 3.5f, IM_COL32(80, 50, 0, 180), 0, 1.0f);
// Tooltip on hover showing unit name
float mdx = mouse.x - sx, mdy = mouse.y - sy;
if (mdx * mdx + mdy * mdy < 64.0f) {
const std::string& nm = unit->getName();
if (!nm.empty()) ImGui::SetTooltip("%s (quest)", nm.c_str());
}
} else {
ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210);
drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot);
}
}
}
// Nearby other-player dots — shown when NPC dots are enabled.
// Party members are already drawn as squares above; other players get a small circle.
if (settingsPanel_.minimapNpcDots_) {
const uint64_t selfGuid = gameHandler.getPlayerGuid();
const auto& partyData = gameHandler.getPartyData();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
if (!entity || entity->getType() != game::ObjectType::PLAYER) continue;
if (entity->getGuid() == selfGuid) continue; // skip self (already drawn as arrow)
// Skip party members (already drawn as squares above)
bool isPartyMember = false;
for (const auto& m : partyData.members) {
if (m.guid == guid) { isPartyMember = true; break; }
}
if (isPartyMember) continue;
glm::vec3 pRender = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(pRender, sx, sy)) continue;
// Blue dot for other nearby players
drawList->AddCircleFilled(ImVec2(sx, sy), 2.0f, IM_COL32(80, 160, 255, 220));
}
}
// 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<game::Unit>(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();
}
}
}
// Interactable game object dots (chests, resource nodes) when NPC dots are enabled.
// Shown as small orange triangles to distinguish from unit dots and loot corpses.
if (settingsPanel_.minimapNpcDots_) {
ImVec2 mouse = ImGui::GetMousePos();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue;
// Only show objects that are likely interactive (chests/nodes: type 3;
// also show type 0=Door when open, but filter by dynamic-flag ACTIVATED).
// For simplicity, show all game objects that have a non-empty cached name.
auto go = std::static_pointer_cast<game::GameObject>(entity);
if (!go) continue;
// Only show if we have name data (avoids cluttering with unknown objects)
const auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry());
if (!goInfo || !goInfo->isValid()) continue;
// Skip transport objects (boats/zeppelins): type 15 = MO_TRANSPORT, 11 = TRANSPORT
if (goInfo->type == 11 || goInfo->type == 15) continue;
glm::vec3 goRender = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(goRender, sx, sy)) continue;
// Triangle size and color: bright cyan for quest objectives, amber for others
bool isQuestGO = minimapQuestGoEntries.count(go->getEntry()) != 0;
const float ts = isQuestGO ? 4.5f : 3.5f;
ImVec2 goTip (sx, sy - ts);
ImVec2 goLeft (sx - ts, sy + ts * 0.6f);
ImVec2 goRight(sx + ts, sy + ts * 0.6f);
if (isQuestGO) {
drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(50, 230, 255, 240));
drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(0, 60, 80, 200), 1.5f);
} else {
drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220));
drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f);
}
// Tooltip on hover
float mdx = mouse.x - sx, mdy = mouse.y - sy;
if (mdx * mdx + mdy * mdy < 64.0f) {
if (isQuestGO)
ImGui::SetTooltip("%s (quest)", goInfo->name.c_str());
else
ImGui::SetTooltip("%s", goInfo->name.c_str());
}
}
}
// Party member dots on minimap — small colored squares with name tooltip on hover
if (gameHandler.isInGroup()) {
const auto& partyData = gameHandler.getPartyData();
ImVec2 mouse = ImGui::GetMousePos();
for (const auto& member : partyData.members) {
if (!member.hasPartyStats) continue;
bool isOnline = (member.onlineStatus & 0x0001) != 0;
bool isDead = (member.onlineStatus & 0x0020) != 0;
bool isGhost = (member.onlineStatus & 0x0010) != 0;
if (!isOnline) continue;
if (member.posX == 0 && member.posY == 0) continue;
// Party stat positions: posY = canonical X (north), posX = canonical Y (west)
glm::vec3 memberRender = core::coords::canonicalToRender(
glm::vec3(static_cast<float>(member.posY),
static_cast<float>(member.posX), 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(memberRender, sx, sy)) continue;
// Determine dot color: class color > leader gold > light blue
ImU32 dotCol;
if (isDead || isGhost) {
dotCol = IM_COL32(140, 140, 140, 200); // gray for dead
} else {
auto mEnt = gameHandler.getEntityManager().getEntity(member.guid);
uint8_t cid = entityClassId(mEnt.get());
if (cid != 0) {
ImVec4 cv = classColorVec4(cid);
dotCol = IM_COL32(
static_cast<int>(cv.x * 255),
static_cast<int>(cv.y * 255),
static_cast<int>(cv.z * 255), 230);
} else if (member.guid == partyData.leaderGuid) {
dotCol = IM_COL32(255, 210, 0, 230); // gold for leader
} else {
dotCol = IM_COL32(100, 180, 255, 230); // blue for others
}
}
// Draw a small square (WoW-style party member dot)
const float hs = 3.5f;
drawList->AddRectFilled(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), dotCol, 1.0f);
drawList->AddRect(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs),
IM_COL32(0, 0, 0, 180), 1.0f, 0, 1.0f);
// Name tooltip on hover
float mdx = mouse.x - sx, mdy = mouse.y - sy;
if (mdx * mdx + mdy * mdy < 64.0f && !member.name.empty()) {
ImGui::SetTooltip("%s", member.name.c_str());
}
}
}
for (const auto& [guid, status] : statuses) {
ImU32 dotColor;
const char* marker = nullptr;
if (status == game::QuestGiverStatus::AVAILABLE) {
dotColor = IM_COL32(255, 210, 0, 255);
marker = "!";
} else if (status == game::QuestGiverStatus::AVAILABLE_LOW) {
dotColor = IM_COL32(160, 160, 160, 255);
marker = "!";
} else if (status == game::QuestGiverStatus::REWARD ||
status == game::QuestGiverStatus::REWARD_REP) {
dotColor = IM_COL32(255, 210, 0, 255);
marker = "?";
} else if (status == game::QuestGiverStatus::INCOMPLETE) {
dotColor = IM_COL32(160, 160, 160, 255);
marker = "?";
} else {
continue;
}
auto entity = gameHandler.getEntityManager().getEntity(guid);
if (!entity) continue;
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 npcRender = core::coords::canonicalToRender(canonical);
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(npcRender, sx, sy)) continue;
// Draw dot with marker text
drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, dotColor);
ImFont* font = ImGui::GetFont();
ImVec2 textSize = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, marker);
drawList->AddText(font, 11.0f,
ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f),
IM_COL32(0, 0, 0, 255), marker);
// Show NPC name and quest status on hover
{
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - sx, mdy = mouse.y - sy;
if (mdx * mdx + mdy * mdy < 64.0f) {
std::string npcName;
if (entity->getType() == game::ObjectType::UNIT) {
auto npcUnit = std::static_pointer_cast<game::Unit>(entity);
npcName = npcUnit->getName();
}
if (!npcName.empty()) {
bool hasQuest = (status == game::QuestGiverStatus::AVAILABLE ||
status == game::QuestGiverStatus::AVAILABLE_LOW);
ImGui::SetTooltip("%s\n%s", npcName.c_str(),
hasQuest ? "Has a quest for you" : "Quest ready to turn in");
}
}
}
}
// Quest kill objective markers — highlight live NPCs matching active quest kill objectives
{
// 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<uint32_t, KillInfo> killInfoMap;
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<uint32_t>(obj.npcOrGoId);
auto it = quest.killCounts.find(npcEntry);
uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0;
if (current < obj.required) {
killInfoMap[npcEntry] = { quest.title, current, obj.required };
}
}
}
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<game::Unit>(entity);
if (!unit || unit->getHealth() == 0) 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()));
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);
// 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);
}
}
}
}
}
// Gossip POI markers (quest / NPC navigation targets)
for (const auto& poi : gameHandler.getGossipPois()) {
// Convert WoW canonical coords to render coords for minimap projection
glm::vec3 poiRender = core::coords::canonicalToRender(glm::vec3(poi.x, poi.y, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(poiRender, sx, sy)) continue;
// Draw as a cyan diamond with tooltip on hover
const float d = 5.0f;
ImVec2 pts[4] = {
{ sx, sy - d },
{ sx + d, sy },
{ sx, sy + d },
{ sx - d, sy },
};
drawList->AddConvexPolyFilled(pts, 4, IM_COL32(0, 210, 255, 220));
drawList->AddPolyline(pts, 4, IM_COL32(255, 255, 255, 160), true, 1.0f);
// Show name label if cursor is within ~8px
ImVec2 cursorPos = ImGui::GetMousePos();
float dx = cursorPos.x - sx, dy = cursorPos.y - sy;
if (!poi.name.empty() && (dx * dx + dy * dy) < 64.0f) {
ImGui::SetTooltip("%s", poi.name.c_str());
}
}
// Minimap pings from party members
for (const auto& ping : gameHandler.getMinimapPings()) {
glm::vec3 pingRender = core::coords::canonicalToRender(glm::vec3(ping.wowX, ping.wowY, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(pingRender, sx, sy)) continue;
float t = ping.age / game::GameHandler::MinimapPing::LIFETIME;
float alpha = 1.0f - t;
float pulse = 1.0f + 1.5f * t; // expands outward as it fades
ImU32 col = IM_COL32(255, 220, 0, static_cast<int>(alpha * 200));
ImU32 col2 = IM_COL32(255, 150, 0, static_cast<int>(alpha * 100));
float r1 = 4.0f * pulse;
float r2 = 8.0f * pulse;
drawList->AddCircle(ImVec2(sx, sy), r1, col, 16, 2.0f);
drawList->AddCircle(ImVec2(sx, sy), r2, col2, 16, 1.0f);
drawList->AddCircleFilled(ImVec2(sx, sy), 2.5f, col);
}
// Party member dots on minimap
{
const auto& partyData = gameHandler.getPartyData();
const uint64_t leaderGuid = partyData.leaderGuid;
for (const auto& member : partyData.members) {
if (!member.isOnline || !member.hasPartyStats) continue;
if (member.posX == 0 && member.posY == 0) continue;
// posX/posY follow same server axis convention as minimap pings:
// server posX = east/west axis → canonical Y (west)
// server posY = north/south axis → canonical X (north)
float wowX = static_cast<float>(member.posY);
float wowY = static_cast<float>(member.posX);
glm::vec3 memberRender = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(memberRender, sx, sy)) continue;
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);
// Raid mark: tiny symbol drawn above the dot
{
static constexpr struct { const char* sym; ImU32 col; } kMMMarks[] = {
{ "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) },
{ "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) },
{ "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) },
{ "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) },
{ "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) },
{ "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) },
{ "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) },
{ "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) },
};
uint8_t pmk = gameHandler.getEntityRaidMark(member.guid);
if (pmk < game::GameHandler::kRaidMarkCount) {
ImFont* mmFont = ImGui::GetFont();
ImVec2 msz = mmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kMMMarks[pmk].sym);
drawList->AddText(mmFont, 9.0f,
ImVec2(sx - msz.x * 0.5f, sy - 4.0f - msz.y),
kMMMarks[pmk].col, kMMMarks[pmk].sym);
}
}
ImVec2 cursorPos = ImGui::GetMousePos();
float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy;
if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) {
uint8_t pmk2 = gameHandler.getEntityRaidMark(member.guid);
if (pmk2 < game::GameHandler::kRaidMarkCount) {
static constexpr const char* kMarkNames[] = {
"Star", "Circle", "Diamond", "Triangle",
"Moon", "Square", "Cross", "Skull"
};
ImGui::SetTooltip("%s {%s}", member.name.c_str(), kMarkNames[pmk2]);
} else {
ImGui::SetTooltip("%s", member.name.c_str());
}
}
}
}
// BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS)
{
const auto& bgPositions = gameHandler.getBgPlayerPositions();
if (!bgPositions.empty()) {
ImVec2 mouse = ImGui::GetMousePos();
// group 0 = typically ally-held flag / first list; group 1 = enemy
static const ImU32 kBgGroupColors[2] = {
IM_COL32( 80, 180, 255, 240), // group 0: blue (alliance)
IM_COL32(220, 50, 50, 240), // group 1: red (horde)
};
for (const auto& bp : bgPositions) {
// Packet coords: wowX=canonical X (north), wowY=canonical Y (west)
glm::vec3 bpRender = core::coords::canonicalToRender(glm::vec3(bp.wowX, bp.wowY, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(bpRender, sx, sy)) continue;
ImU32 col = kBgGroupColors[bp.group & 1];
// Draw a flag-like diamond icon
const float r = 5.0f;
ImVec2 top (sx, sy - r);
ImVec2 right(sx + r, sy );
ImVec2 bot (sx, sy + r);
ImVec2 left (sx - r, sy );
drawList->AddQuadFilled(top, right, bot, left, col);
drawList->AddQuad(top, right, bot, left, IM_COL32(255, 255, 255, 180), 1.0f);
float mdx = mouse.x - sx, mdy = mouse.y - sy;
if (mdx * mdx + mdy * mdy < 64.0f) {
// Show entity name if available, otherwise guid
auto ent = gameHandler.getEntityManager().getEntity(bp.guid);
if (ent) {
std::string nm;
if (ent->getType() == game::ObjectType::PLAYER) {
auto pl = std::static_pointer_cast<game::Unit>(ent);
nm = pl ? pl->getName() : "";
}
if (!nm.empty())
ImGui::SetTooltip("Flag carrier: %s", nm.c_str());
else
ImGui::SetTooltip("Flag carrier");
} else {
ImGui::SetTooltip("Flag carrier");
}
}
}
}
}
// Corpse direction indicator — shown when player is a ghost
if (gameHandler.isPlayerGhost()) {
float corpseCanX = 0.0f, corpseCanY = 0.0f;
if (gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY)) {
glm::vec3 corpseRender = core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f));
float csx = 0.0f, csy = 0.0f;
bool onMap = projectToMinimap(corpseRender, csx, csy);
if (onMap) {
// Draw a small skull-like X marker at the corpse position
const float r = 5.0f;
drawList->AddCircleFilled(ImVec2(csx, csy), r + 1.0f, IM_COL32(0, 0, 0, 140), 12);
drawList->AddCircle(ImVec2(csx, csy), r + 1.0f, IM_COL32(200, 200, 220, 220), 12, 1.5f);
// Draw an X in the circle
drawList->AddLine(ImVec2(csx - 3.0f, csy - 3.0f), ImVec2(csx + 3.0f, csy + 3.0f),
IM_COL32(180, 180, 220, 255), 1.5f);
drawList->AddLine(ImVec2(csx + 3.0f, csy - 3.0f), ImVec2(csx - 3.0f, csy + 3.0f),
IM_COL32(180, 180, 220, 255), 1.5f);
// Tooltip on hover
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - csx, mdy = mouse.y - csy;
if (mdx * mdx + mdy * mdy < 64.0f) {
float dist = gameHandler.getCorpseDistance();
if (dist >= 0.0f)
ImGui::SetTooltip("Your corpse (%.0f yd)", dist);
else
ImGui::SetTooltip("Your corpse");
}
} else {
// Corpse is outside minimap — draw an edge arrow pointing toward it
float dx = corpseRender.x - playerRender.x;
float dy = corpseRender.y - playerRender.y;
// Rotate delta into minimap frame (same as projectToMinimap)
float rx = -(dx * cosB + dy * sinB);
float ry = dx * sinB - dy * cosB;
float len = std::sqrt(rx * rx + ry * ry);
if (len > 0.001f) {
float nx = rx / len;
float ny = ry / len;
// Place arrow at the minimap edge
float edgeR = mapRadius - 7.0f;
float ax = centerX + nx * edgeR;
float ay = centerY + ny * edgeR;
// Arrow pointing outward (toward corpse)
float arrowLen = 6.0f;
float arrowW = 3.5f;
ImVec2 tip(ax + nx * arrowLen, ay + ny * arrowLen);
ImVec2 left(ax - ny * arrowW - nx * arrowLen * 0.4f,
ay + nx * arrowW - ny * arrowLen * 0.4f);
ImVec2 right(ax + ny * arrowW - nx * arrowLen * 0.4f,
ay - nx * arrowW - ny * arrowLen * 0.4f);
drawList->AddTriangleFilled(tip, left, right, IM_COL32(180, 180, 240, 230));
drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f);
// Tooltip on hover
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - ax, mdy = mouse.y - ay;
if (mdx * mdx + mdy * mdy < 100.0f) {
float dist = gameHandler.getCorpseDistance();
if (dist >= 0.0f)
ImGui::SetTooltip("Your corpse (%.0f yd)", dist);
else
ImGui::SetTooltip("Your corpse");
}
}
}
}
}
// Player position arrow at minimap center, pointing in camera facing direction.
// On a rotating minimap the map already turns so forward = screen-up; on a fixed
// minimap we rotate the arrow to match the player's compass heading.
{
// Compute screen-space facing direction for the arrow.
// bearing = clockwise angle from screen-north (0 = facing north/up).
float arrowAngle = 0.0f; // 0 = pointing up (north)
if (!minimap->isRotateWithCamera()) {
// Fixed minimap: arrow must show actual facing relative to north.
glm::vec3 fwd = camera->getForward();
// +render_y = north = screen-up, +render_x = west = screen-left.
// bearing from north clockwise: atan2(-fwd.x_west, fwd.y_north)
// => sin=east component, cos=north component
// In render coords west=+x, east=-x, so sin(bearing)=east=-fwd.x
arrowAngle = std::atan2(-fwd.x, fwd.y); // clockwise from north in screen space
}
// Screen direction the arrow tip points toward
float nx = std::sin(arrowAngle); // screen +X = east
float ny = -std::cos(arrowAngle); // screen -Y = north
// Draw a chevron-style arrow: tip, two base corners, and a notch at the back
const float tipLen = 8.0f; // tip forward distance
const float baseW = 5.0f; // half-width at base
const float notchIn = 3.0f; // how far back the center notch sits
// Perpendicular direction (rotated 90°)
float px = ny; // perpendicular x
float py = -nx; // perpendicular y
ImVec2 tip (centerX + nx * tipLen, centerY + ny * tipLen);
ImVec2 baseL(centerX - nx * baseW + px * baseW, centerY - ny * baseW + py * baseW);
ImVec2 baseR(centerX - nx * baseW - px * baseW, centerY - ny * baseW - py * baseW);
ImVec2 notch(centerX - nx * (baseW - notchIn), centerY - ny * (baseW - notchIn));
// Fill: bright white with slight gold tint, dark outline for readability
drawList->AddTriangleFilled(tip, baseL, notch, IM_COL32(255, 248, 200, 245));
drawList->AddTriangleFilled(tip, notch, baseR, IM_COL32(255, 248, 200, 245));
drawList->AddTriangle(tip, baseL, notch, IM_COL32(60, 40, 0, 200), 1.2f);
drawList->AddTriangle(tip, notch, baseR, IM_COL32(60, 40, 0, 200), 1.2f);
}
// Scroll wheel over minimap → zoom in/out
{
float wheel = ImGui::GetIO().MouseWheel;
if (wheel != 0.0f) {
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - centerX;
float mdy = mouse.y - centerY;
if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) {
if (wheel > 0.0f)
minimap->zoomIn();
else
minimap->zoomOut();
}
}
}
// Ctrl+click on minimap → send minimap ping to party
if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) {
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - centerX;
float mdy = mouse.y - centerY;
float distSq = mdx * mdx + mdy * mdy;
if (distSq <= mapRadius * mapRadius) {
// Invert projectToMinimap: px=mdx, py=mdy → rx=px*viewRadius/mapRadius
float rx = mdx * viewRadius / mapRadius;
float ry = mdy * viewRadius / mapRadius;
// rx/ry are in rotated frame; unrotate to get world dx/dy
// rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB
// Solving: dx = -(rx*cosB - ry*sinB), dy = -(rx*sinB + ry*cosB)
float wdx = -(rx * cosB - ry * sinB);
float wdy = -(rx * sinB + ry * cosB);
// playerRender is in render coords; add delta to get render position then convert to canonical
glm::vec3 clickRender = playerRender + glm::vec3(wdx, wdy, 0.0f);
glm::vec3 clickCanon = core::coords::renderToCanonical(clickRender);
gameHandler.sendMinimapPing(clickCanon.x, clickCanon.y);
}
}
// 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);
}
// Local time clock — displayed just below the coordinate label
{
auto now = std::chrono::system_clock::now();
std::time_t tt = std::chrono::system_clock::to_time_t(now);
std::tm tmLocal{};
#if defined(_WIN32)
localtime_s(&tmLocal, &tt);
#else
localtime_r(&tt, &tmLocal);
#endif
char clockBuf[16];
std::snprintf(clockBuf, sizeof(clockBuf), "%02d:%02d",
tmLocal.tm_hour, tmLocal.tm_min);
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize() * 0.9f; // slightly smaller than coords
ImVec2 clockSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, clockBuf);
float tx = centerX - clockSz.x * 0.5f;
// Position below the coordinate line (+fontSize of coord + 2px gap)
float coordLineH = ImGui::GetFontSize();
float ty = centerY + mapRadius + 3.0f + coordLineH + 2.0f;
float pad = 2.0f;
drawList->AddRectFilled(
ImVec2(tx - pad, ty - pad),
ImVec2(tx + clockSz.x + pad, ty + clockSz.y + pad),
IM_COL32(0, 0, 0, 120), 3.0f);
drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(200, 200, 220, 220), clockBuf);
}
// 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());
}
}
// Instance difficulty indicator — just below zone name, inside minimap top edge
if (gameHandler.isInInstance()) {
static constexpr const char* kDiffLabels[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"};
uint32_t diff = gameHandler.getInstanceDifficulty();
const char* label = (diff < 4) ? kDiffLabels[diff] : "Unknown";
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize() * 0.85f;
ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label);
float tx = centerX - ts.x * 0.5f;
// Position below zone name: top edge + zone font size + small gap
float ty = centerY - mapRadius + 4.0f + ImGui::GetFontSize() + 2.0f;
float pad = 2.0f;
// Color-code: heroic=orange, normal=light gray
ImU32 bgCol = gameHandler.isInstanceHeroic() ? IM_COL32(120, 60, 0, 180) : IM_COL32(0, 0, 0, 160);
ImU32 textCol = gameHandler.isInstanceHeroic() ? IM_COL32(255, 180, 50, 255) : IM_COL32(200, 200, 200, 220);
drawList->AddRectFilled(
ImVec2(tx - pad, ty - pad),
ImVec2(tx + ts.x + pad, ty + ts.y + pad),
bgCol, 2.0f);
drawList->AddText(font, fontSize, ImVec2(tx, ty), textCol, label);
}
// Hover tooltip and right-click context menu
{
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - centerX;
float mdy = mouse.y - centerY;
bool overMinimap = (mdx * mdx + mdy * mdy <= mapRadius * mapRadius);
if (overMinimap) {
ImGui::BeginTooltip();
// 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(colors::kMediumGray, "Ctrl+click to ping");
ImGui::EndTooltip();
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
ImGui::OpenPopup("##minimapContextMenu");
}
}
if (ImGui::BeginPopup("##minimapContextMenu")) {
ImGui::TextColored(ui::colors::kTooltipGold, "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 = settingsPanel_.minimapNpcDots_;
if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) {
settingsPanel_.minimapNpcDots_ = !settingsPanel_.minimapNpcDots_;
}
ImGui::EndPopup();
}
}
auto applyMuteState = [&]() {
auto* ac = services_.audioCoordinator;
float masterScale = settingsPanel_.soundMuted_ ? 0.0f : static_cast<float>(settingsPanel_.pendingMasterVolume) / 100.0f;
audio::AudioEngine::instance().setMasterVolume(masterScale);
if (!ac) return;
if (auto* music = ac->getMusicManager()) {
music->setVolume(settingsPanel_.pendingMusicVolume);
}
if (auto* ambient = ac->getAmbientSoundManager()) {
ambient->setVolumeScale(settingsPanel_.pendingAmbientVolume / 100.0f);
}
if (auto* ui = ac->getUiSoundManager()) {
ui->setVolumeScale(settingsPanel_.pendingUiVolume / 100.0f);
}
if (auto* combat = ac->getCombatSoundManager()) {
combat->setVolumeScale(settingsPanel_.pendingCombatVolume / 100.0f);
}
if (auto* spell = ac->getSpellSoundManager()) {
spell->setVolumeScale(settingsPanel_.pendingSpellVolume / 100.0f);
}
if (auto* movement = ac->getMovementSoundManager()) {
movement->setVolumeScale(settingsPanel_.pendingMovementVolume / 100.0f);
}
if (auto* footstep = ac->getFootstepManager()) {
footstep->setVolumeScale(settingsPanel_.pendingFootstepVolume / 100.0f);
}
if (auto* npcVoice = ac->getNpcVoiceManager()) {
npcVoice->setVolumeScale(settingsPanel_.pendingNpcVoiceVolume / 100.0f);
}
if (auto* mount = ac->getMountSoundManager()) {
mount->setVolumeScale(settingsPanel_.pendingMountVolume / 100.0f);
}
if (auto* activity = ac->getActivitySoundManager()) {
activity->setVolumeScale(settingsPanel_.pendingActivityVolume / 100.0f);
}
};
// Zone name label above the minimap (centered, WoW-style)
// Prefer the server-reported zone/area name (from SMSG_INIT_WORLD_STATES) so sub-zones
// like Ironforge or Wailing Caverns display correctly; fall back to renderer zone name.
{
std::string wsZoneName;
uint32_t wsZoneId = gameHandler.getWorldStateZoneId();
if (wsZoneId != 0)
wsZoneName = gameHandler.getWhoAreaName(wsZoneId);
const std::string& rendererZoneName = renderer ? renderer->getCurrentZoneName() : std::string{};
const std::string& zoneName = !wsZoneName.empty() ? wsZoneName : rendererZoneName;
if (!zoneName.empty()) {
auto* fgDl = ImGui::GetForegroundDrawList();
float zoneTextY = centerY - mapRadius - 16.0f;
ImFont* font = ImGui::GetFont();
// 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);
}
}
}
// Speaker mute button at the minimap top-right corner
ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - 26.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always);
ImGuiWindowFlags muteFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##MinimapMute", nullptr, muteFlags)) {
ImDrawList* draw = ImGui::GetWindowDrawList();
ImVec2 p = ImGui::GetCursorScreenPos();
ImVec2 size(20.0f, 20.0f);
if (ImGui::InvisibleButton("##MinimapMuteButton", size)) {
settingsPanel_.soundMuted_ = !settingsPanel_.soundMuted_;
if (settingsPanel_.soundMuted_) {
settingsPanel_.preMuteVolume_ = audio::AudioEngine::instance().getMasterVolume();
}
applyMuteState();
saveSettings();
}
bool hovered = ImGui::IsItemHovered();
ImU32 bg = settingsPanel_.soundMuted_ ? IM_COL32(135, 42, 42, 230) : IM_COL32(38, 38, 38, 210);
if (hovered) bg = settingsPanel_.soundMuted_ ? IM_COL32(160, 58, 58, 230) : IM_COL32(65, 65, 65, 220);
ImU32 fg = IM_COL32(255, 255, 255, 245);
draw->AddRectFilled(p, ImVec2(p.x + size.x, p.y + size.y), bg, 4.0f);
draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), ImVec2(p.x + size.x - 0.5f, p.y + size.y - 0.5f),
IM_COL32(255, 255, 255, 42), 4.0f);
draw->AddRectFilled(ImVec2(p.x + 4.0f, p.y + 8.0f), ImVec2(p.x + 7.0f, p.y + 12.0f), fg, 1.0f);
draw->AddTriangleFilled(ImVec2(p.x + 7.0f, p.y + 7.0f),
ImVec2(p.x + 7.0f, p.y + 13.0f),
ImVec2(p.x + 11.8f, p.y + 10.0f), fg);
if (settingsPanel_.soundMuted_) {
draw->AddLine(ImVec2(p.x + 13.5f, p.y + 6.2f), ImVec2(p.x + 17.2f, p.y + 13.8f), fg, 1.8f);
draw->AddLine(ImVec2(p.x + 17.2f, p.y + 6.2f), ImVec2(p.x + 13.5f, p.y + 13.8f), fg, 1.8f);
} else {
draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 3.6f, -0.7f, 0.7f, 12);
draw->PathStroke(fg, 0, 1.4f);
draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 5.5f, -0.7f, 0.7f, 12);
draw->PathStroke(fg, 0, 1.2f);
}
if (hovered) ImGui::SetTooltip(settingsPanel_.soundMuted_ ? "Unmute" : "Mute");
}
ImGui::End();
// Friends button at top-left of minimap
{
const auto& contacts = gameHandler.getContacts();
int onlineCount = 0;
for (const auto& c : contacts)
if (c.isFriend() && c.isOnline()) ++onlineCount;
ImGui::SetNextWindowPos(ImVec2(centerX - mapRadius + 4.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always);
ImGuiWindowFlags friendsBtnFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##MinimapFriendsBtn", nullptr, friendsBtnFlags)) {
ImDrawList* draw = ImGui::GetWindowDrawList();
ImVec2 p = ImGui::GetCursorScreenPos();
ImVec2 sz(20.0f, 20.0f);
if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) {
socialPanel_.showSocialFrame_ = !socialPanel_.showSocialFrame_;
}
bool hovered = ImGui::IsItemHovered();
ImU32 bg = socialPanel_.showSocialFrame_
? IM_COL32(42, 100, 42, 230)
: IM_COL32(38, 38, 38, 210);
if (hovered) bg = socialPanel_.showSocialFrame_ ? IM_COL32(58, 130, 58, 230) : IM_COL32(65, 65, 65, 220);
draw->AddRectFilled(p, ImVec2(p.x + sz.x, p.y + sz.y), bg, 4.0f);
draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f),
ImVec2(p.x + sz.x - 0.5f, p.y + sz.y - 0.5f),
IM_COL32(255, 255, 255, 42), 4.0f);
// Simple smiley-face dots as "social" icon
ImU32 fg = IM_COL32(255, 255, 255, 245);
draw->AddCircle(ImVec2(p.x + 10.0f, p.y + 10.0f), 6.5f, fg, 16, 1.2f);
draw->AddCircleFilled(ImVec2(p.x + 7.5f, p.y + 8.0f), 1.2f, fg);
draw->AddCircleFilled(ImVec2(p.x + 12.5f, p.y + 8.0f), 1.2f, fg);
draw->PathArcTo(ImVec2(p.x + 10.0f, p.y + 11.5f), 3.0f, 0.2f, 2.9f, 8);
draw->PathStroke(fg, 0, 1.2f);
// Small green dot if friends online
if (onlineCount > 0) {
draw->AddCircleFilled(ImVec2(p.x + sz.x - 3.5f, p.y + 3.5f),
3.5f, IM_COL32(50, 220, 50, 255));
}
if (hovered) {
if (onlineCount > 0)
ImGui::SetTooltip("Friends (%d online)", onlineCount);
else
ImGui::SetTooltip("Friends");
}
}
ImGui::End();
}
// Zoom buttons at the bottom edge of the minimap
ImGui::SetNextWindowPos(ImVec2(centerX - 22, centerY + mapRadius - 30), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(44, 24), ImGuiCond_Always);
ImGuiWindowFlags zoomFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##MinimapZoom", nullptr, zoomFlags)) {
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2, 0));
if (ImGui::SmallButton("-")) {
if (minimap) minimap->zoomOut();
}
ImGui::SameLine();
if (ImGui::SmallButton("+")) {
if (minimap) minimap->zoomIn();
}
ImGui::PopStyleVar(2);
}
ImGui::End();
// Clock display at bottom-right of minimap (local time)
{
auto now = std::chrono::system_clock::now();
auto tt = std::chrono::system_clock::to_time_t(now);
std::tm tmBuf{};
#ifdef _WIN32
localtime_s(&tmBuf, &tt);
#else
localtime_r(&tt, &tmBuf);
#endif
char clockText[16];
std::snprintf(clockText, sizeof(clockText), "%d:%02d %s",
(tmBuf.tm_hour % 12 == 0) ? 12 : tmBuf.tm_hour % 12,
tmBuf.tm_min,
tmBuf.tm_hour >= 12 ? "PM" : "AM");
ImVec2 clockSz = ImGui::CalcTextSize(clockText);
float clockW = clockSz.x + 10.0f;
float clockH = clockSz.y + 6.0f;
ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - clockW - 2.0f,
centerY + mapRadius - clockH - 2.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(clockW, clockH), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.45f);
ImGuiWindowFlags clockFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoInputs;
if (ImGui::Begin("##MinimapClock", nullptr, clockFlags)) {
ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.8f, 0.85f), "%s", clockText);
}
ImGui::End();
}
// Indicators below the minimap (stacked: new mail, then BG queue, then latency)
float indicatorX = centerX - mapRadius;
float nextIndicatorY = centerY + mapRadius + 4.0f;
const float indicatorW = mapRadius * 2.0f;
constexpr float kIndicatorH = 22.0f;
ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs;
// "New Mail" indicator
if (gameHandler.hasNewMail()) {
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!");
}
ImGui::End();
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<float>(ImGui::GetTime()) * 2.5f);
char talentBuf[40];
snprintf(talentBuf, sizeof(talentBuf), "! %u Talent Point%s Available",
static_cast<unsigned>(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
std::string bgName;
if (slot.arenaType > 0) {
bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena";
} else {
switch (slot.bgTypeId) {
case 1: bgName = "AV"; break;
case 2: bgName = "WSG"; break;
case 3: bgName = "AB"; break;
case 7: bgName = "EotS"; break;
case 9: bgName = "SotA"; break;
case 11: bgName = "IoC"; break;
default: bgName = "BG"; break;
}
}
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 1.5f);
if (slot.avgWaitTimeSec > 0) {
int avgMin = static_cast<int>(slot.avgWaitTimeSec) / 60;
int avgSec = static_cast<int>(slot.avgWaitTimeSec) % 60;
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse),
"Queue: %s (~%d:%02d)", bgName.c_str(), avgMin, avgSec);
} else {
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse),
"In Queue: %s", bgName.c_str());
}
}
ImGui::End();
nextIndicatorY += kIndicatorH;
break; // Show at most one queue slot indicator
}
// LFG queue indicator — shown when Dungeon Finder queue is active (Queued or RoleCheck)
{
using LfgState = game::GameHandler::LfgState;
LfgState lfgSt = gameHandler.getLfgState();
if (lfgSt == LfgState::Queued || lfgSt == LfgState::RoleCheck) {
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##LfgQueueIndicator", nullptr, indicatorFlags)) {
if (lfgSt == LfgState::RoleCheck) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f);
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, pulse), "LFG: Role Check...");
} else {
uint32_t qMs = gameHandler.getLfgTimeInQueueMs();
int qMin = static_cast<int>(qMs / 60000);
int qSec = static_cast<int>((qMs % 60000) / 1000);
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 1.2f);
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, pulse),
"LFG: %d:%02d", qMin, qSec);
}
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
}
// Calendar pending invites indicator (WotLK only)
{
auto* expReg = services_.expansionRegistry;
bool isWotLK = expReg && expReg->getActive() && expReg->getActive()->id == "wotlk";
if (isWotLK) {
uint32_t calPending = gameHandler.getCalendarPendingInvites();
if (calPending > 0) {
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##CalendarIndicator", nullptr, indicatorFlags)) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 2.0f);
char calBuf[48];
snprintf(calBuf, sizeof(calBuf), "Calendar: %u Invite%s",
calPending, calPending == 1 ? "" : "s");
ImGui::TextColored(ImVec4(0.6f, 0.5f, 1.0f, pulse), "%s", calBuf);
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
}
}
// Taxi flight indicator — shown while on a flight path
if (gameHandler.isOnTaxiFlight()) {
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##TaxiIndicator", nullptr, indicatorFlags)) {
const std::string& dest = gameHandler.getTaxiDestName();
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 1.0f);
if (dest.empty()) {
ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "\xe2\x9c\x88 In Flight");
} else {
char buf[64];
snprintf(buf, sizeof(buf), "\xe2\x9c\x88 \xe2\x86\x92 %s", dest.c_str());
ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "%s", buf);
}
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
// Latency + FPS indicator — centered at top of screen
uint32_t latMs = gameHandler.getLatencyMs();
if (settingsPanel_.showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) {
float currentFps = ImGui::GetIO().Framerate;
ImVec4 latColor;
if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f);
else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f);
else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f);
else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f);
ImVec4 fpsColor;
if (currentFps >= 60.0f) fpsColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f);
else if (currentFps >= 30.0f) fpsColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f);
else fpsColor = ImVec4(1.0f, 0.3f, 0.3f, 0.9f);
char infoText[64];
if (latMs > 0)
snprintf(infoText, sizeof(infoText), "%.0f fps | %u ms", currentFps, latMs);
else
snprintf(infoText, sizeof(infoText), "%.0f fps", currentFps);
ImVec2 textSize = ImGui::CalcTextSize(infoText);
float latW = textSize.x + 16.0f;
float latH = textSize.y + 8.0f;
ImGuiIO& lio = ImGui::GetIO();
float latX = (lio.DisplaySize.x - latW) * 0.5f;
ImGui::SetNextWindowPos(ImVec2(latX, 4.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.45f);
if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) {
// Color the FPS and latency portions differently
ImGui::TextColored(fpsColor, "%.0f fps", currentFps);
if (latMs > 0) {
ImGui::SameLine(0, 4);
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.7f), "|");
ImGui::SameLine(0, 4);
ImGui::TextColored(latColor, "%u ms", latMs);
}
}
ImGui::End();
}
// Low durability warning — shown when any equipped item has < 20% durability
if (gameHandler.getState() == game::WorldState::IN_WORLD) {
const auto& inv = gameHandler.getInventory();
float lowestDurPct = 1.0f;
for (int i = 0; i < game::Inventory::NUM_EQUIP_SLOTS; ++i) {
const auto& slot = inv.getEquipSlot(static_cast<game::EquipSlot>(i));
if (slot.empty()) continue;
const auto& it = slot.item;
if (it.maxDurability > 0) {
float pct = static_cast<float>(it.curDurability) / static_cast<float>(it.maxDurability);
if (pct < lowestDurPct) lowestDurPct = pct;
}
}
if (lowestDurPct < 0.20f) {
bool critical = (lowestDurPct < 0.05f);
float pulse = critical
? (0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 4.0f))
: 1.0f;
ImVec4 durWarnColor = critical
? ImVec4(1.0f, 0.2f, 0.2f, pulse)
: ImVec4(1.0f, 0.65f, 0.1f, 0.9f);
const char* durWarnText = critical ? "Item breaking!" : "Low durability";
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##DurabilityIndicator", nullptr, indicatorFlags)) {
ImGui::TextColored(durWarnColor, "%s", durWarnText);
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
}
// Local time clock — always visible below minimap indicators
{
auto now = std::chrono::system_clock::now();
std::time_t tt = std::chrono::system_clock::to_time_t(now);
struct tm tmBuf;
#ifdef _WIN32
localtime_s(&tmBuf, &tt);
#else
localtime_r(&tt, &tmBuf);
#endif
char clockStr[16];
snprintf(clockStr, sizeof(clockStr), "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min);
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
ImGuiWindowFlags clockFlags = indicatorFlags & ~ImGuiWindowFlags_NoInputs;
if (ImGui::Begin("##ClockIndicator", nullptr, clockFlags)) {
ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 0.75f), "%s", clockStr);
if (ImGui::IsItemHovered()) {
char fullTime[32];
snprintf(fullTime, sizeof(fullTime), "%02d:%02d:%02d (local)",
tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec);
ImGui::SetTooltip("%s", fullTime);
}
}
ImGui::End();
}
}
void GameScreen::saveSettings() {
std::string path = SettingsPanel::getSettingsPath();
std::filesystem::path dir = std::filesystem::path(path).parent_path();
std::error_code ec;
std::filesystem::create_directories(dir, ec);
std::ofstream out(path);
if (!out.is_open()) {
LOG_WARNING("Could not save settings to ", path);
return;
}
// Interface
out << "ui_opacity=" << settingsPanel_.pendingUiOpacity << "\n";
out << "minimap_rotate=" << (settingsPanel_.pendingMinimapRotate ? 1 : 0) << "\n";
out << "minimap_square=" << (settingsPanel_.pendingMinimapSquare ? 1 : 0) << "\n";
out << "minimap_npc_dots=" << (settingsPanel_.pendingMinimapNpcDots ? 1 : 0) << "\n";
out << "show_latency_meter=" << (settingsPanel_.pendingShowLatencyMeter ? 1 : 0) << "\n";
out << "show_dps_meter=" << (settingsPanel_.showDPSMeter_ ? 1 : 0) << "\n";
out << "show_cooldown_tracker=" << (settingsPanel_.showCooldownTracker_ ? 1 : 0) << "\n";
out << "separate_bags=" << (settingsPanel_.pendingSeparateBags ? 1 : 0) << "\n";
out << "show_keyring=" << (settingsPanel_.pendingShowKeyring ? 1 : 0) << "\n";
out << "action_bar_scale=" << settingsPanel_.pendingActionBarScale << "\n";
out << "nameplate_scale=" << settingsPanel_.nameplateScale_ << "\n";
out << "show_friendly_nameplates=" << (settingsPanel_.showFriendlyNameplates_ ? 1 : 0) << "\n";
out << "show_action_bar2=" << (settingsPanel_.pendingShowActionBar2 ? 1 : 0) << "\n";
out << "action_bar2_offset_x=" << settingsPanel_.pendingActionBar2OffsetX << "\n";
out << "action_bar2_offset_y=" << settingsPanel_.pendingActionBar2OffsetY << "\n";
out << "show_right_bar=" << (settingsPanel_.pendingShowRightBar ? 1 : 0) << "\n";
out << "show_left_bar=" << (settingsPanel_.pendingShowLeftBar ? 1 : 0) << "\n";
out << "right_bar_offset_y=" << settingsPanel_.pendingRightBarOffsetY << "\n";
out << "left_bar_offset_y=" << settingsPanel_.pendingLeftBarOffsetY << "\n";
out << "damage_flash=" << (settingsPanel_.damageFlashEnabled_ ? 1 : 0) << "\n";
out << "low_health_vignette=" << (settingsPanel_.lowHealthVignetteEnabled_ ? 1 : 0) << "\n";
// Audio
out << "sound_muted=" << (settingsPanel_.soundMuted_ ? 1 : 0) << "\n";
out << "use_original_soundtrack=" << (settingsPanel_.pendingUseOriginalSoundtrack ? 1 : 0) << "\n";
out << "master_volume=" << settingsPanel_.pendingMasterVolume << "\n";
out << "music_volume=" << settingsPanel_.pendingMusicVolume << "\n";
out << "ambient_volume=" << settingsPanel_.pendingAmbientVolume << "\n";
out << "ui_volume=" << settingsPanel_.pendingUiVolume << "\n";
out << "combat_volume=" << settingsPanel_.pendingCombatVolume << "\n";
out << "spell_volume=" << settingsPanel_.pendingSpellVolume << "\n";
out << "movement_volume=" << settingsPanel_.pendingMovementVolume << "\n";
out << "footstep_volume=" << settingsPanel_.pendingFootstepVolume << "\n";
out << "npc_voice_volume=" << settingsPanel_.pendingNpcVoiceVolume << "\n";
out << "mount_volume=" << settingsPanel_.pendingMountVolume << "\n";
out << "activity_volume=" << settingsPanel_.pendingActivityVolume << "\n";
// Gameplay
out << "auto_loot=" << (settingsPanel_.pendingAutoLoot ? 1 : 0) << "\n";
out << "auto_sell_grey=" << (settingsPanel_.pendingAutoSellGrey ? 1 : 0) << "\n";
out << "auto_repair=" << (settingsPanel_.pendingAutoRepair ? 1 : 0) << "\n";
out << "graphics_preset=" << static_cast<int>(settingsPanel_.currentGraphicsPreset) << "\n";
out << "ground_clutter_density=" << settingsPanel_.pendingGroundClutterDensity << "\n";
out << "shadows=" << (settingsPanel_.pendingShadows ? 1 : 0) << "\n";
out << "shadow_distance=" << settingsPanel_.pendingShadowDistance << "\n";
out << "brightness=" << settingsPanel_.pendingBrightness << "\n";
out << "water_refraction=" << (settingsPanel_.pendingWaterRefraction ? 1 : 0) << "\n";
out << "antialiasing=" << settingsPanel_.pendingAntiAliasing << "\n";
out << "fxaa=" << (settingsPanel_.pendingFXAA ? 1 : 0) << "\n";
out << "normal_mapping=" << (settingsPanel_.pendingNormalMapping ? 1 : 0) << "\n";
out << "normal_map_strength=" << settingsPanel_.pendingNormalMapStrength << "\n";
out << "pom=" << (settingsPanel_.pendingPOM ? 1 : 0) << "\n";
out << "pom_quality=" << settingsPanel_.pendingPOMQuality << "\n";
out << "upscaling_mode=" << settingsPanel_.pendingUpscalingMode << "\n";
out << "fsr=" << (settingsPanel_.pendingFSR ? 1 : 0) << "\n";
out << "fsr_quality=" << settingsPanel_.pendingFSRQuality << "\n";
out << "fsr_sharpness=" << settingsPanel_.pendingFSRSharpness << "\n";
out << "fsr2_jitter_sign=" << settingsPanel_.pendingFSR2JitterSign << "\n";
out << "fsr2_mv_scale_x=" << settingsPanel_.pendingFSR2MotionVecScaleX << "\n";
out << "fsr2_mv_scale_y=" << settingsPanel_.pendingFSR2MotionVecScaleY << "\n";
out << "amd_fsr3_framegen=" << (settingsPanel_.pendingAMDFramegen ? 1 : 0) << "\n";
// Controls
out << "mouse_sensitivity=" << settingsPanel_.pendingMouseSensitivity << "\n";
out << "invert_mouse=" << (settingsPanel_.pendingInvertMouse ? 1 : 0) << "\n";
out << "extended_zoom=" << (settingsPanel_.pendingExtendedZoom ? 1 : 0) << "\n";
out << "camera_stiffness=" << settingsPanel_.pendingCameraStiffness << "\n";
out << "camera_pivot_height=" << settingsPanel_.pendingPivotHeight << "\n";
out << "fov=" << settingsPanel_.pendingFov << "\n";
// Quest tracker position/size
out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n";
out << "quest_tracker_y=" << questTrackerPos_.y << "\n";
out << "quest_tracker_w=" << questTrackerSize_.x << "\n";
out << "quest_tracker_h=" << questTrackerSize_.y << "\n";
// Chat
out << "chat_active_tab=" << chatPanel_.activeChatTab << "\n";
out << "chat_timestamps=" << (chatPanel_.chatShowTimestamps ? 1 : 0) << "\n";
out << "chat_font_size=" << chatPanel_.chatFontSize << "\n";
out << "chat_autojoin_general=" << (chatPanel_.chatAutoJoinGeneral ? 1 : 0) << "\n";
out << "chat_autojoin_trade=" << (chatPanel_.chatAutoJoinTrade ? 1 : 0) << "\n";
out << "chat_autojoin_localdefense=" << (chatPanel_.chatAutoJoinLocalDefense ? 1 : 0) << "\n";
out << "chat_autojoin_lfg=" << (chatPanel_.chatAutoJoinLFG ? 1 : 0) << "\n";
out << "chat_autojoin_local=" << (chatPanel_.chatAutoJoinLocal ? 1 : 0) << "\n";
out.close();
// Save keybindings to the same config file (appends [Keybindings] section)
KeybindingManager::getInstance().saveToConfigFile(path);
LOG_INFO("Settings saved to ", path);
}
void GameScreen::loadSettings() {
std::string path = SettingsPanel::getSettingsPath();
std::ifstream in(path);
if (!in.is_open()) return;
std::string line;
while (std::getline(in, line)) {
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string val = line.substr(eq + 1);
try {
// Interface
if (key == "ui_opacity") {
int v = std::stoi(val);
if (v >= 20 && v <= 100) {
settingsPanel_.pendingUiOpacity = v;
settingsPanel_.uiOpacity_ = static_cast<float>(v) / 100.0f;
}
} else if (key == "minimap_rotate") {
// Ignore persisted rotate state; keep north-up.
settingsPanel_.minimapRotate_ = false;
settingsPanel_.pendingMinimapRotate = false;
} else if (key == "minimap_square") {
int v = std::stoi(val);
settingsPanel_.minimapSquare_ = (v != 0);
settingsPanel_.pendingMinimapSquare = settingsPanel_.minimapSquare_;
} else if (key == "minimap_npc_dots") {
int v = std::stoi(val);
settingsPanel_.minimapNpcDots_ = (v != 0);
settingsPanel_.pendingMinimapNpcDots = settingsPanel_.minimapNpcDots_;
} else if (key == "show_latency_meter") {
settingsPanel_.showLatencyMeter_ = (std::stoi(val) != 0);
settingsPanel_.pendingShowLatencyMeter = settingsPanel_.showLatencyMeter_;
} else if (key == "show_dps_meter") {
settingsPanel_.showDPSMeter_ = (std::stoi(val) != 0);
} else if (key == "show_cooldown_tracker") {
settingsPanel_.showCooldownTracker_ = (std::stoi(val) != 0);
} else if (key == "separate_bags") {
settingsPanel_.pendingSeparateBags = (std::stoi(val) != 0);
inventoryScreen.setSeparateBags(settingsPanel_.pendingSeparateBags);
} else if (key == "show_keyring") {
settingsPanel_.pendingShowKeyring = (std::stoi(val) != 0);
inventoryScreen.setShowKeyring(settingsPanel_.pendingShowKeyring);
} else if (key == "action_bar_scale") {
settingsPanel_.pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f);
} else if (key == "nameplate_scale") {
settingsPanel_.nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f);
} else if (key == "show_friendly_nameplates") {
settingsPanel_.showFriendlyNameplates_ = (std::stoi(val) != 0);
} else if (key == "show_action_bar2") {
settingsPanel_.pendingShowActionBar2 = (std::stoi(val) != 0);
} else if (key == "action_bar2_offset_x") {
settingsPanel_.pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f);
} else if (key == "action_bar2_offset_y") {
settingsPanel_.pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f);
} else if (key == "show_right_bar") {
settingsPanel_.pendingShowRightBar = (std::stoi(val) != 0);
} else if (key == "show_left_bar") {
settingsPanel_.pendingShowLeftBar = (std::stoi(val) != 0);
} else if (key == "right_bar_offset_y") {
settingsPanel_.pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f);
} else if (key == "left_bar_offset_y") {
settingsPanel_.pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f);
} else if (key == "damage_flash") {
settingsPanel_.damageFlashEnabled_ = (std::stoi(val) != 0);
} else if (key == "low_health_vignette") {
settingsPanel_.lowHealthVignetteEnabled_ = (std::stoi(val) != 0);
}
// Audio
else if (key == "sound_muted") {
settingsPanel_.soundMuted_ = (std::stoi(val) != 0);
if (settingsPanel_.soundMuted_) {
// Apply mute on load; settingsPanel_.preMuteVolume_ will be set when AudioEngine is available
audio::AudioEngine::instance().setMasterVolume(0.0f);
}
}
else if (key == "use_original_soundtrack") settingsPanel_.pendingUseOriginalSoundtrack = (std::stoi(val) != 0);
else if (key == "master_volume") settingsPanel_.pendingMasterVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "music_volume") settingsPanel_.pendingMusicVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "ambient_volume") settingsPanel_.pendingAmbientVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "ui_volume") settingsPanel_.pendingUiVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "combat_volume") settingsPanel_.pendingCombatVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "spell_volume") settingsPanel_.pendingSpellVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "movement_volume") settingsPanel_.pendingMovementVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "footstep_volume") settingsPanel_.pendingFootstepVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "npc_voice_volume") settingsPanel_.pendingNpcVoiceVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "mount_volume") settingsPanel_.pendingMountVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "activity_volume") settingsPanel_.pendingActivityVolume = std::clamp(std::stoi(val), 0, 100);
// Gameplay
else if (key == "auto_loot") settingsPanel_.pendingAutoLoot = (std::stoi(val) != 0);
else if (key == "auto_sell_grey") settingsPanel_.pendingAutoSellGrey = (std::stoi(val) != 0);
else if (key == "auto_repair") settingsPanel_.pendingAutoRepair = (std::stoi(val) != 0);
else if (key == "graphics_preset") {
int presetVal = std::clamp(std::stoi(val), 0, 4);
settingsPanel_.currentGraphicsPreset = static_cast<SettingsPanel::GraphicsPreset>(presetVal);
settingsPanel_.pendingGraphicsPreset = settingsPanel_.currentGraphicsPreset;
}
else if (key == "ground_clutter_density") settingsPanel_.pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150);
else if (key == "shadows") settingsPanel_.pendingShadows = (std::stoi(val) != 0);
else if (key == "shadow_distance") settingsPanel_.pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f);
else if (key == "brightness") {
settingsPanel_.pendingBrightness = std::clamp(std::stoi(val), 0, 100);
if (auto* r = services_.renderer)
r->setBrightness(static_cast<float>(settingsPanel_.pendingBrightness) / 50.0f);
}
else if (key == "water_refraction") settingsPanel_.pendingWaterRefraction = (std::stoi(val) != 0);
else if (key == "antialiasing") settingsPanel_.pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3);
else if (key == "fxaa") settingsPanel_.pendingFXAA = (std::stoi(val) != 0);
else if (key == "normal_mapping") settingsPanel_.pendingNormalMapping = (std::stoi(val) != 0);
else if (key == "normal_map_strength") settingsPanel_.pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f);
else if (key == "pom") settingsPanel_.pendingPOM = (std::stoi(val) != 0);
else if (key == "pom_quality") settingsPanel_.pendingPOMQuality = std::clamp(std::stoi(val), 0, 2);
else if (key == "upscaling_mode") {
settingsPanel_.pendingUpscalingMode = std::clamp(std::stoi(val), 0, 2);
settingsPanel_.pendingFSR = (settingsPanel_.pendingUpscalingMode == 1);
} else if (key == "fsr") {
settingsPanel_.pendingFSR = (std::stoi(val) != 0);
// Backward compatibility: old configs only had fsr=0/1.
if (settingsPanel_.pendingUpscalingMode == 0 && settingsPanel_.pendingFSR) settingsPanel_.pendingUpscalingMode = 1;
}
else if (key == "fsr_quality") settingsPanel_.pendingFSRQuality = std::clamp(std::stoi(val), 0, 3);
else if (key == "fsr_sharpness") settingsPanel_.pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f);
else if (key == "fsr2_jitter_sign") settingsPanel_.pendingFSR2JitterSign = std::clamp(std::stof(val), -2.0f, 2.0f);
else if (key == "fsr2_mv_scale_x") settingsPanel_.pendingFSR2MotionVecScaleX = std::clamp(std::stof(val), -2.0f, 2.0f);
else if (key == "fsr2_mv_scale_y") settingsPanel_.pendingFSR2MotionVecScaleY = std::clamp(std::stof(val), -2.0f, 2.0f);
else if (key == "amd_fsr3_framegen") settingsPanel_.pendingAMDFramegen = (std::stoi(val) != 0);
// Controls
else if (key == "mouse_sensitivity") settingsPanel_.pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f);
else if (key == "invert_mouse") settingsPanel_.pendingInvertMouse = (std::stoi(val) != 0);
else if (key == "extended_zoom") settingsPanel_.pendingExtendedZoom = (std::stoi(val) != 0);
else if (key == "camera_stiffness") settingsPanel_.pendingCameraStiffness = std::clamp(std::stof(val), 5.0f, 100.0f);
else if (key == "camera_pivot_height") settingsPanel_.pendingPivotHeight = std::clamp(std::stof(val), 0.0f, 3.0f);
else if (key == "fov") {
settingsPanel_.pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f);
if (auto* renderer = services_.renderer) {
if (auto* camera = renderer->getCamera()) camera->setFov(settingsPanel_.pendingFov);
}
}
// Quest tracker position/size
else if (key == "quest_tracker_x") {
// Legacy: ignore absolute X (right_offset supersedes it)
(void)val;
}
else if (key == "quest_tracker_right_offset") {
questTrackerRightOffset_ = std::stof(val);
questTrackerPosInit_ = true;
}
else if (key == "quest_tracker_y") {
questTrackerPos_.y = std::stof(val);
questTrackerPosInit_ = true;
}
else if (key == "quest_tracker_w") {
questTrackerSize_.x = std::max(100.0f, std::stof(val));
}
else if (key == "quest_tracker_h") {
questTrackerSize_.y = std::max(60.0f, std::stof(val));
}
// Chat
else if (key == "chat_active_tab") chatPanel_.activeChatTab = std::clamp(std::stoi(val), 0, 3);
else if (key == "chat_timestamps") chatPanel_.chatShowTimestamps = (std::stoi(val) != 0);
else if (key == "chat_font_size") chatPanel_.chatFontSize = std::clamp(std::stoi(val), 0, 2);
else if (key == "chat_autojoin_general") chatPanel_.chatAutoJoinGeneral = (std::stoi(val) != 0);
else if (key == "chat_autojoin_trade") chatPanel_.chatAutoJoinTrade = (std::stoi(val) != 0);
else if (key == "chat_autojoin_localdefense") chatPanel_.chatAutoJoinLocalDefense = (std::stoi(val) != 0);
else if (key == "chat_autojoin_lfg") chatPanel_.chatAutoJoinLFG = (std::stoi(val) != 0);
else if (key == "chat_autojoin_local") chatPanel_.chatAutoJoinLocal = (std::stoi(val) != 0);
} catch (...) {}
}
// Load keybindings from the same config file
KeybindingManager::getInstance().loadFromConfigFile(path);
LOG_INFO("Settings loaded from ", path);
}
// ============================================================
// Mail Window
// ============================================================
// ============================================================
// Bank Window
// ============================================================
// ============================================================
// Guild Bank Window
// ============================================================
// ============================================================
// Auction House Window
// ============================================================
// ---------------------------------------------------------------------------
// Screen-space weather overlay (rain / snow / storm)
// ---------------------------------------------------------------------------
void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) {
uint32_t wType = gameHandler.getWeatherType();
float intensity = gameHandler.getWeatherIntensity();
if (wType == 0 || intensity < 0.05f) return;
const ImGuiIO& io = ImGui::GetIO();
float sw = io.DisplaySize.x;
float sh = io.DisplaySize.y;
if (sw <= 0.0f || sh <= 0.0f) return;
// Seeded RNG for weather particle positions — replaces std::rand() which
// shares global state and has modulo bias.
static std::mt19937 wxRng(std::random_device{}());
auto wxRandInt = [](int maxExcl) {
return std::uniform_int_distribution<int>(0, std::max(0, maxExcl - 1))(wxRng);
};
ImDrawList* dl = ImGui::GetForegroundDrawList();
const float dt = std::min(io.DeltaTime, 0.05f); // cap delta at 50ms to avoid teleporting particles
if (wType == 1 || wType == 3) {
// ── Rain / Storm ─────────────────────────────────────────────────────
constexpr int MAX_DROPS = 300;
struct RainState {
float x[MAX_DROPS], y[MAX_DROPS];
bool initialized = false;
uint32_t lastType = 0;
float lastW = 0.0f, lastH = 0.0f;
};
static RainState rs;
// Re-seed if weather type or screen size changed
if (!rs.initialized || rs.lastType != wType ||
rs.lastW != sw || rs.lastH != sh) {
for (int i = 0; i < MAX_DROPS; ++i) {
rs.x[i] = static_cast<float>(wxRandInt(static_cast<int>(sw) + 200)) - 100.0f;
rs.y[i] = static_cast<float>(wxRandInt(static_cast<int>(sh)));
}
rs.initialized = true;
rs.lastType = wType;
rs.lastW = sw;
rs.lastH = sh;
}
const float fallSpeed = (wType == 3) ? 680.0f : 440.0f;
const float windSpeed = (wType == 3) ? 110.0f : 65.0f;
const int numDrops = static_cast<int>(MAX_DROPS * std::min(1.0f, intensity));
const float alpha = std::min(1.0f, 0.28f + intensity * 0.38f);
const uint8_t alphaU8 = static_cast<uint8_t>(alpha * 255.0f);
const ImU32 dropCol = IM_COL32(175, 195, 225, alphaU8);
const float dropLen = 7.0f + intensity * 7.0f;
// Normalised wind direction for the trail endpoint
const float invSpeed = 1.0f / std::sqrt(fallSpeed * fallSpeed + windSpeed * windSpeed);
const float trailDx = -windSpeed * invSpeed * dropLen;
const float trailDy = -fallSpeed * invSpeed * dropLen;
for (int i = 0; i < numDrops; ++i) {
rs.x[i] += windSpeed * dt;
rs.y[i] += fallSpeed * dt;
if (rs.y[i] > sh + 10.0f) {
rs.y[i] = -10.0f;
rs.x[i] = static_cast<float>(wxRandInt(static_cast<int>(sw) + 200)) - 100.0f;
}
if (rs.x[i] > sw + 100.0f) rs.x[i] -= sw + 200.0f;
dl->AddLine(ImVec2(rs.x[i], rs.y[i]),
ImVec2(rs.x[i] + trailDx, rs.y[i] + trailDy),
dropCol, 1.0f);
}
// Storm: dark fog-vignette at screen edges
if (wType == 3) {
const float vigAlpha = std::min(1.0f, 0.12f + intensity * 0.18f);
const ImU32 vigCol = IM_COL32(60, 65, 80, static_cast<uint8_t>(vigAlpha * 255.0f));
const float vigW = sw * 0.22f;
const float vigH = sh * 0.22f;
dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(vigW, sh), vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol);
dl->AddRectFilledMultiColor(ImVec2(sw-vigW, 0), ImVec2(sw, sh), IM_COL32_BLACK_TRANS, vigCol, vigCol, IM_COL32_BLACK_TRANS);
dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(sw, vigH), vigCol, vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS);
dl->AddRectFilledMultiColor(ImVec2(0, sh-vigH),ImVec2(sw, sh), IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol, vigCol);
}
} else if (wType == 2) {
// ── Snow ─────────────────────────────────────────────────────────────
constexpr int MAX_FLAKES = 120;
struct SnowState {
float x[MAX_FLAKES], y[MAX_FLAKES], phase[MAX_FLAKES];
bool initialized = false;
float lastW = 0.0f, lastH = 0.0f;
};
static SnowState ss;
if (!ss.initialized || ss.lastW != sw || ss.lastH != sh) {
for (int i = 0; i < MAX_FLAKES; ++i) {
ss.x[i] = static_cast<float>(wxRandInt(static_cast<int>(sw)));
ss.y[i] = static_cast<float>(wxRandInt(static_cast<int>(sh)));
ss.phase[i] = static_cast<float>(wxRandInt(628)) * 0.01f;
}
ss.initialized = true;
ss.lastW = sw;
ss.lastH = sh;
}
const float fallSpeed = 45.0f + intensity * 45.0f;
const int numFlakes = static_cast<int>(MAX_FLAKES * std::min(1.0f, intensity));
const float alpha = std::min(1.0f, 0.5f + intensity * 0.3f);
const uint8_t alphaU8 = static_cast<uint8_t>(alpha * 255.0f);
const float radius = 1.5f + intensity * 1.5f;
const float time = static_cast<float>(ImGui::GetTime());
for (int i = 0; i < numFlakes; ++i) {
float sway = std::sin(time * 0.7f + ss.phase[i]) * 18.0f;
ss.x[i] += sway * dt;
ss.y[i] += fallSpeed * dt;
ss.phase[i] += dt * 0.25f;
if (ss.y[i] > sh + 5.0f) {
ss.y[i] = -5.0f;
ss.x[i] = static_cast<float>(wxRandInt(static_cast<int>(sw)));
}
if (ss.x[i] < -5.0f) ss.x[i] += sw + 10.0f;
if (ss.x[i] > sw + 5.0f) ss.x[i] -= sw + 10.0f;
// Two-tone: bright centre dot + transparent outer ring for depth
dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius, IM_COL32(220, 235, 255, alphaU8));
dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius * 0.45f, IM_COL32(245, 250, 255, std::min(255, alphaU8 + 30)));
}
}
}
// ---------------------------------------------------------------------------
// Dungeon Finder window (toggle with hotkey or bag-bar button)
// ---------------------------------------------------------------------------
// ============================================================
// Instance Lockouts
// ============================================================
// ─── Threat Window ────────────────────────────────────────────────────────────
// ─── BG Scoreboard ────────────────────────────────────────────────────────────
}} // namespace wowee::ui