From c9353853f8d9e2ea1a05ad6fe1db28a6aab0c59d Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 31 Mar 2026 19:49:52 +0300 Subject: [PATCH] `chore(game-ui): extract GameScreen domains` - Extracted `GameScreen` functionality into dedicated UI domains - Added new panels: - `action_bar_panel` - `combat_ui` - `social_panel` - `window_manager` - Updated `game_screen` + CMakeLists.txt integration - Added new headers and sources under ui and ui --- CMakeLists.txt | 4 + include/ui/action_bar_panel.hpp | 78 + include/ui/combat_ui.hpp | 76 + include/ui/game_screen.hpp | 240 +- include/ui/social_panel.hpp | 77 + include/ui/window_manager.hpp | 182 + src/ui/action_bar_panel.cpp | 1751 +++++ src/ui/combat_ui.cpp | 1890 ++++++ src/ui/game_screen.cpp | 10261 +----------------------------- src/ui/social_panel.cpp | 2626 ++++++++ src/ui/window_manager.cpp | 4264 +++++++++++++ 11 files changed, 11054 insertions(+), 10395 deletions(-) create mode 100644 include/ui/action_bar_panel.hpp create mode 100644 include/ui/combat_ui.hpp create mode 100644 include/ui/social_panel.hpp create mode 100644 include/ui/window_manager.hpp create mode 100644 src/ui/action_bar_panel.cpp create mode 100644 src/ui/combat_ui.cpp create mode 100644 src/ui/social_panel.cpp create mode 100644 src/ui/window_manager.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8aa8666f..aef7883a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -560,6 +560,10 @@ set(WOWEE_SOURCES src/ui/toast_manager.cpp src/ui/dialog_manager.cpp src/ui/settings_panel.cpp + src/ui/combat_ui.cpp + src/ui/social_panel.cpp + src/ui/action_bar_panel.cpp + src/ui/window_manager.cpp src/ui/inventory_screen.cpp src/ui/quest_log_screen.cpp src/ui/spellbook_screen.cpp diff --git a/include/ui/action_bar_panel.hpp b/include/ui/action_bar_panel.hpp new file mode 100644 index 00000000..ae650485 --- /dev/null +++ b/include/ui/action_bar_panel.hpp @@ -0,0 +1,78 @@ +// ============================================================ +// ActionBarPanel — extracted from GameScreen +// Owns all action bar rendering: main bar, stance bar, bag bar, +// XP bar, reputation bar, macro resolution. +// ============================================================ +#pragma once +#include +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace ui { + +class ChatPanel; +class SettingsPanel; +class InventoryScreen; +class SpellbookScreen; +class QuestLogScreen; + +class ActionBarPanel { +public: + // Callback type for resolving spell icons (spellId, assetMgr) → VkDescriptorSet + using SpellIconFn = std::function; + + // ---- Action bar render methods ---- + void renderActionBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen, + SpellIconFn getSpellIcon); + void renderStanceBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel, + SpellbookScreen& spellbookScreen, + SpellIconFn getSpellIcon); + void renderBagBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel, + InventoryScreen& inventoryScreen); + void renderXpBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel); + void renderRepBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel); + + // ---- State owned by this panel ---- + + // Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends + std::unordered_map actionFlashEndTimes_; + static constexpr float kActionFlashDuration = 0.5f; + + // Action bar drag state (-1 = not dragging) + int actionBarDragSlot_ = -1; + VkDescriptorSet actionBarDragIcon_ = VK_NULL_HANDLE; + + // Bag bar state + VkDescriptorSet backpackIconTexture_ = VK_NULL_HANDLE; + VkDescriptorSet emptyBagSlotTexture_ = VK_NULL_HANDLE; + int bagBarPickedSlot_ = -1; + int bagBarDragSource_ = -1; + + // Macro editor popup state + uint32_t macroEditorId_ = 0; + bool macroEditorOpen_ = false; + char macroEditorBuf_[256] = {}; + + // Macro cooldown cache: maps macro ID → resolved primary spell ID + std::unordered_map macroPrimarySpellCache_; + size_t macroCacheSpellCount_ = 0; + +private: + uint32_t resolveMacroPrimarySpellId(uint32_t macroId, game::GameHandler& gameHandler); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/combat_ui.hpp b/include/ui/combat_ui.hpp new file mode 100644 index 00000000..7d7a8058 --- /dev/null +++ b/include/ui/combat_ui.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace ui { + +class SettingsPanel; +class SpellbookScreen; + +/** + * Combat UI overlay manager (extracted from GameScreen) + * + * Owns all combat-related rendering: + * cast bar, cooldown tracker, raid warning overlay, floating combat text, + * DPS/HPS meter, buff bar, battleground score HUD, combat log, + * threat window, BG scoreboard. + */ +class CombatUI { +public: + CombatUI() = default; + + // ---- Callback type for spell icon lookup (stays in GameScreen) ---- + using SpellIconFn = std::function; + + // ---- Toggle booleans (written by slash commands / escape handler / settings) ---- + bool showCombatLog_ = false; + bool showThreatWindow_ = false; + bool showBgScoreboard_ = false; + + // ---- Raid Warning / Boss Emote big-text overlay ---- + struct RaidWarnEntry { + std::string text; + float age = 0.0f; + bool isBossEmote = false; + static constexpr float LIFETIME = 5.0f; + }; + std::vector raidWarnEntries_; + bool raidWarnCallbackSet_ = false; + size_t raidWarnChatSeenCount_ = 0; + + // ---- DPS meter state ---- + float dpsCombatAge_ = 0.0f; + bool dpsWasInCombat_ = false; + float dpsEncounterDamage_ = 0.0f; + float dpsEncounterHeal_ = 0.0f; + size_t dpsLogSeenCount_ = 0; + + // ---- Public render methods ---- + void renderCastBar(game::GameHandler& gameHandler, SpellIconFn getSpellIcon); + void renderCooldownTracker(game::GameHandler& gameHandler, + const SettingsPanel& settings, + SpellIconFn getSpellIcon); + void renderRaidWarningOverlay(game::GameHandler& gameHandler); + void renderCombatText(game::GameHandler& gameHandler); + void renderDPSMeter(game::GameHandler& gameHandler, + const SettingsPanel& settings); + void renderBuffBar(game::GameHandler& gameHandler, + SpellbookScreen& spellbookScreen, + SpellIconFn getSpellIcon); + void renderBattlegroundScore(game::GameHandler& gameHandler); + void renderCombatLog(game::GameHandler& gameHandler, + SpellbookScreen& spellbookScreen); + void renderThreatWindow(game::GameHandler& gameHandler); + void renderBgScoreboard(game::GameHandler& gameHandler); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 7e7e8032..0c29e66f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -13,6 +13,10 @@ #include "ui/toast_manager.hpp" #include "ui/dialog_manager.hpp" #include "ui/settings_panel.hpp" +#include "ui/combat_ui.hpp" +#include "ui/social_panel.hpp" +#include "ui/action_bar_panel.hpp" +#include "ui/window_manager.hpp" #include #include #include @@ -59,9 +63,17 @@ private: // Settings panel (extracted from GameScreen — owns all settings UI and config state) SettingsPanel settingsPanel_; - // Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends. - // Populated by the SpellCastFailedCallback; queried during action bar button rendering. - std::unordered_map actionFlashEndTimes_; + // Combat UI (extracted from GameScreen — owns all combat overlay rendering) + CombatUI combatUI_; + + // Social panel (extracted from GameScreen — owns all social/group UI rendering) + SocialPanel socialPanel_; + + // Action bar panel (extracted from GameScreen — owns action/stance/bag/xp/rep bars) + ActionBarPanel actionBarPanel_; + + // Window manager (extracted from GameScreen — owns NPC windows, popups, overlays) + WindowManager windowManager_; // UI state bool showEntityWindow = false; @@ -74,59 +86,21 @@ private: float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) - // Raid Warning / Boss Emote big-text overlay (center-screen, fades after 5s) - struct RaidWarnEntry { - std::string text; - float age = 0.0f; - bool isBossEmote = false; // true = amber, false (raid warning) = red+yellow - static constexpr float LIFETIME = 5.0f; - }; - std::vector raidWarnEntries_; - bool raidWarnCallbackSet_ = false; - size_t raidWarnChatSeenCount_ = 0; // index into chat history for unread scan - // UIErrorsFrame: WoW-style center-bottom error messages (spell fails, out of range, etc.) struct UIErrorEntry { std::string text; float age = 0.0f; }; std::vector uiErrors_; bool uiErrorCallbackSet_ = false; static constexpr float kUIErrorLifetime = 2.5f; bool castFailedCallbackSet_ = false; - static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade - - - - // Death screen: elapsed time since the death dialog first appeared - float deathElapsed_ = 0.0f; - bool deathTimerRunning_ = false; - // WoW forces release after ~6 minutes; show countdown until then - static constexpr float kForcedReleaseSec = 360.0f; bool showPlayerInfo = false; - bool showSocialFrame_ = false; // O key toggles social/friends list - bool showGuildRoster_ = false; - bool showRaidFrames_ = true; // F key toggles raid/party frames bool showWorldMap_ = false; // W key toggles world map - std::string selectedGuildMember_; - bool showGuildNoteEdit_ = false; - bool editingOfficerNote_ = false; - char guildNoteEditBuffer_[256] = {0}; - int guildRosterTab_ = 0; // 0=Roster, 1=Guild Info - char guildMotdEditBuffer_[256] = {0}; - bool showMotdEdit_ = false; - char petitionNameBuffer_[64] = {0}; - char addRankNameBuffer_[64] = {0}; - bool showAddRankModal_ = false; - bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default ImVec2 questTrackerSize_ = ImVec2(220.0f, 200.0f); // saved size float questTrackerRightOffset_ = -1.0f; // pixels from right edge; <0 = use default bool questTrackerPosInit_ = false; - bool showEscapeMenu = false; - // Macro editor popup state - uint32_t macroEditorId_ = 0; // macro index being edited - bool macroEditorOpen_ = false; // deferred OpenPopup flag - char macroEditorBuf_[256] = {}; // edit buffer + /** * Render player info window @@ -170,51 +144,13 @@ private: */ void updateCharacterTextures(game::Inventory& inventory); - // ---- New UI renders ---- - void renderActionBar(game::GameHandler& gameHandler); - void renderStanceBar(game::GameHandler& gameHandler); - void renderBagBar(game::GameHandler& gameHandler); - void renderXpBar(game::GameHandler& gameHandler); - void renderRepBar(game::GameHandler& gameHandler); - void renderCastBar(game::GameHandler& gameHandler); - void renderMirrorTimers(game::GameHandler& gameHandler); - void renderCooldownTracker(game::GameHandler& gameHandler); - void renderCombatText(game::GameHandler& gameHandler); - void renderRaidWarningOverlay(game::GameHandler& gameHandler); - void renderPartyFrames(game::GameHandler& gameHandler); - void renderBossFrames(game::GameHandler& gameHandler); - void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); - void renderBuffBar(game::GameHandler& gameHandler); - void renderSocialFrame(game::GameHandler& gameHandler); - void renderLootWindow(game::GameHandler& gameHandler); - void renderGossipWindow(game::GameHandler& gameHandler); - void renderQuestDetailsWindow(game::GameHandler& gameHandler); - void renderQuestRequestItemsWindow(game::GameHandler& gameHandler); - void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); - void renderVendorWindow(game::GameHandler& gameHandler); - void renderTrainerWindow(game::GameHandler& gameHandler); - void renderBarberShopWindow(game::GameHandler& gameHandler); - void renderStableWindow(game::GameHandler& gameHandler); - void renderTaxiWindow(game::GameHandler& gameHandler); - void renderLogoutCountdown(game::GameHandler& gameHandler); - void renderDeathScreen(game::GameHandler& gameHandler); - void renderReclaimCorpseButton(game::GameHandler& gameHandler); - void renderEscapeMenu(); + void renderMirrorTimers(game::GameHandler& gameHandler); + void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); void renderQuestMarkers(game::GameHandler& gameHandler); void renderMinimapMarkers(game::GameHandler& gameHandler); void renderQuestObjectiveTracker(game::GameHandler& gameHandler); - void renderGuildRoster(game::GameHandler& gameHandler); - void renderMailWindow(game::GameHandler& gameHandler); - void renderMailComposeWindow(game::GameHandler& gameHandler); - void renderBankWindow(game::GameHandler& gameHandler); - void renderGuildBankWindow(game::GameHandler& gameHandler); - void renderAuctionHouseWindow(game::GameHandler& gameHandler); - void renderDungeonFinderWindow(game::GameHandler& gameHandler); - void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); - void renderBattlegroundScore(game::GameHandler& gameHandler); - void renderDPSMeter(game::GameHandler& gameHandler); void renderDurabilityWarning(game::GameHandler& gameHandler); void takeScreenshot(game::GameHandler& gameHandler); @@ -239,144 +175,13 @@ private: bool spellIconDbLoaded_ = false; VkDescriptorSet getSpellIcon(uint32_t spellId, pipeline::AssetManager* am); - // ItemExtendedCost.dbc cache: extendedCostId -> cost details - struct ExtendedCostEntry { - uint32_t honorPoints = 0; - uint32_t arenaPoints = 0; - uint32_t itemId[5] = {}; - uint32_t itemCount[5] = {}; - }; - std::unordered_map extendedCostCache_; - bool extendedCostDbLoaded_ = false; - void loadExtendedCostDBC(); - std::string formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler); - - // Macro cooldown cache: maps macro ID → resolved primary spell ID (0 = no spell found) - std::unordered_map macroPrimarySpellCache_; - size_t macroCacheSpellCount_ = 0; // invalidates cache when spell list changes - uint32_t resolveMacroPrimarySpellId(uint32_t macroId, game::GameHandler& gameHandler); - // Death Knight rune bar: client-predicted fill (0.0=depleted, 1.0=ready) for smooth animation float runeClientFill_[6] = {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f}; - // Action bar drag state (-1 = not dragging) - int actionBarDragSlot_ = -1; - VkDescriptorSet actionBarDragIcon_ = VK_NULL_HANDLE; - - // Bag bar state - VkDescriptorSet backpackIconTexture_ = VK_NULL_HANDLE; - VkDescriptorSet emptyBagSlotTexture_ = VK_NULL_HANDLE; - int bagBarPickedSlot_ = -1; // Visual drag in progress (-1 = none) - int bagBarDragSource_ = -1; // Mouse pressed on this slot, waiting for drag or click (-1 = none) - - // Who Results window - bool showWhoWindow_ = false; - void renderWhoWindow(game::GameHandler& gameHandler); - - // Combat Log window - bool showCombatLog_ = false; - void renderCombatLog(game::GameHandler& gameHandler); - - // Instance Lockouts window - bool showInstanceLockouts_ = false; - - // Dungeon Finder state - bool showDungeonFinder_ = false; - - // Achievements window - bool showAchievementWindow_ = false; - char achievementSearchBuf_[128] = {}; - void renderAchievementWindow(game::GameHandler& gameHandler); - - // Skills / Professions window (K key) - bool showSkillsWindow_ = false; - void renderSkillsWindow(game::GameHandler& gameHandler); - - // Titles window - bool showTitlesWindow_ = false; - void renderTitlesWindow(game::GameHandler& gameHandler); - - // Equipment Set Manager window - bool showEquipSetWindow_ = false; - void renderEquipSetWindow(game::GameHandler& gameHandler); - - // GM Ticket window - bool showGmTicketWindow_ = false; - bool gmTicketWindowWasOpen_ = false; ///< Previous frame state; used to fire one-shot query - char gmTicketBuf_[2048] = {}; - void renderGmTicketWindow(game::GameHandler& gameHandler); - // Pet rename modal (triggered from pet frame context menu) bool petRenameOpen_ = false; char petRenameBuf_[16] = {}; - // Inspect window - bool showInspectWindow_ = false; - void renderInspectWindow(game::GameHandler& gameHandler); - - // Readable text window (books / scrolls / notes) - bool showBookWindow_ = false; - int bookCurrentPage_ = 0; - void renderBookWindow(game::GameHandler& gameHandler); - - // Threat window - bool showThreatWindow_ = false; - void renderThreatWindow(game::GameHandler& gameHandler); - - // BG scoreboard window - bool showBgScoreboard_ = false; - void renderBgScoreboard(game::GameHandler& gameHandler); - uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) - uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) - - // Mail compose state - char mailRecipientBuffer_[256] = ""; - char mailSubjectBuffer_[256] = ""; - char mailBodyBuffer_[2048] = ""; - int mailComposeMoney_[3] = {0, 0, 0}; // gold, silver, copper - - // Vendor search filter - char vendorSearchFilter_[128] = ""; - - // Vendor purchase confirmation for expensive items - bool vendorConfirmOpen_ = false; - uint64_t vendorConfirmGuid_ = 0; - uint32_t vendorConfirmItemId_ = 0; - uint32_t vendorConfirmSlot_ = 0; - uint32_t vendorConfirmQty_ = 1; - uint32_t vendorConfirmPrice_ = 0; - std::string vendorConfirmItemName_; - - // Barber shop UI state - int barberHairStyle_ = 0; - int barberHairColor_ = 0; - int barberFacialHair_ = 0; - int barberOrigHairStyle_ = 0; - int barberOrigHairColor_ = 0; - int barberOrigFacialHair_ = 0; - bool barberInitialized_ = false; - - // Trainer search filter - char trainerSearchFilter_[128] = ""; - - // Auction house UI state - char auctionSearchName_[256] = ""; - int auctionLevelMin_ = 0; - int auctionLevelMax_ = 0; - int auctionQuality_ = 0; - int auctionSellDuration_ = 2; // 0=12h, 1=24h, 2=48h - int auctionSellBid_[3] = {0, 0, 0}; // gold, silver, copper - int auctionSellBuyout_[3] = {0, 0, 0}; // gold, silver, copper - int auctionSelectedItem_ = -1; - int auctionSellSlotIndex_ = -1; // Selected backpack slot for selling - uint32_t auctionBrowseOffset_ = 0; // Pagination offset for browse results - int auctionItemClass_ = -1; // Item class filter (-1 = All) - int auctionItemSubClass_ = -1; // Item subclass filter (-1 = All) - bool auctionUsableOnly_ = false; // Filter to items usable by current class/level - - // Guild bank money input - int guildBankMoneyInput_[3] = {0, 0, 0}; // gold, silver, copper - // Left-click targeting: distinguish click from camera drag glm::vec2 leftClickPressPos_ = glm::vec2(0.0f); bool leftClickWasPress_ = false; @@ -390,15 +195,8 @@ private: void renderWeatherOverlay(game::GameHandler& gameHandler); - // DPS / HPS meter - float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) - bool dpsWasInCombat_ = false; - float dpsEncounterDamage_ = 0.0f; // total player damage this combat - float dpsEncounterHeal_ = 0.0f; // total player healing this combat - size_t dpsLogSeenCount_ = 0; // log entries already scanned - public: - void openDungeonFinder() { showDungeonFinder_ = true; } + void openDungeonFinder() { socialPanel_.showDungeonFinder_ = true; } ToastManager& toastManager() { return toastManager_; } }; diff --git a/include/ui/social_panel.hpp b/include/ui/social_panel.hpp new file mode 100644 index 00000000..30bce495 --- /dev/null +++ b/include/ui/social_panel.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace ui { + +class ChatPanel; +class SpellbookScreen; +class InventoryScreen; + +/** + * Social panel manager (extracted from GameScreen) + * + * Owns all social/group-related rendering: + * party frames, boss frames, guild roster, social/friends frame, + * dungeon finder, who window, inspect window. + */ +class SocialPanel { +public: + SocialPanel() = default; + + // ---- Callback type for spell icon lookup (stays in GameScreen) ---- + using SpellIconFn = std::function; + + // ---- Toggle booleans (written by slash commands / escape handler / keybinds / UI buttons) ---- + bool showSocialFrame_ = false; // O key toggles social/friends list + bool showGuildRoster_ = false; + bool showRaidFrames_ = true; // F key toggles raid/party frames + bool showWhoWindow_ = false; + bool showDungeonFinder_ = false; + bool showInspectWindow_ = false; + + // ---- Guild roster state ---- + std::string selectedGuildMember_; + bool showGuildNoteEdit_ = false; + bool editingOfficerNote_ = false; + char guildNoteEditBuffer_[256] = {0}; + int guildRosterTab_ = 0; // 0=Roster, 1=Guild Info + char guildMotdEditBuffer_[256] = {0}; + bool showMotdEdit_ = false; + char petitionNameBuffer_[64] = {0}; + char addRankNameBuffer_[64] = {0}; + bool showAddRankModal_ = false; + + // ---- LFG state ---- + uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) + uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861) + + // ---- Public render methods ---- + void renderPartyFrames(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + SpellIconFn getSpellIcon); + void renderBossFrames(game::GameHandler& gameHandler, + SpellbookScreen& spellbookScreen, + SpellIconFn getSpellIcon); + void renderGuildRoster(game::GameHandler& gameHandler, + ChatPanel& chatPanel); + void renderSocialFrame(game::GameHandler& gameHandler, + ChatPanel& chatPanel); + void renderDungeonFinderWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel); + void renderWhoWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel); + void renderInspectWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/window_manager.hpp b/include/ui/window_manager.hpp new file mode 100644 index 00000000..f899910b --- /dev/null +++ b/include/ui/window_manager.hpp @@ -0,0 +1,182 @@ +// ============================================================ +// WindowManager — extracted from GameScreen +// Owns all NPC interaction windows, popup dialogs, and misc +// overlay UI: loot, gossip, quest, vendor, trainer, mail, bank, +// auction house, barber, stable, taxi, escape menu, death screen, +// instance lockouts, achievements, GM ticket, books, titles, +// equipment sets, skills. +// ============================================================ +#pragma once +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace ui { + +class ChatPanel; +class SettingsPanel; +class InventoryScreen; +class SpellbookScreen; + +class WindowManager { +public: + // Callback type for resolving spell icons (spellId, assetMgr) → VkDescriptorSet + using SpellIconFn = std::function; + + // ---- NPC interaction windows ---- + void renderLootWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + void renderGossipWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel); + void renderQuestDetailsWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen); + void renderQuestRequestItemsWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen); + void renderQuestOfferRewardWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen); + void renderVendorWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + void renderTrainerWindow(game::GameHandler& gameHandler, + SpellIconFn getSpellIcon); + void renderBarberShopWindow(game::GameHandler& gameHandler); + void renderStableWindow(game::GameHandler& gameHandler); + void renderTaxiWindow(game::GameHandler& gameHandler); + + // ---- Mail and banking ---- + void renderMailWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + void renderMailComposeWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen); + void renderBankWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + void renderGuildBankWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + void renderAuctionHouseWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + + // ---- Popup / overlay windows ---- + void renderEscapeMenu(SettingsPanel& settingsPanel); + void renderLogoutCountdown(game::GameHandler& gameHandler); + void renderDeathScreen(game::GameHandler& gameHandler); + void renderReclaimCorpseButton(game::GameHandler& gameHandler); + void renderInstanceLockouts(game::GameHandler& gameHandler); + void renderAchievementWindow(game::GameHandler& gameHandler); + void renderGmTicketWindow(game::GameHandler& gameHandler); + void renderBookWindow(game::GameHandler& gameHandler); + void renderTitlesWindow(game::GameHandler& gameHandler); + void renderEquipSetWindow(game::GameHandler& gameHandler); + void renderSkillsWindow(game::GameHandler& gameHandler); + + // ---- State owned by this manager ---- + + // Instance lockouts + bool showInstanceLockouts_ = false; + + // Achievements + bool showAchievementWindow_ = false; + char achievementSearchBuf_[128] = {}; + + // Skills / Professions + bool showSkillsWindow_ = false; + + // Titles + bool showTitlesWindow_ = false; + + // Equipment Sets + bool showEquipSetWindow_ = false; + + // GM Ticket + bool showGmTicketWindow_ = false; + bool gmTicketWindowWasOpen_ = false; + char gmTicketBuf_[2048] = {}; + + // Book / scroll reader + bool showBookWindow_ = false; + int bookCurrentPage_ = 0; + + // Death screen + float deathElapsed_ = 0.0f; + bool deathTimerRunning_ = false; + static constexpr float kForcedReleaseSec = 360.0f; + + // Escape menu + bool showEscapeMenu = false; + + // Mail compose + char mailRecipientBuffer_[256] = ""; + char mailSubjectBuffer_[256] = ""; + char mailBodyBuffer_[2048] = ""; + int mailComposeMoney_[3] = {0, 0, 0}; + + // Vendor + char vendorSearchFilter_[128] = ""; + bool vendorConfirmOpen_ = false; + uint64_t vendorConfirmGuid_ = 0; + uint32_t vendorConfirmItemId_ = 0; + uint32_t vendorConfirmSlot_ = 0; + uint32_t vendorConfirmQty_ = 1; + uint32_t vendorConfirmPrice_ = 0; + std::string vendorConfirmItemName_; + bool vendorBagsOpened_ = false; + + // Barber shop + int barberHairStyle_ = 0; + int barberHairColor_ = 0; + int barberFacialHair_ = 0; + int barberOrigHairStyle_ = 0; + int barberOrigHairColor_ = 0; + int barberOrigFacialHair_ = 0; + bool barberInitialized_ = false; + + // Trainer + char trainerSearchFilter_[128] = ""; + + // Auction house + char auctionSearchName_[256] = ""; + int auctionLevelMin_ = 0; + int auctionLevelMax_ = 0; + int auctionQuality_ = 0; + int auctionSellDuration_ = 2; + int auctionSellBid_[3] = {0, 0, 0}; + int auctionSellBuyout_[3] = {0, 0, 0}; + int auctionSelectedItem_ = -1; + int auctionSellSlotIndex_ = -1; + uint32_t auctionBrowseOffset_ = 0; + int auctionItemClass_ = -1; + int auctionItemSubClass_ = -1; + bool auctionUsableOnly_ = false; + + // Guild bank money input + int guildBankMoneyInput_[3] = {0, 0, 0}; + + // ItemExtendedCost.dbc cache + struct ExtendedCostEntry { + uint32_t honorPoints = 0; + uint32_t arenaPoints = 0; + uint32_t itemId[5] = {}; + uint32_t itemCount[5] = {}; + }; + std::unordered_map extendedCostCache_; + bool extendedCostDbLoaded_ = false; + +private: + void loadExtendedCostDBC(); + std::string formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler); +}; + +} // namespace ui +} // namespace wowee diff --git a/src/ui/action_bar_panel.cpp b/src/ui/action_bar_panel.cpp new file mode 100644 index 00000000..59873507 --- /dev/null +++ b/src/ui/action_bar_panel.cpp @@ -0,0 +1,1751 @@ +// ============================================================ +// ActionBarPanel — extracted from GameScreen +// Owns all action bar rendering: main bar, stance bar, bag bar, +// XP bar, reputation bar, macro resolution. +// ============================================================ +#include "ui/action_bar_panel.hpp" +#include "ui/chat_panel.hpp" +#include "ui/settings_panel.hpp" +#include "ui/spellbook_screen.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/quest_log_screen.hpp" +#include "ui/ui_colors.hpp" +#include "core/application.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "rendering/vk_context.hpp" +#include "core/window.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "audio/ui_sound_manager.hpp" +#include +#include +#include +#include +#include +#include + +namespace { + 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; + + // Collect all non-comment, non-empty lines from a macro body. + std::vector allMacroCommands(const std::string& macroText) { + std::vector 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 {}; + } + +} // anonymous namespace + +namespace wowee { +namespace ui { + +uint32_t ActionBarPanel::resolveMacroPrimarySpellId(uint32_t macroId, game::GameHandler& gameHandler) { + // Invalidate cache when spell list changes (learning/unlearning spells) + size_t curSpellCount = gameHandler.getKnownSpells().size(); + if (curSpellCount != macroCacheSpellCount_) { + macroPrimarySpellCache_.clear(); + macroCacheSpellCount_ = curSpellCount; + } + auto cacheIt = macroPrimarySpellCache_.find(macroId); + if (cacheIt != macroPrimarySpellCache_.end()) return cacheIt->second; + + const std::string& macroText = gameHandler.getMacroText(macroId); + uint32_t result = 0; + if (!macroText.empty()) { + for (const auto& cmdLine : allMacroCommands(macroText)) { + std::string cl = cmdLine; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + bool isCast = (cl.rfind("/cast ", 0) == 0); + bool isCastSeq = (cl.rfind("/castsequence ", 0) == 0); + bool isUse = (cl.rfind("/use ", 0) == 0); + if (!isCast && !isCastSeq && !isUse) continue; + size_t sp2 = cmdLine.find(' '); + if (sp2 == std::string::npos) continue; + std::string spellArg = cmdLine.substr(sp2 + 1); + // Strip conditionals [...] + if (!spellArg.empty() && spellArg.front() == '[') { + size_t ce = spellArg.find(']'); + if (ce != std::string::npos) spellArg = spellArg.substr(ce + 1); + } + // Strip reset= spec for castsequence + if (isCastSeq) { + std::string tmp = spellArg; + while (!tmp.empty() && tmp.front() == ' ') tmp.erase(tmp.begin()); + if (tmp.rfind("reset=", 0) == 0) { + size_t spAfter = tmp.find(' '); + if (spAfter != std::string::npos) spellArg = tmp.substr(spAfter + 1); + } + } + // Take first alternative before ';' (for /cast) or first spell before ',' (for /castsequence) + size_t semi = spellArg.find(isCastSeq ? ',' : ';'); + if (semi != std::string::npos) spellArg = spellArg.substr(0, semi); + size_t ss = spellArg.find_first_not_of(" \t!"); + if (ss != std::string::npos) spellArg = spellArg.substr(ss); + size_t se = spellArg.find_last_not_of(" \t"); + if (se != std::string::npos) spellArg.resize(se + 1); + if (spellArg.empty()) continue; + std::string spLow = spellArg; + for (char& c : spLow) c = static_cast(std::tolower(static_cast(c))); + if (isUse) { + // /use resolves an item name → find the item's on-use spell ID + for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { + if (!info.valid) continue; + std::string iName = info.name; + for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); + if (iName == spLow) { + for (const auto& sp : info.spells) { + if (sp.spellId != 0 && sp.spellTrigger == 0) { result = sp.spellId; break; } + } + break; + } + } + } else { + // /cast and /castsequence resolve a spell name + for (uint32_t sid : gameHandler.getKnownSpells()) { + std::string sn = gameHandler.getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == spLow) { result = sid; break; } + } + } + break; + } + } + macroPrimarySpellCache_[macroId] = result; + return result; +} + +void ActionBarPanel::renderActionBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen, + SpellIconFn getSpellIcon) { + // Use ImGui's display size — always in sync with the current swap-chain/frame, + // whereas window->getWidth/Height() can lag by one frame on resize events. + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + float slotSize = 48.0f * settingsPanel.pendingActionBarScale; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH = slotSize + 24.0f; + float barX = (screenW - barW) / 2.0f; + float barY = screenH - barH; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + // Per-slot rendering lambda — shared by both action bars + const auto& bar = gameHandler.getActionBar(); + static constexpr const char* keyLabels1[] = {"1","2","3","4","5","6","7","8","9","0","-","="}; + // "⇧N" labels for bar 2 (UTF-8: E2 87 A7 = U+21E7 UPWARDS WHITE ARROW) + static constexpr const char* keyLabels2[] = { + "\xe2\x87\xa7" "1", "\xe2\x87\xa7" "2", "\xe2\x87\xa7" "3", + "\xe2\x87\xa7" "4", "\xe2\x87\xa7" "5", "\xe2\x87\xa7" "6", + "\xe2\x87\xa7" "7", "\xe2\x87\xa7" "8", "\xe2\x87\xa7" "9", + "\xe2\x87\xa7" "0", "\xe2\x87\xa7" "-", "\xe2\x87\xa7" "=" + }; + + auto renderBarSlot = [&](int absSlot, const char* keyLabel) { + ImGui::BeginGroup(); + ImGui::PushID(absSlot); + + const auto& slot = bar[absSlot]; + bool onCooldown = !slot.isReady(); + + // Macro cooldown: check the cached primary spell's cooldown. + float macroCooldownRemaining = 0.0f; + float macroCooldownTotal = 0.0f; + if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !onCooldown) { + uint32_t macroSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + if (macroSpellId != 0) { + float cd = gameHandler.getSpellCooldown(macroSpellId); + if (cd > 0.0f) { + macroCooldownRemaining = cd; + macroCooldownTotal = cd; + onCooldown = true; + } + } + } + + const bool onGCD = gameHandler.isGCDActive() && !onCooldown && !slot.isEmpty(); + + // Out-of-range check: red tint when a targeted spell cannot reach the current target. + // Applies to SPELL and MACRO slots with a known max range (>5 yd) and an active target. + // Item range is checked below after barItemDef is populated. + bool outOfRange = false; + { + uint32_t rangeCheckSpellId = 0; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) + rangeCheckSpellId = slot.id; + else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) + rangeCheckSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + if (rangeCheckSpellId != 0 && !onCooldown && gameHandler.hasTarget()) { + uint32_t maxRange = spellbookScreen.getSpellMaxRange(rangeCheckSpellId, assetMgr); + if (maxRange > 5) { + auto& em = gameHandler.getEntityManager(); + auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); + auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); + if (playerEnt && targetEnt) { + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + if (std::sqrt(dx*dx + dy*dy + dz*dz) > static_cast(maxRange)) + outOfRange = true; + } + } + } + } + + // Insufficient-power check: tint when player doesn't have enough power to cast. + // Applies to SPELL and MACRO slots with a known power cost. + bool insufficientPower = false; + { + uint32_t powerCheckSpellId = 0; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) + powerCheckSpellId = slot.id; + else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) + powerCheckSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + uint32_t spellCost = 0, spellPowerType = 0; + if (powerCheckSpellId != 0 && !onCooldown) + spellbookScreen.getSpellPowerInfo(powerCheckSpellId, assetMgr, spellCost, spellPowerType); + if (spellCost > 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt && (playerEnt->getType() == game::ObjectType::PLAYER || + playerEnt->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEnt); + if (unit->getPowerType() == static_cast(spellPowerType)) { + if (unit->getPower() < spellCost) + insufficientPower = true; + } + } + } + } + + auto getSpellName = [&](uint32_t spellId) -> std::string { + std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); + if (!name.empty()) return name; + return "Spell #" + std::to_string(spellId); + }; + + // Try to get icon texture for this slot + VkDescriptorSet iconTex = VK_NULL_HANDLE; + const game::ItemDef* barItemDef = nullptr; + uint32_t itemDisplayInfoId = 0; + std::string itemNameFromQuery; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { + iconTex = getSpellIcon(slot.id, assetMgr); + } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + auto& inv = gameHandler.getInventory(); + for (int bi = 0; bi < inv.getBackpackSize(); bi++) { + const auto& bs = inv.getBackpackSlot(bi); + if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } + } + if (!barItemDef) { + for (int ei = 0; ei < game::Inventory::NUM_EQUIP_SLOTS; ei++) { + const auto& es = inv.getEquipSlot(static_cast(ei)); + if (!es.empty() && es.item.itemId == slot.id) { barItemDef = &es.item; break; } + } + } + if (!barItemDef) { + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS && !barItemDef; bag++) { + for (int si = 0; si < inv.getBagSize(bag); si++) { + const auto& bs = inv.getBagSlot(bag, si); + if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } + } + } + } + if (barItemDef && barItemDef->displayInfoId != 0) + itemDisplayInfoId = barItemDef->displayInfoId; + if (itemDisplayInfoId == 0) { + if (auto* info = gameHandler.getItemInfo(slot.id)) { + itemDisplayInfoId = info->displayInfoId; + if (itemNameFromQuery.empty() && !info->name.empty()) + itemNameFromQuery = info->name; + } + } + if (itemDisplayInfoId != 0) + iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); + } + + // Macro icon: #showtooltip [SpellName] → show that spell's icon on the button + bool macroIsUseCmd = false; // tracks if the macro's primary command is /use (for item icon fallback) + if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !iconTex) { + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + std::string showArg = getMacroShowtooltipArg(macroText); + if (showArg.empty() || showArg == "__auto__") { + // No explicit #showtooltip arg — derive spell from first /cast, /castsequence, or /use line + for (const auto& cmdLine : allMacroCommands(macroText)) { + if (cmdLine.size() < 6) continue; + std::string cl = cmdLine; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + bool isCastCmd = (cl.rfind("/cast ", 0) == 0 || cl == "/cast"); + bool isCastSeqCmd = (cl.rfind("/castsequence ", 0) == 0); + bool isUseCmd = (cl.rfind("/use ", 0) == 0); + if (isUseCmd) macroIsUseCmd = true; + if (!isCastCmd && !isCastSeqCmd && !isUseCmd) continue; + size_t sp2 = cmdLine.find(' '); + if (sp2 == std::string::npos) continue; + showArg = cmdLine.substr(sp2 + 1); + // Strip conditionals [...] + if (!showArg.empty() && showArg.front() == '[') { + size_t ce = showArg.find(']'); + if (ce != std::string::npos) showArg = showArg.substr(ce + 1); + } + // Strip reset= spec for castsequence + if (isCastSeqCmd) { + std::string tmp = showArg; + while (!tmp.empty() && tmp.front() == ' ') tmp.erase(tmp.begin()); + if (tmp.rfind("reset=", 0) == 0) { + size_t spA = tmp.find(' '); + if (spA != std::string::npos) showArg = tmp.substr(spA + 1); + } + } + // First alternative: ';' for /cast, ',' for /castsequence + size_t sep = showArg.find(isCastSeqCmd ? ',' : ';'); + if (sep != std::string::npos) showArg = showArg.substr(0, sep); + // Trim and strip '!' + size_t ss = showArg.find_first_not_of(" \t!"); + if (ss != std::string::npos) showArg = showArg.substr(ss); + size_t se = showArg.find_last_not_of(" \t"); + if (se != std::string::npos) showArg.resize(se + 1); + break; + } + } + // Look up the spell icon by name + if (!showArg.empty() && showArg != "__auto__") { + std::string showLower = showArg; + for (char& c : showLower) c = static_cast(std::tolower(static_cast(c))); + // Also strip "(Rank N)" suffix for matching + size_t rankParen = showLower.find('('); + if (rankParen != std::string::npos) showLower.resize(rankParen); + while (!showLower.empty() && showLower.back() == ' ') showLower.pop_back(); + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl == showLower) { + iconTex = assetMgr ? getSpellIcon(sid, assetMgr) : VK_NULL_HANDLE; + if (iconTex) break; + } + } + // Fallback for /use macros: if no spell matched, search item cache for the item icon + if (!iconTex && macroIsUseCmd) { + for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { + if (!info.valid) continue; + std::string iName = info.name; + for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); + if (iName == showLower && info.displayInfoId != 0) { + iconTex = inventoryScreen.getItemIcon(info.displayInfoId); + break; + } + } + } + } + } + } + + // Item-missing check: grey out item slots whose item is not in the player's inventory. + const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 + && barItemDef == nullptr && !onCooldown); + + // Ranged item out-of-range check (runs after barItemDef is populated above). + // invType 15=Ranged (bow/gun/crossbow), 26=Thrown, 28=RangedRight (wand/crossbow). + if (!outOfRange && slot.type == game::ActionBarSlot::ITEM && barItemDef + && !onCooldown && gameHandler.hasTarget()) { + constexpr uint8_t INVTYPE_RANGED = 15; + constexpr uint8_t INVTYPE_THROWN = 26; + constexpr uint8_t INVTYPE_RANGEDRIGHT = 28; + uint32_t itemMaxRange = 0; + if (barItemDef->inventoryType == INVTYPE_RANGED || + barItemDef->inventoryType == INVTYPE_RANGEDRIGHT) + itemMaxRange = 40; + else if (barItemDef->inventoryType == INVTYPE_THROWN) + itemMaxRange = 30; + if (itemMaxRange > 0) { + auto& em = gameHandler.getEntityManager(); + auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); + auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); + if (playerEnt && targetEnt) { + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + if (std::sqrt(dx*dx + dy*dy + dz*dz) > static_cast(itemMaxRange)) + outOfRange = true; + } + } + } + + bool clicked = false; + if (iconTex) { + ImVec4 tintColor(1, 1, 1, 1); + ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); + if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } + else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } + else if (insufficientPower) { tintColor = ImVec4(0.6f, 0.5f, 0.9f, 0.85f); } + else if (itemMissing) { tintColor = ImVec4(0.35f, 0.35f, 0.35f, 0.7f); } + clicked = ImGui::ImageButton("##icon", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(slotSize, slotSize), + ImVec2(0, 0), ImVec2(1, 1), + bgColor, tintColor); + } else { + if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); + else if (insufficientPower)ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.15f, 0.4f, 0.9f)); + else if (itemMissing) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.12f, 0.12f, 0.7f)); + else if (slot.isEmpty()) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); + + char label[32]; + if (slot.type == game::ActionBarSlot::SPELL) { + std::string spellName = getSpellName(slot.id); + if (spellName.size() > 6) spellName = spellName.substr(0, 6); + snprintf(label, sizeof(label), "%s", spellName.c_str()); + } else if (slot.type == game::ActionBarSlot::ITEM && barItemDef) { + std::string itemName = barItemDef->name; + if (itemName.size() > 6) itemName = itemName.substr(0, 6); + snprintf(label, sizeof(label), "%s", itemName.c_str()); + } else if (slot.type == game::ActionBarSlot::ITEM) { + snprintf(label, sizeof(label), "Item"); + } else if (slot.type == game::ActionBarSlot::MACRO) { + snprintf(label, sizeof(label), "Macro"); + } else { + snprintf(label, sizeof(label), "--"); + } + clicked = ImGui::Button(label, ImVec2(slotSize, slotSize)); + ImGui::PopStyleColor(); + } + + // Error-flash overlay: red fade on spell cast failure (~0.5 s). + // Check both spell slots directly and macro slots via their primary spell. + { + uint32_t flashSpellId = 0; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) + flashSpellId = slot.id; + else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) + flashSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + auto flashIt = (flashSpellId != 0) ? actionFlashEndTimes_.find(flashSpellId) : actionFlashEndTimes_.end(); + if (flashIt != actionFlashEndTimes_.end()) { + float now = static_cast(ImGui::GetTime()); + float remaining = flashIt->second - now; + if (remaining > 0.0f) { + float alpha = remaining / kActionFlashDuration; // 1→0 + ImVec2 rMin = ImGui::GetItemRectMin(); + ImVec2 rMax = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddRectFilled( + rMin, rMax, + ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 0.1f, 0.1f, 0.55f * alpha))); + } else { + actionFlashEndTimes_.erase(flashIt); + } + } + } + + bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + ImGui::IsMouseReleased(ImGuiMouseButton_Left); + + if (hoveredOnRelease && spellbookScreen.isDraggingSpell()) { + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::SPELL, + spellbookScreen.getDragSpellId()); + spellbookScreen.consumeDragSpell(); + } else if (hoveredOnRelease && inventoryScreen.isHoldingItem()) { + const auto& held = inventoryScreen.getHeldItem(); + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::ITEM, held.itemId); + inventoryScreen.returnHeldItem(gameHandler.getInventory()); + } else if (clicked && actionBarDragSlot_ >= 0) { + if (absSlot != actionBarDragSlot_) { + const auto& dragSrc = bar[actionBarDragSlot_]; + gameHandler.setActionBarSlot(actionBarDragSlot_, slot.type, slot.id); + gameHandler.setActionBarSlot(absSlot, dragSrc.type, dragSrc.id); + } + actionBarDragSlot_ = -1; + actionBarDragIcon_ = 0; + } else if (clicked && !slot.isEmpty()) { + if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) { + // Check if this spell belongs to an item (e.g., Hearthstone spell 8690). + // Item-use spells must go through CMSG_USE_ITEM, not CMSG_CAST_SPELL. + uint32_t itemForSpell = gameHandler.getItemIdForSpell(slot.id); + if (itemForSpell != 0) { + gameHandler.useItemById(itemForSpell); + } else { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(slot.id, target); + } + } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + gameHandler.useItemById(slot.id); + } else if (slot.type == game::ActionBarSlot::MACRO) { + chatPanel.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); + } + } + + // Right-click context menu for non-empty slots + if (!slot.isEmpty()) { + // Use a unique popup ID per slot so multiple slots don't share state + char ctxId[32]; + snprintf(ctxId, sizeof(ctxId), "##ABCtx%d", absSlot); + if (ImGui::BeginPopupContextItem(ctxId)) { + if (slot.type == game::ActionBarSlot::SPELL) { + std::string spellName = getSpellName(slot.id); + ImGui::TextDisabled("%s", spellName.c_str()); + ImGui::Separator(); + if (onCooldown) ImGui::BeginDisabled(); + if (ImGui::MenuItem("Cast")) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(slot.id, target); + } + if (onCooldown) ImGui::EndDisabled(); + } else if (slot.type == game::ActionBarSlot::ITEM) { + const char* iName = (barItemDef && !barItemDef->name.empty()) + ? barItemDef->name.c_str() + : (!itemNameFromQuery.empty() ? itemNameFromQuery.c_str() : "Item"); + ImGui::TextDisabled("%s", iName); + ImGui::Separator(); + if (ImGui::MenuItem("Use")) { + gameHandler.useItemById(slot.id); + } + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::TextDisabled("Macro #%u", slot.id); + ImGui::Separator(); + if (ImGui::MenuItem("Execute")) { + chatPanel.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); + } + if (ImGui::MenuItem("Edit")) { + const std::string& txt = gameHandler.getMacroText(slot.id); + strncpy(macroEditorBuf_, txt.c_str(), sizeof(macroEditorBuf_) - 1); + macroEditorBuf_[sizeof(macroEditorBuf_) - 1] = '\0'; + macroEditorId_ = slot.id; + macroEditorOpen_ = true; + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Slot")) { + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::EMPTY, 0); + } + ImGui::EndPopup(); + } + } + + // Tooltip + if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { + if (slot.type == game::ActionBarSlot::SPELL) { + // Use the spellbook's rich tooltip (school, cost, cast time, range, description). + // Falls back to the simple name if DBC data isn't loaded yet. + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(slot.id, gameHandler, assetMgr); + if (!richOk) { + ImGui::Text("%s", getSpellName(slot.id).c_str()); + } + // Hearthstone: add location note after the spell tooltip body + if (slot.id == 8690) { + uint32_t mapId = 0; glm::vec3 pos; + if (gameHandler.getHomeBind(mapId, pos)) { + std::string homeLocation; + // Zone name (from zoneId stored in bind point) + uint32_t zoneId = gameHandler.getHomeBindZoneId(); + if (zoneId != 0) { + homeLocation = gameHandler.getWhoAreaName(zoneId); + } + // Fall back to continent name if zone unavailable + if (homeLocation.empty()) { + switch (mapId) { + case 0: homeLocation = "Eastern Kingdoms"; break; + case 1: homeLocation = "Kalimdor"; break; + case 530: homeLocation = "Outland"; break; + case 571: homeLocation = "Northrend"; break; + default: homeLocation = "Unknown"; break; + } + } + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), + "Home: %s", homeLocation.c_str()); + } + } + if (outOfRange) { + ImGui::TextColored(colors::kHostileRed, "Out of range"); + } + if (insufficientPower) { + ImGui::TextColored(ImVec4(0.75f, 0.55f, 1.0f, 1.0f), "Not enough power"); + } + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(kColorRed, + "Cooldown: %d min %d sec", static_cast(cd)/60, static_cast(cd)%60); + else + ImGui::TextColored(kColorRed, "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::BeginTooltip(); + // Show the primary spell's rich tooltip (like WoW does for macro buttons) + uint32_t macroSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + bool showedRich = false; + if (macroSpellId != 0) { + showedRich = spellbookScreen.renderSpellInfoTooltip(macroSpellId, gameHandler, assetMgr); + if (onCooldown && macroCooldownRemaining > 0.0f) { + float cd = macroCooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(kColorRed, + "Cooldown: %d min %d sec", static_cast(cd)/60, static_cast(cd)%60); + else + ImGui::TextColored(kColorRed, "Cooldown: %.1f sec", cd); + } + } + if (!showedRich) { + // For /use macros: try showing the item tooltip instead + if (macroIsUseCmd) { + const std::string& macroText = gameHandler.getMacroText(slot.id); + // Extract item name from first /use command + for (const auto& cmd : allMacroCommands(macroText)) { + std::string cl = cmd; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + if (cl.rfind("/use ", 0) != 0) continue; + size_t sp = cmd.find(' '); + if (sp == std::string::npos) continue; + std::string itemArg = cmd.substr(sp + 1); + while (!itemArg.empty() && itemArg.front() == ' ') itemArg.erase(itemArg.begin()); + while (!itemArg.empty() && itemArg.back() == ' ') itemArg.pop_back(); + std::string itemLow = itemArg; + for (char& c : itemLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { + if (!info.valid) continue; + std::string iName = info.name; + for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); + if (iName == itemLow) { + inventoryScreen.renderItemTooltip(info); + showedRich = true; + break; + } + } + break; + } + } + if (!showedRich) { + ImGui::Text("Macro #%u", slot.id); + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + ImGui::Separator(); + ImGui::TextUnformatted(macroText.c_str()); + } else { + ImGui::TextDisabled("(no text — right-click to Edit)"); + } + } + } + ImGui::EndTooltip(); + } else if (slot.type == game::ActionBarSlot::ITEM) { + ImGui::BeginTooltip(); + // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) + const auto* itemQueryInfo = gameHandler.getItemInfo(slot.id); + if (itemQueryInfo && itemQueryInfo->valid) { + inventoryScreen.renderItemTooltip(*itemQueryInfo); + } else if (barItemDef && !barItemDef->name.empty()) { + ImGui::Text("%s", barItemDef->name.c_str()); + } else if (!itemNameFromQuery.empty()) { + ImGui::Text("%s", itemNameFromQuery.c_str()); + } else { + ImGui::Text("Item #%u", slot.id); + } + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(kColorRed, + "Cooldown: %d min %d sec", static_cast(cd)/60, static_cast(cd)%60); + else + ImGui::TextColored(kColorRed, "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); + } + } + + // Cooldown overlay: WoW-style clock-sweep + time text + if (onCooldown) { + ImVec2 btnMin = ImGui::GetItemRectMin(); + ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + auto* dl = ImGui::GetWindowDrawList(); + + // For macros, use the resolved primary spell cooldown instead of the slot's own. + float effCdTotal = (macroCooldownTotal > 0.0f) ? macroCooldownTotal : slot.cooldownTotal; + float effCdRemaining = (macroCooldownRemaining > 0.0f) ? macroCooldownRemaining : slot.cooldownRemaining; + float total = (effCdTotal > 0.0f) ? effCdTotal : 1.0f; + float elapsed = total - effCdRemaining; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total)); + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 32; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.5f; + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); + } + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 170)); + } + + char cdText[16]; + float cd = effCdRemaining; + if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", static_cast(cd) / 3600); + else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", static_cast(cd) / 60, static_cast(cd) % 60); + else if (cd >= 5.0f) snprintf(cdText, sizeof(cdText), "%ds", static_cast(cd)); + else snprintf(cdText, sizeof(cdText), "%.1f", cd); + ImVec2 textSize = ImGui::CalcTextSize(cdText); + float tx = cx - textSize.x * 0.5f; + float ty = cy - textSize.y * 0.5f; + dl->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 220), cdText); + dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); + } + + // GCD overlay — subtle dark fan sweep (thinner/lighter than regular cooldown) + if (onGCD) { + ImVec2 btnMin = ImGui::GetItemRectMin(); + ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + auto* dl = ImGui::GetWindowDrawList(); + float gcdRem = gameHandler.getGCDRemaining(); + float gcdTotal = gameHandler.getGCDTotal(); + if (gcdTotal > 0.0f) { + float elapsed = gcdTotal - gcdRem; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / gcdTotal)); + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 24; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.4f; + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); + } + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 110)); + } + } + } + + // Auto-attack active glow — pulsing golden border when slot 6603 (Attack) is toggled on + if (slot.type == game::ActionBarSlot::SPELL && slot.id == 6603 + && gameHandler.isAutoAttacking()) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 5.0f); + ImU32 glowCol = IM_COL32( + static_cast(255), + static_cast(200 * pulse), + static_cast(0), + static_cast(200 * pulse)); + ImGui::GetWindowDrawList()->AddRect(bMin, bMax, glowCol, 2.0f, 0, 2.5f); + } + + // Item stack count overlay — bottom-right corner of icon + if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + // Count total of this item across all inventory slots + auto& inv = gameHandler.getInventory(); + int totalCount = 0; + for (int bi = 0; bi < inv.getBackpackSize(); bi++) { + const auto& bs = inv.getBackpackSlot(bi); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) { + for (int si = 0; si < inv.getBagSize(bag); si++) { + const auto& bs = inv.getBagSlot(bag, si); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + } + if (totalCount > 0) { + char countStr[16]; + snprintf(countStr, sizeof(countStr), "%d", totalCount); + ImVec2 btnMax = ImGui::GetItemRectMax(); + ImVec2 tsz = ImGui::CalcTextSize(countStr); + float cx2 = btnMax.x - tsz.x - 2.0f; + float cy2 = btnMax.y - tsz.y - 1.0f; + auto* cdl = ImGui::GetWindowDrawList(); + cdl->AddText(ImVec2(cx2 + 1.0f, cy2 + 1.0f), IM_COL32(0, 0, 0, 200), countStr); + cdl->AddText(ImVec2(cx2, cy2), + totalCount <= 1 ? IM_COL32(220, 100, 100, 255) : IM_COL32(255, 255, 255, 255), + countStr); + } + } + + // Ready glow: animate a gold border for ~1.5s when a cooldown just expires + { + static std::unordered_map slotGlowTimers; // absSlot -> remaining glow seconds + static std::unordered_map slotWasOnCooldown; // absSlot -> last frame state + + float dt = ImGui::GetIO().DeltaTime; + bool wasOnCd = slotWasOnCooldown.count(absSlot) ? slotWasOnCooldown[absSlot] : false; + + // Trigger glow when transitioning from on-cooldown to ready (and slot isn't empty) + if (wasOnCd && !onCooldown && !slot.isEmpty()) { + slotGlowTimers[absSlot] = 1.5f; + } + slotWasOnCooldown[absSlot] = onCooldown; + + auto git = slotGlowTimers.find(absSlot); + if (git != slotGlowTimers.end() && git->second > 0.0f) { + git->second -= dt; + float t = git->second / 1.5f; // 1.0 → 0.0 over lifetime + // Pulse: bright when fresh, fading out + float pulse = std::sin(t * IM_PI * 4.0f) * 0.5f + 0.5f; // 4 pulses + uint8_t alpha = static_cast(200 * t * (0.5f + 0.5f * pulse)); + if (alpha > 0) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* gdl = ImGui::GetWindowDrawList(); + // Gold glow border (2px inset, 3px thick) + gdl->AddRect(ImVec2(bMin.x - 2, bMin.y - 2), + ImVec2(bMax.x + 2, bMax.y + 2), + IM_COL32(255, 215, 0, alpha), 3.0f, 0, 3.0f); + } + if (git->second <= 0.0f) slotGlowTimers.erase(git); + } + } + + // Key label below + ImGui::TextDisabled("%s", keyLabel); + + ImGui::PopID(); + ImGui::EndGroup(); + }; + + // Bar 2 (slots 12-23) — only show if at least one slot is populated + if (settingsPanel.pendingShowActionBar2) { + bool bar2HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; } + + float bar2X = barX + settingsPanel.pendingActionBar2OffsetX; + float bar2Y = barY - barH - 2.0f + settingsPanel.pendingActionBar2OffsetY; + ImGui::SetNextWindowPos(ImVec2(bar2X, bar2Y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar2HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBar2", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + renderBarSlot(game::GameHandler::SLOTS_PER_BAR + i, keyLabels2[i]); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Bar 1 (slots 0-11) + if (ImGui::Begin("##ActionBar", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + renderBarSlot(i, keyLabels1[i]); + } + + // Macro editor modal — opened by "Edit" in action bar context menus + if (macroEditorOpen_) { + ImGui::OpenPopup("Edit Macro###MacroEdit"); + macroEditorOpen_ = false; + } + if (ImGui::BeginPopupModal("Edit Macro###MacroEdit", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Macro #%u (all lines execute; [cond] Spell; Default supported)", macroEditorId_); + ImGui::SetNextItemWidth(320.0f); + ImGui::InputTextMultiline("##MacroText", macroEditorBuf_, sizeof(macroEditorBuf_), + ImVec2(320.0f, 80.0f)); + if (ImGui::Button("Save")) { + gameHandler.setMacroText(macroEditorId_, std::string(macroEditorBuf_)); + macroPrimarySpellCache_.clear(); // invalidate resolved spell IDs + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + + // Right side vertical bar (bar 3, slots 24-35) + if (settingsPanel.pendingShowRightBar) { + bool bar3HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 2 + i].isEmpty()) { bar3HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = screenW - sideBarW - 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + settingsPanel.pendingRightBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar3HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarRight", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 2 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Left side vertical bar (bar 4, slots 36-47) + if (settingsPanel.pendingShowLeftBar) { + bool bar4HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 3 + i].isEmpty()) { bar4HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + settingsPanel.pendingLeftBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar4HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarLeft", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 3 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Vehicle exit button (WotLK): floating button above action bar when player is in a vehicle + if (gameHandler.isInVehicle()) { + const float btnW = 120.0f; + const float btnH = 32.0f; + const float btnX = (screenW - btnW) / 2.0f; + const float btnY = barY - btnH - 6.0f; + + ImGui::SetNextWindowPos(ImVec2(btnX, btnY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW, btnH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGuiWindowFlags vFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##VehicleExit", nullptr, vFlags)) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kLowHealthRed); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.4f, 0.0f, 0.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + if (ImGui::Button("Leave Vehicle", ImVec2(btnW - 8.0f, btnH - 8.0f))) { + gameHandler.sendRequestVehicleExit(); + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); + } + + // Handle action bar drag: render icon at cursor and detect drop outside + if (actionBarDragSlot_ >= 0) { + ImVec2 mousePos = ImGui::GetMousePos(); + + // Draw dragged icon at cursor + if (actionBarDragIcon_) { + ImGui::GetForegroundDrawList()->AddImage( + (ImTextureID)(uintptr_t)actionBarDragIcon_, + ImVec2(mousePos.x - 20, mousePos.y - 20), + ImVec2(mousePos.x + 20, mousePos.y + 20)); + } else { + ImGui::GetForegroundDrawList()->AddRectFilled( + ImVec2(mousePos.x - 20, mousePos.y - 20), + ImVec2(mousePos.x + 20, mousePos.y + 20), + IM_COL32(80, 80, 120, 180)); + } + + // On right mouse release, check if outside the action bar area + if (ImGui::IsMouseReleased(ImGuiMouseButton_Right)) { + bool insideBar = (mousePos.x >= barX && mousePos.x <= barX + barW && + mousePos.y >= barY && mousePos.y <= barY + barH); + if (!insideBar) { + // Dropped outside - clear the slot + gameHandler.setActionBarSlot(actionBarDragSlot_, game::ActionBarSlot::EMPTY, 0); + } + actionBarDragSlot_ = -1; + actionBarDragIcon_ = 0; + } + } +} + +void ActionBarPanel::renderStanceBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel, + SpellbookScreen& spellbookScreen, + SpellIconFn getSpellIcon) { + uint8_t playerClass = gameHandler.getPlayerClass(); + + // Stance/form spell IDs per class (ordered by display priority) + // Class IDs: 1=Warrior, 4=Rogue, 5=Priest, 6=DeathKnight, 11=Druid + static const uint32_t warriorStances[] = { 2457, 71, 2458 }; // Battle, Defensive, Berserker + static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; // Blood, Frost, Unholy + static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; + // Bear, DireBear, Cat, Travel, Aquatic, Moonkin, Tree, Flight, SwiftFlight + static const uint32_t rogueForms[] = { 1784 }; // Stealth + static const uint32_t priestForms[] = { 15473 }; // Shadowform + + const uint32_t* stanceArr = nullptr; + int stanceCount = 0; + switch (playerClass) { + case 1: stanceArr = warriorStances; stanceCount = 3; break; + case 6: stanceArr = dkPresences; stanceCount = 3; break; + case 11: stanceArr = druidForms; stanceCount = 9; break; + case 4: stanceArr = rogueForms; stanceCount = 1; break; + case 5: stanceArr = priestForms; stanceCount = 1; break; + default: return; + } + + // Filter to spells the player actually knows + const auto& known = gameHandler.getKnownSpells(); + std::vector available; + available.reserve(stanceCount); + for (int i = 0; i < stanceCount; ++i) + if (known.count(stanceArr[i])) available.push_back(stanceArr[i]); + + if (available.empty()) return; + + // Detect active stance from permanent player auras (maxDurationMs == -1) + uint32_t activeStance = 0; + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.isEmpty() || aura.maxDurationMs != -1) continue; + for (uint32_t sid : available) { + if (aura.spellId == sid) { activeStance = sid; break; } + } + if (activeStance) break; + } + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + // Match the action bar slot size so they align neatly + float slotSize = 38.0f; + float spacing = 4.0f; + float padding = 6.0f; + int count = static_cast(available.size()); + + float barW = count * slotSize + (count - 1) * spacing + padding * 2.0f; + float barH = slotSize + padding * 2.0f; + + // Position the stance bar immediately to the left of the action bar + float actionSlot = 48.0f * settingsPanel.pendingActionBarScale; + float actionBarW = 12.0f * actionSlot + 11.0f * 4.0f + 8.0f * 2.0f; + float actionBarX = (screenW - actionBarW) / 2.0f; + float actionBarH = actionSlot + 24.0f; + float actionBarY = screenH - actionBarH; + + float barX = actionBarX - barW - 8.0f; + float barY = actionBarY + (actionBarH - barH) / 2.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + if (ImGui::Begin("##StanceBar", nullptr, flags)) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < count; ++i) { + if (i > 0) ImGui::SameLine(0.0f, spacing); + ImGui::PushID(i); + + uint32_t spellId = available[i]; + bool isActive = (spellId == activeStance); + + VkDescriptorSet iconTex = assetMgr ? getSpellIcon(spellId, assetMgr) : VK_NULL_HANDLE; + + ImVec2 pos = ImGui::GetCursorScreenPos(); + ImVec2 posEnd = ImVec2(pos.x + slotSize, pos.y + slotSize); + + // Background — green tint when active + ImU32 bgCol = isActive ? IM_COL32(30, 70, 30, 230) : IM_COL32(20, 20, 20, 220); + ImU32 borderCol = isActive ? IM_COL32(80, 220, 80, 255) : IM_COL32(80, 80, 80, 200); + dl->AddRectFilled(pos, posEnd, bgCol, 4.0f); + + if (iconTex) { + dl->AddImage((ImTextureID)(uintptr_t)iconTex, pos, posEnd); + // Darken inactive buttons slightly + if (!isActive) + dl->AddRectFilled(pos, posEnd, IM_COL32(0, 0, 0, 70), 4.0f); + } + dl->AddRect(pos, posEnd, borderCol, 4.0f, 0, 2.0f); + + ImGui::InvisibleButton("##btn", ImVec2(slotSize, slotSize)); + + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) + gameHandler.castSpell(spellId); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); + if (!name.empty()) ImGui::TextUnformatted(name.c_str()); + else ImGui::Text("Spell #%u", spellId); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); +} + +void ActionBarPanel::renderBagBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel, + InventoryScreen& inventoryScreen) { + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + float slotSize = 42.0f; + float spacing = 4.0f; + float padding = 6.0f; + + // 5 slots: backpack + 4 bags + float barW = 5 * slotSize + 4 * spacing + padding * 2; + float barH = slotSize + padding * 2; + + // Position in bottom right corner + float barX = screenW - barW - 10.0f; + float barY = screenH - barH - 10.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + if (ImGui::Begin("##BagBar", nullptr, flags)) { + auto& inv = gameHandler.getInventory(); + + // Load backpack icon if needed + if (!backpackIconTexture_ && assetMgr && assetMgr->isInitialized()) { + auto blpData = assetMgr->readFile("Interface\\Buttons\\Button-Backpack-Up.blp"); + if (!blpData.empty()) { + auto image = pipeline::BLPLoader::load(blpData); + if (image.isValid()) { + auto* w = core::Application::getInstance().getWindow(); + auto* vkCtx = w ? w->getVkContext() : nullptr; + if (vkCtx) + backpackIconTexture_ = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); + } + } + } + + // Track bag slot screen rects for drop detection + ImVec2 bagSlotMins[4], bagSlotMaxs[4]; + + // Slots 1-4: Bag slots (leftmost) + for (int i = 0; i < 4; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + ImGui::PushID(i + 1); + + game::EquipSlot bagSlot = static_cast(static_cast(game::EquipSlot::BAG1) + i); + const auto& bagItem = inv.getEquipSlot(bagSlot); + + VkDescriptorSet bagIcon = VK_NULL_HANDLE; + if (!bagItem.empty() && bagItem.item.displayInfoId != 0) { + bagIcon = inventoryScreen.getItemIcon(bagItem.item.displayInfoId); + } + // Render the slot as an invisible button so we control all interaction + ImVec2 cpos = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##bagSlot", ImVec2(slotSize, slotSize)); + bagSlotMins[i] = cpos; + bagSlotMaxs[i] = ImVec2(cpos.x + slotSize, cpos.y + slotSize); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + + // Draw background + icon + if (bagIcon) { + dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(25, 25, 25, 230)); + dl->AddImage((ImTextureID)(uintptr_t)bagIcon, bagSlotMins[i], bagSlotMaxs[i]); + } else { + dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(38, 38, 38, 204)); + } + + // Hover highlight + bool hovered = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + if (hovered && bagBarPickedSlot_ < 0) { + dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 100)); + } + + // Track which slot was pressed for drag detection + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && bagBarPickedSlot_ < 0 && bagIcon) { + bagBarDragSource_ = i; + } + + // Click toggles bag open/close (handled in mouse release section below) + + // Dim the slot being dragged + if (bagBarPickedSlot_ == i) { + dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(0, 0, 0, 150)); + } + + // Tooltip + if (hovered && bagBarPickedSlot_ < 0) { + if (bagIcon) + ImGui::SetTooltip("%s", bagItem.item.name.c_str()); + else + ImGui::SetTooltip("Empty Bag Slot"); + } + + // Open bag indicator + if (inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i)) { + dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f); + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("##bagSlotCtx")) { + if (!bagItem.empty()) { + ImGui::TextDisabled("%s", bagItem.item.name.c_str()); + ImGui::Separator(); + bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i); + if (ImGui::MenuItem(isOpen ? "Close Bag" : "Open Bag")) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBag(i); + else + inventoryScreen.toggle(); + } + if (ImGui::MenuItem("Unequip Bag")) { + gameHandler.unequipToBackpack(bagSlot); + } + } else { + ImGui::TextDisabled("Empty Bag Slot"); + } + ImGui::EndPopup(); + } + + // Accept dragged item from inventory + if (hovered && inventoryScreen.isHoldingItem()) { + const auto& heldItem = inventoryScreen.getHeldItem(); + if ((heldItem.inventoryType == 18 || heldItem.bagSlots > 0) && + ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + auto& inventory = gameHandler.getInventory(); + inventoryScreen.dropHeldItemToEquipSlot(inventory, bagSlot); + } + } + + ImGui::PopID(); + } + + // Drag lifecycle: press on a slot sets bagBarDragSource_, + // dragging 3+ pixels promotes to bagBarPickedSlot_ (visual drag), + // releasing completes swap or click + if (bagBarDragSource_ >= 0) { + if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 3.0f) && bagBarPickedSlot_ < 0) { + // If an inventory window is open, hand off drag to inventory held-item + // so the bag can be dropped into backpack/bag slots. + if (inventoryScreen.isOpen() || inventoryScreen.isCharacterOpen()) { + auto equip = static_cast( + static_cast(game::EquipSlot::BAG1) + bagBarDragSource_); + if (inventoryScreen.beginPickupFromEquipSlot(inv, equip)) { + bagBarDragSource_ = -1; + } else { + bagBarPickedSlot_ = bagBarDragSource_; + } + } else { + // Mouse moved enough — start visual drag + bagBarPickedSlot_ = bagBarDragSource_; + } + } + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + if (bagBarPickedSlot_ >= 0) { + // Was dragging — check for drop target + ImVec2 mousePos = ImGui::GetIO().MousePos; + int dropTarget = -1; + for (int j = 0; j < 4; ++j) { + if (j == bagBarPickedSlot_) continue; + if (mousePos.x >= bagSlotMins[j].x && mousePos.x <= bagSlotMaxs[j].x && + mousePos.y >= bagSlotMins[j].y && mousePos.y <= bagSlotMaxs[j].y) { + dropTarget = j; + break; + } + } + if (dropTarget >= 0) { + gameHandler.swapBagSlots(bagBarPickedSlot_, dropTarget); + } + bagBarPickedSlot_ = -1; + } else { + // Was just a click (no drag) — toggle bag + int slot = bagBarDragSource_; + auto equip = static_cast(static_cast(game::EquipSlot::BAG1) + slot); + if (!inv.getEquipSlot(equip).empty()) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBag(slot); + else + inventoryScreen.toggle(); + } + } + bagBarDragSource_ = -1; + } + } + + // Backpack (rightmost slot) + ImGui::SameLine(0, spacing); + ImGui::PushID(0); + if (backpackIconTexture_) { + if (ImGui::ImageButton("##backpack", (ImTextureID)(uintptr_t)backpackIconTexture_, + ImVec2(slotSize, slotSize), + ImVec2(0, 0), ImVec2(1, 1), + ImVec4(0.1f, 0.1f, 0.1f, 0.9f), + colors::kWhite)) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBackpack(); + else + inventoryScreen.toggle(); + } + } else { + if (ImGui::Button("B", ImVec2(slotSize, slotSize))) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBackpack(); + else + inventoryScreen.toggle(); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Backpack"); + } + // Right-click context menu on backpack + if (ImGui::BeginPopupContextItem("##backpackCtx")) { + bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBackpackOpen(); + if (ImGui::MenuItem(isOpen ? "Close Backpack" : "Open Backpack")) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBackpack(); + else + inventoryScreen.toggle(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Open All Bags")) { + inventoryScreen.openAllBags(); + } + if (ImGui::MenuItem("Close All Bags")) { + inventoryScreen.closeAllBags(); + } + ImGui::EndPopup(); + } + if (inventoryScreen.isSeparateBags() && + inventoryScreen.isBackpackOpen()) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 r0 = ImGui::GetItemRectMin(); + ImVec2 r1 = ImGui::GetItemRectMax(); + dl->AddRect(r0, r1, IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f); + } + ImGui::PopID(); + + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + + // Draw dragged bag icon following cursor + if (bagBarPickedSlot_ >= 0) { + auto& inv2 = gameHandler.getInventory(); + auto pickedEquip = static_cast( + static_cast(game::EquipSlot::BAG1) + bagBarPickedSlot_); + const auto& pickedItem = inv2.getEquipSlot(pickedEquip); + VkDescriptorSet pickedIcon = VK_NULL_HANDLE; + if (!pickedItem.empty() && pickedItem.item.displayInfoId != 0) { + pickedIcon = inventoryScreen.getItemIcon(pickedItem.item.displayInfoId); + } + if (pickedIcon) { + ImVec2 mousePos = ImGui::GetIO().MousePos; + float sz = 40.0f; + ImVec2 p0(mousePos.x - sz * 0.5f, mousePos.y - sz * 0.5f); + ImVec2 p1(mousePos.x + sz * 0.5f, mousePos.y + sz * 0.5f); + ImDrawList* fg = ImGui::GetForegroundDrawList(); + fg->AddImage((ImTextureID)(uintptr_t)pickedIcon, p0, p1); + fg->AddRect(p0, p1, IM_COL32(200, 200, 200, 255), 0.0f, 0, 2.0f); + } + } +} + +void ActionBarPanel::renderXpBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel) { + uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); + uint32_t playerLevel = gameHandler.getPlayerLevel(); + // At max level, server sends nextLevelXp=0. Only skip entirely when we have + // no level info at all (not yet logged in / no update-field data). + const bool isMaxLevel = (nextLevelXp == 0 && playerLevel > 0); + if (nextLevelXp == 0 && !isMaxLevel) return; + + uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t restedXp = gameHandler.getPlayerRestedXp(); + bool isResting = gameHandler.isPlayerResting(); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + auto* window = core::Application::getInstance().getWindow(); + (void)window; // Not used for positioning; kept for AssetManager if needed + + // Position just above both action bars (bar1 at screenH-barH, bar2 above that) + float slotSize = 48.0f * settingsPanel.pendingActionBarScale; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH = slotSize + 24.0f; + + float xpBarH = 20.0f; + float xpBarW = barW; + float xpBarX = (screenW - xpBarW) / 2.0f; + // XP bar sits just above whichever bar is topmost. + // bar1 top edge: screenH - barH + // bar2 top edge (when visible): bar1 top - barH - 2 + bar2 vertical offset + float bar1TopY = screenH - barH; + float xpBarY; + if (settingsPanel.pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH - 2.0f + settingsPanel.pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } + + ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); + + if (ImGui::Begin("##XpBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* drawList = ImGui::GetWindowDrawList(); + + if (isMaxLevel) { + // Max-level bar: fully filled in muted gold with "Max Level" label + ImU32 bgML = IM_COL32(15, 12, 5, 220); + ImU32 fgML = IM_COL32(180, 140, 40, 200); + drawList->AddRectFilled(barMin, barMax, bgML, 2.0f); + drawList->AddRectFilled(barMin, barMax, fgML, 2.0f); + drawList->AddRect(barMin, barMax, IM_COL32(100, 80, 20, 220), 2.0f); + const char* mlLabel = "Max Level"; + ImVec2 mlSz = ImGui::CalcTextSize(mlLabel); + drawList->AddText( + ImVec2(barMin.x + (barSize.x - mlSz.x) * 0.5f, + barMin.y + (barSize.y - mlSz.y) * 0.5f), + IM_COL32(255, 230, 120, 255), mlLabel); + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Level %u — Maximum level reached", playerLevel); + } else { + float pct = static_cast(currentXp) / static_cast(nextLevelXp); + if (pct > 1.0f) pct = 1.0f; + + // Custom segmented XP bar (20 bubbles) + ImU32 bg = IM_COL32(15, 15, 20, 220); + ImU32 fg = IM_COL32(148, 51, 238, 255); + ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion + ImU32 seg = IM_COL32(35, 35, 45, 255); + drawList->AddRectFilled(barMin, barMax, bg, 2.0f); + drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); + + float fillW = barSize.x * pct; + if (fillW > 0.0f) { + drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f); + } + + // Rested XP overlay: draw from current XP fill to (currentXp + restedXp) fill + if (restedXp > 0) { + float restedEndPct = std::min(1.0f, static_cast(currentXp + restedXp) + / static_cast(nextLevelXp)); + float restedStartX = barMin.x + fillW; + float restedEndX = barMin.x + barSize.x * restedEndPct; + if (restedEndX > restedStartX) { + drawList->AddRectFilled(ImVec2(restedStartX, barMin.y), + ImVec2(restedEndX, barMax.y), + fgRest, 2.0f); + } + } + + const int segments = 20; + float segW = barSize.x / static_cast(segments); + for (int i = 1; i < segments; ++i) { + float x = barMin.x + segW * i; + drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f); + } + + // Rest indicator "zzz" to the right of the bar when resting + if (isResting) { + const char* zzz = "zzz"; + ImVec2 zSize = ImGui::CalcTextSize(zzz); + float zx = barMax.x - zSize.x - 4.0f; + float zy = barMin.y + (barSize.y - zSize.y) * 0.5f; + drawList->AddText(ImVec2(zx, zy), IM_COL32(180, 150, 255, 220), zzz); + } + + char overlay[96]; + if (restedXp > 0) { + snprintf(overlay, sizeof(overlay), "%u / %u XP (+%u rested)", currentXp, nextLevelXp, restedXp); + } else { + snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + } + ImVec2 textSize = ImGui::CalcTextSize(overlay); + float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; + float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; + drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay); + + ImGui::Dummy(barSize); + + // Tooltip with XP-to-level and rested details + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + uint32_t xpToLevel = (currentXp < nextLevelXp) ? (nextLevelXp - currentXp) : 0; + ImGui::TextColored(ImVec4(0.9f, 0.85f, 1.0f, 1.0f), "Experience"); + ImGui::Separator(); + float xpPct = nextLevelXp > 0 ? (100.0f * currentXp / nextLevelXp) : 0.0f; + ImGui::Text("Current: %u / %u XP (%.1f%%)", currentXp, nextLevelXp, xpPct); + ImGui::Text("To next level: %u XP", xpToLevel); + if (restedXp > 0) { + float restedLevels = static_cast(restedXp) / static_cast(nextLevelXp); + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.78f, 0.60f, 1.0f, 1.0f), + "Rested: +%u XP (%.1f%% of a level)", restedXp, restedLevels * 100.0f); + if (isResting) + ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), + "Resting — accumulating bonus XP"); + } + ImGui::EndTooltip(); + } + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); +} + +void ActionBarPanel::renderRepBar(game::GameHandler& gameHandler, + SettingsPanel& settingsPanel) { + uint32_t factionId = gameHandler.getWatchedFactionId(); + if (factionId == 0) return; + + const auto& standings = gameHandler.getFactionStandings(); + auto it = standings.find(factionId); + if (it == standings.end()) return; + + int32_t standing = it->second; + + // WoW reputation rank thresholds + struct RepRank { const char* name; int32_t min; int32_t max; ImU32 color; }; + static const RepRank kRanks[] = { + { "Hated", -42000, -6001, IM_COL32(180, 40, 40, 255) }, + { "Hostile", -6000, -3001, IM_COL32(180, 40, 40, 255) }, + { "Unfriendly", -3000, -1, IM_COL32(220, 100, 50, 255) }, + { "Neutral", 0, 2999, IM_COL32(200, 200, 60, 255) }, + { "Friendly", 3000, 8999, IM_COL32( 60, 180, 60, 255) }, + { "Honored", 9000, 20999, IM_COL32( 60, 160, 220, 255) }, + { "Revered", 21000, 41999, IM_COL32(140, 80, 220, 255) }, + { "Exalted", 42000, 42999, IM_COL32(255, 200, 50, 255) }, + }; + constexpr int kNumRanks = static_cast(sizeof(kRanks) / sizeof(kRanks[0])); + + int rankIdx = kNumRanks - 1; // default to Exalted + for (int i = 0; i < kNumRanks; ++i) { + if (standing <= kRanks[i].max) { rankIdx = i; break; } + } + const RepRank& rank = kRanks[rankIdx]; + + float fraction = 1.0f; + if (rankIdx < kNumRanks - 1) { + float range = static_cast(rank.max - rank.min + 1); + fraction = static_cast(standing - rank.min) / range; + fraction = std::max(0.0f, std::min(1.0f, fraction)); + } + + const std::string& factionName = gameHandler.getFactionNamePublic(factionId); + + // Position directly above the XP bar + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + float slotSize = 48.0f * settingsPanel.pendingActionBarScale; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH_ab = slotSize + 24.0f; + float xpBarH = 20.0f; + float repBarH = 12.0f; + float xpBarW = barW; + float xpBarX = (screenW - xpBarW) / 2.0f; + + float bar1TopY = screenH - barH_ab; + float xpBarY; + if (settingsPanel.pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH_ab - 2.0f + settingsPanel.pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } + float repBarY = xpBarY - repBarH - 2.0f; + + ImGui::SetNextWindowPos(ImVec2(xpBarX, repBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(xpBarW, repBarH + 4.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); + + if (ImGui::Begin("##RepBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, repBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* dl = ImGui::GetWindowDrawList(); + + dl->AddRectFilled(barMin, barMax, IM_COL32(15, 15, 20, 220), 2.0f); + dl->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); + + float fillW = barSize.x * fraction; + if (fillW > 0.0f) + dl->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), rank.color, 2.0f); + + // Label: "FactionName - Rank" + char label[96]; + snprintf(label, sizeof(label), "%s - %s", factionName.c_str(), rank.name); + ImVec2 textSize = ImGui::CalcTextSize(label); + float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; + float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; + dl->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), label); + + // Tooltip with exact values on hover + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + float cr = ((rank.color ) & 0xFF) / 255.0f; + float cg = ((rank.color >> 8) & 0xFF) / 255.0f; + float cb = ((rank.color >> 16) & 0xFF) / 255.0f; + ImGui::TextColored(ImVec4(cr, cg, cb, 1.0f), "%s", rank.name); + int32_t rankMin = rank.min; + int32_t rankMax = (rankIdx < kNumRanks - 1) ? rank.max : 42000; + ImGui::Text("%s: %d / %d", factionName.c_str(), standing - rankMin, rankMax - rankMin + 1); + ImGui::EndTooltip(); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); +} + + +} // namespace ui +} // namespace wowee diff --git a/src/ui/combat_ui.cpp b/src/ui/combat_ui.cpp new file mode 100644 index 00000000..8ba732e7 --- /dev/null +++ b/src/ui/combat_ui.cpp @@ -0,0 +1,1890 @@ +// ============================================================ +// CombatUI — extracted from GameScreen +// Owns all combat-related UI rendering: cast bar, cooldown tracker, +// raid warning overlay, combat text, DPS meter, buff bar, +// battleground score HUD, combat log, threat window, BG scoreboard. +// ============================================================ +#include "ui/combat_ui.hpp" +#include "ui/settings_panel.hpp" +#include "ui/spellbook_screen.hpp" +#include "ui/ui_colors.hpp" +#include "core/application.hpp" +#include "core/logger.hpp" +#include "core/coordinates.hpp" +#include "rendering/renderer.hpp" +#include "rendering/camera.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" +#include "audio/audio_engine.hpp" +#include "audio/ui_sound_manager.hpp" +#include +#include +#include +#include +#include +#include + +namespace { + using namespace wowee::ui::colors; + constexpr auto& kColorRed = kRed; + constexpr auto& kColorGreen = kGreen; + constexpr auto& kColorBrightGreen = kBrightGreen; + constexpr auto& kColorYellow = kYellow; + + // Format a duration in seconds as compact text: "2h", "3:05", "42" + void fmtDurationCompact(char* buf, size_t sz, int secs) { + if (secs >= 3600) snprintf(buf, sz, "%dh", secs / 3600); + else if (secs >= 60) snprintf(buf, sz, "%d:%02d", secs / 60, secs % 60); + else snprintf(buf, sz, "%d", secs); + } + + // Render "Remaining: Xs" or "Remaining: Xm Ys" in a tooltip (light gray) + void renderAuraRemaining(int remainMs) { + if (remainMs <= 0) return; + int s = remainMs / 1000; + char buf[32]; + if (s < 60) snprintf(buf, sizeof(buf), "Remaining: %ds", s); + else snprintf(buf, sizeof(buf), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(kLightGray, "%s", buf); + } +} // anonymous namespace + +namespace wowee { +namespace ui { + + +// ============================================================ +// Cast Bar (Phase 3) +// ============================================================ + +void CombatUI::renderCastBar(game::GameHandler& gameHandler, SpellIconFn getSpellIcon) { + if (!gameHandler.isCasting()) return; + + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); + VkDescriptorSet iconTex = (currentSpellId != 0 && assetMgr) + ? getSpellIcon(currentSpellId, assetMgr) : VK_NULL_HANDLE; + + float barW = 300.0f; + float barX = (screenW - barW) / 2.0f; + float barY = screenH - 120.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, 40), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f)); + + if (ImGui::Begin("##CastBar", nullptr, flags)) { + const bool channeling = gameHandler.isChanneling(); + // Channels drain right-to-left; regular casts fill left-to-right + float progress = channeling + ? (1.0f - gameHandler.getCastProgress()) + : gameHandler.getCastProgress(); + + // Color by spell school for cast identification; channels always blue + ImVec4 barColor; + if (channeling) { + barColor = ImVec4(0.3f, 0.6f, 0.9f, 1.0f); // blue for channels + } else { + uint32_t school = (currentSpellId != 0) ? gameHandler.getSpellSchoolMask(currentSpellId) : 0; + if (school & 0x04) barColor = ImVec4(0.95f, 0.40f, 0.10f, 1.0f); // Fire: orange-red + else if (school & 0x10) barColor = ImVec4(0.30f, 0.65f, 0.95f, 1.0f); // Frost: icy blue + else if (school & 0x20) barColor = ImVec4(0.55f, 0.15f, 0.70f, 1.0f); // Shadow: purple + else if (school & 0x40) barColor = ImVec4(0.65f, 0.30f, 0.85f, 1.0f); // Arcane: violet + else if (school & 0x08) barColor = ImVec4(0.20f, 0.75f, 0.25f, 1.0f); // Nature: green + else if (school & 0x02) barColor = ImVec4(0.90f, 0.80f, 0.30f, 1.0f); // Holy: golden + else barColor = ImVec4(0.80f, 0.60f, 0.20f, 1.0f); // Physical/default: gold + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + + char overlay[96]; + if (currentSpellId == 0) { + snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining()); + } else { + const std::string& spellName = gameHandler.getSpellName(currentSpellId); + const char* verb = channeling ? "Channeling" : "Casting"; + int queueLeft = gameHandler.getCraftQueueRemaining(); + if (!spellName.empty()) { + if (queueLeft > 0) + snprintf(overlay, sizeof(overlay), "%s (%.1fs) [%d left]", spellName.c_str(), gameHandler.getCastTimeRemaining(), queueLeft); + else + snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); + } else { + snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); + } + } + + // Queued spell icon (right edge): the next spell queued to fire within 400ms. + uint32_t queuedId = gameHandler.getQueuedSpellId(); + VkDescriptorSet queuedTex = (queuedId != 0 && assetMgr) + ? getSpellIcon(queuedId, assetMgr) : VK_NULL_HANDLE; + + const float iconSz = 20.0f; + const float reservedRight = (queuedTex) ? (iconSz + 4.0f) : 0.0f; + + if (iconTex) { + // Spell icon to the left of the progress bar + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(iconSz, iconSz)); + ImGui::SameLine(0, 4); + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); + } else { + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); + } + // Draw queued-spell icon on the right with a ">" arrow prefix tooltip. + if (queuedTex) { + ImGui::SameLine(0, 4); + ImGui::Image((ImTextureID)(uintptr_t)queuedTex, ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(1,1,1,0.8f), ImVec4(0,0,0,0)); // slightly dimmed + if (ImGui::IsItemHovered()) { + const std::string& qn = gameHandler.getSpellName(queuedId); + ImGui::SetTooltip("Queued: %s", qn.empty() ? "Unknown" : qn.c_str()); + } + } + ImGui::PopStyleColor(); + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + + +// ============================================================ +// Cooldown Tracker — floating panel showing all active spell CDs +// ============================================================ + +void CombatUI::renderCooldownTracker(game::GameHandler& gameHandler, + const SettingsPanel& settings, + SpellIconFn getSpellIcon) { + if (!settings.showCooldownTracker_) return; + + const auto& cooldowns = gameHandler.getSpellCooldowns(); + if (cooldowns.empty()) return; + + // Collect spells with remaining cooldown > 0.5s (skip GCD noise) + struct CDEntry { uint32_t spellId; float remaining; }; + std::vector active; + active.reserve(16); + for (const auto& [sid, rem] : cooldowns) { + if (rem > 0.5f) active.push_back({sid, rem}); + } + if (active.empty()) return; + + // Sort: longest remaining first + std::sort(active.begin(), active.end(), [](const CDEntry& a, const CDEntry& b) { + return a.remaining > b.remaining; + }); + + auto* assetMgr = core::Application::getInstance().getAssetManager(); + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + constexpr float TRACKER_W = 200.0f; + constexpr int MAX_SHOWN = 12; + float posX = screenW - TRACKER_W - 10.0f; + float posY = screenH - 220.0f; // above the action bar area + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##CooldownTracker", nullptr, flags)) { + ImGui::TextDisabled("Cooldowns"); + ImGui::Separator(); + + int shown = 0; + for (const auto& cd : active) { + if (shown >= MAX_SHOWN) break; + + const std::string& name = gameHandler.getSpellName(cd.spellId); + if (name.empty()) continue; // skip unnamed spells (internal/passive) + + // Small icon if available + VkDescriptorSet icon = assetMgr ? getSpellIcon(cd.spellId, assetMgr) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(14, 14)); + ImGui::SameLine(0, 3); + } + + // Name (truncated) + remaining time + char timeStr[16]; + if (cd.remaining >= 60.0f) + snprintf(timeStr, sizeof(timeStr), "%dm%ds", static_cast(cd.remaining) / 60, static_cast(cd.remaining) % 60); + else + snprintf(timeStr, sizeof(timeStr), "%.0fs", cd.remaining); + + // Color: red > 30s, orange > 10s, yellow > 5s, green otherwise + ImVec4 cdColor = cd.remaining > 30.0f ? kColorRed : + cd.remaining > 10.0f ? ImVec4(1.0f, 0.6f, 0.2f, 1.0f) : + cd.remaining > 5.0f ? kColorYellow : + colors::kActiveGreen; + + // Truncate name to fit + std::string displayName = name; + if (displayName.size() > 16) displayName = displayName.substr(0, 15) + "\xe2\x80\xa6"; // ellipsis + + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", displayName.c_str()); + ImGui::SameLine(TRACKER_W - 48.0f); + ImGui::TextColored(cdColor, "%s", timeStr); + + ++shown; + } + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + + +// ============================================================ +// Raid Warning / Boss Emote Center-Screen Overlay +// ============================================================ + +void CombatUI::renderRaidWarningOverlay(game::GameHandler& gameHandler) { + // Scan chat history for new RAID_WARNING / RAID_BOSS_EMOTE messages + const auto& chatHistory = gameHandler.getChatHistory(); + size_t newCount = chatHistory.size(); + if (newCount > raidWarnChatSeenCount_) { + // Walk only the new messages (deque — iterate from back by skipping old ones) + size_t toScan = newCount - raidWarnChatSeenCount_; + size_t startIdx = newCount > toScan ? newCount - toScan : 0; + auto* renderer = core::Application::getInstance().getRenderer(); + for (size_t i = startIdx; i < newCount; ++i) { + const auto& msg = chatHistory[i]; + if (msg.type == game::ChatType::RAID_WARNING || + msg.type == game::ChatType::RAID_BOSS_EMOTE || + msg.type == game::ChatType::MONSTER_EMOTE) { + bool isBoss = (msg.type != game::ChatType::RAID_WARNING); + // Limit display text length to avoid giant overlay + std::string text = msg.message; + if (text.size() > 200) text = text.substr(0, 200) + "..."; + raidWarnEntries_.push_back({text, 0.0f, isBoss}); + if (raidWarnEntries_.size() > 3) + raidWarnEntries_.erase(raidWarnEntries_.begin()); + } + // Whisper audio notification + if (msg.type == game::ChatType::WHISPER && renderer) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } + } + raidWarnChatSeenCount_ = newCount; + } + + // Age and remove expired entries + float dt = ImGui::GetIO().DeltaTime; + for (auto& e : raidWarnEntries_) e.age += dt; + raidWarnEntries_.erase( + std::remove_if(raidWarnEntries_.begin(), raidWarnEntries_.end(), + [](const RaidWarnEntry& e){ return e.age >= RaidWarnEntry::LIFETIME; }), + raidWarnEntries_.end()); + + if (raidWarnEntries_.empty()) return; + + ImGuiIO& io = ImGui::GetIO(); + float screenW = io.DisplaySize.x; + float screenH = io.DisplaySize.y; + ImDrawList* fg = ImGui::GetForegroundDrawList(); + + // Stack entries vertically near upper-center (below target frame area) + float baseY = screenH * 0.28f; + for (const auto& e : raidWarnEntries_) { + float alpha = std::clamp(1.0f - (e.age / RaidWarnEntry::LIFETIME), 0.0f, 1.0f); + // Fade in quickly, hold, then fade out last 20% + if (e.age < 0.3f) alpha = e.age / 0.3f; + + // Truncate to fit screen width reasonably + const char* txt = e.text.c_str(); + const float fontSize = 22.0f; + ImFont* font = ImGui::GetFont(); + + // Word-wrap manually: compute text size, center horizontally + float maxW = screenW * 0.7f; + ImVec2 textSz = font->CalcTextSizeA(fontSize, maxW, maxW, txt); + float tx = (screenW - textSz.x) * 0.5f; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 200)); + ImU32 mainCol; + if (e.isBossEmote) { + mainCol = IM_COL32(255, 185, 60, static_cast(alpha * 255)); // amber + } else { + // Raid warning: alternating red/yellow flash during first second + float flashT = std::fmod(e.age * 4.0f, 1.0f); + if (flashT < 0.5f) + mainCol = IM_COL32(255, 50, 50, static_cast(alpha * 255)); + else + mainCol = IM_COL32(255, 220, 50, static_cast(alpha * 255)); + } + + // Background dim box for readability + float pad = 8.0f; + fg->AddRectFilled(ImVec2(tx - pad, baseY - pad), + ImVec2(tx + textSz.x + pad, baseY + textSz.y + pad), + IM_COL32(0, 0, 0, static_cast(alpha * 120)), 4.0f); + + // Shadow + main text + fg->AddText(font, fontSize, ImVec2(tx + 2.0f, baseY + 2.0f), shadowCol, txt, + nullptr, maxW); + fg->AddText(font, fontSize, ImVec2(tx, baseY), mainCol, txt, + nullptr, maxW); + + baseY += textSz.y + 6.0f; + } +} + + +// ============================================================ +// Floating Combat Text (Phase 2) +// ============================================================ + +void CombatUI::renderCombatText(game::GameHandler& gameHandler) { + const auto& entries = gameHandler.getCombatText(); + if (entries.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + if (!window) return; + const float screenW = static_cast(window->getWidth()); + const float screenH = static_cast(window->getHeight()); + + // Camera for world-space projection + auto* appRenderer = core::Application::getInstance().getRenderer(); + rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr; + glm::mat4 viewProj; + if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); + + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + const float baseFontSize = ImGui::GetFontSize(); + + // HUD fallback: entries without world-space anchor use classic screen-position layout. + // We still need an ImGui window for those. + const float hudIncomingX = screenW * 0.40f; + const float hudOutgoingX = screenW * 0.68f; + int hudInIdx = 0, hudOutIdx = 0; + bool needsHudWindow = false; + + for (const auto& entry : entries) { + const float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); + const bool outgoing = entry.isPlayerSource; + + // --- Format text and color (identical logic for both world and HUD paths) --- + ImVec4 color; + char text[128]; + switch (entry.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 1.0f, 0.3f, alpha) : + ImVec4(1.0f, 0.3f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_DAMAGE: + snprintf(text, sizeof(text), "-%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.8f, 0.0f, alpha) : + ImVec4(1.0f, 0.5f, 0.0f, alpha); + break; + case game::CombatTextEntry::HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_HEAL: + snprintf(text, sizeof(text), "+%d!", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::MISS: + snprintf(text, sizeof(text), "Miss"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DODGE: + snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PARRY: + snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::BLOCK: + if (entry.amount > 0) + snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); + else + snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PERIODIC_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.9f, 0.3f, alpha) : + ImVec4(1.0f, 0.4f, 0.4f, alpha); + break; + case game::CombatTextEntry::PERIODIC_HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.4f, 1.0f, 0.5f, alpha); + break; + case game::CombatTextEntry::ENVIRONMENTAL: { + const char* envLabel = ""; + switch (entry.powerType) { + case 0: envLabel = "Fatigue "; break; + case 1: envLabel = "Drowning "; break; + case 2: envLabel = ""; break; + case 3: envLabel = "Lava "; break; + case 4: envLabel = "Slime "; break; + case 5: envLabel = "Fire "; break; + default: envLabel = ""; break; + } + snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); + color = ImVec4(0.9f, 0.5f, 0.2f, alpha); + break; + } + case game::CombatTextEntry::ENERGIZE: + snprintf(text, sizeof(text), "+%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; + case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; + case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; + case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; + default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; + } + break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; + } + break; + case game::CombatTextEntry::XP_GAIN: + snprintf(text, sizeof(text), "+%d XP", entry.amount); + color = ImVec4(0.7f, 0.3f, 1.0f, alpha); + break; + case game::CombatTextEntry::IMMUNE: + snprintf(text, sizeof(text), "Immune!"); + color = ImVec4(0.9f, 0.9f, 0.9f, alpha); + break; + case game::CombatTextEntry::ABSORB: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Absorbed %d", entry.amount); + else + snprintf(text, sizeof(text), "Absorbed"); + color = ImVec4(0.5f, 0.8f, 1.0f, alpha); + break; + case game::CombatTextEntry::RESIST: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Resisted %d", entry.amount); + else + snprintf(text, sizeof(text), "Resisted"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: { + const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!reflectName.empty()) + snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); + else + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; + } + case game::CombatTextEntry::PROC_TRIGGER: { + const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!procName.empty()) + snprintf(text, sizeof(text), "%s!", procName.c_str()); + else + snprintf(text, sizeof(text), "PROC!"); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + } + case game::CombatTextEntry::DISPEL: + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else + snprintf(text, sizeof(text), "Dispel"); + } else { + snprintf(text, sizeof(text), "Dispel"); + } + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; + case game::CombatTextEntry::HONOR_GAIN: + snprintf(text, sizeof(text), "+%d Honor", entry.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + case game::CombatTextEntry::GLANCING: + snprintf(text, sizeof(text), "~%d", entry.amount); + color = outgoing ? + ImVec4(0.75f, 0.75f, 0.5f, alpha) : + ImVec4(0.75f, 0.35f, 0.35f, alpha); + break; + case game::CombatTextEntry::CRUSHING: + snprintf(text, sizeof(text), "%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.55f, 0.1f, alpha) : + ImVec4(1.0f, 0.15f, 0.15f, alpha); + break; + default: + snprintf(text, sizeof(text), "%d", entry.amount); + color = ImVec4(1.0f, 1.0f, 1.0f, alpha); + break; + } + + // --- Rendering style --- + bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || + entry.type == game::CombatTextEntry::CRIT_HEAL); + float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); + ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); + + // --- Try world-space anchor if we have a destination entity --- + // Types that should always stay as HUD elements (no world anchor) + bool isHudOnly = (entry.type == game::CombatTextEntry::XP_GAIN || + entry.type == game::CombatTextEntry::HONOR_GAIN || + entry.type == game::CombatTextEntry::PROC_TRIGGER); + + bool rendered = false; + if (!isHudOnly && camera && entry.dstGuid != 0) { + // Look up the destination entity's render position + glm::vec3 renderPos; + bool havePos = core::Application::getInstance().getRenderPositionForGuid(entry.dstGuid, renderPos); + if (!havePos) { + // Fallback to entity canonical position + auto entity = gameHandler.getEntityManager().getEntity(entry.dstGuid); + if (entity) { + auto* unit = entity->isUnit() ? static_cast(entity.get()) : nullptr; + if (unit) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + havePos = true; + } + } + } + + if (havePos) { + // Float upward from above the entity's head + renderPos.z += 2.5f + entry.age * 1.2f; + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w > 0.01f) { + glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; + if (ndc.x >= -1.5f && ndc.x <= 1.5f && ndc.y >= -1.5f && ndc.y <= 1.5f) { + float sx = (ndc.x * 0.5f + 0.5f) * screenW; + float sy = (ndc.y * 0.5f + 0.5f) * screenH; + + // Horizontal stagger using the random seed + sx += entry.xSeed * 40.0f; + + // Center the text horizontally on the projected point + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + sx -= ts.x * 0.5f; + + // Clamp to screen bounds + sx = std::max(2.0f, std::min(sx, screenW - ts.x - 2.0f)); + + drawList->AddText(font, renderFontSize, + ImVec2(sx + 1.0f, sy + 1.0f), shadowCol, text); + drawList->AddText(font, renderFontSize, + ImVec2(sx, sy), textCol, text); + rendered = true; + } + } + } + } + + // --- HUD fallback for entries without world anchor or HUD-only types --- + if (!rendered) { + if (!needsHudWindow) { + needsHudWindow = true; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImGui::Begin("##CombatText", nullptr, flags); + } + + float yOffset = 200.0f - entry.age * 60.0f; + int& idx = outgoing ? hudOutIdx : hudInIdx; + float baseX = outgoing ? hudOutgoingX : hudIncomingX; + float xOffset = baseX + (idx % 3 - 1) * 60.0f; + ++idx; + + ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); + ImVec2 screenPos = ImGui::GetCursorScreenPos(); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), + shadowCol, text); + dl->AddText(font, renderFontSize, screenPos, textCol, text); + + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + ImGui::Dummy(ts); + } + } + + if (needsHudWindow) { + ImGui::End(); + } +} + + +// ============================================================ +// DPS / HPS Meter +// ============================================================ + +void CombatUI::renderDPSMeter(game::GameHandler& gameHandler, + const SettingsPanel& settings) { + if (!settings.showDPSMeter_) return; + if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + + const float dt = ImGui::GetIO().DeltaTime; + + // Track combat duration for accurate DPS denominator in short fights + bool inCombat = gameHandler.isInCombat(); + if (inCombat && !dpsWasInCombat_) { + // Just entered combat — reset encounter accumulators + dpsEncounterDamage_ = 0.0f; + dpsEncounterHeal_ = 0.0f; + dpsLogSeenCount_ = gameHandler.getCombatLog().size(); + dpsCombatAge_ = 0.0f; + } + if (inCombat) { + dpsCombatAge_ += dt; + // Scan any new log entries since last frame + const auto& log = gameHandler.getCombatLog(); + while (dpsLogSeenCount_ < log.size()) { + const auto& e = log[dpsLogSeenCount_++]; + if (!e.isPlayerSource) continue; + switch (e.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + case game::CombatTextEntry::CRIT_DAMAGE: + case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: + dpsEncounterDamage_ += static_cast(e.amount); + break; + case game::CombatTextEntry::HEAL: + case game::CombatTextEntry::CRIT_HEAL: + case game::CombatTextEntry::PERIODIC_HEAL: + dpsEncounterHeal_ += static_cast(e.amount); + break; + default: break; + } + } + } else if (dpsWasInCombat_) { + // Just left combat — keep encounter totals but stop accumulating + } + dpsWasInCombat_ = inCombat; + + // Sum all player-source damage and healing in the current combat-text window + float totalDamage = 0.0f, totalHeal = 0.0f; + for (const auto& e : gameHandler.getCombatText()) { + if (!e.isPlayerSource) continue; + switch (e.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + case game::CombatTextEntry::CRIT_DAMAGE: + case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: + totalDamage += static_cast(e.amount); + break; + case game::CombatTextEntry::HEAL: + case game::CombatTextEntry::CRIT_HEAL: + case game::CombatTextEntry::PERIODIC_HEAL: + totalHeal += static_cast(e.amount); + break; + default: break; + } + } + + // Only show if there's something to report (rolling window or lingering encounter data) + if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat && + dpsEncounterDamage_ < 1.0f && dpsEncounterHeal_ < 1.0f) return; + + // DPS window = min(combat age, combat-text lifetime) to avoid under-counting + // at the start of a fight and over-counting when entries expire. + float window = std::min(dpsCombatAge_, game::CombatTextEntry::LIFETIME); + if (window < 0.1f) window = 0.1f; + + float dps = totalDamage / window; + float hps = totalHeal / window; + + // Format numbers with K/M suffix for readability + auto fmtNum = [](float v, char* buf, int bufSz) { + if (v >= 1e6f) snprintf(buf, bufSz, "%.1fM", v / 1e6f); + else if (v >= 1000.f) snprintf(buf, bufSz, "%.1fK", v / 1000.f); + else snprintf(buf, bufSz, "%.0f", v); + }; + + char dpsBuf[16], hpsBuf[16]; + fmtNum(dps, dpsBuf, sizeof(dpsBuf)); + fmtNum(hps, hpsBuf, sizeof(hpsBuf)); + + // Position: small floating label just above the action bar, right of center + auto* appWin = core::Application::getInstance().getWindow(); + float screenW = appWin ? static_cast(appWin->getWidth()) : 1280.0f; + float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f; + + // Show encounter row when fight has been going long enough (> 3s) + bool showEnc = (dpsCombatAge_ > 3.0f || (!inCombat && dpsEncounterDamage_ > 0.0f)); + float encDPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterDamage_ / dpsCombatAge_ : 0.0f; + float encHPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterHeal_ / dpsCombatAge_ : 0.0f; + + char encDpsBuf[16], encHpsBuf[16]; + fmtNum(encDPS, encDpsBuf, sizeof(encDpsBuf)); + fmtNum(encHPS, encHpsBuf, sizeof(encHpsBuf)); + + constexpr float WIN_W = 90.0f; + // Extra rows for encounter DPS/HPS if active + int extraRows = 0; + if (showEnc && encDPS > 0.5f) ++extraRows; + if (showEnc && encHPS > 0.5f) ++extraRows; + float WIN_H = 18.0f + extraRows * 14.0f; + if (dps > 0.5f || hps > 0.5f) WIN_H = std::max(WIN_H, 36.0f); + float wx = screenW * 0.5f + 160.0f; // right of cast bar + float wy = screenH - 130.0f; // above action bar area + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoInputs; + ImGui::SetNextWindowPos(ImVec2(wx, wy), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(WIN_W, WIN_H), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.55f); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 3)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.7f)); + + if (ImGui::Begin("##DPSMeter", nullptr, flags)) { + if (dps > 0.5f) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "%s", dpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("dps"); + } + if (hps > 0.5f) { + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), "%s", hpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("hps"); + } + // Encounter totals (full-fight average, shown when fight > 3s) + if (showEnc && encDPS > 0.5f) { + ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 0.80f), "%s", encDpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); + } + if (showEnc && encHPS > 0.5f) { + ImGui::TextColored(ImVec4(0.50f, 1.0f, 0.50f, 0.80f), "%s", encHpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); +} + + +// ============================================================ +// Buff/Debuff Bar (Phase 3) +// ============================================================ + +void CombatUI::renderBuffBar(game::GameHandler& gameHandler, + SpellbookScreen& spellbookScreen, + SpellIconFn getSpellIcon) { + const auto& auras = gameHandler.getPlayerAuras(); + + // Count non-empty auras + int activeCount = 0; + for (const auto& a : auras) { + if (!a.isEmpty()) activeCount++; + } + if (activeCount == 0 && !gameHandler.hasPet()) return; + + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + // Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210) + // Anchored to the right side to stay away from party frames on the left + constexpr float ICON_SIZE = 32.0f; + constexpr int ICONS_PER_ROW = 8; + float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f; + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + // Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210) + ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + + if (ImGui::Begin("##BuffBar", nullptr, flags)) { + // Pre-sort auras: buffs first, then debuffs; within each group, shorter remaining first + uint64_t buffNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector buffSortedIdx; + buffSortedIdx.reserve(auras.size()); + for (size_t i = 0; i < auras.size(); ++i) + if (!auras[i].isEmpty()) buffSortedIdx.push_back(i); + std::sort(buffSortedIdx.begin(), buffSortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = auras[a]; const auto& ab = auras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff < bDebuff; // buffs (0) first + int32_t ra = aa.getRemainingMs(buffNowMs); + int32_t rb = ab.getRemainingMs(buffNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + // Render one pass for buffs, one for debuffs + for (int pass = 0; pass < 2; ++pass) { + bool wantBuff = (pass == 0); + int shown = 0; + for (size_t si = 0; si < buffSortedIdx.size() && shown < 40; ++si) { + size_t i = buffSortedIdx[si]; + const auto& aura = auras[i]; + if (aura.isEmpty()) continue; + + bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag + if (isBuff != wantBuff) continue; // only render matching pass + + if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); + + ImGui::PushID(static_cast(i) + (pass * 256)); + + // Determine border color: buffs = green; debuffs use WoW dispel-type colors + ImVec4 borderColor; + if (isBuff) { + borderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); // green + } else { + // Debuff: color by dispel type (0=none/red, 1=magic/blue, 2=curse/purple, + // 3=disease/brown, 4=poison/green, other=dark-red) + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: borderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: borderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: borderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: borderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } + + // Try to get spell icon + VkDescriptorSet iconTex = VK_NULL_HANDLE; + if (assetMgr) { + iconTex = getSpellIcon(aura.spellId, assetMgr); + } + + if (iconTex) { + ImGui::PushStyleColor(ImGuiCol_Button, borderColor); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2)); + ImGui::ImageButton("##aura", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(ICON_SIZE - 4, ICON_SIZE - 4)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderColor); + const std::string& pAuraName = gameHandler.getSpellName(aura.spellId); + char label[32]; + if (!pAuraName.empty()) + snprintf(label, sizeof(label), "%.6s", pAuraName.c_str()); + else + snprintf(label, sizeof(label), "%u", aura.spellId); + ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE)); + ImGui::PopStyleColor(); + } + + // Compute remaining duration once (shared by overlay and tooltip) + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remainMs = aura.getRemainingMs(nowMs); + + // Clock-sweep overlay: dark fan shows elapsed time (WoW style) + if (remainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 iconMin2 = ImGui::GetItemRectMin(); + ImVec2 iconMax2 = ImGui::GetItemRectMax(); + float cx2 = (iconMin2.x + iconMax2.x) * 0.5f; + float cy2 = (iconMin2.y + iconMax2.y) * 0.5f; + float fanR2 = (iconMax2.x - iconMin2.x) * 0.5f; + float total2 = static_cast(aura.maxDurationMs); + float elapsedFrac2 = std::clamp( + 1.0f - static_cast(remainMs) / total2, 0.0f, 1.0f); + if (elapsedFrac2 > 0.005f) { + constexpr int SWEEP_SEGS = 24; + float sa = -IM_PI * 0.5f; + float ea = sa + elapsedFrac2 * 2.0f * IM_PI; + ImVec2 pts[SWEEP_SEGS + 2]; + pts[0] = ImVec2(cx2, cy2); + for (int s = 0; s <= SWEEP_SEGS; ++s) { + float a = sa + (ea - sa) * s / static_cast(SWEEP_SEGS); + pts[s + 1] = ImVec2(cx2 + std::cos(a) * fanR2, + cy2 + std::sin(a) * fanR2); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + pts, SWEEP_SEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + + // Duration countdown overlay — always visible on the icon bottom + if (remainMs > 0) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + ImVec2 iconMax = ImGui::GetItemRectMax(); + char timeStr[12]; + int secs = (remainMs + 999) / 1000; // ceiling seconds + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%d", secs); + ImVec2 textSize = ImGui::CalcTextSize(timeStr); + float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; + float cy = iconMax.y - textSize.y - 2.0f; + // Choose timer color based on urgency + ImU32 timerColor; + if (remainMs < 10000) { + // < 10s: pulse red + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + timerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (remainMs < 30000) { + timerColor = IM_COL32(255, 165, 0, 255); // orange + } else { + timerColor = IM_COL32(255, 255, 255, 255); // white + } + // Drop shadow for readability over any icon colour + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), + IM_COL32(0, 0, 0, 200), timeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), + timerColor, timeStr); + } + + // Stack / charge count overlay — upper-left corner of the icon + if (aura.charges > 1) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + // Drop shadow then bright yellow text + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Right-click to cancel buffs / dismount + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + if (gameHandler.isMounted()) { + gameHandler.dismount(); + } else if (isBuff) { + gameHandler.cancelAura(aura.spellId); + } + } + + // Tooltip: rich spell info + remaining duration + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } + renderAuraRemaining(remainMs); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + shown++; + } // end aura loop + // Add visual gap between buffs and debuffs + if (pass == 0 && shown > 0) ImGui::Spacing(); + } // end pass loop + + // Dismiss Pet button + if (gameHandler.hasPet()) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f)); + if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) { + gameHandler.dismissPet(); + } + ImGui::PopStyleColor(2); + } + + // Temporary weapon enchant timers (Shaman imbues, Rogue poisons, whetstones, etc.) + { + const auto& timers = gameHandler.getTempEnchantTimers(); + if (!timers.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + static constexpr ImVec4 kEnchantSlotColors[] = { + colors::kOrange, // main-hand: gold + ImVec4(0.5f, 0.8f, 0.9f, 1.0f), // off-hand: teal + ImVec4(0.7f, 0.5f, 0.9f, 1.0f), // ranged: purple + }; + uint64_t enchNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + for (const auto& t : timers) { + if (t.slot > 2) continue; + uint64_t remMs = (t.expireMs > enchNowMs) ? (t.expireMs - enchNowMs) : 0; + if (remMs == 0) continue; + + ImVec4 col = kEnchantSlotColors[t.slot]; + // Flash red when < 60s remaining + if (remMs < 60000) { + float pulse = 0.6f + 0.4f * std::sin( + static_cast(ImGui::GetTime()) * 4.0f); + col = ImVec4(pulse, 0.2f, 0.1f, 1.0f); + } + + // Format remaining time + uint32_t secs = static_cast((remMs + 999) / 1000); + char timeStr[16]; + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh%02dm", secs / 3600, (secs % 3600) / 60); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%ds", secs); + + ImGui::PushID(static_cast(t.slot) + 5000); + ImGui::PushStyleColor(ImGuiCol_Button, col); + char label[40]; + snprintf(label, sizeof(label), "~%s %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], timeStr); + ImGui::Button(label, ImVec2(-1, 16)); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Temporary weapon enchant: %s\nRemaining: %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], + timeStr); + ImGui::PopStyleColor(); + ImGui::PopID(); + } + } + } + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); +} + +// WSG 489 – Alliance / Horde flag captures (max 3) +// AB 529 – Alliance / Horde resource scores (max 1600) +// AV 30 – Alliance / Horde reinforcements +// EotS 566 – Alliance / Horde resource scores (max 1600) +// ============================================================================ +void CombatUI::renderBattlegroundScore(game::GameHandler& gameHandler) { + // Only show when in a recognised battleground map + uint32_t mapId = gameHandler.getWorldStateMapId(); + + // World state key sets per battleground + // Keys from the WoW 3.3.5a WorldState.dbc / client source + struct BgScoreDef { + uint32_t mapId; + const char* name; + uint32_t allianceKey; // world state key for Alliance value + uint32_t hordeKey; // world state key for Horde value + uint32_t maxKey; // max score world state key (0 = use hardcoded) + uint32_t hardcodedMax; // used when maxKey == 0 + const char* unit; // suffix label (e.g. "flags", "resources") + }; + + static constexpr BgScoreDef kBgDefs[] = { + // Warsong Gulch: 3 flag captures wins + { 489, "Warsong Gulch", 1581, 1582, 0, 3, "flags" }, + // Arathi Basin: 1600 resources wins + { 529, "Arathi Basin", 1218, 1219, 0, 1600, "resources" }, + // Alterac Valley: reinforcements count down from 600 / 800 etc. + { 30, "Alterac Valley", 1322, 1323, 0, 600, "reinforcements" }, + // Eye of the Storm: 1600 resources wins + { 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" }, + // Strand of the Ancients (WotLK) + { 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" }, + // Isle of Conquest (WotLK): reinforcements (300 default) + { 628, "Isle of Conquest", 4221, 4222, 0, 300, "reinforcements" }, + }; + + const BgScoreDef* def = nullptr; + for (const auto& d : kBgDefs) { + if (d.mapId == mapId) { def = &d; break; } + } + if (!def) return; + + auto allianceOpt = gameHandler.getWorldState(def->allianceKey); + auto hordeOpt = gameHandler.getWorldState(def->hordeKey); + if (!allianceOpt && !hordeOpt) return; + + uint32_t allianceScore = allianceOpt.value_or(0); + uint32_t hordeScore = hordeOpt.value_or(0); + uint32_t maxScore = def->hardcodedMax; + if (def->maxKey != 0) { + if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv; + } + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Width scales with screen but stays reasonable + float frameW = 260.0f; + float frameH = 60.0f; + float posX = screenW / 2.0f - frameW / 2.0f; + float posY = 4.0f; + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, frameH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + + if (ImGui::Begin("##BGScore", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus | + ImGuiWindowFlags_NoSavedSettings)) { + + // BG name centred at top + float nameW = ImGui::CalcTextSize(def->name).x; + ImGui::SetCursorPosX((frameW - nameW) / 2.0f); + ImGui::TextColored(colors::kBrightGold, "%s", def->name); + + // Alliance score | separator | Horde score + float innerW = frameW - 12.0f; + float halfW = innerW / 2.0f - 4.0f; + + ImGui::SetCursorPosX(6.0f); + ImGui::BeginGroup(); + { + // Alliance (blue) + char aBuf[32]; + if (maxScore > 0 && strlen(def->unit) > 0) + snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u / %u", allianceScore, maxScore); + else + snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u", allianceScore); + ImGui::TextColored(colors::kLightBlue, "%s", aBuf); + } + ImGui::EndGroup(); + + ImGui::SameLine(halfW + 16.0f); + + ImGui::BeginGroup(); + { + // Horde (red) + char hBuf[32]; + if (maxScore > 0 && strlen(def->unit) > 0) + snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u / %u", hordeScore, maxScore); + else + snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u", hordeScore); + ImGui::TextColored(colors::kHostileRed, "%s", hBuf); + } + ImGui::EndGroup(); + } + ImGui::End(); + ImGui::PopStyleVar(2); +} + + +// ─── Combat Log Window ──────────────────────────────────────────────────────── +void CombatUI::renderCombatLog(game::GameHandler& gameHandler, + SpellbookScreen& spellbookScreen) { + if (!showCombatLog_) return; + + const auto& log = gameHandler.getCombatLog(); + + ImGui::SetNextWindowSize(ImVec2(520, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(160, 200), ImGuiCond_FirstUseEver); + + char title[64]; + snprintf(title, sizeof(title), "Combat Log (%zu)###CombatLog", log.size()); + if (!ImGui::Begin(title, &showCombatLog_)) { + ImGui::End(); + return; + } + + // Filter toggles + static bool filterDamage = true; + static bool filterHeal = true; + static bool filterMisc = true; + static bool autoScroll = true; + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 2)); + ImGui::Checkbox("Damage", &filterDamage); ImGui::SameLine(); + ImGui::Checkbox("Healing", &filterHeal); ImGui::SameLine(); + ImGui::Checkbox("Misc", &filterMisc); ImGui::SameLine(); + ImGui::Checkbox("Auto-scroll", &autoScroll); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 40.0f); + if (ImGui::SmallButton("Clear")) + gameHandler.clearCombatLog(); + ImGui::PopStyleVar(); + ImGui::Separator(); + + // Helper: categorize entry + auto isDamageType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || + t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || + t == T::ENVIRONMENTAL || t == T::GLANCING || t == T::CRUSHING; + }; + auto isHealType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::HEAL || t == T::CRIT_HEAL || t == T::PERIODIC_HEAL; + }; + + // Two-column table: Time | Event description + ImGuiTableFlags tableFlags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingFixedFit; + float availH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##CombatLogTable", 2, tableFlags, ImVec2(0.0f, availH))) { + ImGui::TableSetupScrollFreeze(0, 0); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("Event", ImGuiTableColumnFlags_WidthStretch); + + for (const auto& e : log) { + // Apply filters + bool isDmg = isDamageType(e.type); + bool isHeal = isHealType(e.type); + bool isMisc = !isDmg && !isHeal; + if (isDmg && !filterDamage) continue; + if (isHeal && !filterHeal) continue; + if (isMisc && !filterMisc) continue; + + // Format timestamp as HH:MM:SS + char timeBuf[10]; + { + struct tm* tm_info = std::localtime(&e.timestamp); + if (tm_info) + snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", + tm_info->tm_hour, tm_info->tm_min, tm_info->tm_sec); + else + snprintf(timeBuf, sizeof(timeBuf), "--:--:--"); + } + + // Build event description and choose color + char desc[256]; + ImVec4 color; + using T = game::CombatTextEntry; + const char* src = e.sourceName.empty() ? (e.isPlayerSource ? "You" : "?") : e.sourceName.c_str(); + const char* tgt = e.targetName.empty() ? "?" : e.targetName.c_str(); + const std::string& spellName = (e.spellId != 0) ? gameHandler.getSpellName(e.spellId) : std::string(); + const char* spell = spellName.empty() ? nullptr : spellName.c_str(); + + switch (e.type) { + case T::MELEE_DAMAGE: + snprintf(desc, sizeof(desc), "%s hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : colors::kSoftRed; + break; + case T::CRIT_DAMAGE: + snprintf(desc, sizeof(desc), "%s crits %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 1.0f, 0.0f, 1.0f) : colors::kBrightRed; + break; + case T::SPELL_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s hits %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's spell hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : colors::kSoftRed; + break; + case T::PERIODIC_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s ticks %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's DoT ticks %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.9f, 0.7f, 0.3f, 1.0f) : ImVec4(0.9f, 0.3f, 0.3f, 1.0f); + break; + case T::HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s heals %s for %d (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s heals %s for %d", src, tgt, e.amount); + color = kColorGreen; + break; + case T::CRIT_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s critically heals %s for %d! (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s critically heals %s for %d!", src, tgt, e.amount); + color = kColorBrightGreen; + break; + case T::PERIODIC_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s heals %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's HoT heals %s for %d", src, tgt, e.amount); + color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); + break; + case T::MISS: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s misses %s", src, spell, tgt); + else + snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); + color = colors::kMediumGray; + break; + case T::DODGE: + if (spell) + snprintf(desc, sizeof(desc), "%s dodges %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); + color = colors::kMediumGray; + break; + case T::PARRY: + if (spell) + snprintf(desc, sizeof(desc), "%s parries %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); + color = colors::kMediumGray; + break; + case T::BLOCK: + if (spell) + snprintf(desc, sizeof(desc), "%s blocks %s's %s (%d blocked)", tgt, src, spell, e.amount); + else + snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); + color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); + break; + case T::EVADE: + if (spell) + snprintf(desc, sizeof(desc), "%s evades %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s evades %s's attack", tgt, src); + color = colors::kMediumGray; + break; + case T::IMMUNE: + if (spell) + snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); + else + snprintf(desc, sizeof(desc), "%s is immune", tgt); + color = colors::kSilver; + break; + case T::ABSORB: + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s's %s absorbs %d", src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s absorbs %s", tgt, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + else + snprintf(desc, sizeof(desc), "Absorbed"); + color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); + break; + case T::RESIST: + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s resists %s's %s (%d resisted)", tgt, src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s resists %s's %s", tgt, src, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d resisted", e.amount); + else + snprintf(desc, sizeof(desc), "Resisted"); + color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); + break; + case T::DEFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s deflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s deflects %s's attack", tgt, src); + color = ImVec4(0.65f, 0.8f, 0.95f, 1.0f); + break; + case T::REFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s reflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; + case T::ENVIRONMENTAL: { + const char* envName = "Environmental"; + switch (e.powerType) { + case 0: envName = "Fatigue"; break; + case 1: envName = "Drowning"; break; + case 2: envName = "Falling"; break; + case 3: envName = "Lava"; break; + case 4: envName = "Slime"; break; + case 5: envName = "Fire"; break; + } + snprintf(desc, sizeof(desc), "%s damage: %d", envName, e.amount); + color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); + break; + } + case T::ENERGIZE: { + const char* pwrName = "power"; + switch (e.powerType) { + case 0: pwrName = "Mana"; break; + case 1: pwrName = "Rage"; break; + case 2: pwrName = "Focus"; break; + case 3: pwrName = "Energy"; break; + case 4: pwrName = "Happiness"; break; + case 6: pwrName = "Runic Power"; break; + } + if (spell) + snprintf(desc, sizeof(desc), "%s gains %d %s (%s)", tgt, e.amount, pwrName, spell); + else + snprintf(desc, sizeof(desc), "%s gains %d %s", tgt, e.amount, pwrName); + color = colors::kLightBlue; + break; + } + case T::POWER_DRAIN: { + const char* drainName = "power"; + switch (e.powerType) { + case 0: drainName = "Mana"; break; + case 1: drainName = "Rage"; break; + case 2: drainName = "Focus"; break; + case 3: drainName = "Energy"; break; + case 4: drainName = "Happiness"; break; + case 6: drainName = "Runic Power"; break; + } + if (spell) + snprintf(desc, sizeof(desc), "%s loses %d %s to %s's %s", tgt, e.amount, drainName, src, spell); + else + snprintf(desc, sizeof(desc), "%s loses %d %s", tgt, e.amount, drainName); + color = ImVec4(0.45f, 0.75f, 1.0f, 1.0f); + break; + } + case T::XP_GAIN: + snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); + color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); + break; + case T::PROC_TRIGGER: + if (spell) + snprintf(desc, sizeof(desc), "%s procs!", spell); + else + snprintf(desc, sizeof(desc), "Proc triggered"); + color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); + break; + case T::DISPEL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s dispels %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); + color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); + break; + case T::STEAL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s steals %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s steals from %s", src, tgt); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; + case T::INTERRUPT: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s interrupts %s's %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s", tgt); + else + snprintf(desc, sizeof(desc), "%s interrupted", tgt); + color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); + break; + case T::INSTAKILL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s with %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s instantly kills %s with %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s", tgt); + else + snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); + color = colors::kBrightRed; + break; + case T::HONOR_GAIN: + snprintf(desc, sizeof(desc), "You gain %d honor", e.amount); + color = colors::kBrightGold; + break; + case T::GLANCING: + snprintf(desc, sizeof(desc), "%s glances %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.75f, 0.75f, 0.5f, 1.0f) + : ImVec4(0.75f, 0.4f, 0.4f, 1.0f); + break; + case T::CRUSHING: + snprintf(desc, sizeof(desc), "%s crushes %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.55f, 0.1f, 1.0f) + : ImVec4(1.0f, 0.15f, 0.15f, 1.0f); + break; + default: + snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", static_cast(e.type), e.amount); + color = ui::colors::kLightGray; + break; + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("%s", timeBuf); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(color, "%s", desc); + // Hover tooltip: show rich spell info for entries with a known spell + if (e.spellId != 0 && ImGui::IsItemHovered()) { + auto* assetMgrLog = core::Application::getInstance().getAssetManager(); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog); + if (!richOk) { + ImGui::Text("%s", spellName.c_str()); + } + ImGui::EndTooltip(); + } + } + + // Auto-scroll to bottom + if (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) + ImGui::SetScrollHereY(1.0f); + + ImGui::EndTable(); + } + + ImGui::End(); +} + + +// ─── Threat Window ──────────────────────────────────────────────────────────── +void CombatUI::renderThreatWindow(game::GameHandler& gameHandler) { + if (!showThreatWindow_) return; + + const auto* list = gameHandler.getTargetThreatList(); + + ImGui::SetNextWindowSize(ImVec2(280, 220), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(10, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.85f); + + if (!ImGui::Begin("Threat###ThreatWin", &showThreatWindow_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + if (!list || list->empty()) { + ImGui::TextDisabled("No threat data for current target."); + ImGui::End(); + return; + } + + uint32_t maxThreat = list->front().threat; + + // Pre-scan to find the player's rank and threat percentage + uint64_t playerGuid = gameHandler.getPlayerGuid(); + int playerRank = 0; + float playerPct = 0.0f; + { + int scan = 0; + for (const auto& e : *list) { + ++scan; + if (e.victimGuid == playerGuid) { + playerRank = scan; + playerPct = (maxThreat > 0) ? static_cast(e.threat) / static_cast(maxThreat) : 0.0f; + break; + } + if (scan >= 10) break; + } + } + + // Status bar: aggro alert or rank summary + if (playerRank == 1) { + // Player has aggro — persistent red warning + ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.25f, 1.0f), "!! YOU HAVE AGGRO !!"); + } else if (playerRank > 1 && playerPct >= 0.8f) { + // Close to pulling — pulsing warning + float pulse = 0.55f + 0.45f * sinf(static_cast(ImGui::GetTime()) * 5.0f); + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.1f, pulse), "! PULLING AGGRO (%.0f%%) !", playerPct * 100.0f); + } else if (playerRank > 0) { + ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "You: #%d %.0f%% threat", playerRank, playerPct * 100.0f); + } + + ImGui::TextDisabled("%-19s Threat", "Player"); + ImGui::Separator(); + + int rank = 0; + for (const auto& entry : *list) { + ++rank; + bool isPlayer = (entry.victimGuid == playerGuid); + + // Resolve name + std::string victimName; + auto entity = gameHandler.getEntityManager().getEntity(entry.victimGuid); + if (entity) { + if (entity->getType() == game::ObjectType::PLAYER) { + auto p = std::static_pointer_cast(entity); + victimName = p->getName().empty() ? "Player" : p->getName(); + } else if (entity->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(entity); + victimName = u->getName().empty() ? "NPC" : u->getName(); + } + } + if (victimName.empty()) + victimName = "0x" + [&](){ + char buf[20]; snprintf(buf, sizeof(buf), "%llX", + static_cast(entry.victimGuid)); return std::string(buf); }(); + + // Colour: gold for #1 (tank), red if player is highest, white otherwise + ImVec4 col = ui::colors::kWhite; + if (rank == 1) col = ui::colors::kTooltipGold; // gold + if (isPlayer && rank == 1) col = kColorRed; // red — you have aggro + + // Threat bar + float pct = (maxThreat > 0) ? static_cast(entry.threat) / static_cast(maxThreat) : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f)); + char barLabel[48]; + snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f); + ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel); + ImGui::PopStyleColor(); + ImGui::SameLine(); + + ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat); + + if (rank >= 10) break; // cap display at 10 entries + } + + ImGui::End(); +} + + +// ─── BG Scoreboard ──────────────────────────────────────────────────────────── +void CombatUI::renderBgScoreboard(game::GameHandler& gameHandler) { + if (!showBgScoreboard_) return; + + const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard(); + + ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); + + const char* title = data && data->isArena ? "Arena Score###BgScore" + : "Battleground Score###BgScore"; + if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!data) { + ImGui::TextDisabled("No score data yet."); + ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground or arena."); + ImGui::End(); + return; + } + + // Arena team rating banner (shown only for arenas) + if (data->isArena) { + for (int t = 0; t < 2; ++t) { + const auto& at = data->arenaTeams[t]; + if (at.teamName.empty()) continue; + int32_t ratingDelta = static_cast(at.ratingChange); + ImVec4 teamCol = (t == 0) ? colors::kHostileRed // team 0: red + : colors::kLightBlue; // team 1: blue + ImGui::TextColored(teamCol, "%s", at.teamName.c_str()); + ImGui::SameLine(); + char ratingBuf[32]; + if (ratingDelta >= 0) + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta); + else + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta); + ImGui::TextDisabled("%s", ratingBuf); + } + ImGui::Separator(); + } + + // Winner banner + if (data->hasWinner) { + const char* winnerStr; + ImVec4 winnerColor; + if (data->isArena) { + // For arenas, winner byte 0/1 refers to team index in arenaTeams[] + const auto& winTeam = data->arenaTeams[data->winner & 1]; + winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str(); + winnerColor = (data->winner == 0) ? colors::kHostileRed + : colors::kLightBlue; + } else { + winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; + winnerColor = (data->winner == 1) ? colors::kLightBlue + : colors::kHostileRed; + } + float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); + ImGui::TextColored(winnerColor, "%s", winnerStr); + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kBrightGold, "Victory!"); + ImGui::Separator(); + } + + // Refresh button + if (ImGui::SmallButton("Refresh")) { + gameHandler.requestPvpLog(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu players", data->players.size()); + + // Score table + constexpr ImGuiTableFlags kTableFlags = + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV | + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable; + + // Build dynamic column count based on what BG-specific stats are present + int numBgCols = 0; + std::vector bgColNames; + for (const auto& ps : data->players) { + for (const auto& [fieldName, val] : ps.bgStats) { + // Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps") + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + bool found = false; + for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } } + if (!found) bgColNames.push_back(shortName); + } + } + numBgCols = static_cast(bgColNames.size()); + + // Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific + int totalCols = 6 + numBgCols; + float tableH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f); + for (const auto& col : bgColNames) + ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableHeadersRow(); + + // Sort: Alliance first, then Horde; within each team by KB desc + std::vector sorted; + sorted.reserve(data->players.size()); + for (const auto& ps : data->players) sorted.push_back(&ps); + std::stable_sort(sorted.begin(), sorted.end(), + [](const game::GameHandler::BgPlayerScore* a, + const game::GameHandler::BgPlayerScore* b) { + if (a->team != b->team) return a->team > b->team; // Alliance(1) first + return a->killingBlows > b->killingBlows; + }); + + uint64_t playerGuid = gameHandler.getPlayerGuid(); + for (const auto* ps : sorted) { + ImGui::TableNextRow(); + + // Team + ImGui::TableNextColumn(); + if (ps->team == 1) + ImGui::TextColored(colors::kLightBlue, "Alliance"); + else + ImGui::TextColored(colors::kHostileRed, "Horde"); + + // Name (highlight player's own row) + ImGui::TableNextColumn(); + bool isSelf = (ps->guid == playerGuid); + if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); + const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str(); + ImGui::TextUnformatted(nameStr); + if (isSelf) ImGui::PopStyleColor(); + + ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor); + + for (const auto& col : bgColNames) { + ImGui::TableNextColumn(); + uint32_t val = 0; + for (const auto& [fieldName, fval] : ps->bgStats) { + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + if (shortName == col) { val = fval; break; } + } + if (val > 0) ImGui::Text("%u", val); + else ImGui::TextDisabled("-"); + } + } + ImGui::EndTable(); + } + + ImGui::End(); +} + + +} // namespace ui +} // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 25db1ec7..98938406 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -280,7 +280,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { gameHandler.setSpellCastFailedCallback([this](uint32_t spellId) { if (spellId == 0) return; float now = static_cast(ImGui::GetTime()); - actionFlashEndTimes_[spellId] = now + kActionFlashDuration; + actionBarPanel_.actionFlashEndTimes_[spellId] = now + actionBarPanel_.kActionFlashDuration; }); castFailedCallbackSet_ = true; } @@ -473,78 +473,83 @@ void GameScreen::render(game::GameHandler& gameHandler) { chatPanel_.render(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); // Process slash commands that affect GameScreen state auto cmds = chatPanel_.consumeSlashCommands(); - if (cmds.showInspect) showInspectWindow_ = true; - if (cmds.toggleThreat) showThreatWindow_ = !showThreatWindow_; - if (cmds.showBgScore) showBgScoreboard_ = !showBgScoreboard_; - if (cmds.showGmTicket) showGmTicketWindow_ = true; - if (cmds.showWho) showWhoWindow_ = true; - if (cmds.toggleCombatLog) showCombatLog_ = !showCombatLog_; + 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 ---- - renderActionBar(gameHandler); - renderStanceBar(gameHandler); - renderBagBar(gameHandler); - renderXpBar(gameHandler); - renderRepBar(gameHandler); - renderCastBar(gameHandler); + 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); - renderCooldownTracker(gameHandler); + combatUI_.renderCooldownTracker(gameHandler, settingsPanel_, spellIconFn); renderQuestObjectiveTracker(gameHandler); renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ - renderBattlegroundScore(gameHandler); - renderRaidWarningOverlay(gameHandler); - renderCombatText(gameHandler); - renderDPSMeter(gameHandler); + 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 (showRaidFrames_) { - renderPartyFrames(gameHandler); + if (socialPanel_.showRaidFrames_) { + socialPanel_.renderPartyFrames(gameHandler, chatPanel_, spellIconFn); } - renderBossFrames(gameHandler); + socialPanel_.renderBossFrames(gameHandler, spellbookScreen, spellIconFn); dialogManager_.renderDialogs(gameHandler, inventoryScreen, chatPanel_); - renderGuildRoster(gameHandler); - renderSocialFrame(gameHandler); - renderBuffBar(gameHandler); - renderLootWindow(gameHandler); - renderGossipWindow(gameHandler); - renderQuestDetailsWindow(gameHandler); - renderQuestRequestItemsWindow(gameHandler); - renderQuestOfferRewardWindow(gameHandler); - renderVendorWindow(gameHandler); - renderTrainerWindow(gameHandler); - renderBarberShopWindow(gameHandler); - renderStableWindow(gameHandler); - renderTaxiWindow(gameHandler); - renderMailWindow(gameHandler); - renderMailComposeWindow(gameHandler); - renderBankWindow(gameHandler); - renderGuildBankWindow(gameHandler); - renderAuctionHouseWindow(gameHandler); - renderDungeonFinderWindow(gameHandler); - renderInstanceLockouts(gameHandler); - renderWhoWindow(gameHandler); - renderCombatLog(gameHandler); - renderAchievementWindow(gameHandler); - renderSkillsWindow(gameHandler); - renderTitlesWindow(gameHandler); - renderEquipSetWindow(gameHandler); - renderGmTicketWindow(gameHandler); - renderInspectWindow(gameHandler); - renderBookWindow(gameHandler); - renderThreatWindow(gameHandler); - renderBgScoreboard(gameHandler); + 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); } - renderLogoutCountdown(gameHandler); - renderDeathScreen(gameHandler); - renderReclaimCorpseButton(gameHandler); + windowManager_.renderLogoutCountdown(gameHandler); + windowManager_.renderDeathScreen(gameHandler); + windowManager_.renderReclaimCorpseButton(gameHandler); dialogManager_.renderLateDialogs(gameHandler); chatPanel_.renderBubbles(gameHandler); - renderEscapeMenu(); + windowManager_.renderEscapeMenu(settingsPanel_); settingsPanel_.renderSettingsWindow(inventoryScreen, chatPanel_, [this]() { saveSettings(); }); toastManager_.renderLateToasts(gameHandler); renderWeatherOverlay(gameHandler); @@ -596,8 +601,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Auto-open bags once when vendor window first opens if (gameHandler.isVendorWindowOpen()) { - if (!vendorBagsOpened_) { - vendorBagsOpened_ = true; + if (!windowManager_.vendorBagsOpened_) { + windowManager_.vendorBagsOpened_ = true; if (inventoryScreen.isSeparateBags()) { inventoryScreen.openAllBags(); } else if (!inventoryScreen.isOpen()) { @@ -605,7 +610,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } } else { - vendorBagsOpened_ = false; + windowManager_.vendorBagsOpened_ = false; } // Bags (B key toggle handled inside) @@ -1033,8 +1038,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SETTINGS, true)) { if (settingsPanel_.showSettingsWindow) { settingsPanel_.showSettingsWindow = false; - } else if (showEscapeMenu) { - showEscapeMenu = false; + } else if (windowManager_.showEscapeMenu) { + windowManager_.showEscapeMenu = false; settingsPanel_.showEscapeSettingsNotice = false; } else if (gameHandler.isCasting()) { gameHandler.cancelCast(); @@ -1062,12 +1067,12 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.closeQuestRequestItems(); } else if (gameHandler.isTradeOpen()) { gameHandler.cancelTrade(); - } else if (showWhoWindow_) { - showWhoWindow_ = false; - } else if (showCombatLog_) { - showCombatLog_ = false; - } else if (showSocialFrame_) { - showSocialFrame_ = false; + } 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()) { @@ -1081,7 +1086,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else if (showWorldMap_) { showWorldMap_ = false; } else { - showEscapeMenu = true; + windowManager_.showEscapeMenu = true; } } @@ -1115,19 +1120,19 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { - showRaidFrames_ = !showRaidFrames_; + socialPanel_.showRaidFrames_ = !socialPanel_.showRaidFrames_; } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { - showAchievementWindow_ = !showAchievementWindow_; + windowManager_.showAchievementWindow_ = !windowManager_.showAchievementWindow_; } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SKILLS)) { - showSkillsWindow_ = !showSkillsWindow_; + windowManager_.showSkillsWindow_ = !windowManager_.showSkillsWindow_; } // Toggle Titles window with H (hero/title screen — no conflicting keybinding) if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { - showTitlesWindow_ = !showTitlesWindow_; + windowManager_.showTitlesWindow_ = !windowManager_.showTitlesWindow_; } // Screenshot (PrintScreen key) @@ -2516,7 +2521,7 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { gameHandler.proposeDuel(tGuid); if (ImGui::MenuItem("Inspect")) { gameHandler.inspectTarget(); - showInspectWindow_ = true; + socialPanel_.showInspectWindow_ = true; } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) @@ -2625,7 +2630,7 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } if (ImGui::MenuItem("Inspect")) { gameHandler.inspectTarget(); - showInspectWindow_ = true; + socialPanel_.showInspectWindow_ = true; } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) { @@ -2903,7 +2908,7 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f)); - if (ImGui::SmallButton("Threat")) showThreatWindow_ = !showThreatWindow_; + if (ImGui::SmallButton("Threat")) combatUI_.showThreatWindow_ = !combatUI_.showThreatWindow_; ImGui::PopStyleColor(2); } @@ -3443,7 +3448,7 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(focus->getGuid()); gameHandler.inspectTarget(); - showInspectWindow_ = true; + socialPanel_.showInspectWindow_ = true; } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) @@ -3552,7 +3557,7 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(fGuid); gameHandler.inspectTarget(); - showInspectWindow_ = true; + socialPanel_.showInspectWindow_ = true; } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) @@ -4340,973 +4345,7 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage return ds; } -uint32_t GameScreen::resolveMacroPrimarySpellId(uint32_t macroId, game::GameHandler& gameHandler) { - // Invalidate cache when spell list changes (learning/unlearning spells) - size_t curSpellCount = gameHandler.getKnownSpells().size(); - if (curSpellCount != macroCacheSpellCount_) { - macroPrimarySpellCache_.clear(); - macroCacheSpellCount_ = curSpellCount; - } - auto cacheIt = macroPrimarySpellCache_.find(macroId); - if (cacheIt != macroPrimarySpellCache_.end()) return cacheIt->second; - const std::string& macroText = gameHandler.getMacroText(macroId); - uint32_t result = 0; - if (!macroText.empty()) { - for (const auto& cmdLine : allMacroCommands(macroText)) { - std::string cl = cmdLine; - for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); - bool isCast = (cl.rfind("/cast ", 0) == 0); - bool isCastSeq = (cl.rfind("/castsequence ", 0) == 0); - bool isUse = (cl.rfind("/use ", 0) == 0); - if (!isCast && !isCastSeq && !isUse) continue; - size_t sp2 = cmdLine.find(' '); - if (sp2 == std::string::npos) continue; - std::string spellArg = cmdLine.substr(sp2 + 1); - // Strip conditionals [...] - if (!spellArg.empty() && spellArg.front() == '[') { - size_t ce = spellArg.find(']'); - if (ce != std::string::npos) spellArg = spellArg.substr(ce + 1); - } - // Strip reset= spec for castsequence - if (isCastSeq) { - std::string tmp = spellArg; - while (!tmp.empty() && tmp.front() == ' ') tmp.erase(tmp.begin()); - if (tmp.rfind("reset=", 0) == 0) { - size_t spAfter = tmp.find(' '); - if (spAfter != std::string::npos) spellArg = tmp.substr(spAfter + 1); - } - } - // Take first alternative before ';' (for /cast) or first spell before ',' (for /castsequence) - size_t semi = spellArg.find(isCastSeq ? ',' : ';'); - if (semi != std::string::npos) spellArg = spellArg.substr(0, semi); - size_t ss = spellArg.find_first_not_of(" \t!"); - if (ss != std::string::npos) spellArg = spellArg.substr(ss); - size_t se = spellArg.find_last_not_of(" \t"); - if (se != std::string::npos) spellArg.resize(se + 1); - if (spellArg.empty()) continue; - std::string spLow = spellArg; - for (char& c : spLow) c = static_cast(std::tolower(static_cast(c))); - if (isUse) { - // /use resolves an item name → find the item's on-use spell ID - for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { - if (!info.valid) continue; - std::string iName = info.name; - for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); - if (iName == spLow) { - for (const auto& sp : info.spells) { - if (sp.spellId != 0 && sp.spellTrigger == 0) { result = sp.spellId; break; } - } - break; - } - } - } else { - // /cast and /castsequence resolve a spell name - for (uint32_t sid : gameHandler.getKnownSpells()) { - std::string sn = gameHandler.getSpellName(sid); - for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); - if (sn == spLow) { result = sid; break; } - } - } - break; - } - } - macroPrimarySpellCache_[macroId] = result; - return result; -} - -void GameScreen::renderActionBar(game::GameHandler& gameHandler) { - // Use ImGui's display size — always in sync with the current swap-chain/frame, - // whereas window->getWidth/Height() can lag by one frame on resize events. - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - auto* assetMgr = core::Application::getInstance().getAssetManager(); - - float slotSize = 48.0f * settingsPanel_.pendingActionBarScale; - float spacing = 4.0f; - float padding = 8.0f; - float barW = 12 * slotSize + 11 * spacing + padding * 2; - float barH = slotSize + 24.0f; - float barX = (screenW - barW) / 2.0f; - float barY = screenH - barH; - - ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); - - // Per-slot rendering lambda — shared by both action bars - const auto& bar = gameHandler.getActionBar(); - static constexpr const char* keyLabels1[] = {"1","2","3","4","5","6","7","8","9","0","-","="}; - // "⇧N" labels for bar 2 (UTF-8: E2 87 A7 = U+21E7 UPWARDS WHITE ARROW) - static constexpr const char* keyLabels2[] = { - "\xe2\x87\xa7" "1", "\xe2\x87\xa7" "2", "\xe2\x87\xa7" "3", - "\xe2\x87\xa7" "4", "\xe2\x87\xa7" "5", "\xe2\x87\xa7" "6", - "\xe2\x87\xa7" "7", "\xe2\x87\xa7" "8", "\xe2\x87\xa7" "9", - "\xe2\x87\xa7" "0", "\xe2\x87\xa7" "-", "\xe2\x87\xa7" "=" - }; - - auto renderBarSlot = [&](int absSlot, const char* keyLabel) { - ImGui::BeginGroup(); - ImGui::PushID(absSlot); - - const auto& slot = bar[absSlot]; - bool onCooldown = !slot.isReady(); - - // Macro cooldown: check the cached primary spell's cooldown. - float macroCooldownRemaining = 0.0f; - float macroCooldownTotal = 0.0f; - if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !onCooldown) { - uint32_t macroSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); - if (macroSpellId != 0) { - float cd = gameHandler.getSpellCooldown(macroSpellId); - if (cd > 0.0f) { - macroCooldownRemaining = cd; - macroCooldownTotal = cd; - onCooldown = true; - } - } - } - - const bool onGCD = gameHandler.isGCDActive() && !onCooldown && !slot.isEmpty(); - - // Out-of-range check: red tint when a targeted spell cannot reach the current target. - // Applies to SPELL and MACRO slots with a known max range (>5 yd) and an active target. - // Item range is checked below after barItemDef is populated. - bool outOfRange = false; - { - uint32_t rangeCheckSpellId = 0; - if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) - rangeCheckSpellId = slot.id; - else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) - rangeCheckSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); - if (rangeCheckSpellId != 0 && !onCooldown && gameHandler.hasTarget()) { - uint32_t maxRange = spellbookScreen.getSpellMaxRange(rangeCheckSpellId, assetMgr); - if (maxRange > 5) { - auto& em = gameHandler.getEntityManager(); - auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); - auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); - if (playerEnt && targetEnt) { - float dx = playerEnt->getX() - targetEnt->getX(); - float dy = playerEnt->getY() - targetEnt->getY(); - float dz = playerEnt->getZ() - targetEnt->getZ(); - if (std::sqrt(dx*dx + dy*dy + dz*dz) > static_cast(maxRange)) - outOfRange = true; - } - } - } - } - - // Insufficient-power check: tint when player doesn't have enough power to cast. - // Applies to SPELL and MACRO slots with a known power cost. - bool insufficientPower = false; - { - uint32_t powerCheckSpellId = 0; - if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) - powerCheckSpellId = slot.id; - else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) - powerCheckSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); - uint32_t spellCost = 0, spellPowerType = 0; - if (powerCheckSpellId != 0 && !onCooldown) - spellbookScreen.getSpellPowerInfo(powerCheckSpellId, assetMgr, spellCost, spellPowerType); - if (spellCost > 0) { - auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); - if (playerEnt && (playerEnt->getType() == game::ObjectType::PLAYER || - playerEnt->getType() == game::ObjectType::UNIT)) { - auto unit = std::static_pointer_cast(playerEnt); - if (unit->getPowerType() == static_cast(spellPowerType)) { - if (unit->getPower() < spellCost) - insufficientPower = true; - } - } - } - } - - auto getSpellName = [&](uint32_t spellId) -> std::string { - std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); - if (!name.empty()) return name; - return "Spell #" + std::to_string(spellId); - }; - - // Try to get icon texture for this slot - VkDescriptorSet iconTex = VK_NULL_HANDLE; - const game::ItemDef* barItemDef = nullptr; - uint32_t itemDisplayInfoId = 0; - std::string itemNameFromQuery; - if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { - iconTex = getSpellIcon(slot.id, assetMgr); - } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { - auto& inv = gameHandler.getInventory(); - for (int bi = 0; bi < inv.getBackpackSize(); bi++) { - const auto& bs = inv.getBackpackSlot(bi); - if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } - } - if (!barItemDef) { - for (int ei = 0; ei < game::Inventory::NUM_EQUIP_SLOTS; ei++) { - const auto& es = inv.getEquipSlot(static_cast(ei)); - if (!es.empty() && es.item.itemId == slot.id) { barItemDef = &es.item; break; } - } - } - if (!barItemDef) { - for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS && !barItemDef; bag++) { - for (int si = 0; si < inv.getBagSize(bag); si++) { - const auto& bs = inv.getBagSlot(bag, si); - if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } - } - } - } - if (barItemDef && barItemDef->displayInfoId != 0) - itemDisplayInfoId = barItemDef->displayInfoId; - if (itemDisplayInfoId == 0) { - if (auto* info = gameHandler.getItemInfo(slot.id)) { - itemDisplayInfoId = info->displayInfoId; - if (itemNameFromQuery.empty() && !info->name.empty()) - itemNameFromQuery = info->name; - } - } - if (itemDisplayInfoId != 0) - iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); - } - - // Macro icon: #showtooltip [SpellName] → show that spell's icon on the button - bool macroIsUseCmd = false; // tracks if the macro's primary command is /use (for item icon fallback) - if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !iconTex) { - const std::string& macroText = gameHandler.getMacroText(slot.id); - if (!macroText.empty()) { - std::string showArg = getMacroShowtooltipArg(macroText); - if (showArg.empty() || showArg == "__auto__") { - // No explicit #showtooltip arg — derive spell from first /cast, /castsequence, or /use line - for (const auto& cmdLine : allMacroCommands(macroText)) { - if (cmdLine.size() < 6) continue; - std::string cl = cmdLine; - for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); - bool isCastCmd = (cl.rfind("/cast ", 0) == 0 || cl == "/cast"); - bool isCastSeqCmd = (cl.rfind("/castsequence ", 0) == 0); - bool isUseCmd = (cl.rfind("/use ", 0) == 0); - if (isUseCmd) macroIsUseCmd = true; - if (!isCastCmd && !isCastSeqCmd && !isUseCmd) continue; - size_t sp2 = cmdLine.find(' '); - if (sp2 == std::string::npos) continue; - showArg = cmdLine.substr(sp2 + 1); - // Strip conditionals [...] - if (!showArg.empty() && showArg.front() == '[') { - size_t ce = showArg.find(']'); - if (ce != std::string::npos) showArg = showArg.substr(ce + 1); - } - // Strip reset= spec for castsequence - if (isCastSeqCmd) { - std::string tmp = showArg; - while (!tmp.empty() && tmp.front() == ' ') tmp.erase(tmp.begin()); - if (tmp.rfind("reset=", 0) == 0) { - size_t spA = tmp.find(' '); - if (spA != std::string::npos) showArg = tmp.substr(spA + 1); - } - } - // First alternative: ';' for /cast, ',' for /castsequence - size_t sep = showArg.find(isCastSeqCmd ? ',' : ';'); - if (sep != std::string::npos) showArg = showArg.substr(0, sep); - // Trim and strip '!' - size_t ss = showArg.find_first_not_of(" \t!"); - if (ss != std::string::npos) showArg = showArg.substr(ss); - size_t se = showArg.find_last_not_of(" \t"); - if (se != std::string::npos) showArg.resize(se + 1); - break; - } - } - // Look up the spell icon by name - if (!showArg.empty() && showArg != "__auto__") { - std::string showLower = showArg; - for (char& c : showLower) c = static_cast(std::tolower(static_cast(c))); - // Also strip "(Rank N)" suffix for matching - size_t rankParen = showLower.find('('); - if (rankParen != std::string::npos) showLower.resize(rankParen); - while (!showLower.empty() && showLower.back() == ' ') showLower.pop_back(); - for (uint32_t sid : gameHandler.getKnownSpells()) { - const std::string& sn = gameHandler.getSpellName(sid); - if (sn.empty()) continue; - std::string snl = sn; - for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); - if (snl == showLower) { - iconTex = assetMgr ? getSpellIcon(sid, assetMgr) : VK_NULL_HANDLE; - if (iconTex) break; - } - } - // Fallback for /use macros: if no spell matched, search item cache for the item icon - if (!iconTex && macroIsUseCmd) { - for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { - if (!info.valid) continue; - std::string iName = info.name; - for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); - if (iName == showLower && info.displayInfoId != 0) { - iconTex = inventoryScreen.getItemIcon(info.displayInfoId); - break; - } - } - } - } - } - } - - // Item-missing check: grey out item slots whose item is not in the player's inventory. - const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 - && barItemDef == nullptr && !onCooldown); - - // Ranged item out-of-range check (runs after barItemDef is populated above). - // invType 15=Ranged (bow/gun/crossbow), 26=Thrown, 28=RangedRight (wand/crossbow). - if (!outOfRange && slot.type == game::ActionBarSlot::ITEM && barItemDef - && !onCooldown && gameHandler.hasTarget()) { - constexpr uint8_t INVTYPE_RANGED = 15; - constexpr uint8_t INVTYPE_THROWN = 26; - constexpr uint8_t INVTYPE_RANGEDRIGHT = 28; - uint32_t itemMaxRange = 0; - if (barItemDef->inventoryType == INVTYPE_RANGED || - barItemDef->inventoryType == INVTYPE_RANGEDRIGHT) - itemMaxRange = 40; - else if (barItemDef->inventoryType == INVTYPE_THROWN) - itemMaxRange = 30; - if (itemMaxRange > 0) { - auto& em = gameHandler.getEntityManager(); - auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); - auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); - if (playerEnt && targetEnt) { - float dx = playerEnt->getX() - targetEnt->getX(); - float dy = playerEnt->getY() - targetEnt->getY(); - float dz = playerEnt->getZ() - targetEnt->getZ(); - if (std::sqrt(dx*dx + dy*dy + dz*dz) > static_cast(itemMaxRange)) - outOfRange = true; - } - } - } - - bool clicked = false; - if (iconTex) { - ImVec4 tintColor(1, 1, 1, 1); - ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); - if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } - else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } - else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } - else if (insufficientPower) { tintColor = ImVec4(0.6f, 0.5f, 0.9f, 0.85f); } - else if (itemMissing) { tintColor = ImVec4(0.35f, 0.35f, 0.35f, 0.7f); } - clicked = ImGui::ImageButton("##icon", - (ImTextureID)(uintptr_t)iconTex, - ImVec2(slotSize, slotSize), - ImVec2(0, 0), ImVec2(1, 1), - bgColor, tintColor); - } else { - if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); - else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); - else if (insufficientPower)ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.15f, 0.4f, 0.9f)); - else if (itemMissing) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.12f, 0.12f, 0.7f)); - else if (slot.isEmpty()) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); - - char label[32]; - if (slot.type == game::ActionBarSlot::SPELL) { - std::string spellName = getSpellName(slot.id); - if (spellName.size() > 6) spellName = spellName.substr(0, 6); - snprintf(label, sizeof(label), "%s", spellName.c_str()); - } else if (slot.type == game::ActionBarSlot::ITEM && barItemDef) { - std::string itemName = barItemDef->name; - if (itemName.size() > 6) itemName = itemName.substr(0, 6); - snprintf(label, sizeof(label), "%s", itemName.c_str()); - } else if (slot.type == game::ActionBarSlot::ITEM) { - snprintf(label, sizeof(label), "Item"); - } else if (slot.type == game::ActionBarSlot::MACRO) { - snprintf(label, sizeof(label), "Macro"); - } else { - snprintf(label, sizeof(label), "--"); - } - clicked = ImGui::Button(label, ImVec2(slotSize, slotSize)); - ImGui::PopStyleColor(); - } - - // Error-flash overlay: red fade on spell cast failure (~0.5 s). - // Check both spell slots directly and macro slots via their primary spell. - { - uint32_t flashSpellId = 0; - if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) - flashSpellId = slot.id; - else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) - flashSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); - auto flashIt = (flashSpellId != 0) ? actionFlashEndTimes_.find(flashSpellId) : actionFlashEndTimes_.end(); - if (flashIt != actionFlashEndTimes_.end()) { - float now = static_cast(ImGui::GetTime()); - float remaining = flashIt->second - now; - if (remaining > 0.0f) { - float alpha = remaining / kActionFlashDuration; // 1→0 - ImVec2 rMin = ImGui::GetItemRectMin(); - ImVec2 rMax = ImGui::GetItemRectMax(); - ImGui::GetWindowDrawList()->AddRectFilled( - rMin, rMax, - ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 0.1f, 0.1f, 0.55f * alpha))); - } else { - actionFlashEndTimes_.erase(flashIt); - } - } - } - - bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && - ImGui::IsMouseReleased(ImGuiMouseButton_Left); - - if (hoveredOnRelease && spellbookScreen.isDraggingSpell()) { - gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::SPELL, - spellbookScreen.getDragSpellId()); - spellbookScreen.consumeDragSpell(); - } else if (hoveredOnRelease && inventoryScreen.isHoldingItem()) { - const auto& held = inventoryScreen.getHeldItem(); - gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::ITEM, held.itemId); - inventoryScreen.returnHeldItem(gameHandler.getInventory()); - } else if (clicked && actionBarDragSlot_ >= 0) { - if (absSlot != actionBarDragSlot_) { - const auto& dragSrc = bar[actionBarDragSlot_]; - gameHandler.setActionBarSlot(actionBarDragSlot_, slot.type, slot.id); - gameHandler.setActionBarSlot(absSlot, dragSrc.type, dragSrc.id); - } - actionBarDragSlot_ = -1; - actionBarDragIcon_ = 0; - } else if (clicked && !slot.isEmpty()) { - if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) { - // Check if this spell belongs to an item (e.g., Hearthstone spell 8690). - // Item-use spells must go through CMSG_USE_ITEM, not CMSG_CAST_SPELL. - uint32_t itemForSpell = gameHandler.getItemIdForSpell(slot.id); - if (itemForSpell != 0) { - gameHandler.useItemById(itemForSpell); - } else { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(slot.id, target); - } - } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { - gameHandler.useItemById(slot.id); - } else if (slot.type == game::ActionBarSlot::MACRO) { - chatPanel_.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); - } - } - - // Right-click context menu for non-empty slots - if (!slot.isEmpty()) { - // Use a unique popup ID per slot so multiple slots don't share state - char ctxId[32]; - snprintf(ctxId, sizeof(ctxId), "##ABCtx%d", absSlot); - if (ImGui::BeginPopupContextItem(ctxId)) { - if (slot.type == game::ActionBarSlot::SPELL) { - std::string spellName = getSpellName(slot.id); - ImGui::TextDisabled("%s", spellName.c_str()); - ImGui::Separator(); - if (onCooldown) ImGui::BeginDisabled(); - if (ImGui::MenuItem("Cast")) { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(slot.id, target); - } - if (onCooldown) ImGui::EndDisabled(); - } else if (slot.type == game::ActionBarSlot::ITEM) { - const char* iName = (barItemDef && !barItemDef->name.empty()) - ? barItemDef->name.c_str() - : (!itemNameFromQuery.empty() ? itemNameFromQuery.c_str() : "Item"); - ImGui::TextDisabled("%s", iName); - ImGui::Separator(); - if (ImGui::MenuItem("Use")) { - gameHandler.useItemById(slot.id); - } - } else if (slot.type == game::ActionBarSlot::MACRO) { - ImGui::TextDisabled("Macro #%u", slot.id); - ImGui::Separator(); - if (ImGui::MenuItem("Execute")) { - chatPanel_.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); - } - if (ImGui::MenuItem("Edit")) { - const std::string& txt = gameHandler.getMacroText(slot.id); - strncpy(macroEditorBuf_, txt.c_str(), sizeof(macroEditorBuf_) - 1); - macroEditorBuf_[sizeof(macroEditorBuf_) - 1] = '\0'; - macroEditorId_ = slot.id; - macroEditorOpen_ = true; - } - } - ImGui::Separator(); - if (ImGui::MenuItem("Clear Slot")) { - gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::EMPTY, 0); - } - ImGui::EndPopup(); - } - } - - // Tooltip - if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { - if (slot.type == game::ActionBarSlot::SPELL) { - // Use the spellbook's rich tooltip (school, cost, cast time, range, description). - // Falls back to the simple name if DBC data isn't loaded yet. - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip(slot.id, gameHandler, assetMgr); - if (!richOk) { - ImGui::Text("%s", getSpellName(slot.id).c_str()); - } - // Hearthstone: add location note after the spell tooltip body - if (slot.id == 8690) { - uint32_t mapId = 0; glm::vec3 pos; - if (gameHandler.getHomeBind(mapId, pos)) { - std::string homeLocation; - // Zone name (from zoneId stored in bind point) - uint32_t zoneId = gameHandler.getHomeBindZoneId(); - if (zoneId != 0) { - homeLocation = gameHandler.getWhoAreaName(zoneId); - } - // Fall back to continent name if zone unavailable - if (homeLocation.empty()) { - switch (mapId) { - case 0: homeLocation = "Eastern Kingdoms"; break; - case 1: homeLocation = "Kalimdor"; break; - case 530: homeLocation = "Outland"; break; - case 571: homeLocation = "Northrend"; break; - default: homeLocation = "Unknown"; break; - } - } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), - "Home: %s", homeLocation.c_str()); - } - } - if (outOfRange) { - ImGui::TextColored(colors::kHostileRed, "Out of range"); - } - if (insufficientPower) { - ImGui::TextColored(ImVec4(0.75f, 0.55f, 1.0f, 1.0f), "Not enough power"); - } - if (onCooldown) { - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) - ImGui::TextColored(kColorRed, - "Cooldown: %d min %d sec", static_cast(cd)/60, static_cast(cd)%60); - else - ImGui::TextColored(kColorRed, "Cooldown: %.1f sec", cd); - } - ImGui::EndTooltip(); - } else if (slot.type == game::ActionBarSlot::MACRO) { - ImGui::BeginTooltip(); - // Show the primary spell's rich tooltip (like WoW does for macro buttons) - uint32_t macroSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); - bool showedRich = false; - if (macroSpellId != 0) { - showedRich = spellbookScreen.renderSpellInfoTooltip(macroSpellId, gameHandler, assetMgr); - if (onCooldown && macroCooldownRemaining > 0.0f) { - float cd = macroCooldownRemaining; - if (cd >= 60.0f) - ImGui::TextColored(kColorRed, - "Cooldown: %d min %d sec", static_cast(cd)/60, static_cast(cd)%60); - else - ImGui::TextColored(kColorRed, "Cooldown: %.1f sec", cd); - } - } - if (!showedRich) { - // For /use macros: try showing the item tooltip instead - if (macroIsUseCmd) { - const std::string& macroText = gameHandler.getMacroText(slot.id); - // Extract item name from first /use command - for (const auto& cmd : allMacroCommands(macroText)) { - std::string cl = cmd; - for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); - if (cl.rfind("/use ", 0) != 0) continue; - size_t sp = cmd.find(' '); - if (sp == std::string::npos) continue; - std::string itemArg = cmd.substr(sp + 1); - while (!itemArg.empty() && itemArg.front() == ' ') itemArg.erase(itemArg.begin()); - while (!itemArg.empty() && itemArg.back() == ' ') itemArg.pop_back(); - std::string itemLow = itemArg; - for (char& c : itemLow) c = static_cast(std::tolower(static_cast(c))); - for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { - if (!info.valid) continue; - std::string iName = info.name; - for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); - if (iName == itemLow) { - inventoryScreen.renderItemTooltip(info); - showedRich = true; - break; - } - } - break; - } - } - if (!showedRich) { - ImGui::Text("Macro #%u", slot.id); - const std::string& macroText = gameHandler.getMacroText(slot.id); - if (!macroText.empty()) { - ImGui::Separator(); - ImGui::TextUnformatted(macroText.c_str()); - } else { - ImGui::TextDisabled("(no text — right-click to Edit)"); - } - } - } - ImGui::EndTooltip(); - } else if (slot.type == game::ActionBarSlot::ITEM) { - ImGui::BeginTooltip(); - // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) - const auto* itemQueryInfo = gameHandler.getItemInfo(slot.id); - if (itemQueryInfo && itemQueryInfo->valid) { - inventoryScreen.renderItemTooltip(*itemQueryInfo); - } else if (barItemDef && !barItemDef->name.empty()) { - ImGui::Text("%s", barItemDef->name.c_str()); - } else if (!itemNameFromQuery.empty()) { - ImGui::Text("%s", itemNameFromQuery.c_str()); - } else { - ImGui::Text("Item #%u", slot.id); - } - if (onCooldown) { - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) - ImGui::TextColored(kColorRed, - "Cooldown: %d min %d sec", static_cast(cd)/60, static_cast(cd)%60); - else - ImGui::TextColored(kColorRed, "Cooldown: %.1f sec", cd); - } - ImGui::EndTooltip(); - } - } - - // Cooldown overlay: WoW-style clock-sweep + time text - if (onCooldown) { - ImVec2 btnMin = ImGui::GetItemRectMin(); - ImVec2 btnMax = ImGui::GetItemRectMax(); - float cx = (btnMin.x + btnMax.x) * 0.5f; - float cy = (btnMin.y + btnMax.y) * 0.5f; - float r = (btnMax.x - btnMin.x) * 0.5f; - auto* dl = ImGui::GetWindowDrawList(); - - // For macros, use the resolved primary spell cooldown instead of the slot's own. - float effCdTotal = (macroCooldownTotal > 0.0f) ? macroCooldownTotal : slot.cooldownTotal; - float effCdRemaining = (macroCooldownRemaining > 0.0f) ? macroCooldownRemaining : slot.cooldownRemaining; - float total = (effCdTotal > 0.0f) ? effCdTotal : 1.0f; - float elapsed = total - effCdRemaining; - float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total)); - if (elapsedFrac > 0.005f) { - constexpr int N_SEGS = 32; - float startAngle = -IM_PI * 0.5f; - float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; - float fanR = r * 1.5f; - ImVec2 pts[N_SEGS + 2]; - pts[0] = ImVec2(cx, cy); - for (int s = 0; s <= N_SEGS; ++s) { - float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); - pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); - } - dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 170)); - } - - char cdText[16]; - float cd = effCdRemaining; - if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", static_cast(cd) / 3600); - else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", static_cast(cd) / 60, static_cast(cd) % 60); - else if (cd >= 5.0f) snprintf(cdText, sizeof(cdText), "%ds", static_cast(cd)); - else snprintf(cdText, sizeof(cdText), "%.1f", cd); - ImVec2 textSize = ImGui::CalcTextSize(cdText); - float tx = cx - textSize.x * 0.5f; - float ty = cy - textSize.y * 0.5f; - dl->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 220), cdText); - dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); - } - - // GCD overlay — subtle dark fan sweep (thinner/lighter than regular cooldown) - if (onGCD) { - ImVec2 btnMin = ImGui::GetItemRectMin(); - ImVec2 btnMax = ImGui::GetItemRectMax(); - float cx = (btnMin.x + btnMax.x) * 0.5f; - float cy = (btnMin.y + btnMax.y) * 0.5f; - float r = (btnMax.x - btnMin.x) * 0.5f; - auto* dl = ImGui::GetWindowDrawList(); - float gcdRem = gameHandler.getGCDRemaining(); - float gcdTotal = gameHandler.getGCDTotal(); - if (gcdTotal > 0.0f) { - float elapsed = gcdTotal - gcdRem; - float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / gcdTotal)); - if (elapsedFrac > 0.005f) { - constexpr int N_SEGS = 24; - float startAngle = -IM_PI * 0.5f; - float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; - float fanR = r * 1.4f; - ImVec2 pts[N_SEGS + 2]; - pts[0] = ImVec2(cx, cy); - for (int s = 0; s <= N_SEGS; ++s) { - float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); - pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); - } - dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 110)); - } - } - } - - // Auto-attack active glow — pulsing golden border when slot 6603 (Attack) is toggled on - if (slot.type == game::ActionBarSlot::SPELL && slot.id == 6603 - && gameHandler.isAutoAttacking()) { - ImVec2 bMin = ImGui::GetItemRectMin(); - ImVec2 bMax = ImGui::GetItemRectMax(); - float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 5.0f); - ImU32 glowCol = IM_COL32( - static_cast(255), - static_cast(200 * pulse), - static_cast(0), - static_cast(200 * pulse)); - ImGui::GetWindowDrawList()->AddRect(bMin, bMax, glowCol, 2.0f, 0, 2.5f); - } - - // Item stack count overlay — bottom-right corner of icon - if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { - // Count total of this item across all inventory slots - auto& inv = gameHandler.getInventory(); - int totalCount = 0; - for (int bi = 0; bi < inv.getBackpackSize(); bi++) { - const auto& bs = inv.getBackpackSlot(bi); - if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; - } - for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) { - for (int si = 0; si < inv.getBagSize(bag); si++) { - const auto& bs = inv.getBagSlot(bag, si); - if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; - } - } - if (totalCount > 0) { - char countStr[16]; - snprintf(countStr, sizeof(countStr), "%d", totalCount); - ImVec2 btnMax = ImGui::GetItemRectMax(); - ImVec2 tsz = ImGui::CalcTextSize(countStr); - float cx2 = btnMax.x - tsz.x - 2.0f; - float cy2 = btnMax.y - tsz.y - 1.0f; - auto* cdl = ImGui::GetWindowDrawList(); - cdl->AddText(ImVec2(cx2 + 1.0f, cy2 + 1.0f), IM_COL32(0, 0, 0, 200), countStr); - cdl->AddText(ImVec2(cx2, cy2), - totalCount <= 1 ? IM_COL32(220, 100, 100, 255) : IM_COL32(255, 255, 255, 255), - countStr); - } - } - - // Ready glow: animate a gold border for ~1.5s when a cooldown just expires - { - static std::unordered_map slotGlowTimers; // absSlot -> remaining glow seconds - static std::unordered_map slotWasOnCooldown; // absSlot -> last frame state - - float dt = ImGui::GetIO().DeltaTime; - bool wasOnCd = slotWasOnCooldown.count(absSlot) ? slotWasOnCooldown[absSlot] : false; - - // Trigger glow when transitioning from on-cooldown to ready (and slot isn't empty) - if (wasOnCd && !onCooldown && !slot.isEmpty()) { - slotGlowTimers[absSlot] = 1.5f; - } - slotWasOnCooldown[absSlot] = onCooldown; - - auto git = slotGlowTimers.find(absSlot); - if (git != slotGlowTimers.end() && git->second > 0.0f) { - git->second -= dt; - float t = git->second / 1.5f; // 1.0 → 0.0 over lifetime - // Pulse: bright when fresh, fading out - float pulse = std::sin(t * IM_PI * 4.0f) * 0.5f + 0.5f; // 4 pulses - uint8_t alpha = static_cast(200 * t * (0.5f + 0.5f * pulse)); - if (alpha > 0) { - ImVec2 bMin = ImGui::GetItemRectMin(); - ImVec2 bMax = ImGui::GetItemRectMax(); - auto* gdl = ImGui::GetWindowDrawList(); - // Gold glow border (2px inset, 3px thick) - gdl->AddRect(ImVec2(bMin.x - 2, bMin.y - 2), - ImVec2(bMax.x + 2, bMax.y + 2), - IM_COL32(255, 215, 0, alpha), 3.0f, 0, 3.0f); - } - if (git->second <= 0.0f) slotGlowTimers.erase(git); - } - } - - // Key label below - ImGui::TextDisabled("%s", keyLabel); - - ImGui::PopID(); - ImGui::EndGroup(); - }; - - // Bar 2 (slots 12-23) — only show if at least one slot is populated - if (settingsPanel_.pendingShowActionBar2) { - bool bar2HasContent = false; - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) - if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; } - - float bar2X = barX + settingsPanel_.pendingActionBar2OffsetX; - float bar2Y = barY - barH - 2.0f + settingsPanel_.pendingActionBar2OffsetY; - ImGui::SetNextWindowPos(ImVec2(bar2X, bar2Y), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, - bar2HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); - if (ImGui::Begin("##ActionBar2", nullptr, flags)) { - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - if (i > 0) ImGui::SameLine(0, spacing); - renderBarSlot(game::GameHandler::SLOTS_PER_BAR + i, keyLabels2[i]); - } - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(4); - } - - // Bar 1 (slots 0-11) - if (ImGui::Begin("##ActionBar", nullptr, flags)) { - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - if (i > 0) ImGui::SameLine(0, spacing); - renderBarSlot(i, keyLabels1[i]); - } - - // Macro editor modal — opened by "Edit" in action bar context menus - if (macroEditorOpen_) { - ImGui::OpenPopup("Edit Macro###MacroEdit"); - macroEditorOpen_ = false; - } - if (ImGui::BeginPopupModal("Edit Macro###MacroEdit", nullptr, - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { - ImGui::Text("Macro #%u (all lines execute; [cond] Spell; Default supported)", macroEditorId_); - ImGui::SetNextItemWidth(320.0f); - ImGui::InputTextMultiline("##MacroText", macroEditorBuf_, sizeof(macroEditorBuf_), - ImVec2(320.0f, 80.0f)); - if (ImGui::Button("Save")) { - gameHandler.setMacroText(macroEditorId_, std::string(macroEditorBuf_)); - macroPrimarySpellCache_.clear(); // invalidate resolved spell IDs - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel")) { - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - } - ImGui::End(); - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(4); - - // Right side vertical bar (bar 3, slots 24-35) - if (settingsPanel_.pendingShowRightBar) { - bool bar3HasContent = false; - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) - if (!bar[game::GameHandler::SLOTS_PER_BAR * 2 + i].isEmpty()) { bar3HasContent = true; break; } - - float sideBarW = slotSize + padding * 2; - float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; - float sideBarX = screenW - sideBarW - 4.0f; - float sideBarY = (screenH - sideBarH) / 2.0f + settingsPanel_.pendingRightBarOffsetY; - - ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, - bar3HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); - if (ImGui::Begin("##ActionBarRight", nullptr, flags)) { - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 2 + i, ""); - } - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(4); - } - - // Left side vertical bar (bar 4, slots 36-47) - if (settingsPanel_.pendingShowLeftBar) { - bool bar4HasContent = false; - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) - if (!bar[game::GameHandler::SLOTS_PER_BAR * 3 + i].isEmpty()) { bar4HasContent = true; break; } - - float sideBarW = slotSize + padding * 2; - float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; - float sideBarX = 4.0f; - float sideBarY = (screenH - sideBarH) / 2.0f + settingsPanel_.pendingLeftBarOffsetY; - - ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, - bar4HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); - if (ImGui::Begin("##ActionBarLeft", nullptr, flags)) { - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 3 + i, ""); - } - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(4); - } - - // Vehicle exit button (WotLK): floating button above action bar when player is in a vehicle - if (gameHandler.isInVehicle()) { - const float btnW = 120.0f; - const float btnH = 32.0f; - const float btnX = (screenW - btnW) / 2.0f; - const float btnY = barY - btnH - 6.0f; - - ImGui::SetNextWindowPos(ImVec2(btnX, btnY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(btnW, btnH), ImGuiCond_Always); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); - ImGuiWindowFlags vFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground; - if (ImGui::Begin("##VehicleExit", nullptr, vFlags)) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kLowHealthRed); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.4f, 0.0f, 0.0f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); - if (ImGui::Button("Leave Vehicle", ImVec2(btnW - 8.0f, btnH - 8.0f))) { - gameHandler.sendRequestVehicleExit(); - } - ImGui::PopStyleVar(); - ImGui::PopStyleColor(3); - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(3); - } - - // Handle action bar drag: render icon at cursor and detect drop outside - if (actionBarDragSlot_ >= 0) { - ImVec2 mousePos = ImGui::GetMousePos(); - - // Draw dragged icon at cursor - if (actionBarDragIcon_) { - ImGui::GetForegroundDrawList()->AddImage( - (ImTextureID)(uintptr_t)actionBarDragIcon_, - ImVec2(mousePos.x - 20, mousePos.y - 20), - ImVec2(mousePos.x + 20, mousePos.y + 20)); - } else { - ImGui::GetForegroundDrawList()->AddRectFilled( - ImVec2(mousePos.x - 20, mousePos.y - 20), - ImVec2(mousePos.x + 20, mousePos.y + 20), - IM_COL32(80, 80, 120, 180)); - } - - // On right mouse release, check if outside the action bar area - if (ImGui::IsMouseReleased(ImGuiMouseButton_Right)) { - bool insideBar = (mousePos.x >= barX && mousePos.x <= barX + barW && - mousePos.y >= barY && mousePos.y <= barY + barH); - if (!insideBar) { - // Dropped outside - clear the slot - gameHandler.setActionBarSlot(actionBarDragSlot_, game::ActionBarSlot::EMPTY, 0); - } - actionBarDragSlot_ = -1; - actionBarDragIcon_ = 0; - } - } -} // ============================================================ // Stance / Form / Presence Bar @@ -5316,805 +4355,26 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { // Active form is detected by checking permanent player auras. // ============================================================ -void GameScreen::renderStanceBar(game::GameHandler& gameHandler) { - uint8_t playerClass = gameHandler.getPlayerClass(); - - // Stance/form spell IDs per class (ordered by display priority) - // Class IDs: 1=Warrior, 4=Rogue, 5=Priest, 6=DeathKnight, 11=Druid - static const uint32_t warriorStances[] = { 2457, 71, 2458 }; // Battle, Defensive, Berserker - static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; // Blood, Frost, Unholy - static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; - // Bear, DireBear, Cat, Travel, Aquatic, Moonkin, Tree, Flight, SwiftFlight - static const uint32_t rogueForms[] = { 1784 }; // Stealth - static const uint32_t priestForms[] = { 15473 }; // Shadowform - - const uint32_t* stanceArr = nullptr; - int stanceCount = 0; - switch (playerClass) { - case 1: stanceArr = warriorStances; stanceCount = 3; break; - case 6: stanceArr = dkPresences; stanceCount = 3; break; - case 11: stanceArr = druidForms; stanceCount = 9; break; - case 4: stanceArr = rogueForms; stanceCount = 1; break; - case 5: stanceArr = priestForms; stanceCount = 1; break; - default: return; - } - - // Filter to spells the player actually knows - const auto& known = gameHandler.getKnownSpells(); - std::vector available; - available.reserve(stanceCount); - for (int i = 0; i < stanceCount; ++i) - if (known.count(stanceArr[i])) available.push_back(stanceArr[i]); - - if (available.empty()) return; - - // Detect active stance from permanent player auras (maxDurationMs == -1) - uint32_t activeStance = 0; - for (const auto& aura : gameHandler.getPlayerAuras()) { - if (aura.isEmpty() || aura.maxDurationMs != -1) continue; - for (uint32_t sid : available) { - if (aura.spellId == sid) { activeStance = sid; break; } - } - if (activeStance) break; - } - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - auto* assetMgr = core::Application::getInstance().getAssetManager(); - - // Match the action bar slot size so they align neatly - float slotSize = 38.0f; - float spacing = 4.0f; - float padding = 6.0f; - int count = static_cast(available.size()); - - float barW = count * slotSize + (count - 1) * spacing + padding * 2.0f; - float barH = slotSize + padding * 2.0f; - - // Position the stance bar immediately to the left of the action bar - float actionSlot = 48.0f * settingsPanel_.pendingActionBarScale; - float actionBarW = 12.0f * actionSlot + 11.0f * 4.0f + 8.0f * 2.0f; - float actionBarX = (screenW - actionBarW) / 2.0f; - float actionBarH = actionSlot + 24.0f; - float actionBarY = screenH - actionBarH; - - float barX = actionBarX - barW - 8.0f; - float barY = actionBarY + (actionBarH - barH) / 2.0f; - - ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); - - if (ImGui::Begin("##StanceBar", nullptr, flags)) { - ImDrawList* dl = ImGui::GetWindowDrawList(); - - for (int i = 0; i < count; ++i) { - if (i > 0) ImGui::SameLine(0.0f, spacing); - ImGui::PushID(i); - - uint32_t spellId = available[i]; - bool isActive = (spellId == activeStance); - - VkDescriptorSet iconTex = assetMgr ? getSpellIcon(spellId, assetMgr) : VK_NULL_HANDLE; - - ImVec2 pos = ImGui::GetCursorScreenPos(); - ImVec2 posEnd = ImVec2(pos.x + slotSize, pos.y + slotSize); - - // Background — green tint when active - ImU32 bgCol = isActive ? IM_COL32(30, 70, 30, 230) : IM_COL32(20, 20, 20, 220); - ImU32 borderCol = isActive ? IM_COL32(80, 220, 80, 255) : IM_COL32(80, 80, 80, 200); - dl->AddRectFilled(pos, posEnd, bgCol, 4.0f); - - if (iconTex) { - dl->AddImage((ImTextureID)(uintptr_t)iconTex, pos, posEnd); - // Darken inactive buttons slightly - if (!isActive) - dl->AddRectFilled(pos, posEnd, IM_COL32(0, 0, 0, 70), 4.0f); - } - dl->AddRect(pos, posEnd, borderCol, 4.0f, 0, 2.0f); - - ImGui::InvisibleButton("##btn", ImVec2(slotSize, slotSize)); - - if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) - gameHandler.castSpell(spellId); - - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); - if (!name.empty()) ImGui::TextUnformatted(name.c_str()); - else ImGui::Text("Spell #%u", spellId); - ImGui::EndTooltip(); - } - - ImGui::PopID(); - } - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(4); -} // ============================================================ // Bag Bar // ============================================================ -void GameScreen::renderBagBar(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; - auto* assetMgr = core::Application::getInstance().getAssetManager(); - - float slotSize = 42.0f; - float spacing = 4.0f; - float padding = 6.0f; - - // 5 slots: backpack + 4 bags - float barW = 5 * slotSize + 4 * spacing + padding * 2; - float barH = slotSize + padding * 2; - - // Position in bottom right corner - float barX = screenW - barW - 10.0f; - float barY = screenH - barH - 10.0f; - - ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); - - if (ImGui::Begin("##BagBar", nullptr, flags)) { - auto& inv = gameHandler.getInventory(); - - // Load backpack icon if needed - if (!backpackIconTexture_ && assetMgr && assetMgr->isInitialized()) { - auto blpData = assetMgr->readFile("Interface\\Buttons\\Button-Backpack-Up.blp"); - if (!blpData.empty()) { - auto image = pipeline::BLPLoader::load(blpData); - if (image.isValid()) { - auto* w = core::Application::getInstance().getWindow(); - auto* vkCtx = w ? w->getVkContext() : nullptr; - if (vkCtx) - backpackIconTexture_ = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); - } - } - } - - // Track bag slot screen rects for drop detection - ImVec2 bagSlotMins[4], bagSlotMaxs[4]; - - // Slots 1-4: Bag slots (leftmost) - for (int i = 0; i < 4; ++i) { - if (i > 0) ImGui::SameLine(0, spacing); - ImGui::PushID(i + 1); - - game::EquipSlot bagSlot = static_cast(static_cast(game::EquipSlot::BAG1) + i); - const auto& bagItem = inv.getEquipSlot(bagSlot); - - VkDescriptorSet bagIcon = VK_NULL_HANDLE; - if (!bagItem.empty() && bagItem.item.displayInfoId != 0) { - bagIcon = inventoryScreen.getItemIcon(bagItem.item.displayInfoId); - } - // Render the slot as an invisible button so we control all interaction - ImVec2 cpos = ImGui::GetCursorScreenPos(); - ImGui::InvisibleButton("##bagSlot", ImVec2(slotSize, slotSize)); - bagSlotMins[i] = cpos; - bagSlotMaxs[i] = ImVec2(cpos.x + slotSize, cpos.y + slotSize); - - ImDrawList* dl = ImGui::GetWindowDrawList(); - - // Draw background + icon - if (bagIcon) { - dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(25, 25, 25, 230)); - dl->AddImage((ImTextureID)(uintptr_t)bagIcon, bagSlotMins[i], bagSlotMaxs[i]); - } else { - dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(38, 38, 38, 204)); - } - - // Hover highlight - bool hovered = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); - if (hovered && bagBarPickedSlot_ < 0) { - dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 100)); - } - - // Track which slot was pressed for drag detection - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && bagBarPickedSlot_ < 0 && bagIcon) { - bagBarDragSource_ = i; - } - - // Click toggles bag open/close (handled in mouse release section below) - - // Dim the slot being dragged - if (bagBarPickedSlot_ == i) { - dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(0, 0, 0, 150)); - } - - // Tooltip - if (hovered && bagBarPickedSlot_ < 0) { - if (bagIcon) - ImGui::SetTooltip("%s", bagItem.item.name.c_str()); - else - ImGui::SetTooltip("Empty Bag Slot"); - } - - // Open bag indicator - if (inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i)) { - dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f); - } - - // Right-click context menu - if (ImGui::BeginPopupContextItem("##bagSlotCtx")) { - if (!bagItem.empty()) { - ImGui::TextDisabled("%s", bagItem.item.name.c_str()); - ImGui::Separator(); - bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i); - if (ImGui::MenuItem(isOpen ? "Close Bag" : "Open Bag")) { - if (inventoryScreen.isSeparateBags()) - inventoryScreen.toggleBag(i); - else - inventoryScreen.toggle(); - } - if (ImGui::MenuItem("Unequip Bag")) { - gameHandler.unequipToBackpack(bagSlot); - } - } else { - ImGui::TextDisabled("Empty Bag Slot"); - } - ImGui::EndPopup(); - } - - // Accept dragged item from inventory - if (hovered && inventoryScreen.isHoldingItem()) { - const auto& heldItem = inventoryScreen.getHeldItem(); - if ((heldItem.inventoryType == 18 || heldItem.bagSlots > 0) && - ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - auto& inventory = gameHandler.getInventory(); - inventoryScreen.dropHeldItemToEquipSlot(inventory, bagSlot); - } - } - - ImGui::PopID(); - } - - // Drag lifecycle: press on a slot sets bagBarDragSource_, - // dragging 3+ pixels promotes to bagBarPickedSlot_ (visual drag), - // releasing completes swap or click - if (bagBarDragSource_ >= 0) { - if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 3.0f) && bagBarPickedSlot_ < 0) { - // If an inventory window is open, hand off drag to inventory held-item - // so the bag can be dropped into backpack/bag slots. - if (inventoryScreen.isOpen() || inventoryScreen.isCharacterOpen()) { - auto equip = static_cast( - static_cast(game::EquipSlot::BAG1) + bagBarDragSource_); - if (inventoryScreen.beginPickupFromEquipSlot(inv, equip)) { - bagBarDragSource_ = -1; - } else { - bagBarPickedSlot_ = bagBarDragSource_; - } - } else { - // Mouse moved enough — start visual drag - bagBarPickedSlot_ = bagBarDragSource_; - } - } - if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - if (bagBarPickedSlot_ >= 0) { - // Was dragging — check for drop target - ImVec2 mousePos = ImGui::GetIO().MousePos; - int dropTarget = -1; - for (int j = 0; j < 4; ++j) { - if (j == bagBarPickedSlot_) continue; - if (mousePos.x >= bagSlotMins[j].x && mousePos.x <= bagSlotMaxs[j].x && - mousePos.y >= bagSlotMins[j].y && mousePos.y <= bagSlotMaxs[j].y) { - dropTarget = j; - break; - } - } - if (dropTarget >= 0) { - gameHandler.swapBagSlots(bagBarPickedSlot_, dropTarget); - } - bagBarPickedSlot_ = -1; - } else { - // Was just a click (no drag) — toggle bag - int slot = bagBarDragSource_; - auto equip = static_cast(static_cast(game::EquipSlot::BAG1) + slot); - if (!inv.getEquipSlot(equip).empty()) { - if (inventoryScreen.isSeparateBags()) - inventoryScreen.toggleBag(slot); - else - inventoryScreen.toggle(); - } - } - bagBarDragSource_ = -1; - } - } - - // Backpack (rightmost slot) - ImGui::SameLine(0, spacing); - ImGui::PushID(0); - if (backpackIconTexture_) { - if (ImGui::ImageButton("##backpack", (ImTextureID)(uintptr_t)backpackIconTexture_, - ImVec2(slotSize, slotSize), - ImVec2(0, 0), ImVec2(1, 1), - ImVec4(0.1f, 0.1f, 0.1f, 0.9f), - colors::kWhite)) { - if (inventoryScreen.isSeparateBags()) - inventoryScreen.toggleBackpack(); - else - inventoryScreen.toggle(); - } - } else { - if (ImGui::Button("B", ImVec2(slotSize, slotSize))) { - if (inventoryScreen.isSeparateBags()) - inventoryScreen.toggleBackpack(); - else - inventoryScreen.toggle(); - } - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Backpack"); - } - // Right-click context menu on backpack - if (ImGui::BeginPopupContextItem("##backpackCtx")) { - bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBackpackOpen(); - if (ImGui::MenuItem(isOpen ? "Close Backpack" : "Open Backpack")) { - if (inventoryScreen.isSeparateBags()) - inventoryScreen.toggleBackpack(); - else - inventoryScreen.toggle(); - } - ImGui::Separator(); - if (ImGui::MenuItem("Open All Bags")) { - inventoryScreen.openAllBags(); - } - if (ImGui::MenuItem("Close All Bags")) { - inventoryScreen.closeAllBags(); - } - ImGui::EndPopup(); - } - if (inventoryScreen.isSeparateBags() && - inventoryScreen.isBackpackOpen()) { - ImDrawList* dl = ImGui::GetWindowDrawList(); - ImVec2 r0 = ImGui::GetItemRectMin(); - ImVec2 r1 = ImGui::GetItemRectMax(); - dl->AddRect(r0, r1, IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f); - } - ImGui::PopID(); - - } - ImGui::End(); - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(4); - - // Draw dragged bag icon following cursor - if (bagBarPickedSlot_ >= 0) { - auto& inv2 = gameHandler.getInventory(); - auto pickedEquip = static_cast( - static_cast(game::EquipSlot::BAG1) + bagBarPickedSlot_); - const auto& pickedItem = inv2.getEquipSlot(pickedEquip); - VkDescriptorSet pickedIcon = VK_NULL_HANDLE; - if (!pickedItem.empty() && pickedItem.item.displayInfoId != 0) { - pickedIcon = inventoryScreen.getItemIcon(pickedItem.item.displayInfoId); - } - if (pickedIcon) { - ImVec2 mousePos = ImGui::GetIO().MousePos; - float sz = 40.0f; - ImVec2 p0(mousePos.x - sz * 0.5f, mousePos.y - sz * 0.5f); - ImVec2 p1(mousePos.x + sz * 0.5f, mousePos.y + sz * 0.5f); - ImDrawList* fg = ImGui::GetForegroundDrawList(); - fg->AddImage((ImTextureID)(uintptr_t)pickedIcon, p0, p1); - fg->AddRect(p0, p1, IM_COL32(200, 200, 200, 255), 0.0f, 0, 2.0f); - } - } -} // ============================================================ // XP Bar // ============================================================ -void GameScreen::renderXpBar(game::GameHandler& gameHandler) { - uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); - uint32_t playerLevel = gameHandler.getPlayerLevel(); - // At max level, server sends nextLevelXp=0. Only skip entirely when we have - // no level info at all (not yet logged in / no update-field data). - const bool isMaxLevel = (nextLevelXp == 0 && playerLevel > 0); - if (nextLevelXp == 0 && !isMaxLevel) return; - - uint32_t currentXp = gameHandler.getPlayerXp(); - uint32_t restedXp = gameHandler.getPlayerRestedXp(); - bool isResting = gameHandler.isPlayerResting(); - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - auto* window = core::Application::getInstance().getWindow(); - (void)window; // Not used for positioning; kept for AssetManager if needed - - // Position just above both action bars (bar1 at screenH-barH, bar2 above that) - float slotSize = 48.0f * settingsPanel_.pendingActionBarScale; - float spacing = 4.0f; - float padding = 8.0f; - float barW = 12 * slotSize + 11 * spacing + padding * 2; - float barH = slotSize + 24.0f; - - float xpBarH = 20.0f; - float xpBarW = barW; - float xpBarX = (screenW - xpBarW) / 2.0f; - // XP bar sits just above whichever bar is topmost. - // bar1 top edge: screenH - barH - // bar2 top edge (when visible): bar1 top - barH - 2 + bar2 vertical offset - float bar1TopY = screenH - barH; - float xpBarY; - if (settingsPanel_.pendingShowActionBar2) { - float bar2TopY = bar1TopY - barH - 2.0f + settingsPanel_.pendingActionBar2OffsetY; - xpBarY = bar2TopY - xpBarH - 2.0f; - } else { - xpBarY = bar1TopY - xpBarH - 2.0f; - } - - ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); - - if (ImGui::Begin("##XpBar", nullptr, flags)) { - ImVec2 barMin = ImGui::GetCursorScreenPos(); - ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); - ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); - auto* drawList = ImGui::GetWindowDrawList(); - - if (isMaxLevel) { - // Max-level bar: fully filled in muted gold with "Max Level" label - ImU32 bgML = IM_COL32(15, 12, 5, 220); - ImU32 fgML = IM_COL32(180, 140, 40, 200); - drawList->AddRectFilled(barMin, barMax, bgML, 2.0f); - drawList->AddRectFilled(barMin, barMax, fgML, 2.0f); - drawList->AddRect(barMin, barMax, IM_COL32(100, 80, 20, 220), 2.0f); - const char* mlLabel = "Max Level"; - ImVec2 mlSz = ImGui::CalcTextSize(mlLabel); - drawList->AddText( - ImVec2(barMin.x + (barSize.x - mlSz.x) * 0.5f, - barMin.y + (barSize.y - mlSz.y) * 0.5f), - IM_COL32(255, 230, 120, 255), mlLabel); - ImGui::Dummy(barSize); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Level %u — Maximum level reached", playerLevel); - } else { - float pct = static_cast(currentXp) / static_cast(nextLevelXp); - if (pct > 1.0f) pct = 1.0f; - - // Custom segmented XP bar (20 bubbles) - ImU32 bg = IM_COL32(15, 15, 20, 220); - ImU32 fg = IM_COL32(148, 51, 238, 255); - ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion - ImU32 seg = IM_COL32(35, 35, 45, 255); - drawList->AddRectFilled(barMin, barMax, bg, 2.0f); - drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); - - float fillW = barSize.x * pct; - if (fillW > 0.0f) { - drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f); - } - - // Rested XP overlay: draw from current XP fill to (currentXp + restedXp) fill - if (restedXp > 0) { - float restedEndPct = std::min(1.0f, static_cast(currentXp + restedXp) - / static_cast(nextLevelXp)); - float restedStartX = barMin.x + fillW; - float restedEndX = barMin.x + barSize.x * restedEndPct; - if (restedEndX > restedStartX) { - drawList->AddRectFilled(ImVec2(restedStartX, barMin.y), - ImVec2(restedEndX, barMax.y), - fgRest, 2.0f); - } - } - - const int segments = 20; - float segW = barSize.x / static_cast(segments); - for (int i = 1; i < segments; ++i) { - float x = barMin.x + segW * i; - drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f); - } - - // Rest indicator "zzz" to the right of the bar when resting - if (isResting) { - const char* zzz = "zzz"; - ImVec2 zSize = ImGui::CalcTextSize(zzz); - float zx = barMax.x - zSize.x - 4.0f; - float zy = barMin.y + (barSize.y - zSize.y) * 0.5f; - drawList->AddText(ImVec2(zx, zy), IM_COL32(180, 150, 255, 220), zzz); - } - - char overlay[96]; - if (restedXp > 0) { - snprintf(overlay, sizeof(overlay), "%u / %u XP (+%u rested)", currentXp, nextLevelXp, restedXp); - } else { - snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); - } - ImVec2 textSize = ImGui::CalcTextSize(overlay); - float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; - float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; - drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay); - - ImGui::Dummy(barSize); - - // Tooltip with XP-to-level and rested details - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - uint32_t xpToLevel = (currentXp < nextLevelXp) ? (nextLevelXp - currentXp) : 0; - ImGui::TextColored(ImVec4(0.9f, 0.85f, 1.0f, 1.0f), "Experience"); - ImGui::Separator(); - float xpPct = nextLevelXp > 0 ? (100.0f * currentXp / nextLevelXp) : 0.0f; - ImGui::Text("Current: %u / %u XP (%.1f%%)", currentXp, nextLevelXp, xpPct); - ImGui::Text("To next level: %u XP", xpToLevel); - if (restedXp > 0) { - float restedLevels = static_cast(restedXp) / static_cast(nextLevelXp); - ImGui::Spacing(); - ImGui::TextColored(ImVec4(0.78f, 0.60f, 1.0f, 1.0f), - "Rested: +%u XP (%.1f%% of a level)", restedXp, restedLevels * 100.0f); - if (isResting) - ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), - "Resting — accumulating bonus XP"); - } - ImGui::EndTooltip(); - } - } - } - ImGui::End(); - - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(2); -} // ============================================================ // Reputation Bar // ============================================================ -void GameScreen::renderRepBar(game::GameHandler& gameHandler) { - uint32_t factionId = gameHandler.getWatchedFactionId(); - if (factionId == 0) return; - - const auto& standings = gameHandler.getFactionStandings(); - auto it = standings.find(factionId); - if (it == standings.end()) return; - - int32_t standing = it->second; - - // WoW reputation rank thresholds - struct RepRank { const char* name; int32_t min; int32_t max; ImU32 color; }; - static const RepRank kRanks[] = { - { "Hated", -42000, -6001, IM_COL32(180, 40, 40, 255) }, - { "Hostile", -6000, -3001, IM_COL32(180, 40, 40, 255) }, - { "Unfriendly", -3000, -1, IM_COL32(220, 100, 50, 255) }, - { "Neutral", 0, 2999, IM_COL32(200, 200, 60, 255) }, - { "Friendly", 3000, 8999, IM_COL32( 60, 180, 60, 255) }, - { "Honored", 9000, 20999, IM_COL32( 60, 160, 220, 255) }, - { "Revered", 21000, 41999, IM_COL32(140, 80, 220, 255) }, - { "Exalted", 42000, 42999, IM_COL32(255, 200, 50, 255) }, - }; - constexpr int kNumRanks = static_cast(sizeof(kRanks) / sizeof(kRanks[0])); - - int rankIdx = kNumRanks - 1; // default to Exalted - for (int i = 0; i < kNumRanks; ++i) { - if (standing <= kRanks[i].max) { rankIdx = i; break; } - } - const RepRank& rank = kRanks[rankIdx]; - - float fraction = 1.0f; - if (rankIdx < kNumRanks - 1) { - float range = static_cast(rank.max - rank.min + 1); - fraction = static_cast(standing - rank.min) / range; - fraction = std::max(0.0f, std::min(1.0f, fraction)); - } - - const std::string& factionName = gameHandler.getFactionNamePublic(factionId); - - // Position directly above the XP bar - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - float slotSize = 48.0f * settingsPanel_.pendingActionBarScale; - float spacing = 4.0f; - float padding = 8.0f; - float barW = 12 * slotSize + 11 * spacing + padding * 2; - float barH_ab = slotSize + 24.0f; - float xpBarH = 20.0f; - float repBarH = 12.0f; - float xpBarW = barW; - float xpBarX = (screenW - xpBarW) / 2.0f; - - float bar1TopY = screenH - barH_ab; - float xpBarY; - if (settingsPanel_.pendingShowActionBar2) { - float bar2TopY = bar1TopY - barH_ab - 2.0f + settingsPanel_.pendingActionBar2OffsetY; - xpBarY = bar2TopY - xpBarH - 2.0f; - } else { - xpBarY = bar1TopY - xpBarH - 2.0f; - } - float repBarY = xpBarY - repBarH - 2.0f; - - ImGui::SetNextWindowPos(ImVec2(xpBarX, repBarY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(xpBarW, repBarH + 4.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); - - if (ImGui::Begin("##RepBar", nullptr, flags)) { - ImVec2 barMin = ImGui::GetCursorScreenPos(); - ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, repBarH - 4.0f); - ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); - auto* dl = ImGui::GetWindowDrawList(); - - dl->AddRectFilled(barMin, barMax, IM_COL32(15, 15, 20, 220), 2.0f); - dl->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); - - float fillW = barSize.x * fraction; - if (fillW > 0.0f) - dl->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), rank.color, 2.0f); - - // Label: "FactionName - Rank" - char label[96]; - snprintf(label, sizeof(label), "%s - %s", factionName.c_str(), rank.name); - ImVec2 textSize = ImGui::CalcTextSize(label); - float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; - float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; - dl->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), label); - - // Tooltip with exact values on hover - ImGui::Dummy(barSize); - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - float cr = ((rank.color ) & 0xFF) / 255.0f; - float cg = ((rank.color >> 8) & 0xFF) / 255.0f; - float cb = ((rank.color >> 16) & 0xFF) / 255.0f; - ImGui::TextColored(ImVec4(cr, cg, cb, 1.0f), "%s", rank.name); - int32_t rankMin = rank.min; - int32_t rankMax = (rankIdx < kNumRanks - 1) ? rank.max : 42000; - ImGui::Text("%s: %d / %d", factionName.c_str(), standing - rankMin, rankMax - rankMin + 1); - ImGui::EndTooltip(); - } - } - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(2); -} // ============================================================ // Cast Bar (Phase 3) // ============================================================ -void GameScreen::renderCastBar(game::GameHandler& gameHandler) { - if (!gameHandler.isCasting()) return; - - auto* assetMgr = core::Application::getInstance().getAssetManager(); - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); - VkDescriptorSet iconTex = (currentSpellId != 0 && assetMgr) - ? getSpellIcon(currentSpellId, assetMgr) : VK_NULL_HANDLE; - - float barW = 300.0f; - float barX = (screenW - barW) / 2.0f; - float barY = screenH - 120.0f; - - ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(barW, 40), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings | - ImGuiWindowFlags_NoFocusOnAppearing; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f)); - - if (ImGui::Begin("##CastBar", nullptr, flags)) { - const bool channeling = gameHandler.isChanneling(); - // Channels drain right-to-left; regular casts fill left-to-right - float progress = channeling - ? (1.0f - gameHandler.getCastProgress()) - : gameHandler.getCastProgress(); - - // Color by spell school for cast identification; channels always blue - ImVec4 barColor; - if (channeling) { - barColor = ImVec4(0.3f, 0.6f, 0.9f, 1.0f); // blue for channels - } else { - uint32_t school = (currentSpellId != 0) ? gameHandler.getSpellSchoolMask(currentSpellId) : 0; - if (school & 0x04) barColor = ImVec4(0.95f, 0.40f, 0.10f, 1.0f); // Fire: orange-red - else if (school & 0x10) barColor = ImVec4(0.30f, 0.65f, 0.95f, 1.0f); // Frost: icy blue - else if (school & 0x20) barColor = ImVec4(0.55f, 0.15f, 0.70f, 1.0f); // Shadow: purple - else if (school & 0x40) barColor = ImVec4(0.65f, 0.30f, 0.85f, 1.0f); // Arcane: violet - else if (school & 0x08) barColor = ImVec4(0.20f, 0.75f, 0.25f, 1.0f); // Nature: green - else if (school & 0x02) barColor = ImVec4(0.90f, 0.80f, 0.30f, 1.0f); // Holy: golden - else barColor = ImVec4(0.80f, 0.60f, 0.20f, 1.0f); // Physical/default: gold - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); - - char overlay[96]; - if (currentSpellId == 0) { - snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining()); - } else { - const std::string& spellName = gameHandler.getSpellName(currentSpellId); - const char* verb = channeling ? "Channeling" : "Casting"; - int queueLeft = gameHandler.getCraftQueueRemaining(); - if (!spellName.empty()) { - if (queueLeft > 0) - snprintf(overlay, sizeof(overlay), "%s (%.1fs) [%d left]", spellName.c_str(), gameHandler.getCastTimeRemaining(), queueLeft); - else - snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); - } else { - snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); - } - } - - // Queued spell icon (right edge): the next spell queued to fire within 400ms. - uint32_t queuedId = gameHandler.getQueuedSpellId(); - VkDescriptorSet queuedTex = (queuedId != 0 && assetMgr) - ? getSpellIcon(queuedId, assetMgr) : VK_NULL_HANDLE; - - const float iconSz = 20.0f; - const float reservedRight = (queuedTex) ? (iconSz + 4.0f) : 0.0f; - - if (iconTex) { - // Spell icon to the left of the progress bar - ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(iconSz, iconSz)); - ImGui::SameLine(0, 4); - ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); - } else { - ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); - } - // Draw queued-spell icon on the right with a ">" arrow prefix tooltip. - if (queuedTex) { - ImGui::SameLine(0, 4); - ImGui::Image((ImTextureID)(uintptr_t)queuedTex, ImVec2(iconSz, iconSz), - ImVec2(0,0), ImVec2(1,1), - ImVec4(1,1,1,0.8f), ImVec4(0,0,0,0)); // slightly dimmed - if (ImGui::IsItemHovered()) { - const std::string& qn = gameHandler.getSpellName(queuedId); - ImGui::SetTooltip("Queued: %s", qn.empty() ? "Unknown" : qn.c_str()); - } - } - ImGui::PopStyleColor(); - } - ImGui::End(); - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); -} - // ============================================================ // Mirror Timers (breath / fatigue / feign death) // ============================================================ @@ -6171,94 +4431,6 @@ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { // Cooldown Tracker — floating panel showing all active spell CDs // ============================================================ -void GameScreen::renderCooldownTracker(game::GameHandler& gameHandler) { - if (!settingsPanel_.showCooldownTracker_) return; - - const auto& cooldowns = gameHandler.getSpellCooldowns(); - if (cooldowns.empty()) return; - - // Collect spells with remaining cooldown > 0.5s (skip GCD noise) - struct CDEntry { uint32_t spellId; float remaining; }; - std::vector active; - active.reserve(16); - for (const auto& [sid, rem] : cooldowns) { - if (rem > 0.5f) active.push_back({sid, rem}); - } - if (active.empty()) return; - - // Sort: longest remaining first - std::sort(active.begin(), active.end(), [](const CDEntry& a, const CDEntry& b) { - return a.remaining > b.remaining; - }); - - auto* assetMgr = core::Application::getInstance().getAssetManager(); - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - constexpr float TRACKER_W = 200.0f; - constexpr int MAX_SHOWN = 12; - float posX = screenW - TRACKER_W - 10.0f; - float posY = screenH - 220.0f; // above the action bar area - - ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); - ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0.0f), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.75f); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoBringToFrontOnFocus; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); - - if (ImGui::Begin("##CooldownTracker", nullptr, flags)) { - ImGui::TextDisabled("Cooldowns"); - ImGui::Separator(); - - int shown = 0; - for (const auto& cd : active) { - if (shown >= MAX_SHOWN) break; - - const std::string& name = gameHandler.getSpellName(cd.spellId); - if (name.empty()) continue; // skip unnamed spells (internal/passive) - - // Small icon if available - VkDescriptorSet icon = assetMgr ? getSpellIcon(cd.spellId, assetMgr) : VK_NULL_HANDLE; - if (icon) { - ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(14, 14)); - ImGui::SameLine(0, 3); - } - - // Name (truncated) + remaining time - char timeStr[16]; - if (cd.remaining >= 60.0f) - snprintf(timeStr, sizeof(timeStr), "%dm%ds", static_cast(cd.remaining) / 60, static_cast(cd.remaining) % 60); - else - snprintf(timeStr, sizeof(timeStr), "%.0fs", cd.remaining); - - // Color: red > 30s, orange > 10s, yellow > 5s, green otherwise - ImVec4 cdColor = cd.remaining > 30.0f ? kColorRed : - cd.remaining > 10.0f ? ImVec4(1.0f, 0.6f, 0.2f, 1.0f) : - cd.remaining > 5.0f ? kColorYellow : - colors::kActiveGreen; - - // Truncate name to fit - std::string displayName = name; - if (displayName.size() > 16) displayName = displayName.substr(0, 15) + "\xe2\x80\xa6"; // ellipsis - - ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", displayName.c_str()); - ImGui::SameLine(TRACKER_W - 48.0f); - ImGui::TextColored(cdColor, "%s", timeStr); - - ++shown; - } - } - ImGui::End(); - ImGui::PopStyleVar(3); -} - // ============================================================ // Quest Objective Tracker (right-side HUD) // ============================================================ @@ -6487,592 +4659,14 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { // Raid Warning / Boss Emote Center-Screen Overlay // ============================================================ -void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) { - // Scan chat history for new RAID_WARNING / RAID_BOSS_EMOTE messages - const auto& chatHistory = gameHandler.getChatHistory(); - size_t newCount = chatHistory.size(); - if (newCount > raidWarnChatSeenCount_) { - // Walk only the new messages (deque — iterate from back by skipping old ones) - size_t toScan = newCount - raidWarnChatSeenCount_; - size_t startIdx = newCount > toScan ? newCount - toScan : 0; - auto* renderer = core::Application::getInstance().getRenderer(); - for (size_t i = startIdx; i < newCount; ++i) { - const auto& msg = chatHistory[i]; - if (msg.type == game::ChatType::RAID_WARNING || - msg.type == game::ChatType::RAID_BOSS_EMOTE || - msg.type == game::ChatType::MONSTER_EMOTE) { - bool isBoss = (msg.type != game::ChatType::RAID_WARNING); - // Limit display text length to avoid giant overlay - std::string text = msg.message; - if (text.size() > 200) text = text.substr(0, 200) + "..."; - raidWarnEntries_.push_back({text, 0.0f, isBoss}); - if (raidWarnEntries_.size() > 3) - raidWarnEntries_.erase(raidWarnEntries_.begin()); - } - // Whisper audio notification - if (msg.type == game::ChatType::WHISPER && renderer) { - if (auto* ui = renderer->getUiSoundManager()) - ui->playWhisperReceived(); - } - } - raidWarnChatSeenCount_ = newCount; - } - - // Age and remove expired entries - float dt = ImGui::GetIO().DeltaTime; - for (auto& e : raidWarnEntries_) e.age += dt; - raidWarnEntries_.erase( - std::remove_if(raidWarnEntries_.begin(), raidWarnEntries_.end(), - [](const RaidWarnEntry& e){ return e.age >= RaidWarnEntry::LIFETIME; }), - raidWarnEntries_.end()); - - if (raidWarnEntries_.empty()) return; - - ImGuiIO& io = ImGui::GetIO(); - float screenW = io.DisplaySize.x; - float screenH = io.DisplaySize.y; - ImDrawList* fg = ImGui::GetForegroundDrawList(); - - // Stack entries vertically near upper-center (below target frame area) - float baseY = screenH * 0.28f; - for (const auto& e : raidWarnEntries_) { - float alpha = std::clamp(1.0f - (e.age / RaidWarnEntry::LIFETIME), 0.0f, 1.0f); - // Fade in quickly, hold, then fade out last 20% - if (e.age < 0.3f) alpha = e.age / 0.3f; - - // Truncate to fit screen width reasonably - const char* txt = e.text.c_str(); - const float fontSize = 22.0f; - ImFont* font = ImGui::GetFont(); - - // Word-wrap manually: compute text size, center horizontally - float maxW = screenW * 0.7f; - ImVec2 textSz = font->CalcTextSizeA(fontSize, maxW, maxW, txt); - float tx = (screenW - textSz.x) * 0.5f; - - ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 200)); - ImU32 mainCol; - if (e.isBossEmote) { - mainCol = IM_COL32(255, 185, 60, static_cast(alpha * 255)); // amber - } else { - // Raid warning: alternating red/yellow flash during first second - float flashT = std::fmod(e.age * 4.0f, 1.0f); - if (flashT < 0.5f) - mainCol = IM_COL32(255, 50, 50, static_cast(alpha * 255)); - else - mainCol = IM_COL32(255, 220, 50, static_cast(alpha * 255)); - } - - // Background dim box for readability - float pad = 8.0f; - fg->AddRectFilled(ImVec2(tx - pad, baseY - pad), - ImVec2(tx + textSz.x + pad, baseY + textSz.y + pad), - IM_COL32(0, 0, 0, static_cast(alpha * 120)), 4.0f); - - // Shadow + main text - fg->AddText(font, fontSize, ImVec2(tx + 2.0f, baseY + 2.0f), shadowCol, txt, - nullptr, maxW); - fg->AddText(font, fontSize, ImVec2(tx, baseY), mainCol, txt, - nullptr, maxW); - - baseY += textSz.y + 6.0f; - } -} - // ============================================================ // Floating Combat Text (Phase 2) // ============================================================ -void GameScreen::renderCombatText(game::GameHandler& gameHandler) { - const auto& entries = gameHandler.getCombatText(); - if (entries.empty()) return; - - auto* window = core::Application::getInstance().getWindow(); - if (!window) return; - const float screenW = static_cast(window->getWidth()); - const float screenH = static_cast(window->getHeight()); - - // Camera for world-space projection - auto* appRenderer = core::Application::getInstance().getRenderer(); - rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr; - glm::mat4 viewProj; - if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - - ImDrawList* drawList = ImGui::GetForegroundDrawList(); - ImFont* font = ImGui::GetFont(); - const float baseFontSize = ImGui::GetFontSize(); - - // HUD fallback: entries without world-space anchor use classic screen-position layout. - // We still need an ImGui window for those. - const float hudIncomingX = screenW * 0.40f; - const float hudOutgoingX = screenW * 0.68f; - int hudInIdx = 0, hudOutIdx = 0; - bool needsHudWindow = false; - - for (const auto& entry : entries) { - const float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); - const bool outgoing = entry.isPlayerSource; - - // --- Format text and color (identical logic for both world and HUD paths) --- - ImVec4 color; - char text[128]; - switch (entry.type) { - case game::CombatTextEntry::MELEE_DAMAGE: - case game::CombatTextEntry::SPELL_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 1.0f, 0.3f, alpha) : - ImVec4(1.0f, 0.3f, 0.3f, alpha); - break; - case game::CombatTextEntry::CRIT_DAMAGE: - snprintf(text, sizeof(text), "-%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.8f, 0.0f, alpha) : - ImVec4(1.0f, 0.5f, 0.0f, alpha); - break; - case game::CombatTextEntry::HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::CRIT_HEAL: - snprintf(text, sizeof(text), "+%d!", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::MISS: - snprintf(text, sizeof(text), "Miss"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); - break; - case game::CombatTextEntry::DODGE: - snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PARRY: - snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::BLOCK: - if (entry.amount > 0) - snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); - else - snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::EVADE: - snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PERIODIC_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.9f, 0.3f, alpha) : - ImVec4(1.0f, 0.4f, 0.4f, alpha); - break; - case game::CombatTextEntry::PERIODIC_HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.4f, 1.0f, 0.5f, alpha); - break; - case game::CombatTextEntry::ENVIRONMENTAL: { - const char* envLabel = ""; - switch (entry.powerType) { - case 0: envLabel = "Fatigue "; break; - case 1: envLabel = "Drowning "; break; - case 2: envLabel = ""; break; - case 3: envLabel = "Lava "; break; - case 4: envLabel = "Slime "; break; - case 5: envLabel = "Fire "; break; - default: envLabel = ""; break; - } - snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); - color = ImVec4(0.9f, 0.5f, 0.2f, alpha); - break; - } - case game::CombatTextEntry::ENERGIZE: - snprintf(text, sizeof(text), "+%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; - case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; - case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; - case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; - default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; - } - break; - case game::CombatTextEntry::POWER_DRAIN: - snprintf(text, sizeof(text), "-%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; - case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; - case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; - case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; - default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; - } - break; - case game::CombatTextEntry::XP_GAIN: - snprintf(text, sizeof(text), "+%d XP", entry.amount); - color = ImVec4(0.7f, 0.3f, 1.0f, alpha); - break; - case game::CombatTextEntry::IMMUNE: - snprintf(text, sizeof(text), "Immune!"); - color = ImVec4(0.9f, 0.9f, 0.9f, alpha); - break; - case game::CombatTextEntry::ABSORB: - if (entry.amount > 0) - snprintf(text, sizeof(text), "Absorbed %d", entry.amount); - else - snprintf(text, sizeof(text), "Absorbed"); - color = ImVec4(0.5f, 0.8f, 1.0f, alpha); - break; - case game::CombatTextEntry::RESIST: - if (entry.amount > 0) - snprintf(text, sizeof(text), "Resisted %d", entry.amount); - else - snprintf(text, sizeof(text), "Resisted"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); - break; - case game::CombatTextEntry::DEFLECT: - snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); - color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) - : ImVec4(0.5f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::REFLECT: { - const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!reflectName.empty()) - snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); - else - snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); - color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) - : ImVec4(0.75f, 0.85f, 1.0f, alpha); - break; - } - case game::CombatTextEntry::PROC_TRIGGER: { - const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!procName.empty()) - snprintf(text, sizeof(text), "%s!", procName.c_str()); - else - snprintf(text, sizeof(text), "PROC!"); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); - break; - } - case game::CombatTextEntry::DISPEL: - if (entry.spellId != 0) { - const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); - if (!dispelledName.empty()) - snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); - else - snprintf(text, sizeof(text), "Dispel"); - } else { - snprintf(text, sizeof(text), "Dispel"); - } - color = ImVec4(0.6f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::STEAL: - if (entry.spellId != 0) { - const std::string& stolenName = gameHandler.getSpellName(entry.spellId); - if (!stolenName.empty()) - snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); - else - snprintf(text, sizeof(text), "Spellsteal"); - } else { - snprintf(text, sizeof(text), "Spellsteal"); - } - color = ImVec4(0.8f, 0.7f, 1.0f, alpha); - break; - case game::CombatTextEntry::INTERRUPT: { - const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!interruptedName.empty()) - snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); - else - snprintf(text, sizeof(text), "Interrupt"); - color = ImVec4(1.0f, 0.6f, 0.9f, alpha); - break; - } - case game::CombatTextEntry::INSTAKILL: - snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); - color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) - : ImVec4(1.0f, 0.1f, 0.1f, alpha); - break; - case game::CombatTextEntry::HONOR_GAIN: - snprintf(text, sizeof(text), "+%d Honor", entry.amount); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); - break; - case game::CombatTextEntry::GLANCING: - snprintf(text, sizeof(text), "~%d", entry.amount); - color = outgoing ? - ImVec4(0.75f, 0.75f, 0.5f, alpha) : - ImVec4(0.75f, 0.35f, 0.35f, alpha); - break; - case game::CombatTextEntry::CRUSHING: - snprintf(text, sizeof(text), "%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.55f, 0.1f, alpha) : - ImVec4(1.0f, 0.15f, 0.15f, alpha); - break; - default: - snprintf(text, sizeof(text), "%d", entry.amount); - color = ImVec4(1.0f, 1.0f, 1.0f, alpha); - break; - } - - // --- Rendering style --- - bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || - entry.type == game::CombatTextEntry::CRIT_HEAL); - float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; - - ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); - ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); - - // --- Try world-space anchor if we have a destination entity --- - // Types that should always stay as HUD elements (no world anchor) - bool isHudOnly = (entry.type == game::CombatTextEntry::XP_GAIN || - entry.type == game::CombatTextEntry::HONOR_GAIN || - entry.type == game::CombatTextEntry::PROC_TRIGGER); - - bool rendered = false; - if (!isHudOnly && camera && entry.dstGuid != 0) { - // Look up the destination entity's render position - glm::vec3 renderPos; - bool havePos = core::Application::getInstance().getRenderPositionForGuid(entry.dstGuid, renderPos); - if (!havePos) { - // Fallback to entity canonical position - auto entity = gameHandler.getEntityManager().getEntity(entry.dstGuid); - if (entity) { - auto* unit = entity->isUnit() ? static_cast(entity.get()) : nullptr; - if (unit) { - renderPos = core::coords::canonicalToRender( - glm::vec3(unit->getX(), unit->getY(), unit->getZ())); - havePos = true; - } - } - } - - if (havePos) { - // Float upward from above the entity's head - renderPos.z += 2.5f + entry.age * 1.2f; - - // Project to screen - glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); - if (clipPos.w > 0.01f) { - glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; - if (ndc.x >= -1.5f && ndc.x <= 1.5f && ndc.y >= -1.5f && ndc.y <= 1.5f) { - float sx = (ndc.x * 0.5f + 0.5f) * screenW; - float sy = (ndc.y * 0.5f + 0.5f) * screenH; - - // Horizontal stagger using the random seed - sx += entry.xSeed * 40.0f; - - // Center the text horizontally on the projected point - ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); - sx -= ts.x * 0.5f; - - // Clamp to screen bounds - sx = std::max(2.0f, std::min(sx, screenW - ts.x - 2.0f)); - - drawList->AddText(font, renderFontSize, - ImVec2(sx + 1.0f, sy + 1.0f), shadowCol, text); - drawList->AddText(font, renderFontSize, - ImVec2(sx, sy), textCol, text); - rendered = true; - } - } - } - } - - // --- HUD fallback for entries without world anchor or HUD-only types --- - if (!rendered) { - if (!needsHudWindow) { - needsHudWindow = true; - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, 400)); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; - ImGui::Begin("##CombatText", nullptr, flags); - } - - float yOffset = 200.0f - entry.age * 60.0f; - int& idx = outgoing ? hudOutIdx : hudInIdx; - float baseX = outgoing ? hudOutgoingX : hudIncomingX; - float xOffset = baseX + (idx % 3 - 1) * 60.0f; - ++idx; - - ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); - ImVec2 screenPos = ImGui::GetCursorScreenPos(); - - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), - shadowCol, text); - dl->AddText(font, renderFontSize, screenPos, textCol, text); - - ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); - ImGui::Dummy(ts); - } - } - - if (needsHudWindow) { - ImGui::End(); - } -} - // ============================================================ // DPS / HPS Meter // ============================================================ -void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { - if (!settingsPanel_.showDPSMeter_) return; - if (gameHandler.getState() != game::WorldState::IN_WORLD) return; - - const float dt = ImGui::GetIO().DeltaTime; - - // Track combat duration for accurate DPS denominator in short fights - bool inCombat = gameHandler.isInCombat(); - if (inCombat && !dpsWasInCombat_) { - // Just entered combat — reset encounter accumulators - dpsEncounterDamage_ = 0.0f; - dpsEncounterHeal_ = 0.0f; - dpsLogSeenCount_ = gameHandler.getCombatLog().size(); - dpsCombatAge_ = 0.0f; - } - if (inCombat) { - dpsCombatAge_ += dt; - // Scan any new log entries since last frame - const auto& log = gameHandler.getCombatLog(); - while (dpsLogSeenCount_ < log.size()) { - const auto& e = log[dpsLogSeenCount_++]; - if (!e.isPlayerSource) continue; - switch (e.type) { - case game::CombatTextEntry::MELEE_DAMAGE: - case game::CombatTextEntry::SPELL_DAMAGE: - case game::CombatTextEntry::CRIT_DAMAGE: - case game::CombatTextEntry::PERIODIC_DAMAGE: - case game::CombatTextEntry::GLANCING: - case game::CombatTextEntry::CRUSHING: - dpsEncounterDamage_ += static_cast(e.amount); - break; - case game::CombatTextEntry::HEAL: - case game::CombatTextEntry::CRIT_HEAL: - case game::CombatTextEntry::PERIODIC_HEAL: - dpsEncounterHeal_ += static_cast(e.amount); - break; - default: break; - } - } - } else if (dpsWasInCombat_) { - // Just left combat — keep encounter totals but stop accumulating - } - dpsWasInCombat_ = inCombat; - - // Sum all player-source damage and healing in the current combat-text window - float totalDamage = 0.0f, totalHeal = 0.0f; - for (const auto& e : gameHandler.getCombatText()) { - if (!e.isPlayerSource) continue; - switch (e.type) { - case game::CombatTextEntry::MELEE_DAMAGE: - case game::CombatTextEntry::SPELL_DAMAGE: - case game::CombatTextEntry::CRIT_DAMAGE: - case game::CombatTextEntry::PERIODIC_DAMAGE: - case game::CombatTextEntry::GLANCING: - case game::CombatTextEntry::CRUSHING: - totalDamage += static_cast(e.amount); - break; - case game::CombatTextEntry::HEAL: - case game::CombatTextEntry::CRIT_HEAL: - case game::CombatTextEntry::PERIODIC_HEAL: - totalHeal += static_cast(e.amount); - break; - default: break; - } - } - - // Only show if there's something to report (rolling window or lingering encounter data) - if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat && - dpsEncounterDamage_ < 1.0f && dpsEncounterHeal_ < 1.0f) return; - - // DPS window = min(combat age, combat-text lifetime) to avoid under-counting - // at the start of a fight and over-counting when entries expire. - float window = std::min(dpsCombatAge_, game::CombatTextEntry::LIFETIME); - if (window < 0.1f) window = 0.1f; - - float dps = totalDamage / window; - float hps = totalHeal / window; - - // Format numbers with K/M suffix for readability - auto fmtNum = [](float v, char* buf, int bufSz) { - if (v >= 1e6f) snprintf(buf, bufSz, "%.1fM", v / 1e6f); - else if (v >= 1000.f) snprintf(buf, bufSz, "%.1fK", v / 1000.f); - else snprintf(buf, bufSz, "%.0f", v); - }; - - char dpsBuf[16], hpsBuf[16]; - fmtNum(dps, dpsBuf, sizeof(dpsBuf)); - fmtNum(hps, hpsBuf, sizeof(hpsBuf)); - - // Position: small floating label just above the action bar, right of center - auto* appWin = core::Application::getInstance().getWindow(); - float screenW = appWin ? static_cast(appWin->getWidth()) : 1280.0f; - float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f; - - // Show encounter row when fight has been going long enough (> 3s) - bool showEnc = (dpsCombatAge_ > 3.0f || (!inCombat && dpsEncounterDamage_ > 0.0f)); - float encDPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterDamage_ / dpsCombatAge_ : 0.0f; - float encHPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterHeal_ / dpsCombatAge_ : 0.0f; - - char encDpsBuf[16], encHpsBuf[16]; - fmtNum(encDPS, encDpsBuf, sizeof(encDpsBuf)); - fmtNum(encHPS, encHpsBuf, sizeof(encHpsBuf)); - - constexpr float WIN_W = 90.0f; - // Extra rows for encounter DPS/HPS if active - int extraRows = 0; - if (showEnc && encDPS > 0.5f) ++extraRows; - if (showEnc && encHPS > 0.5f) ++extraRows; - float WIN_H = 18.0f + extraRows * 14.0f; - if (dps > 0.5f || hps > 0.5f) WIN_H = std::max(WIN_H, 36.0f); - float wx = screenW * 0.5f + 160.0f; // right of cast bar - float wy = screenH - 130.0f; // above action bar area - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoInputs; - ImGui::SetNextWindowPos(ImVec2(wx, wy), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(WIN_W, WIN_H), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.55f); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 3)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.7f)); - - if (ImGui::Begin("##DPSMeter", nullptr, flags)) { - if (dps > 0.5f) { - ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "%s", dpsBuf); - ImGui::SameLine(0, 2); - ImGui::TextDisabled("dps"); - } - if (hps > 0.5f) { - ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), "%s", hpsBuf); - ImGui::SameLine(0, 2); - ImGui::TextDisabled("hps"); - } - // Encounter totals (full-fight average, shown when fight > 3s) - if (showEnc && encDPS > 0.5f) { - ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 0.80f), "%s", encDpsBuf); - ImGui::SameLine(0, 2); - ImGui::TextDisabled("enc"); - } - if (showEnc && encHPS > 0.5f) { - ImGui::TextColored(ImVec4(0.50f, 1.0f, 0.50f, 0.80f), "%s", encHpsBuf); - ImGui::SameLine(0, 2); - ImGui::TextDisabled("enc"); - } - } - ImGui::End(); - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(2); -} - // ============================================================ // Nameplates — world-space health bars projected to screen // ============================================================ @@ -7664,7 +5258,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(nameplateCtxGuid_); gameHandler.inspectTarget(); - showInspectWindow_ = true; + socialPanel_.showInspectWindow_ = true; } ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) @@ -7685,677 +5279,6 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Party Frames (Phase 4) // ============================================================ -void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { - if (!gameHandler.isInGroup()) return; - - auto* assetMgr = core::Application::getInstance().getAssetManager(); - const auto& partyData = gameHandler.getPartyData(); - const bool isRaid = (partyData.groupType == 1); - float frameY = 120.0f; - - // ---- Raid frame layout ---- - if (isRaid) { - // Organize members by subgroup (0-7, up to 5 members each) - constexpr int MAX_SUBGROUPS = 8; - constexpr int MAX_PER_GROUP = 5; - std::vector subgroups[MAX_SUBGROUPS]; - for (const auto& m : partyData.members) { - int sg = m.subGroup < MAX_SUBGROUPS ? m.subGroup : 0; - if (static_cast(subgroups[sg].size()) < MAX_PER_GROUP) - subgroups[sg].push_back(&m); - } - - // Count non-empty subgroups to determine layout - int activeSgs = 0; - for (int sg = 0; sg < MAX_SUBGROUPS; sg++) - if (!subgroups[sg].empty()) activeSgs++; - - // Compact raid cell: name + 2 narrow bars - constexpr float CELL_W = 90.0f; - constexpr float CELL_H = 42.0f; - constexpr float BAR_H = 7.0f; - constexpr float CELL_PAD = 3.0f; - - float winW = activeSgs * (CELL_W + CELL_PAD) + CELL_PAD + 8.0f; - float winH = MAX_PER_GROUP * (CELL_H + CELL_PAD) + CELL_PAD + 20.0f; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - float raidX = (screenW - winW) / 2.0f; - float raidY = screenH - winH - 120.0f; // above action bar area - - ImGui::SetNextWindowPos(ImVec2(raidX, raidY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Always); - - ImGuiWindowFlags raidFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar; - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(CELL_PAD, CELL_PAD)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.07f, 0.07f, 0.1f, 0.85f)); - - if (ImGui::Begin("##RaidFrames", nullptr, raidFlags)) { - ImDrawList* draw = ImGui::GetWindowDrawList(); - ImVec2 winPos = ImGui::GetWindowPos(); - - int colIdx = 0; - for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { - if (subgroups[sg].empty()) continue; - - float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); - - for (int row = 0; row < static_cast(subgroups[sg].size()); row++) { - const auto& m = *subgroups[sg][row]; - float cellY = winPos.y + CELL_PAD + 14.0f + row * (CELL_H + CELL_PAD); - - ImVec2 cellMin(colX, cellY); - ImVec2 cellMax(colX + CELL_W, cellY + CELL_H); - - // Cell background - bool isTarget = (gameHandler.getTargetGuid() == m.guid); - ImU32 bg = isTarget ? IM_COL32(60, 80, 120, 200) : IM_COL32(30, 30, 40, 180); - draw->AddRectFilled(cellMin, cellMax, bg, 3.0f); - if (isTarget) - draw->AddRect(cellMin, cellMax, IM_COL32(100, 150, 255, 200), 3.0f); - - // Dead/ghost overlay - bool isOnline = (m.onlineStatus & 0x0001) != 0; - bool isDead = (m.onlineStatus & 0x0020) != 0; - bool isGhost = (m.onlineStatus & 0x0010) != 0; - - // Out-of-range check (40 yard threshold) - bool isOOR = false; - if (m.hasPartyStats && isOnline && !isDead && !isGhost && m.zoneId != 0) { - auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); - if (playerEnt) { - float dx = playerEnt->getX() - static_cast(m.posX); - float dy = playerEnt->getY() - static_cast(m.posY); - isOOR = (dx * dx + dy * dy) > (40.0f * 40.0f); - } - } - // Dim cell overlay when out of range - if (isOOR) - draw->AddRectFilled(cellMin, cellMax, IM_COL32(0, 0, 0, 80), 3.0f); - - // Name text (truncated) — class color when alive+online, gray when dead/offline - char truncName[16]; - snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); - bool isMemberLeader = (m.guid == partyData.leaderGuid); - ImU32 nameCol; - if (!isOnline || isDead || isGhost) { - nameCol = IM_COL32(140, 140, 140, 200); // gray for dead/offline - } else { - // Default: gold for leader, light gray for others - nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : IM_COL32(220, 220, 220, 255); - // Override with WoW class color if entity is loaded - auto mEnt = gameHandler.getEntityManager().getEntity(m.guid); - uint8_t cid = entityClassId(mEnt.get()); - if (cid != 0) nameCol = classColorU32(cid); - } - draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); - - // Leader crown star in top-right of cell - if (isMemberLeader) - draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*"); - - // Raid mark symbol — small, just to the left of the leader crown - { - static constexpr struct { const char* sym; ImU32 col; } kCellMarks[] = { - { "\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 rmk = gameHandler.getEntityRaidMark(m.guid); - if (rmk < game::GameHandler::kRaidMarkCount) { - ImFont* rmFont = ImGui::GetFont(); - ImVec2 rmsz = rmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kCellMarks[rmk].sym); - float rmX = cellMax.x - 10.0f - 2.0f - rmsz.x; - draw->AddText(rmFont, 9.0f, - ImVec2(rmX, cellMin.y + 2.0f), - kCellMarks[rmk].col, kCellMarks[rmk].sym); - } - } - - // LFG role badge in bottom-right corner of cell - if (m.roles & 0x02) - draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T"); - else if (m.roles & 0x04) - draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(60, 220, 80, 230), "H"); - else if (m.roles & 0x08) - draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D"); - - // Tactical role badge in bottom-left corner (flags from SMSG_GROUP_LIST / SMSG_REAL_GROUP_UPDATE) - // 0x01=Assistant, 0x02=Main Tank, 0x04=Main Assist - if (m.flags & 0x02) - draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(255, 140, 0, 230), "MT"); - else if (m.flags & 0x04) - draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(100, 180, 255, 230), "MA"); - else if (m.flags & 0x01) - draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(180, 215, 255, 180), "A"); - - // Health bar - uint32_t hp = m.hasPartyStats ? m.curHealth : 0; - uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; - if (maxHp > 0) { - float pct = static_cast(hp) / static_cast(maxHp); - float barY = cellMin.y + 16.0f; - ImVec2 barBg(cellMin.x + 3.0f, barY); - ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H); - draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f); - ImVec2 barFill(barBg.x, barBg.y); - ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); - ImU32 hpCol = isOOR ? IM_COL32(100, 100, 100, 160) : - pct > 0.5f ? IM_COL32(60, 180, 60, 255) : - pct > 0.2f ? IM_COL32(200, 180, 50, 255) : - IM_COL32(200, 60, 60, 255); - draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); - // HP percentage or OOR text centered on bar - char hpPct[8]; - if (isOOR) - snprintf(hpPct, sizeof(hpPct), "OOR"); - else - snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(pct * 100.0f + 0.5f)); - ImVec2 ts = ImGui::CalcTextSize(hpPct); - float tx = (barBg.x + barBgEnd.x - ts.x) * 0.5f; - float ty = barBg.y + (BAR_H - ts.y) * 0.5f; - draw->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), hpPct); - draw->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 230), hpPct); - } - - // Power bar - if (m.hasPartyStats && m.maxPower > 0) { - float pct = static_cast(m.curPower) / static_cast(m.maxPower); - float barY = cellMin.y + 16.0f + BAR_H + 2.0f; - ImVec2 barBg(cellMin.x + 3.0f, barY); - ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H - 2.0f); - draw->AddRectFilled(barBg, barBgEnd, IM_COL32(30, 30, 40, 200), 2.0f); - ImVec2 barFill(barBg.x, barBg.y); - ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); - ImU32 pwrCol; - switch (m.powerType) { - case 0: pwrCol = IM_COL32(50, 80, 220, 255); break; // Mana - case 1: pwrCol = IM_COL32(200, 50, 50, 255); break; // Rage - case 3: pwrCol = IM_COL32(220, 210, 50, 255); break; // Energy - case 6: pwrCol = IM_COL32(180, 30, 50, 255); break; // Runic Power - default: pwrCol = IM_COL32(80, 120, 80, 255); break; - } - draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f); - } - - // Dispellable debuff dots at the bottom of the raid cell - // Mirrors party frame debuff indicators for healers in 25/40-man raids - if (!isDead && !isGhost) { - const std::vector* unitAuras = nullptr; - if (m.guid == gameHandler.getPlayerGuid()) - unitAuras = &gameHandler.getPlayerAuras(); - else if (m.guid == gameHandler.getTargetGuid()) - unitAuras = &gameHandler.getTargetAuras(); - else - unitAuras = gameHandler.getUnitAuras(m.guid); - - if (unitAuras) { - bool shown[5] = {}; - float dotX = cellMin.x + 4.0f; - const float dotY = cellMax.y - 5.0f; - const float DOT_R = 3.5f; - ImVec2 mouse = ImGui::GetMousePos(); - for (const auto& aura : *unitAuras) { - if (aura.isEmpty()) continue; - if ((aura.flags & 0x80) == 0) continue; // debuffs only - uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); - if (dt == 0 || dt > 4 || shown[dt]) continue; - shown[dt] = true; - ImVec4 dc; - switch (dt) { - case 1: dc = ImVec4(0.25f, 0.50f, 1.00f, 0.90f); break; // Magic: blue - case 2: dc = ImVec4(0.70f, 0.15f, 0.90f, 0.90f); break; // Curse: purple - case 3: dc = ImVec4(0.65f, 0.45f, 0.10f, 0.90f); break; // Disease: brown - case 4: dc = ImVec4(0.10f, 0.75f, 0.10f, 0.90f); break; // Poison: green - default: continue; - } - ImU32 dotColU = ImGui::ColorConvertFloat4ToU32(dc); - draw->AddCircleFilled(ImVec2(dotX, dotY), DOT_R, dotColU); - draw->AddCircle(ImVec2(dotX, dotY), DOT_R + 0.5f, IM_COL32(0, 0, 0, 160), 8, 1.0f); - - float mdx = mouse.x - dotX, mdy = mouse.y - dotY; - if (mdx * mdx + mdy * mdy < (DOT_R + 4.0f) * (DOT_R + 4.0f)) { - ImGui::BeginTooltip(); - ImGui::TextColored(dc, "%s", kDispelNames[dt]); - for (const auto& da : *unitAuras) { - if (da.isEmpty() || (da.flags & 0x80) == 0) continue; - if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; - const std::string& dName = gameHandler.getSpellName(da.spellId); - if (!dName.empty()) - ImGui::Text(" %s", dName.c_str()); - } - ImGui::EndTooltip(); - } - dotX += 9.0f; - } - } - } - - // Clickable invisible region over the whole cell - ImGui::SetCursorScreenPos(cellMin); - ImGui::PushID(static_cast(m.guid)); - if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { - gameHandler.setTarget(m.guid); - } - if (ImGui::IsItemHovered()) { - gameHandler.setMouseoverGuid(m.guid); - } - if (ImGui::BeginPopupContextItem("RaidMemberCtx")) { - ImGui::TextDisabled("%s", m.name.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Target")) - gameHandler.setTarget(m.guid); - if (ImGui::MenuItem("Set Focus")) - gameHandler.setFocus(m.guid); - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(m.name); - } - if (ImGui::MenuItem("Trade")) - gameHandler.initiateTrade(m.guid); - if (ImGui::MenuItem("Inspect")) { - gameHandler.setTarget(m.guid); - gameHandler.inspectTarget(); - showInspectWindow_ = true; - } - bool isLeader = (partyData.leaderGuid == gameHandler.getPlayerGuid()); - if (isLeader) { - ImGui::Separator(); - if (ImGui::MenuItem("Kick from Raid")) - gameHandler.uninvitePlayer(m.name); - } - ImGui::Separator(); - if (ImGui::BeginMenu("Set Raid Mark")) { - for (int mi = 0; mi < 8; ++mi) { - if (ImGui::MenuItem(kRaidMarkNames[mi])) - gameHandler.setRaidMark(m.guid, static_cast(mi)); - } - ImGui::Separator(); - if (ImGui::MenuItem("Clear Mark")) - gameHandler.setRaidMark(m.guid, 0xFF); - ImGui::EndMenu(); - } - ImGui::EndPopup(); - } - ImGui::PopID(); - } - colIdx++; - } - - // Subgroup header row - colIdx = 0; - for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { - if (subgroups[sg].empty()) continue; - float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); - char sgLabel[8]; - snprintf(sgLabel, sizeof(sgLabel), "G%d", sg + 1); - draw->AddText(ImVec2(colX + CELL_W / 2 - 8.0f, winPos.y + CELL_PAD), IM_COL32(160, 160, 180, 200), sgLabel); - colIdx++; - } - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(2); - return; - } - - // ---- Party frame layout (5-man) ---- - ImGui::SetNextWindowPos(ImVec2(10.0f, frameY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_AlwaysAutoResize; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f)); - - if (ImGui::Begin("##PartyFrames", nullptr, flags)) { - const uint64_t leaderGuid = partyData.leaderGuid; - for (const auto& member : partyData.members) { - ImGui::PushID(static_cast(member.guid)); - - bool isLeader = (member.guid == leaderGuid); - - // Name with level and status info — leader gets a gold star prefix - std::string label = (isLeader ? "* " : " ") + member.name; - if (member.hasPartyStats && member.level > 0) { - label += " [" + std::to_string(member.level) + "]"; - } - if (member.hasPartyStats) { - bool isOnline = (member.onlineStatus & 0x0001) != 0; - bool isDead = (member.onlineStatus & 0x0020) != 0; - bool isGhost = (member.onlineStatus & 0x0010) != 0; - if (!isOnline) label += " (offline)"; - else if (isDead || isGhost) label += " (dead)"; - } - - // Clickable name to target — use WoW class colors when entity is loaded, - // fall back to gold for leader / light gray for others - ImVec4 nameColor = isLeader - ? colors::kBrightGold - : colors::kVeryLightGray; - { - auto memberEntity = gameHandler.getEntityManager().getEntity(member.guid); - uint8_t cid = entityClassId(memberEntity.get()); - if (cid != 0) nameColor = classColorVec4(cid); - } - ImGui::PushStyleColor(ImGuiCol_Text, nameColor); - if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { - gameHandler.setTarget(member.guid); - } - // Set mouseover for [target=mouseover] macro conditionals - if (ImGui::IsItemHovered()) { - gameHandler.setMouseoverGuid(member.guid); - } - // Zone tooltip on name hover - if (ImGui::IsItemHovered() && member.hasPartyStats && member.zoneId != 0) { - std::string zoneName = gameHandler.getWhoAreaName(member.zoneId); - if (!zoneName.empty()) - ImGui::SetTooltip("%s", zoneName.c_str()); - } - ImGui::PopStyleColor(); - - // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set - if (member.roles != 0) { - ImGui::SameLine(); - if (member.roles & 0x02) ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "[T]"); - if (member.roles & 0x04) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.3f, 1.0f), "[H]"); } - if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } - } - - // Tactical role badge (MT/MA/Asst) from group flags - if (member.flags & 0x02) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.0f, 0.9f), "[MT]"); - } else if (member.flags & 0x04) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 0.9f), "[MA]"); - } else if (member.flags & 0x01) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 0.7f), "[A]"); - } - - // Raid mark symbol — shown on same line as name when this party member has a mark - { - static constexpr struct { const char* sym; ImU32 col; } kPartyMarks[] = { - { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star - { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle - { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond - { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle - { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon - { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square - { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross - { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull - }; - uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); - if (pmk < game::GameHandler::kRaidMarkCount) { - ImGui::SameLine(); - ImGui::TextColored( - ImGui::ColorConvertU32ToFloat4(kPartyMarks[pmk].col), - "%s", kPartyMarks[pmk].sym); - } - } - - // Health bar: prefer party stats, fall back to entity - uint32_t hp = 0, maxHp = 0; - if (member.hasPartyStats && member.maxHealth > 0) { - hp = member.curHealth; - maxHp = member.maxHealth; - } else { - auto entity = gameHandler.getEntityManager().getEntity(member.guid); - if (entity && (entity->getType() == game::ObjectType::PLAYER || entity->getType() == game::ObjectType::UNIT)) { - auto unit = std::static_pointer_cast(entity); - hp = unit->getHealth(); - maxHp = unit->getMaxHealth(); - } - } - // Check dead/ghost state for health bar rendering - bool memberDead = false; - bool memberOffline = false; - if (member.hasPartyStats) { - bool isOnline2 = (member.onlineStatus & 0x0001) != 0; - bool isDead2 = (member.onlineStatus & 0x0020) != 0; - bool isGhost2 = (member.onlineStatus & 0x0010) != 0; - memberDead = isDead2 || isGhost2; - memberOffline = !isOnline2; - } - - // Out-of-range check: compare player position to member's reported position - // Range threshold: 40 yards (standard heal/spell range) - bool memberOutOfRange = false; - if (member.hasPartyStats && !memberOffline && !memberDead && - member.zoneId != 0) { - // Same map: use 2D Euclidean distance in WoW coordinates (yards) - auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); - if (playerEntity) { - float dx = playerEntity->getX() - static_cast(member.posX); - float dy = playerEntity->getY() - static_cast(member.posY); - float distSq = dx * dx + dy * dy; - memberOutOfRange = (distSq > 40.0f * 40.0f); - } - } - - if (memberDead) { - // Gray "Dead" bar for fallen party members - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.35f, 0.35f, 0.35f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f)); - ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Dead"); - ImGui::PopStyleColor(2); - } else if (memberOffline) { - // Dim bar for offline members - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.25f, 0.25f, 0.25f, 0.6f)); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.1f, 0.1f, 0.1f, 0.6f)); - ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Offline"); - ImGui::PopStyleColor(2); - } else if (maxHp > 0) { - float pct = static_cast(hp) / static_cast(maxHp); - // Out-of-range: desaturate health bar to gray - ImVec4 hpBarColor = memberOutOfRange - ? ImVec4(0.45f, 0.45f, 0.45f, 0.7f) - : (pct > 0.5f ? colors::kHealthGreen : - pct > 0.2f ? colors::kMidHealthYellow : - colors::kLowHealthRed); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpBarColor); - char hpText[32]; - if (memberOutOfRange) { - snprintf(hpText, sizeof(hpText), "OOR"); - } else if (maxHp >= 10000) { - snprintf(hpText, sizeof(hpText), "%dk/%dk", - static_cast(hp) / 1000, static_cast(maxHp) / 1000); - } else { - snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); - } - ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); - ImGui::PopStyleColor(); - } - - // Power bar (mana/rage/energy) from party stats — hidden for dead/offline/OOR - if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) { - float powerPct = static_cast(member.curPower) / static_cast(member.maxPower); - ImVec4 powerColor; - switch (member.powerType) { - case 0: powerColor = colors::kManaBlue; break; // Mana (blue) - 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 = kColorDarkGray; break; - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); - ImGui::ProgressBar(powerPct, ImVec2(-1, 8), ""); - ImGui::PopStyleColor(); - } - - // Dispellable debuff indicators — small colored dots for party member debuffs - // Only show magic/curse/disease/poison (types 1-4); skip non-dispellable - if (!memberDead && !memberOffline) { - const std::vector* unitAuras = nullptr; - if (member.guid == gameHandler.getPlayerGuid()) - unitAuras = &gameHandler.getPlayerAuras(); - else if (member.guid == gameHandler.getTargetGuid()) - unitAuras = &gameHandler.getTargetAuras(); - else - unitAuras = gameHandler.getUnitAuras(member.guid); - - if (unitAuras) { - bool anyDebuff = false; - for (const auto& aura : *unitAuras) { - if (aura.isEmpty()) continue; - if ((aura.flags & 0x80) == 0) continue; // only debuffs - uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); - if (dt == 0) continue; // skip non-dispellable - anyDebuff = true; - break; - } - if (anyDebuff) { - // Render one dot per unique dispel type present - bool shown[5] = {}; - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 1.0f)); - for (const auto& aura : *unitAuras) { - if (aura.isEmpty()) continue; - if ((aura.flags & 0x80) == 0) continue; - uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); - if (dt == 0 || dt > 4 || shown[dt]) continue; - shown[dt] = true; - ImVec4 dotCol; - switch (dt) { - case 1: dotCol = ImVec4(0.25f, 0.50f, 1.00f, 1.0f); break; // Magic: blue - case 2: dotCol = ImVec4(0.70f, 0.15f, 0.90f, 1.0f); break; // Curse: purple - case 3: dotCol = ImVec4(0.65f, 0.45f, 0.10f, 1.0f); break; // Disease: brown - case 4: dotCol = ImVec4(0.10f, 0.75f, 0.10f, 1.0f); break; // Poison: green - default: break; - } - ImGui::PushStyleColor(ImGuiCol_Button, dotCol); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); - ImGui::Button("##d", ImVec2(8.0f, 8.0f)); - ImGui::PopStyleColor(2); - if (ImGui::IsItemHovered()) { - // Find spell name(s) of this dispel type - ImGui::BeginTooltip(); - ImGui::TextColored(dotCol, "%s", kDispelNames[dt]); - for (const auto& da : *unitAuras) { - if (da.isEmpty() || (da.flags & 0x80) == 0) continue; - if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; - const std::string& dName = gameHandler.getSpellName(da.spellId); - if (!dName.empty()) - ImGui::Text(" %s", dName.c_str()); - } - ImGui::EndTooltip(); - } - ImGui::SameLine(); - } - ImGui::NewLine(); - ImGui::PopStyleVar(); - } - } - } - - // Party member cast bar — shows when the party member is casting - if (auto* cs = gameHandler.getUnitCastState(member.guid)) { - float castPct = (cs->timeTotal > 0.0f) - ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, colors::kMidHealthYellow); - char pcastLabel[48]; - const std::string& spellNm = gameHandler.getSpellName(cs->spellId); - if (!spellNm.empty()) - snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining); - else - snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - { - VkDescriptorSet pIcon = (cs->spellId != 0 && assetMgr) - ? getSpellIcon(cs->spellId, assetMgr) : VK_NULL_HANDLE; - if (pIcon) { - ImGui::Image((ImTextureID)(uintptr_t)pIcon, ImVec2(10, 10)); - ImGui::SameLine(0, 2); - ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); - } else { - ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); - } - } - ImGui::PopStyleColor(); - } - - // Right-click context menu for party member actions - if (ImGui::BeginPopupContextItem("PartyMemberCtx")) { - ImGui::TextDisabled("%s", member.name.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Target")) { - gameHandler.setTarget(member.guid); - } - if (ImGui::MenuItem("Set Focus")) { - gameHandler.setFocus(member.guid); - } - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(member.name); - } - if (ImGui::MenuItem("Follow")) { - gameHandler.setTarget(member.guid); - gameHandler.followTarget(); - } - if (ImGui::MenuItem("Trade")) { - gameHandler.initiateTrade(member.guid); - } - if (ImGui::MenuItem("Duel")) { - gameHandler.proposeDuel(member.guid); - } - if (ImGui::MenuItem("Inspect")) { - gameHandler.setTarget(member.guid); - gameHandler.inspectTarget(); - showInspectWindow_ = true; - } - ImGui::Separator(); - if (!member.name.empty()) { - if (ImGui::MenuItem("Add Friend")) { - gameHandler.addFriend(member.name); - } - if (ImGui::MenuItem("Ignore")) { - gameHandler.addIgnore(member.name); - } - } - // Leader-only actions - bool isLeader = (gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()); - if (isLeader) { - ImGui::Separator(); - if (ImGui::MenuItem("Kick from Group")) { - gameHandler.uninvitePlayer(member.name); - } - } - ImGui::Separator(); - if (ImGui::BeginMenu("Set Raid Mark")) { - for (int mi = 0; mi < 8; ++mi) { - if (ImGui::MenuItem(kRaidMarkNames[mi])) - gameHandler.setRaidMark(member.guid, static_cast(mi)); - } - ImGui::Separator(); - if (ImGui::MenuItem("Clear Mark")) - gameHandler.setRaidMark(member.guid, 0xFF); - ImGui::EndMenu(); - } - ImGui::EndPopup(); - } - - ImGui::Separator(); - ImGui::PopID(); - } - } - ImGui::End(); - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); -} - // ============================================================ // Durability Warning (equipment damage indicator) // ============================================================ @@ -8523,3204 +5446,54 @@ void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaT // Boss Encounter Frames // ============================================================ -void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { - auto* assetMgr = core::Application::getInstance().getAssetManager(); - - // Collect active boss unit slots - struct BossSlot { uint32_t slot; uint64_t guid; }; - std::vector active; - for (uint32_t s = 0; s < game::GameHandler::kMaxEncounterSlots; ++s) { - uint64_t g = gameHandler.getEncounterUnitGuid(s); - if (g != 0) active.push_back({s, g}); - } - if (active.empty()) return; - - const float frameW = 200.0f; - const float startX = ImGui::GetIO().DisplaySize.x - frameW - 10.0f; - float frameY = 120.0f; - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_AlwaysAutoResize; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.05f, 0.05f, 0.85f)); - - ImGui::SetNextWindowPos(ImVec2(startX, frameY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); - - if (ImGui::Begin("##BossFrames", nullptr, flags)) { - for (const auto& bs : active) { - ImGui::PushID(static_cast(bs.guid)); - - // Try to resolve name, health, and power from entity manager - std::string name = "Boss"; - uint32_t hp = 0, maxHp = 0; - uint8_t bossPowerType = 0; - uint32_t bossPower = 0, bossMaxPower = 0; - auto entity = gameHandler.getEntityManager().getEntity(bs.guid); - if (entity && (entity->getType() == game::ObjectType::UNIT || - entity->getType() == game::ObjectType::PLAYER)) { - auto unit = std::static_pointer_cast(entity); - const auto& n = unit->getName(); - if (!n.empty()) name = n; - hp = unit->getHealth(); - maxHp = unit->getMaxHealth(); - bossPowerType = unit->getPowerType(); - bossPower = unit->getPower(); - bossMaxPower = unit->getMaxPower(); - } - - // Clickable name to target - if (ImGui::Selectable(name.c_str(), gameHandler.getTargetGuid() == bs.guid)) { - gameHandler.setTarget(bs.guid); - } - - if (maxHp > 0) { - float pct = static_cast(hp) / static_cast(maxHp); - // Boss health bar in red shades - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - pct > 0.5f ? colors::kLowHealthRed : - pct > 0.2f ? ImVec4(0.9f, 0.5f, 0.1f, 1.0f) : - ImVec4(1.0f, 0.8f, 0.1f, 1.0f)); - char label[32]; - std::snprintf(label, sizeof(label), "%u / %u", hp, maxHp); - ImGui::ProgressBar(pct, ImVec2(-1, 14), label); - ImGui::PopStyleColor(); - } - - // Boss power bar — shown when boss has a non-zero power pool - // Energy bosses (type 3) are particularly important: full energy signals ability use - if (bossMaxPower > 0 && bossPower > 0) { - float bpPct = static_cast(bossPower) / static_cast(bossMaxPower); - ImVec4 bpColor; - switch (bossPowerType) { - case 0: bpColor = ImVec4(0.2f, 0.3f, 0.9f, 1.0f); break; // Mana: blue - case 1: bpColor = colors::kDarkRed; break; // Rage: red - case 2: bpColor = colors::kOrange; break; // Focus: orange - case 3: bpColor = ImVec4(0.9f, 0.9f, 0.1f, 1.0f); break; // Energy: yellow - default: bpColor = ImVec4(0.4f, 0.8f, 0.4f, 1.0f); break; - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bpColor); - char bpLabel[24]; - std::snprintf(bpLabel, sizeof(bpLabel), "%u", bossPower); - ImGui::ProgressBar(bpPct, ImVec2(-1, 6), bpLabel); - ImGui::PopStyleColor(); - } - - // Boss cast bar — shown when the boss is casting (critical for interrupt) - if (auto* cs = gameHandler.getUnitCastState(bs.guid)) { - float castPct = (cs->timeTotal > 0.0f) - ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; - uint32_t bspell = cs->spellId; - const std::string& bcastName = (bspell != 0) - ? gameHandler.getSpellName(bspell) : ""; - // Green = interruptible, Red = immune; pulse when > 80% complete - ImVec4 bcastColor; - if (castPct > 0.8f) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - bcastColor = cs->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 { - bcastColor = cs->interruptible - ? colors::kCastGreen - : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor); - char bcastLabel[72]; - if (!bcastName.empty()) - snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)", - bcastName.c_str(), cs->timeRemaining); - else - snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - { - VkDescriptorSet bIcon = (bspell != 0 && assetMgr) - ? getSpellIcon(bspell, assetMgr) : VK_NULL_HANDLE; - if (bIcon) { - ImGui::Image((ImTextureID)(uintptr_t)bIcon, ImVec2(12, 12)); - ImGui::SameLine(0, 2); - ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); - } else { - ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); - } - } - ImGui::PopStyleColor(); - } - - // Boss aura row: debuffs first (player DoTs), then boss buffs - { - const std::vector* bossAuras = nullptr; - if (bs.guid == gameHandler.getTargetGuid()) - bossAuras = &gameHandler.getTargetAuras(); - else - bossAuras = gameHandler.getUnitAuras(bs.guid); - - if (bossAuras) { - int bossActive = 0; - for (const auto& a : *bossAuras) if (!a.isEmpty()) bossActive++; - if (bossActive > 0) { - constexpr float BA_ICON = 16.0f; - constexpr int BA_PER_ROW = 10; - - uint64_t baNowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - - // Sort: player-applied debuffs first (most relevant), then others - const uint64_t pguid = gameHandler.getPlayerGuid(); - std::vector baIdx; - baIdx.reserve(bossAuras->size()); - for (size_t i = 0; i < bossAuras->size(); ++i) - if (!(*bossAuras)[i].isEmpty()) baIdx.push_back(i); - std::sort(baIdx.begin(), baIdx.end(), [&](size_t a, size_t b) { - const auto& aa = (*bossAuras)[a]; - const auto& ab = (*bossAuras)[b]; - bool aPlayerDot = (aa.flags & 0x80) != 0 && aa.casterGuid == pguid; - bool bPlayerDot = (ab.flags & 0x80) != 0 && ab.casterGuid == pguid; - if (aPlayerDot != bPlayerDot) return aPlayerDot > bPlayerDot; - bool aDebuff = (aa.flags & 0x80) != 0; - bool bDebuff = (ab.flags & 0x80) != 0; - if (aDebuff != bDebuff) return aDebuff > bDebuff; - int32_t ra = aa.getRemainingMs(baNowMs); - int32_t rb = ab.getRemainingMs(baNowMs); - 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 baShown = 0; - for (size_t si = 0; si < baIdx.size() && baShown < 20; ++si) { - const auto& aura = (*bossAuras)[baIdx[si]]; - bool isBuff = (aura.flags & 0x80) == 0; - bool isPlayerCast = (aura.casterGuid == pguid); - - if (baShown > 0 && baShown % BA_PER_ROW != 0) ImGui::SameLine(); - ImGui::PushID(static_cast(baIdx[si]) + 7000); - - ImVec4 borderCol; - if (isBuff) { - // Boss buffs: gold for important enrage/shield types - borderCol = ImVec4(0.8f, 0.6f, 0.1f, 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 = isPlayerCast - ? ImVec4(0.90f, 0.30f, 0.10f, 0.9f) // player DoT: orange-red - : ImVec4(0.60f, 0.20f, 0.20f, 0.9f); // other debuff: dark red - break; - } - } - - VkDescriptorSet baIcon = assetMgr - ? getSpellIcon(aura.spellId, assetMgr) : VK_NULL_HANDLE; - if (baIcon) { - ImGui::PushStyleColor(ImGuiCol_Button, borderCol); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); - ImGui::ImageButton("##baura", - (ImTextureID)(uintptr_t)baIcon, - ImVec2(BA_ICON - 2, BA_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(BA_ICON, BA_ICON)); - ImGui::PopStyleColor(); - } - - // Duration overlay - int32_t baRemain = aura.getRemainingMs(baNowMs); - if (baRemain > 0) { - ImVec2 imin = ImGui::GetItemRectMin(); - ImVec2 imax = ImGui::GetItemRectMax(); - char ts[12]; - fmtDurationCompact(ts, sizeof(ts), (baRemain + 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); - } - - // Stack / charge count — upper-left corner (parity with target/focus frames) - if (aura.charges > 1) { - ImVec2 baMin = ImGui::GetItemRectMin(); - char chargeStr[8]; - snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); - ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 2, baMin.y + 2), - IM_COL32(0, 0, 0, 200), chargeStr); - ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 1, baMin.y + 1), - IM_COL32(255, 220, 50, 255), chargeStr); - } - - // Tooltip - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip( - aura.spellId, gameHandler, assetMgr); - if (!richOk) { - std::string nm = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); - ImGui::Text("%s", nm.c_str()); - } - if (isPlayerCast && !isBuff) - ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "Your DoT"); - renderAuraRemaining(baRemain); - ImGui::EndTooltip(); - } - - ImGui::PopID(); - baShown++; - } - ImGui::PopStyleVar(); - } - } - } - - ImGui::PopID(); - ImGui::Spacing(); - } - } - ImGui::End(); - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); -} - -void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { - // Guild Roster toggle (customizable keybind) - if (!chatPanel_.isChatInputActive() && !ImGui::GetIO().WantTextInput && - !ImGui::GetIO().WantCaptureKeyboard && - KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { - showGuildRoster_ = !showGuildRoster_; - if (showGuildRoster_) { - // Open friends tab directly if not in guild - if (!gameHandler.isInGuild()) { - guildRosterTab_ = 2; // Friends tab - } else { - // Re-query guild name if we have guildId but no name yet - if (gameHandler.getGuildName().empty()) { - const auto* ch = gameHandler.getActiveCharacter(); - if (ch && ch->hasGuild()) { - gameHandler.queryGuildInfo(ch->guildId); - } - } - gameHandler.requestGuildRoster(); - gameHandler.requestGuildInfo(); - } - } - } - - // Petition creation dialog (shown when NPC sends SMSG_PETITION_SHOWLIST) - if (gameHandler.hasPetitionShowlist()) { - ImGui::OpenPopup("CreateGuildPetition"); - gameHandler.clearPetitionDialog(); - } - if (ImGui::BeginPopupModal("CreateGuildPetition", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("Create Guild Charter"); - ImGui::Separator(); - uint32_t cost = gameHandler.getPetitionCost(); - ImGui::TextDisabled("Cost:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(cost); - ImGui::Spacing(); - ImGui::Text("Guild Name:"); - ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_)); - ImGui::Spacing(); - if (ImGui::Button("Create", ImVec2(120, 0))) { - if (petitionNameBuffer_[0] != '\0') { - gameHandler.buyPetition(gameHandler.getPetitionNpcGuid(), petitionNameBuffer_); - petitionNameBuffer_[0] = '\0'; - ImGui::CloseCurrentPopup(); - } - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(120, 0))) { - petitionNameBuffer_[0] = '\0'; - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - - // Petition signatures window (shown when a petition item is used or offered) - if (gameHandler.hasPetitionSignaturesUI()) { - ImGui::OpenPopup("PetitionSignatures"); - gameHandler.clearPetitionSignaturesUI(); - } - if (ImGui::BeginPopupModal("PetitionSignatures", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - const auto& pInfo = gameHandler.getPetitionInfo(); - if (!pInfo.guildName.empty()) - ImGui::Text("Guild Charter: %s", pInfo.guildName.c_str()); - else - ImGui::Text("Guild Charter"); - ImGui::Separator(); - - ImGui::Text("Signatures: %u / %u", pInfo.signatureCount, pInfo.signaturesRequired); - ImGui::Spacing(); - - if (!pInfo.signatures.empty()) { - for (size_t i = 0; i < pInfo.signatures.size(); ++i) { - const auto& sig = pInfo.signatures[i]; - // Try to resolve name from entity manager - std::string sigName; - if (sig.playerGuid != 0) { - auto entity = gameHandler.getEntityManager().getEntity(sig.playerGuid); - if (entity) { - auto* unit = entity->isUnit() ? static_cast(entity.get()) : nullptr; - if (unit) sigName = unit->getName(); - } - } - if (sigName.empty()) - sigName = "Player " + std::to_string(i + 1); - ImGui::BulletText("%s", sigName.c_str()); - } - ImGui::Spacing(); - } - - // If we're not the owner, show Sign button - bool isOwner = (pInfo.ownerGuid == gameHandler.getPlayerGuid()); - if (!isOwner) { - if (ImGui::Button("Sign", ImVec2(120, 0))) { - gameHandler.signPetition(pInfo.petitionGuid); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - } else if (pInfo.signatureCount >= pInfo.signaturesRequired) { - // Owner with enough sigs — turn in - if (ImGui::Button("Turn In", ImVec2(120, 0))) { - gameHandler.turnInPetition(pInfo.petitionGuid); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - } - if (ImGui::Button("Close", ImVec2(120, 0))) - ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - } - - if (!showGuildRoster_) return; - - // Get zone manager for name lookup - game::ZoneManager* zoneManager = nullptr; - if (auto* renderer = core::Application::getInstance().getRenderer()) { - zoneManager = renderer->getZoneManager(); - } - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once); - ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once); - - std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Social") : "Social"; - bool open = showGuildRoster_; - if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { - // Tab bar: Roster | Guild Info - if (ImGui::BeginTabBar("GuildTabs")) { - if (ImGui::BeginTabItem("Roster")) { - guildRosterTab_ = 0; - if (!gameHandler.hasGuildRoster()) { - ImGui::Text("Loading roster..."); - } else { - const auto& roster = gameHandler.getGuildRoster(); - - // MOTD - if (!roster.motd.empty()) { - ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str()); - ImGui::Separator(); - } - - // Count online - int onlineCount = 0; - for (const auto& m : roster.members) { - if (m.online) ++onlineCount; - } - ImGui::Text("%d members (%d online)", static_cast(roster.members.size()), onlineCount); - ImGui::Separator(); - - const auto& rankNames = gameHandler.getGuildRankNames(); - - // Table - if (ImGui::BeginTable("GuildRoster", 7, - ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | - ImGuiTableFlags_Sortable)) { - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); - ImGui::TableSetupColumn("Rank"); - ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); - ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("Note"); - ImGui::TableSetupColumn("Officer Note"); - ImGui::TableHeadersRow(); - - // Online members first, then offline - auto sortedMembers = roster.members; - std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) { - if (a.online != b.online) return a.online > b.online; - return a.name < b.name; - }); - - for (const auto& m : sortedMembers) { - ImGui::TableNextRow(); - ImVec4 textColor = m.online ? ui::colors::kWhite - : kColorDarkGray; - ImVec4 nameColor = m.online ? classColorVec4(m.classId) : textColor; - - ImGui::TableNextColumn(); - ImGui::TextColored(nameColor, "%s", m.name.c_str()); - - // Right-click context menu - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - selectedGuildMember_ = m.name; - ImGui::OpenPopup("GuildMemberContext"); - } - - ImGui::TableNextColumn(); - // Show rank name instead of index - if (m.rankIndex < rankNames.size()) { - ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str()); - } else { - ImGui::TextColored(textColor, "Rank %u", m.rankIndex); - } - - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%u", m.level); - - ImGui::TableNextColumn(); - const char* className = classNameStr(m.classId); - ImVec4 classCol = m.online ? classColorVec4(m.classId) : textColor; - ImGui::TextColored(classCol, "%s", className); - - ImGui::TableNextColumn(); - // Zone name lookup - if (zoneManager) { - const auto* zoneInfo = zoneManager->getZoneInfo(m.zoneId); - if (zoneInfo && !zoneInfo->name.empty()) { - ImGui::TextColored(textColor, "%s", zoneInfo->name.c_str()); - } else { - ImGui::TextColored(textColor, "%u", m.zoneId); - } - } else { - ImGui::TextColored(textColor, "%u", m.zoneId); - } - - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); - - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.officerNote.c_str()); - } - ImGui::EndTable(); - } - - // Context menu popup - if (ImGui::BeginPopup("GuildMemberContext")) { - ImGui::TextDisabled("%s", selectedGuildMember_.c_str()); - ImGui::Separator(); - // Social actions — only for online members - bool memberOnline = false; - for (const auto& mem : roster.members) { - if (mem.name == selectedGuildMember_) { memberOnline = mem.online; break; } - } - if (memberOnline) { - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(selectedGuildMember_); - } - if (ImGui::MenuItem("Invite to Group")) { - gameHandler.inviteToGroup(selectedGuildMember_); - } - ImGui::Separator(); - } - if (!selectedGuildMember_.empty()) { - if (ImGui::MenuItem("Add Friend")) - gameHandler.addFriend(selectedGuildMember_); - if (ImGui::MenuItem("Ignore")) - gameHandler.addIgnore(selectedGuildMember_); - ImGui::Separator(); - } - if (ImGui::MenuItem("Promote")) { - gameHandler.promoteGuildMember(selectedGuildMember_); - } - if (ImGui::MenuItem("Demote")) { - gameHandler.demoteGuildMember(selectedGuildMember_); - } - if (ImGui::MenuItem("Kick")) { - gameHandler.kickGuildMember(selectedGuildMember_); - } - ImGui::Separator(); - if (ImGui::MenuItem("Set Public Note...")) { - showGuildNoteEdit_ = true; - editingOfficerNote_ = false; - guildNoteEditBuffer_[0] = '\0'; - // Pre-fill with existing note - for (const auto& mem : roster.members) { - if (mem.name == selectedGuildMember_) { - snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str()); - break; - } - } - } - if (ImGui::MenuItem("Set Officer Note...")) { - showGuildNoteEdit_ = true; - editingOfficerNote_ = true; - guildNoteEditBuffer_[0] = '\0'; - for (const auto& mem : roster.members) { - if (mem.name == selectedGuildMember_) { - snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str()); - break; - } - } - } - ImGui::Separator(); - if (ImGui::MenuItem("Set as Leader")) { - gameHandler.setGuildLeader(selectedGuildMember_); - } - ImGui::EndPopup(); - } - - // Note edit modal - if (showGuildNoteEdit_) { - ImGui::OpenPopup("EditGuildNote"); - showGuildNoteEdit_ = false; - } - if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("%s %s for %s:", - editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str()); - ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_)); - if (ImGui::Button("Save")) { - if (editingOfficerNote_) { - gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_); - } else { - gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_); - } - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel")) { - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - } - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Guild Info")) { - guildRosterTab_ = 1; - const auto& infoData = gameHandler.getGuildInfoData(); - const auto& queryData = gameHandler.getGuildQueryData(); - const auto& roster = gameHandler.getGuildRoster(); - const auto& rankNames = gameHandler.getGuildRankNames(); - - // Guild name (large, gold) - ImGui::PushFont(nullptr); // default font - ImGui::TextColored(ui::colors::kTooltipGold, "<%s>", gameHandler.getGuildName().c_str()); - ImGui::PopFont(); - ImGui::Separator(); - - // Creation date - if (infoData.isValid()) { - ImGui::Text("Created: %u/%u/%u", infoData.creationDay, infoData.creationMonth, infoData.creationYear); - ImGui::Text("Members: %u | Accounts: %u", infoData.numMembers, infoData.numAccounts); - } - ImGui::Spacing(); - - // Guild description / info text - if (!roster.guildInfo.empty()) { - ImGui::TextColored(colors::kSilver, "Description:"); - ImGui::TextWrapped("%s", roster.guildInfo.c_str()); - } - ImGui::Spacing(); - - // MOTD with edit button - ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD:"); - ImGui::SameLine(); - if (!roster.motd.empty()) { - ImGui::TextWrapped("%s", roster.motd.c_str()); - } else { - ImGui::TextColored(kColorDarkGray, "(not set)"); - } - if (ImGui::Button("Set MOTD")) { - showMotdEdit_ = true; - snprintf(guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_), "%s", roster.motd.c_str()); - } - ImGui::Spacing(); - - // MOTD edit modal - if (showMotdEdit_) { - ImGui::OpenPopup("EditMotd"); - showMotdEdit_ = false; - } - if (ImGui::BeginPopupModal("EditMotd", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("Set Message of the Day:"); - ImGui::InputText("##motdinput", guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_)); - if (ImGui::Button("Save", ImVec2(120, 0))) { - gameHandler.setGuildMotd(guildMotdEditBuffer_); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(120, 0))) { - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - - // Emblem info - if (queryData.isValid()) { - ImGui::Separator(); - ImGui::Text("Emblem: Style %u, Color %u | Border: Style %u, Color %u | BG: %u", - queryData.emblemStyle, queryData.emblemColor, - queryData.borderStyle, queryData.borderColor, queryData.backgroundColor); - } - - // Rank list - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "Ranks:"); - for (size_t i = 0; i < rankNames.size(); ++i) { - if (rankNames[i].empty()) continue; - // Show rank permission summary from roster data - if (i < roster.ranks.size()) { - uint32_t rights = roster.ranks[i].rights; - std::string perms; - if (rights & 0x01) perms += "Invite "; - if (rights & 0x02) perms += "Remove "; - if (rights & 0x40) perms += "Promote "; - if (rights & 0x80) perms += "Demote "; - if (rights & 0x04) perms += "OChat "; - if (rights & 0x10) perms += "MOTD "; - ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); - if (!perms.empty()) { - ImGui::SameLine(); - ImGui::TextColored(kColorDarkGray, "[%s]", perms.c_str()); - } - } else { - ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); - } - } - - // Rank management buttons - ImGui::Spacing(); - if (ImGui::Button("Add Rank")) { - showAddRankModal_ = true; - addRankNameBuffer_[0] = '\0'; - } - ImGui::SameLine(); - if (ImGui::Button("Delete Last Rank")) { - gameHandler.deleteGuildRank(); - } - - // Add rank modal - if (showAddRankModal_) { - ImGui::OpenPopup("AddGuildRank"); - showAddRankModal_ = false; - } - if (ImGui::BeginPopupModal("AddGuildRank", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("New Rank Name:"); - ImGui::InputText("##rankname", addRankNameBuffer_, sizeof(addRankNameBuffer_)); - if (ImGui::Button("Add", ImVec2(120, 0))) { - if (addRankNameBuffer_[0] != '\0') { - gameHandler.addGuildRank(addRankNameBuffer_); - ImGui::CloseCurrentPopup(); - } - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(120, 0))) { - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - - ImGui::EndTabItem(); - } - - // ---- Friends tab ---- - if (ImGui::BeginTabItem("Friends")) { - guildRosterTab_ = 2; - const auto& contacts = gameHandler.getContacts(); - - // Add Friend row - static char addFriendBuf[64] = {}; - ImGui::SetNextItemWidth(180.0f); - ImGui::InputText("##addfriend", addFriendBuf, sizeof(addFriendBuf)); - ImGui::SameLine(); - if (ImGui::Button("Add Friend") && addFriendBuf[0] != '\0') { - gameHandler.addFriend(addFriendBuf); - addFriendBuf[0] = '\0'; - } - ImGui::Separator(); - - // Note-edit state - static std::string friendNoteTarget; - static char friendNoteBuf[256] = {}; - static bool openNotePopup = false; - - // Filter to friends only - int friendCount = 0; - for (size_t ci = 0; ci < contacts.size(); ++ci) { - const auto& c = contacts[ci]; - if (!c.isFriend()) continue; - ++friendCount; - - ImGui::PushID(static_cast(ci)); - - // Status dot - ImU32 dotColor = c.isOnline() - ? IM_COL32(80, 200, 80, 255) - : IM_COL32(120, 120, 120, 255); - ImVec2 cursor = ImGui::GetCursorScreenPos(); - ImGui::GetWindowDrawList()->AddCircleFilled( - ImVec2(cursor.x + 6.0f, cursor.y + 8.0f), 5.0f, dotColor); - ImGui::Dummy(ImVec2(14.0f, 0.0f)); - ImGui::SameLine(); - - // Name as Selectable for right-click context menu - const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); - ImVec4 nameCol = c.isOnline() - ? ui::colors::kWhite - : colors::kInactiveGray; - ImGui::PushStyleColor(ImGuiCol_Text, nameCol); - ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap, ImVec2(130.0f, 0.0f)); - ImGui::PopStyleColor(); - - // Double-click to whisper - if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) - && !c.name.empty()) { - chatPanel_.setWhisperTarget(c.name); - } - - // Right-click context menu - if (ImGui::BeginPopupContextItem("FriendCtx")) { - ImGui::TextDisabled("%s", displayName); - ImGui::Separator(); - if (ImGui::MenuItem("Whisper") && !c.name.empty()) { - chatPanel_.setWhisperTarget(c.name); - } - if (c.isOnline() && ImGui::MenuItem("Invite to Group") && !c.name.empty()) { - gameHandler.inviteToGroup(c.name); - } - if (ImGui::MenuItem("Edit Note")) { - friendNoteTarget = c.name; - strncpy(friendNoteBuf, c.note.c_str(), sizeof(friendNoteBuf) - 1); - friendNoteBuf[sizeof(friendNoteBuf) - 1] = '\0'; - openNotePopup = true; - } - ImGui::Separator(); - if (ImGui::MenuItem("Remove Friend")) { - gameHandler.removeFriend(c.name); - } - ImGui::EndPopup(); - } - - // Note tooltip on hover - if (ImGui::IsItemHovered() && !c.note.empty()) { - ImGui::BeginTooltip(); - ImGui::TextDisabled("Note: %s", c.note.c_str()); - ImGui::EndTooltip(); - } - - // Level, class, and status - if (c.isOnline()) { - ImGui::SameLine(150.0f); - const char* statusLabel = - (c.status == 2) ? " (AFK)" : - (c.status == 3) ? " (DND)" : ""; - // Class color for the level/class display - ImVec4 friendClassCol = classColorVec4(static_cast(c.classId)); - const char* friendClassName = classNameStr(static_cast(c.classId)); - if (c.level > 0 && c.classId > 0) { - ImGui::TextColored(friendClassCol, "Lv%u %s%s", c.level, friendClassName, statusLabel); - } else if (c.level > 0) { - ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); - } else if (*statusLabel) { - ImGui::TextDisabled("%s", statusLabel + 1); - } - - // Tooltip: zone info - if (ImGui::IsItemHovered() && c.areaId != 0) { - ImGui::BeginTooltip(); - if (zoneManager) { - const auto* zi = zoneManager->getZoneInfo(c.areaId); - if (zi && !zi->name.empty()) - ImGui::Text("Zone: %s", zi->name.c_str()); - else - ImGui::TextDisabled("Area ID: %u", c.areaId); - } else { - ImGui::TextDisabled("Area ID: %u", c.areaId); - } - ImGui::EndTooltip(); - } - } - - ImGui::PopID(); - } - - if (friendCount == 0) { - ImGui::TextDisabled("No friends found."); - } - - // Note edit modal - if (openNotePopup) { - ImGui::OpenPopup("EditFriendNote"); - openNotePopup = false; - } - if (ImGui::BeginPopupModal("EditFriendNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("Note for %s:", friendNoteTarget.c_str()); - ImGui::SetNextItemWidth(240.0f); - ImGui::InputText("##fnote", friendNoteBuf, sizeof(friendNoteBuf)); - if (ImGui::Button("Save", ImVec2(110, 0))) { - gameHandler.setFriendNote(friendNoteTarget, friendNoteBuf); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(110, 0))) { - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - - ImGui::EndTabItem(); - } - - // ---- Ignore List tab ---- - if (ImGui::BeginTabItem("Ignore")) { - guildRosterTab_ = 3; - const auto& contacts = gameHandler.getContacts(); - - // Add Ignore row - static char addIgnoreBuf[64] = {}; - ImGui::SetNextItemWidth(180.0f); - ImGui::InputText("##addignore", addIgnoreBuf, sizeof(addIgnoreBuf)); - ImGui::SameLine(); - if (ImGui::Button("Ignore Player") && addIgnoreBuf[0] != '\0') { - gameHandler.addIgnore(addIgnoreBuf); - addIgnoreBuf[0] = '\0'; - } - ImGui::Separator(); - - int ignoreCount = 0; - for (size_t ci = 0; ci < contacts.size(); ++ci) { - const auto& c = contacts[ci]; - if (!c.isIgnored()) continue; - ++ignoreCount; - - ImGui::PushID(static_cast(ci) + 10000); - const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); - ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap); - if (ImGui::BeginPopupContextItem("IgnoreCtx")) { - ImGui::TextDisabled("%s", displayName); - ImGui::Separator(); - if (ImGui::MenuItem("Remove Ignore")) { - gameHandler.removeIgnore(c.name); - } - ImGui::EndPopup(); - } - ImGui::PopID(); - } - - if (ignoreCount == 0) { - ImGui::TextDisabled("Ignore list is empty."); - } - - ImGui::EndTabItem(); - } - - ImGui::EndTabBar(); - } - } - ImGui::End(); - showGuildRoster_ = open; -} - // ============================================================ -// Social Frame — compact online friends panel (toggled by showSocialFrame_) +// Social Frame — compact online friends panel (toggled by socialPanel_.showSocialFrame_) // ============================================================ -void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { - if (!showSocialFrame_) return; - - const auto& contacts = gameHandler.getContacts(); - // Count online friends for early-out - int onlineCount = 0; - for (const auto& c : contacts) - if (c.isFriend() && c.isOnline()) ++onlineCount; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW - 230.0f, 240.0f), ImGuiCond_Once); - ImGui::SetNextWindowSize(ImVec2(220.0f, 0.0f), ImGuiCond_Always); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f)); - - // State for "Set Note" inline editing - static int noteEditContactIdx = -1; - static char noteEditBuf[128] = {}; - - bool open = showSocialFrame_; - char socialTitle[32]; - snprintf(socialTitle, sizeof(socialTitle), "Social (%d online)##SocialFrame", onlineCount); - if (ImGui::Begin(socialTitle, &open, - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { - - // Get zone manager for area name lookups - game::ZoneManager* socialZoneMgr = nullptr; - if (auto* rend = core::Application::getInstance().getRenderer()) - socialZoneMgr = rend->getZoneManager(); - - if (ImGui::BeginTabBar("##SocialTabs")) { - // ---- Friends tab ---- - if (ImGui::BeginTabItem("Friends")) { - ImGui::BeginChild("##FriendsList", ImVec2(200, 200), false); - - // Online friends first - int shown = 0; - for (int pass = 0; pass < 2; ++pass) { - bool wantOnline = (pass == 0); - for (size_t ci = 0; ci < contacts.size(); ++ci) { - const auto& c = contacts[ci]; - if (!c.isFriend()) continue; - if (c.isOnline() != wantOnline) continue; - - ImGui::PushID(static_cast(ci)); - - // Status dot - ImU32 dotColor; - if (!c.isOnline()) dotColor = IM_COL32(100, 100, 100, 200); - else if (c.status == 2) dotColor = IM_COL32(255, 200, 50, 255); // AFK - else if (c.status == 3) dotColor = IM_COL32(255, 120, 50, 255); // DND - else dotColor = IM_COL32( 50, 220, 50, 255); // online - - ImVec2 dotMin = ImGui::GetCursorScreenPos(); - dotMin.y += 4.0f; - ImGui::GetWindowDrawList()->AddCircleFilled( - ImVec2(dotMin.x + 5.0f, dotMin.y + 5.0f), 4.5f, dotColor); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); - - const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); - ImVec4 nameCol = c.isOnline() - ? classColorVec4(static_cast(c.classId)) - : kColorDarkGray; - ImGui::TextColored(nameCol, "%s", displayName); - - if (c.isOnline() && c.level > 0) { - ImGui::SameLine(); - // Show level and class name in class color - ImGui::TextColored(classColorVec4(static_cast(c.classId)), - "Lv%u %s", c.level, classNameStr(static_cast(c.classId))); - } - - // Tooltip: zone info and note - if (ImGui::IsItemHovered() || (c.isOnline() && ImGui::IsItemHovered())) { - if (c.isOnline() && (c.areaId != 0 || !c.note.empty())) { - ImGui::BeginTooltip(); - if (c.areaId != 0) { - const char* zoneName = nullptr; - if (socialZoneMgr) { - const auto* zi = socialZoneMgr->getZoneInfo(c.areaId); - if (zi && !zi->name.empty()) zoneName = zi->name.c_str(); - } - if (zoneName) - ImGui::Text("Zone: %s", zoneName); - else - ImGui::Text("Area ID: %u", c.areaId); - } - if (!c.note.empty()) - ImGui::TextDisabled("Note: %s", c.note.c_str()); - ImGui::EndTooltip(); - } - } - - // Right-click context menu - if (ImGui::BeginPopupContextItem("FriendCtx")) { - ImGui::TextDisabled("%s", displayName); - ImGui::Separator(); - if (c.isOnline()) { - if (ImGui::MenuItem("Whisper")) { - showSocialFrame_ = false; - chatPanel_.setWhisperTarget(c.name); - } - if (ImGui::MenuItem("Invite to Group")) - gameHandler.inviteToGroup(c.name); - if (c.guid != 0 && ImGui::MenuItem("Trade")) - gameHandler.initiateTrade(c.guid); - } - if (ImGui::MenuItem("Set Note")) { - noteEditContactIdx = static_cast(ci); - strncpy(noteEditBuf, c.note.c_str(), sizeof(noteEditBuf) - 1); - noteEditBuf[sizeof(noteEditBuf) - 1] = '\0'; - ImGui::OpenPopup("##SetFriendNote"); - } - if (ImGui::MenuItem("Remove Friend")) - gameHandler.removeFriend(c.name); - ImGui::EndPopup(); - } - - ++shown; - ImGui::PopID(); - } - // Separator between online and offline if there are both - if (pass == 0 && shown > 0) { - ImGui::Separator(); - } - } - - if (shown == 0) { - ImGui::TextDisabled("No friends yet."); - } - - ImGui::EndChild(); - - // "Set Note" modal popup - if (ImGui::BeginPopup("##SetFriendNote")) { - const std::string& noteName = (noteEditContactIdx >= 0 && - noteEditContactIdx < static_cast(contacts.size())) - ? contacts[noteEditContactIdx].name : ""; - ImGui::TextDisabled("Note for %s:", noteName.c_str()); - ImGui::SetNextItemWidth(180.0f); - bool confirm = ImGui::InputText("##noteinput", noteEditBuf, sizeof(noteEditBuf), - ImGuiInputTextFlags_EnterReturnsTrue); - ImGui::SameLine(); - if (confirm || ImGui::Button("OK")) { - if (!noteName.empty()) - gameHandler.setFriendNote(noteName, noteEditBuf); - noteEditContactIdx = -1; - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel")) { - noteEditContactIdx = -1; - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - - ImGui::Separator(); - - // Add friend - static char addFriendBuf[64] = {}; - ImGui::SetNextItemWidth(140.0f); - ImGui::InputText("##sf_addfriend", addFriendBuf, sizeof(addFriendBuf)); - ImGui::SameLine(); - if (ImGui::Button("+##addfriend") && addFriendBuf[0] != '\0') { - gameHandler.addFriend(addFriendBuf); - addFriendBuf[0] = '\0'; - } - - ImGui::EndTabItem(); - } - - // ---- Ignore tab ---- - if (ImGui::BeginTabItem("Ignore")) { - const auto& ignores = gameHandler.getIgnoreCache(); - ImGui::BeginChild("##IgnoreList", ImVec2(200, 200), false); - - if (ignores.empty()) { - ImGui::TextDisabled("Ignore list is empty."); - } else { - for (const auto& kv : ignores) { - ImGui::PushID(kv.first.c_str()); - ImGui::TextUnformatted(kv.first.c_str()); - if (ImGui::BeginPopupContextItem("IgnoreCtx")) { - ImGui::TextDisabled("%s", kv.first.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Unignore")) - gameHandler.removeIgnore(kv.first); - ImGui::EndPopup(); - } - ImGui::PopID(); - } - } - - ImGui::EndChild(); - ImGui::Separator(); - - // Add ignore - static char addIgnBuf[64] = {}; - ImGui::SetNextItemWidth(140.0f); - ImGui::InputText("##sf_addignore", addIgnBuf, sizeof(addIgnBuf)); - ImGui::SameLine(); - if (ImGui::Button("+##addignore") && addIgnBuf[0] != '\0') { - gameHandler.addIgnore(addIgnBuf); - addIgnBuf[0] = '\0'; - } - - ImGui::EndTabItem(); - } - - // ---- Channels tab ---- - if (ImGui::BeginTabItem("Channels")) { - const auto& channels = gameHandler.getJoinedChannels(); - ImGui::BeginChild("##ChannelList", ImVec2(200, 200), false); - - if (channels.empty()) { - ImGui::TextDisabled("Not in any channels."); - } else { - for (size_t ci = 0; ci < channels.size(); ++ci) { - ImGui::PushID(static_cast(ci)); - ImGui::TextUnformatted(channels[ci].c_str()); - if (ImGui::BeginPopupContextItem("ChanCtx")) { - ImGui::TextDisabled("%s", channels[ci].c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Leave Channel")) - gameHandler.leaveChannel(channels[ci]); - ImGui::EndPopup(); - } - ImGui::PopID(); - } - } - - ImGui::EndChild(); - ImGui::Separator(); - - // Join a channel - static char joinChanBuf[64] = {}; - ImGui::SetNextItemWidth(140.0f); - ImGui::InputText("##sf_joinchan", joinChanBuf, sizeof(joinChanBuf)); - ImGui::SameLine(); - if (ImGui::Button("+##joinchan") && joinChanBuf[0] != '\0') { - gameHandler.joinChannel(joinChanBuf); - joinChanBuf[0] = '\0'; - } - - ImGui::EndTabItem(); - } - - // ---- Arena tab (WotLK: shows per-team rating/record + roster) ---- - const auto& arenaStats = gameHandler.getArenaTeamStats(); - if (!arenaStats.empty()) { - if (ImGui::BeginTabItem("Arena")) { - ImGui::BeginChild("##ArenaList", ImVec2(0, 0), false); - - for (size_t ai = 0; ai < arenaStats.size(); ++ai) { - const auto& ts = arenaStats[ai]; - ImGui::PushID(static_cast(ai)); - - // Team header: "2v2: Team Name" or fallback "Team #id" - std::string teamLabel; - if (ts.teamType > 0) - teamLabel = std::to_string(ts.teamType) + "v" + std::to_string(ts.teamType) + ": "; - if (!ts.teamName.empty()) - teamLabel += ts.teamName; - else - teamLabel += "Team #" + std::to_string(ts.teamId); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel.c_str()); - - ImGui::Indent(8.0f); - // Rating and rank - ImGui::Text("Rating: %u", ts.rating); - if (ts.rank > 0) { - ImGui::SameLine(0, 6); - ImGui::TextDisabled("(Rank #%u)", ts.rank); - } - - // Weekly record - uint32_t weekLosses = ts.weekGames > ts.weekWins - ? ts.weekGames - ts.weekWins : 0; - ImGui::Text("Week: %u W / %u L", ts.weekWins, weekLosses); - - // Season record - uint32_t seasLosses = ts.seasonGames > ts.seasonWins - ? ts.seasonGames - ts.seasonWins : 0; - ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); - - // Roster members (from SMSG_ARENA_TEAM_ROSTER) - const auto* roster = gameHandler.getArenaTeamRoster(ts.teamId); - if (roster && !roster->members.empty()) { - ImGui::Spacing(); - ImGui::TextDisabled("-- Roster (%zu members) --", - roster->members.size()); - ImGui::SameLine(); - if (ImGui::SmallButton("Refresh")) - gameHandler.requestArenaTeamRoster(ts.teamId); - - // Column headers - ImGui::Columns(4, "##arenaRosterCols", false); - ImGui::SetColumnWidth(0, 110.0f); - ImGui::SetColumnWidth(1, 60.0f); - ImGui::SetColumnWidth(2, 60.0f); - ImGui::SetColumnWidth(3, 60.0f); - ImGui::TextDisabled("Name"); ImGui::NextColumn(); - ImGui::TextDisabled("Rating"); ImGui::NextColumn(); - ImGui::TextDisabled("Week"); ImGui::NextColumn(); - ImGui::TextDisabled("Season"); ImGui::NextColumn(); - ImGui::Separator(); - - for (const auto& m : roster->members) { - // Name coloured green (online) or grey (offline) - if (m.online) - ImGui::TextColored(ImVec4(0.4f,1.0f,0.4f,1.0f), - "%s", m.name.c_str()); - else - ImGui::TextDisabled("%s", m.name.c_str()); - ImGui::NextColumn(); - - ImGui::Text("%u", m.personalRating); - ImGui::NextColumn(); - - uint32_t wL = m.weekGames > m.weekWins - ? m.weekGames - m.weekWins : 0; - ImGui::Text("%uW/%uL", m.weekWins, wL); - ImGui::NextColumn(); - - uint32_t sL = m.seasonGames > m.seasonWins - ? m.seasonGames - m.seasonWins : 0; - ImGui::Text("%uW/%uL", m.seasonWins, sL); - ImGui::NextColumn(); - } - ImGui::Columns(1); - } else { - ImGui::Spacing(); - if (ImGui::SmallButton("Load Roster")) - gameHandler.requestArenaTeamRoster(ts.teamId); - } - - ImGui::Unindent(8.0f); - - if (ai + 1 < arenaStats.size()) - ImGui::Separator(); - - ImGui::PopID(); - } - - ImGui::EndChild(); - ImGui::EndTabItem(); - } - } - - ImGui::EndTabBar(); - } - } - ImGui::End(); - showSocialFrame_ = open; - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); -} - // ============================================================ // Buff/Debuff Bar (Phase 3) // ============================================================ -void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { - const auto& auras = gameHandler.getPlayerAuras(); - - // Count non-empty auras - int activeCount = 0; - for (const auto& a : auras) { - if (!a.isEmpty()) activeCount++; - } - if (activeCount == 0 && !gameHandler.hasPet()) return; - - auto* assetMgr = core::Application::getInstance().getAssetManager(); - - // Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210) - // Anchored to the right side to stay away from party frames on the left - constexpr float ICON_SIZE = 32.0f; - constexpr int ICONS_PER_ROW = 8; - float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f; - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - // Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210) - ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar; - - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); - - if (ImGui::Begin("##BuffBar", nullptr, flags)) { - // Pre-sort auras: buffs first, then debuffs; within each group, shorter remaining first - uint64_t buffNowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - std::vector buffSortedIdx; - buffSortedIdx.reserve(auras.size()); - for (size_t i = 0; i < auras.size(); ++i) - if (!auras[i].isEmpty()) buffSortedIdx.push_back(i); - std::sort(buffSortedIdx.begin(), buffSortedIdx.end(), [&](size_t a, size_t b) { - const auto& aa = auras[a]; const auto& ab = auras[b]; - bool aDebuff = (aa.flags & 0x80) != 0; - bool bDebuff = (ab.flags & 0x80) != 0; - if (aDebuff != bDebuff) return aDebuff < bDebuff; // buffs (0) first - int32_t ra = aa.getRemainingMs(buffNowMs); - int32_t rb = ab.getRemainingMs(buffNowMs); - if (ra < 0 && rb < 0) return false; - if (ra < 0) return false; - if (rb < 0) return true; - return ra < rb; - }); - - // Render one pass for buffs, one for debuffs - for (int pass = 0; pass < 2; ++pass) { - bool wantBuff = (pass == 0); - int shown = 0; - for (size_t si = 0; si < buffSortedIdx.size() && shown < 40; ++si) { - size_t i = buffSortedIdx[si]; - const auto& aura = auras[i]; - if (aura.isEmpty()) continue; - - bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag - if (isBuff != wantBuff) continue; // only render matching pass - - if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); - - ImGui::PushID(static_cast(i) + (pass * 256)); - - // Determine border color: buffs = green; debuffs use WoW dispel-type colors - ImVec4 borderColor; - if (isBuff) { - borderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); // green - } else { - // Debuff: color by dispel type (0=none/red, 1=magic/blue, 2=curse/purple, - // 3=disease/brown, 4=poison/green, other=dark-red) - uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); - switch (dt) { - case 1: borderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue - case 2: borderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple - case 3: borderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown - case 4: borderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green - default: borderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red - } - } - - // Try to get spell icon - VkDescriptorSet iconTex = VK_NULL_HANDLE; - if (assetMgr) { - iconTex = getSpellIcon(aura.spellId, assetMgr); - } - - if (iconTex) { - ImGui::PushStyleColor(ImGuiCol_Button, borderColor); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2)); - ImGui::ImageButton("##aura", - (ImTextureID)(uintptr_t)iconTex, - ImVec2(ICON_SIZE - 4, ICON_SIZE - 4)); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, borderColor); - const std::string& pAuraName = gameHandler.getSpellName(aura.spellId); - char label[32]; - if (!pAuraName.empty()) - snprintf(label, sizeof(label), "%.6s", pAuraName.c_str()); - else - snprintf(label, sizeof(label), "%u", aura.spellId); - ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE)); - ImGui::PopStyleColor(); - } - - // Compute remaining duration once (shared by overlay and tooltip) - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - int32_t remainMs = aura.getRemainingMs(nowMs); - - // Clock-sweep overlay: dark fan shows elapsed time (WoW style) - if (remainMs > 0 && aura.maxDurationMs > 0) { - ImVec2 iconMin2 = ImGui::GetItemRectMin(); - ImVec2 iconMax2 = ImGui::GetItemRectMax(); - float cx2 = (iconMin2.x + iconMax2.x) * 0.5f; - float cy2 = (iconMin2.y + iconMax2.y) * 0.5f; - float fanR2 = (iconMax2.x - iconMin2.x) * 0.5f; - float total2 = static_cast(aura.maxDurationMs); - float elapsedFrac2 = std::clamp( - 1.0f - static_cast(remainMs) / total2, 0.0f, 1.0f); - if (elapsedFrac2 > 0.005f) { - constexpr int SWEEP_SEGS = 24; - float sa = -IM_PI * 0.5f; - float ea = sa + elapsedFrac2 * 2.0f * IM_PI; - ImVec2 pts[SWEEP_SEGS + 2]; - pts[0] = ImVec2(cx2, cy2); - for (int s = 0; s <= SWEEP_SEGS; ++s) { - float a = sa + (ea - sa) * s / static_cast(SWEEP_SEGS); - pts[s + 1] = ImVec2(cx2 + std::cos(a) * fanR2, - cy2 + std::sin(a) * fanR2); - } - ImGui::GetWindowDrawList()->AddConvexPolyFilled( - pts, SWEEP_SEGS + 2, IM_COL32(0, 0, 0, 145)); - } - } - - // Duration countdown overlay — always visible on the icon bottom - if (remainMs > 0) { - ImVec2 iconMin = ImGui::GetItemRectMin(); - ImVec2 iconMax = ImGui::GetItemRectMax(); - char timeStr[12]; - int secs = (remainMs + 999) / 1000; // ceiling seconds - if (secs >= 3600) - snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600); - else if (secs >= 60) - snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); - else - snprintf(timeStr, sizeof(timeStr), "%d", secs); - ImVec2 textSize = ImGui::CalcTextSize(timeStr); - float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; - float cy = iconMax.y - textSize.y - 2.0f; - // Choose timer color based on urgency - ImU32 timerColor; - if (remainMs < 10000) { - // < 10s: pulse red - float pulse = 0.7f + 0.3f * std::sin( - static_cast(ImGui::GetTime()) * 6.0f); - timerColor = IM_COL32( - static_cast(255 * pulse), - static_cast(80 * pulse), - static_cast(60 * pulse), 255); - } else if (remainMs < 30000) { - timerColor = IM_COL32(255, 165, 0, 255); // orange - } else { - timerColor = IM_COL32(255, 255, 255, 255); // white - } - // Drop shadow for readability over any icon colour - ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), - IM_COL32(0, 0, 0, 200), timeStr); - ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - timerColor, timeStr); - } - - // Stack / charge count overlay — upper-left corner of the icon - if (aura.charges > 1) { - ImVec2 iconMin = ImGui::GetItemRectMin(); - char chargeStr[8]; - snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); - // Drop shadow then bright yellow text - ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3), - IM_COL32(0, 0, 0, 200), chargeStr); - ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2), - IM_COL32(255, 220, 50, 255), chargeStr); - } - - // Right-click to cancel buffs / dismount - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - if (gameHandler.isMounted()) { - gameHandler.dismount(); - } else if (isBuff) { - gameHandler.cancelAura(aura.spellId); - } - } - - // Tooltip: rich spell info + remaining duration - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); - if (!richOk) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); - ImGui::Text("%s", name.c_str()); - } - renderAuraRemaining(remainMs); - ImGui::EndTooltip(); - } - - ImGui::PopID(); - shown++; - } // end aura loop - // Add visual gap between buffs and debuffs - if (pass == 0 && shown > 0) ImGui::Spacing(); - } // end pass loop - - // Dismiss Pet button - if (gameHandler.hasPet()) { - ImGui::Spacing(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f)); - if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) { - gameHandler.dismissPet(); - } - ImGui::PopStyleColor(2); - } - - // Temporary weapon enchant timers (Shaman imbues, Rogue poisons, whetstones, etc.) - { - const auto& timers = gameHandler.getTempEnchantTimers(); - if (!timers.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - static constexpr ImVec4 kEnchantSlotColors[] = { - colors::kOrange, // main-hand: gold - ImVec4(0.5f, 0.8f, 0.9f, 1.0f), // off-hand: teal - ImVec4(0.7f, 0.5f, 0.9f, 1.0f), // ranged: purple - }; - uint64_t enchNowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - - for (const auto& t : timers) { - if (t.slot > 2) continue; - uint64_t remMs = (t.expireMs > enchNowMs) ? (t.expireMs - enchNowMs) : 0; - if (remMs == 0) continue; - - ImVec4 col = kEnchantSlotColors[t.slot]; - // Flash red when < 60s remaining - if (remMs < 60000) { - float pulse = 0.6f + 0.4f * std::sin( - static_cast(ImGui::GetTime()) * 4.0f); - col = ImVec4(pulse, 0.2f, 0.1f, 1.0f); - } - - // Format remaining time - uint32_t secs = static_cast((remMs + 999) / 1000); - char timeStr[16]; - if (secs >= 3600) - snprintf(timeStr, sizeof(timeStr), "%dh%02dm", secs / 3600, (secs % 3600) / 60); - else if (secs >= 60) - snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); - else - snprintf(timeStr, sizeof(timeStr), "%ds", secs); - - ImGui::PushID(static_cast(t.slot) + 5000); - ImGui::PushStyleColor(ImGuiCol_Button, col); - char label[40]; - snprintf(label, sizeof(label), "~%s %s", - game::GameHandler::kTempEnchantSlotNames[t.slot], timeStr); - ImGui::Button(label, ImVec2(-1, 16)); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Temporary weapon enchant: %s\nRemaining: %s", - game::GameHandler::kTempEnchantSlotNames[t.slot], - timeStr); - ImGui::PopStyleColor(); - ImGui::PopID(); - } - } - } - } - ImGui::End(); - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); -} - // ============================================================ // Loot Window (Phase 5) // ============================================================ -void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isLootWindowOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); - - bool open = true; - if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { - const auto& loot = gameHandler.getCurrentLoot(); - - // Gold (auto-looted on open; shown for feedback) - if (loot.gold > 0) { - ImGui::TextDisabled("Gold:"); - ImGui::SameLine(0, 4); - renderCoinsText(loot.getGold(), loot.getSilver(), loot.getCopper()); - ImGui::Separator(); - } - - // Items with icons and labels - constexpr float iconSize = 32.0f; - int lootSlotClicked = -1; // defer loot pickup to avoid iterator invalidation - for (const auto& item : loot.items) { - ImGui::PushID(item.slotIndex); - - // Get item info for name and quality - const auto* info = gameHandler.getItemInfo(item.itemId); - std::string itemName; - game::ItemQuality quality = game::ItemQuality::COMMON; - if (info && !info->name.empty()) { - itemName = info->name; - quality = static_cast(info->quality); - } else { - itemName = "Item #" + std::to_string(item.itemId); - } - ImVec4 qColor = InventoryScreen::getQualityColor(quality); - bool startsQuest = (info && info->startQuestId != 0); - - // Get item icon - uint32_t displayId = item.displayInfoId; - if (displayId == 0 && info) displayId = info->displayInfoId; - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(displayId); - - ImVec2 cursor = ImGui::GetCursorScreenPos(); - float rowH = std::max(iconSize, ImGui::GetTextLineHeight() * 2.0f); - - // Invisible selectable for click handling - if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) { - if (ImGui::GetIO().KeyShift && info && !info->name.empty()) { - // Shift-click: insert item link into chat - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } else { - lootSlotClicked = item.slotIndex; - } - } - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - lootSlotClicked = item.slotIndex; - } - bool hovered = ImGui::IsItemHovered(); - - // Show item tooltip on hover - if (hovered && info && info->valid) { - inventoryScreen.renderItemTooltip(*info); - } else if (hovered && info && !info->name.empty()) { - // Item info received but not yet fully valid — show name at minimum - ImGui::SetTooltip("%s", info->name.c_str()); - } - - ImDrawList* drawList = ImGui::GetWindowDrawList(); - - // Draw hover highlight - if (hovered) { - drawList->AddRectFilled(cursor, - ImVec2(cursor.x + ImGui::GetContentRegionAvail().x + iconSize + 8.0f, - cursor.y + rowH), - IM_COL32(255, 255, 255, 30)); - } - - // Draw icon - if (iconTex) { - drawList->AddImage((ImTextureID)(uintptr_t)iconTex, - cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize)); - drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize), - ImGui::ColorConvertFloat4ToU32(qColor)); - } else { - drawList->AddRectFilled(cursor, - ImVec2(cursor.x + iconSize, cursor.y + iconSize), - IM_COL32(40, 40, 50, 200)); - drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize), - IM_COL32(80, 80, 80, 200)); - } - // Quest-starter: gold outer glow border + "!" badge on top-right corner - if (startsQuest) { - drawList->AddRect(ImVec2(cursor.x - 2.0f, cursor.y - 2.0f), - ImVec2(cursor.x + iconSize + 2.0f, cursor.y + iconSize + 2.0f), - IM_COL32(255, 210, 0, 210), 0.0f, 0, 2.0f); - drawList->AddText(ImVec2(cursor.x + iconSize - 10.0f, cursor.y + 1.0f), - IM_COL32(255, 210, 0, 255), "!"); - } - - // Draw item name - float textX = cursor.x + iconSize + 6.0f; - float textY = cursor.y + 2.0f; - drawList->AddText(ImVec2(textX, textY), - ImGui::ColorConvertFloat4ToU32(qColor), itemName.c_str()); - - // Draw count or "Begins a Quest" label on second line - float secondLineY = textY + ImGui::GetTextLineHeight(); - if (startsQuest) { - drawList->AddText(ImVec2(textX, secondLineY), - IM_COL32(255, 210, 0, 255), "Begins a Quest"); - } else if (item.count > 1) { - char countStr[32]; - snprintf(countStr, sizeof(countStr), "x%u", item.count); - drawList->AddText(ImVec2(textX, secondLineY), IM_COL32(200, 200, 200, 220), countStr); - } - - ImGui::PopID(); - } - - // Process deferred loot pickup (after loop to avoid iterator invalidation) - if (lootSlotClicked >= 0) { - if (gameHandler.hasMasterLootCandidates()) { - // Master looter: open popup to choose recipient - char popupId[32]; - snprintf(popupId, sizeof(popupId), "##MLGive%d", lootSlotClicked); - ImGui::OpenPopup(popupId); - } else { - gameHandler.lootItem(static_cast(lootSlotClicked)); - } - } - - // Master loot "Give to" popups - if (gameHandler.hasMasterLootCandidates()) { - for (const auto& item : loot.items) { - char popupId[32]; - snprintf(popupId, sizeof(popupId), "##MLGive%d", item.slotIndex); - if (ImGui::BeginPopup(popupId)) { - ImGui::TextDisabled("Give to:"); - ImGui::Separator(); - const auto& candidates = gameHandler.getMasterLootCandidates(); - for (uint64_t candidateGuid : candidates) { - auto entity = gameHandler.getEntityManager().getEntity(candidateGuid); - auto* unit = (entity && entity->isUnit()) ? static_cast(entity.get()) : nullptr; - const char* cName = unit ? unit->getName().c_str() : nullptr; - char nameBuf[64]; - if (!cName || cName[0] == '\0') { - snprintf(nameBuf, sizeof(nameBuf), "Player 0x%llx", - static_cast(candidateGuid)); - cName = nameBuf; - } - if (ImGui::MenuItem(cName)) { - gameHandler.lootMasterGive(item.slotIndex, candidateGuid); - ImGui::CloseCurrentPopup(); - } - } - ImGui::EndPopup(); - } - } - } - - if (loot.items.empty() && loot.gold == 0) { - gameHandler.closeLoot(); - } - - ImGui::Spacing(); - bool hasItems = !loot.items.empty(); - if (hasItems) { - if (ImGui::Button("Loot All", ImVec2(-1, 0))) { - for (const auto& item : loot.items) { - gameHandler.lootItem(item.slotIndex); - } - } - ImGui::Spacing(); - } - if (ImGui::Button("Close", ImVec2(-1, 0))) { - gameHandler.closeLoot(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeLoot(); - } -} // ============================================================ // Gossip Window (Phase 5) // ============================================================ -void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isGossipWindowOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always); - - bool open = true; - if (ImGui::Begin("NPC Dialog", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { - const auto& gossip = gameHandler.getCurrentGossip(); - - // NPC name (from creature cache) - auto npcEntity = gameHandler.getEntityManager().getEntity(gossip.npcGuid); - if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(npcEntity); - if (!unit->getName().empty()) { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str()); - ImGui::Separator(); - } - } - - ImGui::Spacing(); - - // Gossip option icons - matches WoW GossipOptionIcon enum - static constexpr const char* gossipIcons[] = { - "[Chat]", // 0 = GOSSIP_ICON_CHAT - "[Vendor]", // 1 = GOSSIP_ICON_VENDOR - "[Taxi]", // 2 = GOSSIP_ICON_TAXI - "[Trainer]", // 3 = GOSSIP_ICON_TRAINER - "[Interact]", // 4 = GOSSIP_ICON_INTERACT_1 - "[Interact]", // 5 = GOSSIP_ICON_INTERACT_2 - "[Banker]", // 6 = GOSSIP_ICON_MONEY_BAG (banker) - "[Chat]", // 7 = GOSSIP_ICON_TALK - "[Tabard]", // 8 = GOSSIP_ICON_TABARD - "[Battlemaster]", // 9 = GOSSIP_ICON_BATTLE - "[Option]", // 10 = GOSSIP_ICON_DOT - }; - - // Default text for server-sent gossip option placeholders - static const std::unordered_map gossipPlaceholders = { - {"GOSSIP_OPTION_BANKER", "I would like to check my deposit box."}, - {"GOSSIP_OPTION_AUCTIONEER", "I'd like to browse your auctions."}, - {"GOSSIP_OPTION_VENDOR", "I want to browse your goods."}, - {"GOSSIP_OPTION_TAXIVENDOR", "I'd like to fly."}, - {"GOSSIP_OPTION_TRAINER", "I seek training."}, - {"GOSSIP_OPTION_INNKEEPER", "Make this inn your home."}, - {"GOSSIP_OPTION_SPIRITGUIDE", "Return me to life."}, - {"GOSSIP_OPTION_SPIRITHEALER", "Bring me back to life."}, - {"GOSSIP_OPTION_STABLEPET", "I'd like to stable my pet."}, - {"GOSSIP_OPTION_ARMORER", "I need to repair my equipment."}, - {"GOSSIP_OPTION_GOSSIP", "What can you tell me?"}, - {"GOSSIP_OPTION_BATTLEFIELD", "I'd like to go to the battleground."}, - {"GOSSIP_OPTION_TABARDDESIGNER", "I want to create a guild tabard."}, - {"GOSSIP_OPTION_PETITIONER", "I want to create a guild."}, - }; - - for (const auto& opt : gossip.options) { - ImGui::PushID(static_cast(opt.id)); - - // Determine icon label - use text-based detection for shared icons - const char* icon = (opt.icon < 11) ? gossipIcons[opt.icon] : "[Option]"; - if (opt.text == "GOSSIP_OPTION_AUCTIONEER") icon = "[Auctioneer]"; - else if (opt.text == "GOSSIP_OPTION_BANKER") icon = "[Banker]"; - else if (opt.text == "GOSSIP_OPTION_VENDOR") icon = "[Vendor]"; - else if (opt.text == "GOSSIP_OPTION_TRAINER") icon = "[Trainer]"; - else if (opt.text == "GOSSIP_OPTION_INNKEEPER") icon = "[Innkeeper]"; - else if (opt.text == "GOSSIP_OPTION_STABLEPET") icon = "[Stable Master]"; - else if (opt.text == "GOSSIP_OPTION_ARMORER") icon = "[Repair]"; - - // Resolve placeholder text from server - std::string displayText = opt.text; - auto placeholderIt = gossipPlaceholders.find(displayText); - if (placeholderIt != gossipPlaceholders.end()) { - displayText = placeholderIt->second; - } - - std::string processedText = chatPanel_.replaceGenderPlaceholders(displayText, gameHandler); - std::string label = std::string(icon) + " " + processedText; - if (ImGui::Selectable(label.c_str())) { - if (opt.text == "GOSSIP_OPTION_ARMORER") { - gameHandler.setVendorCanRepair(true); - } - gameHandler.selectGossipOption(opt.id); - } - ImGui::PopID(); - } - - // Fallback: some spirit healers don't send gossip options. - if (gossip.options.empty() && gameHandler.isPlayerGhost()) { - bool isSpirit = false; - if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(npcEntity); - std::string name = unit->getName(); - std::transform(name.begin(), name.end(), name.begin(), - [](unsigned char c){ return static_cast(std::tolower(c)); }); - if (name.find("spirit healer") != std::string::npos || - name.find("spirit guide") != std::string::npos) { - isSpirit = true; - } - } - if (isSpirit) { - if (ImGui::Selectable("[Spiritguide] Return to Graveyard")) { - gameHandler.activateSpiritHealer(gossip.npcGuid); - gameHandler.closeGossip(); - } - } - } - - // Quest items - if (!gossip.quests.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(kColorYellow, "Quests:"); - for (size_t qi = 0; qi < gossip.quests.size(); qi++) { - const auto& quest = gossip.quests[qi]; - ImGui::PushID(static_cast(qi)); - - // Determine icon and color based on QuestGiverStatus stored in questIcon - // 5=INCOMPLETE (gray?), 6=REWARD_REP (yellow?), 7=AVAILABLE_LOW (gray!), - // 8=AVAILABLE (yellow!), 10=REWARD (yellow?) - const char* statusIcon = "!"; - ImVec4 statusColor = kColorYellow; // yellow - switch (quest.questIcon) { - case 5: // INCOMPLETE — in progress but not done - statusIcon = "?"; - statusColor = colors::kMediumGray; // gray - break; - case 6: // REWARD_REP — repeatable, ready to turn in - case 10: // REWARD — ready to turn in - statusIcon = "?"; - statusColor = kColorYellow; // yellow - break; - case 7: // AVAILABLE_LOW — available but gray (low-level) - statusIcon = "!"; - statusColor = colors::kMediumGray; // gray - break; - default: // AVAILABLE (8) and any others - statusIcon = "!"; - statusColor = kColorYellow; // yellow - break; - } - - // Render: colored icon glyph then [Lv] Title - ImGui::TextColored(statusColor, "%s", statusIcon); - ImGui::SameLine(0, 4); - char qlabel[256]; - snprintf(qlabel, sizeof(qlabel), "[%d] %s", quest.questLevel, quest.title.c_str()); - ImGui::PushStyleColor(ImGuiCol_Text, statusColor); - if (ImGui::Selectable(qlabel)) { - gameHandler.selectGossipQuest(quest.questId); - } - ImGui::PopStyleColor(); - ImGui::PopID(); - } - } - - ImGui::Spacing(); - if (ImGui::Button("Close", ImVec2(-1, 0))) { - gameHandler.closeGossip(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeGossip(); - } -} // ============================================================ // Quest Details Window // ============================================================ -void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isQuestDetailsOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); - - bool open = true; - const auto& quest = gameHandler.getQuestDetails(); - std::string processedTitle = chatPanel_.replaceGenderPlaceholders(quest.title, gameHandler); - if (ImGui::Begin(processedTitle.c_str(), &open)) { - // Quest description - if (!quest.details.empty()) { - std::string processedDetails = chatPanel_.replaceGenderPlaceholders(quest.details, gameHandler); - ImGui::TextWrapped("%s", processedDetails.c_str()); - } - - // Objectives - if (!quest.objectives.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "Objectives:"); - std::string processedObjectives = chatPanel_.replaceGenderPlaceholders(quest.objectives, gameHandler); - ImGui::TextWrapped("%s", processedObjectives.c_str()); - } - - // Choice reward items (player picks one) - auto renderQuestRewardItem = [&](const game::QuestRewardItem& ri) { - gameHandler.ensureItemInfo(ri.itemId); - auto* info = gameHandler.getItemInfo(ri.itemId); - VkDescriptorSet iconTex = VK_NULL_HANDLE; - uint32_t dispId = ri.displayInfoId; - if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; - if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); - - std::string label; - ImVec4 nameCol = ui::colors::kWhite; - if (info && info->valid && !info->name.empty()) { - label = info->name; - nameCol = InventoryScreen::getQualityColor(static_cast(info->quality)); - } else { - label = "Item " + std::to_string(ri.itemId); - } - if (ri.count > 1) label += " x" + std::to_string(ri.count); - - if (iconTex) { - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - if (ImGui::IsItemHovered() && info && info->valid) - inventoryScreen.renderItemTooltip(*info); - ImGui::SameLine(); - } - ImGui::TextColored(nameCol, " %s", label.c_str()); - if (ImGui::IsItemHovered() && info && info->valid) - inventoryScreen.renderItemTooltip(*info); - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - }; - - if (!quest.rewardChoiceItems.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "Choose one reward:"); - for (const auto& ri : quest.rewardChoiceItems) { - renderQuestRewardItem(ri); - } - } - - // Fixed reward items (always given) - if (!quest.rewardItems.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "You will receive:"); - for (const auto& ri : quest.rewardItems) { - renderQuestRewardItem(ri); - } - } - - // XP and money rewards - if (quest.rewardXp > 0 || quest.rewardMoney > 0) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "Rewards:"); - if (quest.rewardXp > 0) { - ImGui::Text(" %u experience", quest.rewardXp); - } - if (quest.rewardMoney > 0) { - ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(quest.rewardMoney); - } - } - - if (quest.suggestedPlayers > 1) { - ImGui::TextColored(ui::colors::kLightGray, - "Suggested players: %u", quest.suggestedPlayers); - } - - // Accept / Decline buttons - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - if (ImGui::Button("Accept", ImVec2(buttonW, 0))) { - gameHandler.acceptQuest(); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(buttonW, 0))) { - gameHandler.declineQuest(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.declineQuest(); - } -} // ============================================================ // Quest Request Items Window (turn-in progress check) // ============================================================ -void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isQuestRequestItemsOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(450, 350), ImGuiCond_Appearing); - - bool open = true; - const auto& quest = gameHandler.getQuestRequestItems(); - auto countItemInInventory = [&](uint32_t itemId) -> uint32_t { - const auto& inv = gameHandler.getInventory(); - uint32_t total = 0; - for (int i = 0; i < inv.getBackpackSize(); ++i) { - const auto& slot = inv.getBackpackSlot(i); - if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount; - } - for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; ++bag) { - int bagSize = inv.getBagSize(bag); - for (int s = 0; s < bagSize; ++s) { - const auto& slot = inv.getBagSlot(bag, s); - if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount; - } - } - return total; - }; - - std::string processedTitle = chatPanel_.replaceGenderPlaceholders(quest.title, gameHandler); - if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { - if (!quest.completionText.empty()) { - std::string processedCompletionText = chatPanel_.replaceGenderPlaceholders(quest.completionText, gameHandler); - ImGui::TextWrapped("%s", processedCompletionText.c_str()); - } - - // Required items - if (!quest.requiredItems.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "Required Items:"); - for (const auto& item : quest.requiredItems) { - uint32_t have = countItemInInventory(item.itemId); - bool enough = have >= item.count; - ImVec4 textCol = enough ? colors::kLightGreen : ImVec4(1.0f, 0.6f, 0.6f, 1.0f); - auto* info = gameHandler.getItemInfo(item.itemId); - const char* name = (info && info->valid) ? info->name.c_str() : nullptr; - - // Show icon if display info is available - uint32_t dispId = item.displayInfoId; - if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; - if (dispId != 0) { - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); - if (iconTex) { - ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); - ImGui::SameLine(); - } - } - if (name && *name) { - ImGui::TextColored(textCol, "%s %u/%u", name, have, item.count); - } else { - ImGui::TextColored(textCol, "Item %u %u/%u", item.itemId, have, item.count); - } - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - } - } - - if (quest.requiredMoney > 0) { - ImGui::Spacing(); - ImGui::TextDisabled("Required money:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(quest.requiredMoney); - } - - // Complete / Cancel buttons - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) { - gameHandler.completeQuest(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) { - gameHandler.closeQuestRequestItems(); - } - - if (!quest.isCompletable()) { - ImGui::TextDisabled("Server flagged this quest as incomplete; completion will be server-validated."); - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeQuestRequestItems(); - } -} // ============================================================ // Quest Offer Reward Window (choose reward) // ============================================================ -void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isQuestOfferRewardOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); - - bool open = true; - const auto& quest = gameHandler.getQuestOfferReward(); - static int selectedChoice = -1; - - // Auto-select if only one choice reward - if (quest.choiceRewards.size() == 1 && selectedChoice == -1) { - selectedChoice = 0; - } - - std::string processedTitle = chatPanel_.replaceGenderPlaceholders(quest.title, gameHandler); - if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { - if (!quest.rewardText.empty()) { - std::string processedRewardText = chatPanel_.replaceGenderPlaceholders(quest.rewardText, gameHandler); - ImGui::TextWrapped("%s", processedRewardText.c_str()); - } - - // Choice rewards (pick one) - // Trigger item info fetch for all reward items - for (const auto& item : quest.choiceRewards) gameHandler.ensureItemInfo(item.itemId); - for (const auto& item : quest.fixedRewards) gameHandler.ensureItemInfo(item.itemId); - - // Helper: resolve icon tex + quality color for a reward item - auto resolveRewardItemVis = [&](const game::QuestRewardItem& ri) - -> std::pair - { - auto* info = gameHandler.getItemInfo(ri.itemId); - uint32_t dispId = ri.displayInfoId; - if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; - VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; - ImVec4 col = (info && info->valid) - ? InventoryScreen::getQualityColor(static_cast(info->quality)) - : ui::colors::kWhite; - return {iconTex, col}; - }; - - // Helper: show full item tooltip (reuses InventoryScreen's rich tooltip) - auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 /*nameCol*/) { - auto* info = gameHandler.getItemInfo(ri.itemId); - if (!info || !info->valid) { - ImGui::BeginTooltip(); - ImGui::TextDisabled("Loading item data..."); - ImGui::EndTooltip(); - return; - } - inventoryScreen.renderItemTooltip(*info); - }; - - if (!quest.choiceRewards.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "Choose a reward:"); - - for (size_t i = 0; i < quest.choiceRewards.size(); ++i) { - const auto& item = quest.choiceRewards[i]; - auto* info = gameHandler.getItemInfo(item.itemId); - auto [iconTex, qualityColor] = resolveRewardItemVis(item); - - std::string label; - if (info && info->valid && !info->name.empty()) label = info->name; - else label = "Item " + std::to_string(item.itemId); - if (item.count > 1) label += " x" + std::to_string(item.count); - - bool selected = (selectedChoice == static_cast(i)); - ImGui::PushID(static_cast(i)); - - // Icon then selectable on same line - if (iconTex) { - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(20, 20)); - if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); - ImGui::SameLine(); - } - ImGui::PushStyleColor(ImGuiCol_Text, qualityColor); - if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) { - if (ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } else { - selectedChoice = static_cast(i); - } - } - ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); - - ImGui::PopID(); - } - } - - // Fixed rewards (always given) - if (!quest.fixedRewards.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "You will also receive:"); - for (const auto& item : quest.fixedRewards) { - auto* info = gameHandler.getItemInfo(item.itemId); - auto [iconTex, qualityColor] = resolveRewardItemVis(item); - - std::string label; - if (info && info->valid && !info->name.empty()) label = info->name; - else label = "Item " + std::to_string(item.itemId); - if (item.count > 1) label += " x" + std::to_string(item.count); - - if (iconTex) { - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); - ImGui::SameLine(); - } - ImGui::TextColored(qualityColor, " %s", label.c_str()); - if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - } - } - - // Money / XP rewards - if (quest.rewardXp > 0 || quest.rewardMoney > 0) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ui::colors::kTooltipGold, "Rewards:"); - if (quest.rewardXp > 0) - ImGui::Text(" %u experience", quest.rewardXp); - if (quest.rewardMoney > 0) { - ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(quest.rewardMoney); - } - } - - // Complete button - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - - bool canComplete = quest.choiceRewards.empty() || selectedChoice >= 0; - if (!canComplete) ImGui::BeginDisabled(); - if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) { - uint32_t rewardIdx = 0; - if (!quest.choiceRewards.empty() && selectedChoice >= 0 && - selectedChoice < static_cast(quest.choiceRewards.size())) { - // Server expects the original slot index from its fixed-size reward array. - rewardIdx = quest.choiceRewards[static_cast(selectedChoice)].choiceSlot; - } - gameHandler.chooseQuestReward(rewardIdx); - selectedChoice = -1; - } - if (!canComplete) ImGui::EndDisabled(); - - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) { - gameHandler.closeQuestOfferReward(); - selectedChoice = -1; - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeQuestOfferReward(); - selectedChoice = -1; - } -} // ============================================================ // ItemExtendedCost.dbc loader // ============================================================ -void GameScreen::loadExtendedCostDBC() { - if (extendedCostDbLoaded_) return; - extendedCostDbLoaded_ = true; - auto* am = core::Application::getInstance().getAssetManager(); - if (!am || !am->isInitialized()) return; - auto dbc = am->loadDBC("ItemExtendedCost.dbc"); - if (!dbc || !dbc->isLoaded()) return; - // WotLK ItemExtendedCost.dbc: field 0=ID, 1=honorPoints, 2=arenaPoints, - // 3=arenaSlotRestrictions, 4-8=itemId[5], 9-13=itemCount[5], 14=reqRating, 15=purchaseGroup - for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { - uint32_t id = dbc->getUInt32(i, 0); - if (id == 0) continue; - ExtendedCostEntry e; - e.honorPoints = dbc->getUInt32(i, 1); - e.arenaPoints = dbc->getUInt32(i, 2); - for (int j = 0; j < 5; ++j) { - e.itemId[j] = dbc->getUInt32(i, 4 + j); - e.itemCount[j] = dbc->getUInt32(i, 9 + j); - } - extendedCostCache_[id] = e; - } - LOG_INFO("ItemExtendedCost.dbc: loaded ", extendedCostCache_.size(), " entries"); -} -std::string GameScreen::formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler) { - loadExtendedCostDBC(); - auto it = extendedCostCache_.find(extendedCostId); - if (it == extendedCostCache_.end()) return "[Tokens]"; - const auto& e = it->second; - std::string result; - if (e.honorPoints > 0) { - result += std::to_string(e.honorPoints) + " Honor"; - } - if (e.arenaPoints > 0) { - if (!result.empty()) result += ", "; - result += std::to_string(e.arenaPoints) + " Arena"; - } - for (int j = 0; j < 5; ++j) { - if (e.itemId[j] == 0 || e.itemCount[j] == 0) continue; - if (!result.empty()) result += ", "; - gameHandler.ensureItemInfo(e.itemId[j]); // query if not cached - const auto* itemInfo = gameHandler.getItemInfo(e.itemId[j]); - if (itemInfo && itemInfo->valid && !itemInfo->name.empty()) { - result += std::to_string(e.itemCount[j]) + "x " + itemInfo->name; - } else { - result += std::to_string(e.itemCount[j]) + "x Item#" + std::to_string(e.itemId[j]); - } - } - return result.empty() ? "[Tokens]" : result; -} // ============================================================ // Vendor Window (Phase 5) // ============================================================ -void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isVendorWindowOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 100), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); - - bool open = true; - if (ImGui::Begin("Vendor", &open)) { - const auto& vendor = gameHandler.getVendorItems(); - - // Show player money - uint64_t money = gameHandler.getMoneyCopper(); - ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(money); - - if (vendor.canRepair) { - ImGui::SameLine(); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.0f); - if (ImGui::SmallButton("Repair All")) { - gameHandler.repairAll(vendor.vendorGuid, false); - } - if (ImGui::IsItemHovered()) { - // Show durability summary of all equipment - const auto& inv = gameHandler.getInventory(); - int damagedCount = 0; - int brokenCount = 0; - for (int s = 0; s < static_cast(game::EquipSlot::BAG1); s++) { - const auto& slot = inv.getEquipSlot(static_cast(s)); - if (slot.empty() || slot.item.maxDurability == 0) continue; - if (slot.item.curDurability == 0) brokenCount++; - else if (slot.item.curDurability < slot.item.maxDurability) damagedCount++; - } - if (brokenCount > 0) - ImGui::SetTooltip("Repair all equipped items\n%d damaged, %d broken", damagedCount, brokenCount); - else if (damagedCount > 0) - ImGui::SetTooltip("Repair all equipped items\n%d item%s need repair", damagedCount, damagedCount > 1 ? "s" : ""); - else - ImGui::SetTooltip("All equipment is in good condition"); - } - if (gameHandler.isInGuild()) { - ImGui::SameLine(); - if (ImGui::SmallButton("Repair (Guild)")) { - gameHandler.repairAll(vendor.vendorGuid, true); - } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Repair all equipped items using guild bank funds"); - } - } - } - ImGui::Separator(); - - ImGui::TextColored(ui::colors::kLightGray, "Right-click bag items to sell"); - - // Count grey (POOR quality) sellable items across backpack and bags - const auto& inv = gameHandler.getInventory(); - int junkCount = 0; - for (int i = 0; i < inv.getBackpackSize(); ++i) { - const auto& sl = inv.getBackpackSlot(i); - if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) - ++junkCount; - } - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { - for (int s = 0; s < inv.getBagSize(b); ++s) { - const auto& sl = inv.getBagSlot(b, s); - if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) - ++junkCount; - } - } - if (junkCount > 0) { - char junkLabel[64]; - snprintf(junkLabel, sizeof(junkLabel), "Sell All Junk (%d item%s)", - junkCount, junkCount == 1 ? "" : "s"); - if (ImGui::Button(junkLabel, ImVec2(-1, 0))) { - for (int i = 0; i < inv.getBackpackSize(); ++i) { - const auto& sl = inv.getBackpackSlot(i); - if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) - gameHandler.sellItemBySlot(i); - } - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { - for (int s = 0; s < inv.getBagSize(b); ++s) { - const auto& sl = inv.getBagSlot(b, s); - if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) - gameHandler.sellItemInBag(b, s); - } - } - } - } - ImGui::Separator(); - - const auto& buyback = gameHandler.getBuybackItems(); - if (!buyback.empty()) { - ImGui::TextColored(ui::colors::kTooltipGold, "Buy Back"); - if (ImGui::BeginTable("BuybackTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); - ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 110.0f); - ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 62.0f); - ImGui::TableHeadersRow(); - // Show all buyback items (most recently sold first) - for (int i = 0; i < static_cast(buyback.size()); ++i) { - const auto& entry = buyback[i]; - gameHandler.ensureItemInfo(entry.item.itemId); - auto* bbInfo = gameHandler.getItemInfo(entry.item.itemId); - uint32_t sellPrice = entry.item.sellPrice; - if (sellPrice == 0) { - if (bbInfo && bbInfo->valid) sellPrice = bbInfo->sellPrice; - } - uint64_t price = static_cast(sellPrice) * - static_cast(entry.count > 0 ? entry.count : 1); - uint32_t g = static_cast(price / 10000); - uint32_t s = static_cast((price / 100) % 100); - uint32_t c = static_cast(price % 100); - bool canAfford = money >= price; - - ImGui::TableNextRow(); - ImGui::PushID(8000 + i); - ImGui::TableSetColumnIndex(0); - { - uint32_t dispId = entry.item.displayInfoId; - if (bbInfo && bbInfo->valid && bbInfo->displayInfoId != 0) dispId = bbInfo->displayInfoId; - if (dispId != 0) { - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); - if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); - } - } - ImGui::TableSetColumnIndex(1); - game::ItemQuality bbQuality = entry.item.quality; - if (bbInfo && bbInfo->valid) bbQuality = static_cast(bbInfo->quality); - ImVec4 bbQc = InventoryScreen::getQualityColor(bbQuality); - const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); - if (entry.count > 1) { - ImGui::TextColored(bbQc, "%s x%u", name, entry.count); - } else { - ImGui::TextColored(bbQc, "%s", name); - } - if (ImGui::IsItemHovered() && bbInfo && bbInfo->valid) - inventoryScreen.renderItemTooltip(*bbInfo); - ImGui::TableSetColumnIndex(2); - if (canAfford) { - renderCoinsText(g, s, c); - } else { - ImGui::TextColored(kColorRed, "%ug %us %uc", g, s, c); - } - ImGui::TableSetColumnIndex(3); - if (!canAfford) ImGui::BeginDisabled(); - char bbLabel[32]; - snprintf(bbLabel, sizeof(bbLabel), "Buy Back##bb%d", i); - if (ImGui::SmallButton(bbLabel)) { - gameHandler.buyBackItem(static_cast(i)); - } - if (!canAfford) ImGui::EndDisabled(); - ImGui::PopID(); - } - ImGui::EndTable(); - } - ImGui::Separator(); - } - - if (vendor.items.empty()) { - ImGui::TextDisabled("This vendor has nothing for sale."); - } else { - // Search + quantity controls on one row - ImGui::SetNextItemWidth(200.0f); - ImGui::InputTextWithHint("##VendorSearch", "Search...", vendorSearchFilter_, sizeof(vendorSearchFilter_)); - ImGui::SameLine(); - ImGui::Text("Qty:"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(60.0f); - static int vendorBuyQty = 1; - ImGui::InputInt("##VendorQty", &vendorBuyQty, 1, 5); - if (vendorBuyQty < 1) vendorBuyQty = 1; - if (vendorBuyQty > 99) vendorBuyQty = 99; - ImGui::Spacing(); - - if (ImGui::BeginTable("VendorTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { - ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); - ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("Stock", ImGuiTableColumnFlags_WidthFixed, 60.0f); - ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f); - ImGui::TableHeadersRow(); - - std::string vendorFilter(vendorSearchFilter_); - // Lowercase filter for case-insensitive match - for (char& c : vendorFilter) c = static_cast(std::tolower(static_cast(c))); - - for (int vi = 0; vi < static_cast(vendor.items.size()); ++vi) { - const auto& item = vendor.items[vi]; - - // Proactively ensure vendor item info is loaded - gameHandler.ensureItemInfo(item.itemId); - auto* info = gameHandler.getItemInfo(item.itemId); - - // Apply search filter - if (!vendorFilter.empty()) { - std::string nameLC = info && info->valid ? info->name : ("Item " + std::to_string(item.itemId)); - for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); - if (nameLC.find(vendorFilter) == std::string::npos) { - ImGui::PushID(vi); - ImGui::PopID(); - continue; - } - } - - ImGui::TableNextRow(); - ImGui::PushID(vi); - - // Icon column - ImGui::TableSetColumnIndex(0); - { - uint32_t dispId = item.displayInfoId; - if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; - if (dispId != 0) { - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); - if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); - } - } - - // Name column - ImGui::TableSetColumnIndex(1); - if (info && info->valid) { - ImVec4 qc = InventoryScreen::getQualityColor(static_cast(info->quality)); - ImGui::TextColored(qc, "%s", info->name.c_str()); - if (ImGui::IsItemHovered()) { - inventoryScreen.renderItemTooltip(*info, &gameHandler.getInventory()); - } - // Shift-click: insert item link into chat - if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - } else { - ImGui::Text("Item %u", item.itemId); - } - - ImGui::TableSetColumnIndex(2); - if (item.buyPrice == 0 && item.extendedCost != 0) { - // Token-only item — show detailed cost from ItemExtendedCost.dbc - std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "%s", costStr.c_str()); - } else { - uint32_t g = item.buyPrice / 10000; - uint32_t s = (item.buyPrice / 100) % 100; - uint32_t c = item.buyPrice % 100; - bool canAfford = money >= item.buyPrice; - if (canAfford) { - renderCoinsText(g, s, c); - } else { - ImGui::TextColored(kColorRed, "%ug %us %uc", g, s, c); - } - // Show additional token cost if both gold and tokens are required - if (item.extendedCost != 0) { - std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); - if (costStr != "[Tokens]") { - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 0.8f), "+ %s", costStr.c_str()); - } - } - } - - ImGui::TableSetColumnIndex(3); - if (item.maxCount < 0) { - ImGui::TextDisabled("Inf"); - } else if (item.maxCount == 0) { - ImGui::TextColored(kColorRed, "Out"); - } else if (item.maxCount <= 5) { - ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), "%d", item.maxCount); - } else { - ImGui::Text("%d", item.maxCount); - } - - ImGui::TableSetColumnIndex(4); - bool outOfStock = (item.maxCount == 0); - if (outOfStock) ImGui::BeginDisabled(); - std::string buyBtnId = "Buy##vendor_" + std::to_string(vi); - if (ImGui::SmallButton(buyBtnId.c_str())) { - int qty = vendorBuyQty; - if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount; - uint32_t totalCost = item.buyPrice * static_cast(qty); - if (totalCost >= 10000) { // >= 1 gold: confirm - vendorConfirmOpen_ = true; - vendorConfirmGuid_ = vendor.vendorGuid; - vendorConfirmItemId_ = item.itemId; - vendorConfirmSlot_ = item.slot; - vendorConfirmQty_ = static_cast(qty); - vendorConfirmPrice_ = totalCost; - vendorConfirmItemName_ = (info && info->valid) ? info->name : "Item"; - } else { - gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, - static_cast(qty)); - } - } - if (outOfStock) ImGui::EndDisabled(); - - ImGui::PopID(); - } - - ImGui::EndTable(); - } - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeVendor(); - } - - // Vendor purchase confirmation popup for expensive items - if (vendorConfirmOpen_) { - ImGui::OpenPopup("Confirm Purchase##vendor"); - vendorConfirmOpen_ = false; - } - if (ImGui::BeginPopupModal("Confirm Purchase##vendor", nullptr, - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { - ImGui::Text("Buy %s", vendorConfirmItemName_.c_str()); - if (vendorConfirmQty_ > 1) - ImGui::Text("Quantity: %u", vendorConfirmQty_); - uint32_t g = vendorConfirmPrice_ / 10000; - uint32_t s = (vendorConfirmPrice_ / 100) % 100; - uint32_t c = vendorConfirmPrice_ % 100; - ImGui::Text("Cost: %ug %us %uc", g, s, c); - ImGui::Spacing(); - if (ImGui::Button("Buy", ImVec2(80, 0))) { - gameHandler.buyItem(vendorConfirmGuid_, vendorConfirmItemId_, - vendorConfirmSlot_, vendorConfirmQty_); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(80, 0))) { - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } -} // ============================================================ // Trainer // ============================================================ -void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isTrainerWindowOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - auto* assetMgr = core::Application::getInstance().getAssetManager(); - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, 100), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(500, 450), ImGuiCond_Appearing); - - bool open = true; - if (ImGui::Begin("Trainer", &open)) { - // If user clicked window close, short-circuit before rendering large trainer tables. - if (!open) { - ImGui::End(); - gameHandler.closeTrainer(); - return; - } - - const auto& trainer = gameHandler.getTrainerSpells(); - const bool isProfessionTrainer = (trainer.trainerType == 2); - - // NPC name - auto npcEntity = gameHandler.getEntityManager().getEntity(trainer.trainerGuid); - if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(npcEntity); - if (!unit->getName().empty()) { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str()); - } - } - - // Greeting - if (!trainer.greeting.empty()) { - ImGui::TextWrapped("%s", trainer.greeting.c_str()); - } - ImGui::Separator(); - - // Player money - uint64_t money = gameHandler.getMoneyCopper(); - ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(money); - - // Filter controls - static bool showUnavailable = false; - ImGui::Checkbox("Show unavailable spells", &showUnavailable); - ImGui::SameLine(); - ImGui::SetNextItemWidth(-1.0f); - ImGui::InputTextWithHint("##TrainerSearch", "Search...", trainerSearchFilter_, sizeof(trainerSearchFilter_)); - ImGui::Separator(); - - if (trainer.spells.empty()) { - ImGui::TextDisabled("This trainer has nothing to teach you."); - } else { - // Known spells for checking - const auto& knownSpells = gameHandler.getKnownSpells(); - auto isKnown = [&](uint32_t id) { - if (id == 0) return true; - // Check if spell is in knownSpells list - bool found = knownSpells.count(id); - if (found) return true; - - // Also check if spell is in trainer list with state=2 (explicitly known) - // state=0 means unavailable (could be no prereqs, wrong level, etc.) - don't count as known - for (const auto& ts : trainer.spells) { - if (ts.spellId == id && ts.state == 2) { - return true; - } - } - return false; - }; - uint32_t playerLevel = gameHandler.getPlayerLevel(); - - // Renders spell rows into the current table - auto renderSpellRows = [&](const std::vector& spells) { - for (const auto* spell : spells) { - // Check prerequisites client-side first - bool prereq1Met = isKnown(spell->chainNode1); - bool prereq2Met = isKnown(spell->chainNode2); - bool prereq3Met = isKnown(spell->chainNode3); - bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; - bool levelMet = (spell->reqLevel == 0 || playerLevel >= spell->reqLevel); - bool alreadyKnown = isKnown(spell->spellId); - - // Dynamically determine effective state based on current prerequisites - // Server sends state, but we override if prerequisites are now met - uint8_t effectiveState = spell->state; - if (spell->state == 1 && prereqsMet && levelMet) { - // Server said unavailable, but we now meet all requirements - effectiveState = 0; // Treat as available - } - - // Filter: skip unavailable spells if checkbox is unchecked - // Use effectiveState so spells with newly met prereqs aren't filtered - if (!showUnavailable && effectiveState == 1) { - continue; - } - - // Apply text search filter - if (trainerSearchFilter_[0] != '\0') { - std::string trainerFilter(trainerSearchFilter_); - for (char& c : trainerFilter) c = static_cast(std::tolower(static_cast(c))); - const std::string& spellName = gameHandler.getSpellName(spell->spellId); - std::string nameLC = spellName.empty() ? std::to_string(spell->spellId) : spellName; - for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); - if (nameLC.find(trainerFilter) == std::string::npos) { - ImGui::PushID(static_cast(spell->spellId)); - ImGui::PopID(); - continue; - } - } - - ImGui::TableNextRow(); - ImGui::PushID(static_cast(spell->spellId)); - - ImVec4 color; - const char* statusLabel; - // WotLK trainer states: 0=available, 1=unavailable, 2=known - if (effectiveState == 2 || alreadyKnown) { - color = colors::kQueueGreen; - statusLabel = "Known"; - } else if (effectiveState == 0) { - color = ui::colors::kWhite; - statusLabel = "Available"; - } else { - color = ImVec4(0.6f, 0.3f, 0.3f, 1.0f); - statusLabel = "Unavailable"; - } - - // Icon column - ImGui::TableSetColumnIndex(0); - { - VkDescriptorSet spellIcon = getSpellIcon(spell->spellId, assetMgr); - if (spellIcon) { - if (effectiveState == 1 && !alreadyKnown) { - ImGui::ImageWithBg((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18), - ImVec2(0, 0), ImVec2(1, 1), - ImVec4(0, 0, 0, 0), ImVec4(0.5f, 0.5f, 0.5f, 0.6f)); - } else { - ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18)); - } - } - } - - // Spell name - ImGui::TableSetColumnIndex(1); - const std::string& name = gameHandler.getSpellName(spell->spellId); - const std::string& rank = gameHandler.getSpellRank(spell->spellId); - if (!name.empty()) { - if (!rank.empty()) - ImGui::TextColored(color, "%s (%s)", name.c_str(), rank.c_str()); - else - ImGui::TextColored(color, "%s", name.c_str()); - } else { - ImGui::TextColored(color, "Spell #%u", spell->spellId); - } - - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - if (!name.empty()) { - ImGui::TextColored(kColorYellow, "%s", name.c_str()); - if (!rank.empty()) ImGui::TextColored(kColorGray, "%s", rank.c_str()); - } - const std::string& spDesc = gameHandler.getSpellDescription(spell->spellId); - if (!spDesc.empty()) { - ImGui::Spacing(); - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); - ImGui::TextWrapped("%s", spDesc.c_str()); - ImGui::PopTextWrapPos(); - ImGui::Spacing(); - } - ImGui::TextDisabled("Status: %s", statusLabel); - if (spell->reqLevel > 0) { - ImVec4 lvlColor = levelMet ? ui::colors::kLightGray : kColorRed; - ImGui::TextColored(lvlColor, "Required Level: %u", spell->reqLevel); - } - if (spell->reqSkill > 0) ImGui::Text("Required Skill: %u (value %u)", spell->reqSkill, spell->reqSkillValue); - auto showPrereq = [&](uint32_t node) { - if (node == 0) return; - bool met = isKnown(node); - const std::string& pname = gameHandler.getSpellName(node); - ImVec4 pcolor = met ? colors::kQueueGreen : kColorRed; - if (!pname.empty()) - ImGui::TextColored(pcolor, "Requires: %s%s", pname.c_str(), met ? " (known)" : ""); - else - ImGui::TextColored(pcolor, "Requires: Spell #%u%s", node, met ? " (known)" : ""); - }; - showPrereq(spell->chainNode1); - showPrereq(spell->chainNode2); - showPrereq(spell->chainNode3); - ImGui::EndTooltip(); - } - - // Level - ImGui::TableSetColumnIndex(2); - ImGui::TextColored(color, "%u", spell->reqLevel); - - // Cost - ImGui::TableSetColumnIndex(3); - if (spell->spellCost > 0) { - uint32_t g = spell->spellCost / 10000; - uint32_t s = (spell->spellCost / 100) % 100; - uint32_t c = spell->spellCost % 100; - bool canAfford = money >= spell->spellCost; - if (canAfford) { - renderCoinsText(g, s, c); - } else { - ImGui::TextColored(kColorRed, "%ug %us %uc", g, s, c); - } - } else { - ImGui::TextColored(color, "Free"); - } - - // Train button - only enabled if available, affordable, prereqs met - ImGui::TableSetColumnIndex(4); - // Use effectiveState so newly available spells (after learning prereqs) can be trained - bool canTrain = !alreadyKnown && effectiveState == 0 - && prereqsMet && levelMet - && (money >= spell->spellCost); - - // Debug logging for first 3 spells to see why buttons are disabled - static int logCount = 0; - static uint64_t lastTrainerGuid = 0; - if (trainer.trainerGuid != lastTrainerGuid) { - logCount = 0; - lastTrainerGuid = trainer.trainerGuid; - } - if (logCount < 3) { - LOG_INFO("Trainer button debug: spellId=", spell->spellId, - " alreadyKnown=", alreadyKnown, " state=", static_cast(spell->state), - " prereqsMet=", prereqsMet, " (", prereq1Met, ",", prereq2Met, ",", prereq3Met, ")", - " levelMet=", levelMet, - " reqLevel=", spell->reqLevel, " playerLevel=", playerLevel, - " chain1=", spell->chainNode1, " chain2=", spell->chainNode2, " chain3=", spell->chainNode3, - " canAfford=", (money >= spell->spellCost), - " canTrain=", canTrain); - logCount++; - } - - if (isProfessionTrainer && alreadyKnown) { - // Profession trainer: known recipes show "Create" button to craft - bool isCasting = gameHandler.isCasting(); - if (isCasting) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Create")) { - gameHandler.castSpell(spell->spellId, 0); - } - if (isCasting) ImGui::EndDisabled(); - } else { - if (!canTrain) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Train")) { - gameHandler.trainSpell(spell->spellId); - } - if (!canTrain) ImGui::EndDisabled(); - } - - ImGui::PopID(); - } - }; - - auto renderSpellTable = [&](const char* tableId, const std::vector& spells) { - if (ImGui::BeginTable(tableId, 5, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { - ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); - ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); - ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 55.0f); - ImGui::TableHeadersRow(); - renderSpellRows(spells); - ImGui::EndTable(); - } - }; - - const auto& tabs = gameHandler.getTrainerTabs(); - if (tabs.size() > 1) { - // Multiple tabs - show tab bar - if (ImGui::BeginTabBar("TrainerTabs")) { - for (size_t i = 0; i < tabs.size(); i++) { - char tabLabel[64]; - snprintf(tabLabel, sizeof(tabLabel), "%s (%zu)", - tabs[i].name.c_str(), tabs[i].spells.size()); - - if (ImGui::BeginTabItem(tabLabel)) { - char tableId[32]; - snprintf(tableId, sizeof(tableId), "TT%zu", i); - renderSpellTable(tableId, tabs[i].spells); - ImGui::EndTabItem(); - } - } - ImGui::EndTabBar(); - } - } else { - // Single tab or no categorization - flat list - std::vector allSpells; - allSpells.reserve(trainer.spells.size()); - for (const auto& spell : trainer.spells) { - allSpells.push_back(&spell); - } - renderSpellTable("TrainerTable", allSpells); - } - - // Count how many spells are trainable right now - int trainableCount = 0; - uint64_t totalCost = 0; - for (const auto& spell : trainer.spells) { - bool prereq1Met = isKnown(spell.chainNode1); - bool prereq2Met = isKnown(spell.chainNode2); - bool prereq3Met = isKnown(spell.chainNode3); - bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; - bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); - bool alreadyKnown = isKnown(spell.spellId); - uint8_t effectiveState = spell.state; - if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; - bool canTrain = !alreadyKnown && effectiveState == 0 - && prereqsMet && levelMet - && (money >= spell.spellCost); - if (canTrain) { - ++trainableCount; - totalCost += spell.spellCost; - } - } - - ImGui::Separator(); - bool canAffordAll = (money >= totalCost); - bool hasTrainable = (trainableCount > 0) && canAffordAll; - if (!hasTrainable) ImGui::BeginDisabled(); - uint32_t tag = static_cast(totalCost / 10000); - uint32_t tas = static_cast((totalCost / 100) % 100); - uint32_t tac = static_cast(totalCost % 100); - char trainAllLabel[80]; - if (trainableCount == 0) { - snprintf(trainAllLabel, sizeof(trainAllLabel), "Train All Available (none)"); - } else { - snprintf(trainAllLabel, sizeof(trainAllLabel), - "Train All Available (%d spell%s, %ug %us %uc)", - trainableCount, trainableCount == 1 ? "" : "s", - tag, tas, tac); - } - if (ImGui::Button(trainAllLabel, ImVec2(-1.0f, 0.0f))) { - for (const auto& spell : trainer.spells) { - bool prereq1Met = isKnown(spell.chainNode1); - bool prereq2Met = isKnown(spell.chainNode2); - bool prereq3Met = isKnown(spell.chainNode3); - bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; - bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); - bool alreadyKnown = isKnown(spell.spellId); - uint8_t effectiveState = spell.state; - if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; - bool canTrain = !alreadyKnown && effectiveState == 0 - && prereqsMet && levelMet - && (money >= spell.spellCost); - if (canTrain) { - gameHandler.trainSpell(spell.spellId); - } - } - } - if (!hasTrainable) ImGui::EndDisabled(); - - // Profession trainer: craft quantity controls - if (isProfessionTrainer) { - ImGui::Separator(); - static int craftQuantity = 1; - static uint32_t selectedCraftSpell = 0; - - // Show craft queue status if active - int queueRemaining = gameHandler.getCraftQueueRemaining(); - if (queueRemaining > 0) { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), - "Crafting... %d remaining", queueRemaining); - ImGui::SameLine(); - if (ImGui::SmallButton("Stop")) { - gameHandler.cancelCraftQueue(); - gameHandler.cancelCast(); - } - } else { - // Spell selector + quantity input - // Build list of known (craftable) spells - std::vector craftable; - for (const auto& spell : trainer.spells) { - if (isKnown(spell.spellId)) { - craftable.push_back(&spell); - } - } - if (!craftable.empty()) { - // Combo box for recipe selection - const char* previewName = "Select recipe..."; - for (const auto* sp : craftable) { - if (sp->spellId == selectedCraftSpell) { - const std::string& n = gameHandler.getSpellName(sp->spellId); - if (!n.empty()) previewName = n.c_str(); - break; - } - } - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f); - if (ImGui::BeginCombo("##CraftSelect", previewName)) { - for (const auto* sp : craftable) { - const std::string& n = gameHandler.getSpellName(sp->spellId); - const std::string& r = gameHandler.getSpellRank(sp->spellId); - char label[128]; - if (!r.empty()) - snprintf(label, sizeof(label), "%s (%s)##%u", - n.empty() ? "???" : n.c_str(), r.c_str(), sp->spellId); - else - snprintf(label, sizeof(label), "%s##%u", - n.empty() ? "???" : n.c_str(), sp->spellId); - if (ImGui::Selectable(label, sp->spellId == selectedCraftSpell)) { - selectedCraftSpell = sp->spellId; - } - } - ImGui::EndCombo(); - } - ImGui::SameLine(); - ImGui::SetNextItemWidth(50.0f); - ImGui::InputInt("##CraftQty", &craftQuantity, 0, 0); - if (craftQuantity < 1) craftQuantity = 1; - if (craftQuantity > 99) craftQuantity = 99; - ImGui::SameLine(); - bool canCraft = selectedCraftSpell != 0 && !gameHandler.isCasting(); - if (!canCraft) ImGui::BeginDisabled(); - if (ImGui::Button("Create")) { - if (craftQuantity == 1) { - gameHandler.castSpell(selectedCraftSpell, 0); - } else { - gameHandler.startCraftQueue(selectedCraftSpell, craftQuantity); - } - } - ImGui::SameLine(); - if (ImGui::Button("Create All")) { - // Queue a large count — server stops the queue automatically - // when materials run out (sends SPELL_FAILED_REAGENTS). - gameHandler.startCraftQueue(selectedCraftSpell, 999); - } - if (!canCraft) ImGui::EndDisabled(); - } - } - } - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeTrainer(); - } -} // ============================================================ // Teleporter Panel @@ -11730,617 +5503,32 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { // Escape Menu // ============================================================ -void GameScreen::renderEscapeMenu() { - if (!showEscapeMenu) return; - - ImGuiIO& io = ImGui::GetIO(); - float screenW = io.DisplaySize.x; - float screenH = io.DisplaySize.y; - ImVec2 size(260.0f, 248.0f); - ImVec2 pos((screenW - size.x) * 0.5f, (screenH - size.y) * 0.5f); - - ImGui::SetNextWindowPos(pos, ImGuiCond_Always); - ImGui::SetNextWindowSize(size, ImGuiCond_Always); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar; - - if (ImGui::Begin("##EscapeMenu", nullptr, flags)) { - ImGui::Text("Game Menu"); - ImGui::Separator(); - - if (ImGui::Button("Logout", ImVec2(-1, 0))) { - core::Application::getInstance().logoutToLogin(); - showEscapeMenu = false; - settingsPanel_.showEscapeSettingsNotice = false; - } - if (ImGui::Button("Quit", ImVec2(-1, 0))) { - auto* renderer = core::Application::getInstance().getRenderer(); - if (renderer) { - if (auto* music = renderer->getMusicManager()) { - music->stopMusic(0.0f); - } - } - core::Application::getInstance().shutdown(); - } - if (ImGui::Button("Settings", ImVec2(-1, 0))) { - settingsPanel_.showEscapeSettingsNotice = false; - settingsPanel_.showSettingsWindow = true; - settingsPanel_.settingsInit = false; - showEscapeMenu = false; - } - if (ImGui::Button("Instance Lockouts", ImVec2(-1, 0))) { - showInstanceLockouts_ = true; - showEscapeMenu = false; - } - if (ImGui::Button("Help / GM Ticket", ImVec2(-1, 0))) { - showGmTicketWindow_ = true; - showEscapeMenu = false; - } - - ImGui::Spacing(); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); - if (ImGui::Button("Back to Game", ImVec2(-1, 0))) { - showEscapeMenu = false; - settingsPanel_.showEscapeSettingsNotice = false; - } - ImGui::PopStyleVar(); - } - ImGui::End(); -} // ============================================================ // Barber Shop Window // ============================================================ -void GameScreen::renderBarberShopWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isBarberShopOpen()) { - barberInitialized_ = false; - return; - } - - const auto* ch = gameHandler.getActiveCharacter(); - if (!ch) return; - - uint8_t race = static_cast(ch->race); - game::Gender gender = ch->gender; - game::Race raceEnum = ch->race; - - // Initialize sliders from current appearance - if (!barberInitialized_) { - barberOrigHairStyle_ = static_cast((ch->appearanceBytes >> 16) & 0xFF); - barberOrigHairColor_ = static_cast((ch->appearanceBytes >> 24) & 0xFF); - barberOrigFacialHair_ = static_cast(ch->facialFeatures); - barberHairStyle_ = barberOrigHairStyle_; - barberHairColor_ = barberOrigHairColor_; - barberFacialHair_ = barberOrigFacialHair_; - barberInitialized_ = true; - } - - int maxHairStyle = static_cast(game::getMaxHairStyle(raceEnum, gender)); - int maxHairColor = static_cast(game::getMaxHairColor(raceEnum, gender)); - int maxFacialHair = static_cast(game::getMaxFacialFeature(raceEnum, gender)); - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - float winW = 300.0f; - float winH = 220.0f; - ImGui::SetNextWindowPos(ImVec2((screenW - winW) / 2.0f, (screenH - winH) / 2.0f), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Appearing); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; - bool open = true; - if (ImGui::Begin("Barber Shop", &open, flags)) { - ImGui::Text("Choose your new look:"); - ImGui::Separator(); - ImGui::Spacing(); - - ImGui::PushItemWidth(-1); - - // Hair Style - ImGui::Text("Hair Style"); - ImGui::SliderInt("##HairStyle", &barberHairStyle_, 0, maxHairStyle, - "%d"); - - // Hair Color - ImGui::Text("Hair Color"); - ImGui::SliderInt("##HairColor", &barberHairColor_, 0, maxHairColor, - "%d"); - - // Facial Hair / Piercings / Markings - const char* facialLabel = (gender == game::Gender::FEMALE) ? "Piercings" : "Facial Hair"; - // Some races use "Markings" or "Tusks" etc. - if (race == 8 || race == 6) facialLabel = "Features"; // Trolls, Tauren - ImGui::Text("%s", facialLabel); - ImGui::SliderInt("##FacialHair", &barberFacialHair_, 0, maxFacialHair, - "%d"); - - ImGui::PopItemWidth(); - - ImGui::Spacing(); - ImGui::Separator(); - - // Show whether anything changed - bool changed = (barberHairStyle_ != barberOrigHairStyle_ || - barberHairColor_ != barberOrigHairColor_ || - barberFacialHair_ != barberOrigFacialHair_); - - // OK / Reset / Cancel buttons - float btnW = 80.0f; - float totalW = btnW * 3 + ImGui::GetStyle().ItemSpacing.x * 2; - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) / 2.0f); - - if (!changed) ImGui::BeginDisabled(); - if (ImGui::Button("OK", ImVec2(btnW, 0))) { - gameHandler.sendAlterAppearance( - static_cast(barberHairStyle_), - static_cast(barberHairColor_), - static_cast(barberFacialHair_)); - // Keep window open — server will respond with SMSG_BARBER_SHOP_RESULT - } - if (!changed) ImGui::EndDisabled(); - - ImGui::SameLine(); - if (!changed) ImGui::BeginDisabled(); - if (ImGui::Button("Reset", ImVec2(btnW, 0))) { - barberHairStyle_ = barberOrigHairStyle_; - barberHairColor_ = barberOrigHairColor_; - barberFacialHair_ = barberOrigFacialHair_; - } - if (!changed) ImGui::EndDisabled(); - - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { - gameHandler.closeBarberShop(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeBarberShop(); - } -} // ============================================================ // Pet Stable Window // ============================================================ -void GameScreen::renderStableWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isStableWindowOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), - ImGuiCond_Once); - ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); - - bool open = true; - if (!ImGui::Begin("Pet Stable", &open, - kDialogFlags)) { - ImGui::End(); - if (!open) { - // User closed the window; clear stable state - gameHandler.closeStableWindow(); - } - return; - } - - const auto& pets = gameHandler.getStabledPets(); - uint8_t numSlots = gameHandler.getStableSlots(); - - ImGui::TextDisabled("Stable slots: %u", static_cast(numSlots)); - ImGui::Separator(); - - // Active pets section - bool hasActivePets = false; - for (const auto& p : pets) { - if (p.isActive) { hasActivePets = true; break; } - } - - if (hasActivePets) { - ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Active / Summoned"); - for (const auto& p : pets) { - if (!p.isActive) continue; - ImGui::PushID(static_cast(p.petNumber) * -1 - 1); - - const std::string displayName = p.name.empty() - ? ("Pet #" + std::to_string(p.petNumber)) - : p.name; - ImGui::Text(" %s (Level %u)", displayName.c_str(), p.level); - ImGui::SameLine(); - ImGui::TextDisabled("[Active]"); - - // Offer to stable the active pet if there are free slots - uint8_t usedSlots = 0; - for (const auto& sp : pets) { if (!sp.isActive) ++usedSlots; } - if (usedSlots < numSlots) { - ImGui::SameLine(); - if (ImGui::SmallButton("Store in stable")) { - // Slot 1 is first stable slot; server handles free slot assignment. - gameHandler.stablePet(1); - } - } - ImGui::PopID(); - } - ImGui::Separator(); - } - - // Stabled pets section - ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f), "Stabled Pets"); - - bool hasStabledPets = false; - for (const auto& p : pets) { - if (!p.isActive) { hasStabledPets = true; break; } - } - - if (!hasStabledPets) { - ImGui::TextDisabled(" (No pets in stable)"); - } else { - for (const auto& p : pets) { - if (p.isActive) continue; - ImGui::PushID(static_cast(p.petNumber)); - - const std::string displayName = p.name.empty() - ? ("Pet #" + std::to_string(p.petNumber)) - : p.name; - ImGui::Text(" %s (Level %u, Entry %u)", - displayName.c_str(), p.level, p.entry); - ImGui::SameLine(); - if (ImGui::SmallButton("Retrieve")) { - gameHandler.unstablePet(p.petNumber); - } - ImGui::PopID(); - } - } - - // Empty slots - uint8_t usedStableSlots = 0; - for (const auto& p : pets) { if (!p.isActive) ++usedStableSlots; } - if (usedStableSlots < numSlots) { - ImGui::TextDisabled(" %u empty slot(s) available", - static_cast(numSlots - usedStableSlots)); - } - - ImGui::Separator(); - if (ImGui::Button("Refresh")) { - gameHandler.requestStabledPetList(); - } - ImGui::SameLine(); - if (ImGui::Button("Close")) { - gameHandler.closeStableWindow(); - } - - ImGui::End(); - if (!open) { - gameHandler.closeStableWindow(); - } -} // ============================================================ // Taxi Window // ============================================================ -void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isTaxiWindowOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always); - - bool open = true; - if (ImGui::Begin("Flight Master", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { - const auto& taxiData = gameHandler.getTaxiData(); - const auto& nodes = gameHandler.getTaxiNodes(); - uint32_t currentNode = gameHandler.getTaxiCurrentNode(); - - // Get current node's map to filter destinations - uint32_t currentMapId = 0; - auto curIt = nodes.find(currentNode); - if (curIt != nodes.end()) { - currentMapId = curIt->second.mapId; - ImGui::TextColored(colors::kActiveGreen, "Current: %s", curIt->second.name.c_str()); - ImGui::Separator(); - } - - ImGui::Text("Select a destination:"); - ImGui::Spacing(); - - static uint32_t selectedNodeId = 0; - int destCount = 0; - if (ImGui::BeginTable("TaxiNodes", 3, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Destination", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, 60.0f); - ImGui::TableHeadersRow(); - - for (const auto& [nodeId, node] : nodes) { - if (nodeId == currentNode) continue; - if (node.mapId != currentMapId) continue; - if (!taxiData.isNodeKnown(nodeId)) continue; - - uint32_t costCopper = gameHandler.getTaxiCostTo(nodeId); - uint32_t gold = costCopper / 10000; - uint32_t silver = (costCopper / 100) % 100; - uint32_t copper = costCopper % 100; - - ImGui::PushID(static_cast(nodeId)); - ImGui::TableNextRow(); - - ImGui::TableSetColumnIndex(0); - bool isSelected = (selectedNodeId == nodeId); - if (ImGui::Selectable(node.name.c_str(), isSelected, - ImGuiSelectableFlags_SpanAllColumns | - ImGuiSelectableFlags_AllowDoubleClick)) { - selectedNodeId = nodeId; - LOG_INFO("Taxi UI: Selected dest=", nodeId); - if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { - LOG_INFO("Taxi UI: Double-click activate dest=", nodeId); - gameHandler.activateTaxi(nodeId); - } - } - - ImGui::TableSetColumnIndex(1); - renderCoinsText(gold, silver, copper); - - ImGui::TableSetColumnIndex(2); - if (ImGui::SmallButton("Fly")) { - selectedNodeId = nodeId; - LOG_INFO("Taxi UI: Fly clicked dest=", nodeId); - gameHandler.activateTaxi(nodeId); - } - - ImGui::PopID(); - destCount++; - } - ImGui::EndTable(); - } - - if (destCount == 0) { - ImGui::TextColored(ui::colors::kLightGray, "No destinations available."); - } - - ImGui::Spacing(); - ImGui::Separator(); - if (selectedNodeId != 0 && ImGui::Button("Fly Selected", ImVec2(-1, 0))) { - LOG_INFO("Taxi UI: Fly Selected dest=", selectedNodeId); - gameHandler.activateTaxi(selectedNodeId); - } - if (ImGui::Button("Close", ImVec2(-1, 0))) { - gameHandler.closeTaxi(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeTaxi(); - } -} // ============================================================ // Logout Countdown // ============================================================ -void GameScreen::renderLogoutCountdown(game::GameHandler& gameHandler) { - if (!gameHandler.isLoggingOut()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - constexpr float W = 280.0f; - constexpr float H = 80.0f; - ImGui::SetNextWindowPos(ImVec2((screenW - W) * 0.5f, screenH * 0.5f - H * 0.5f - 60.0f), - ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(W, H), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.88f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.8f, 1.0f)); - - if (ImGui::Begin("##LogoutCountdown", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) { - - float cd = gameHandler.getLogoutCountdown(); - if (cd > 0.0f) { - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 6.0f); - ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out in 20s...").x) * 0.5f); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), - "Logging out in %ds...", static_cast(std::ceil(cd))); - - // Progress bar (20 second countdown) - float frac = 1.0f - std::min(cd / 20.0f, 1.0f); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.5f, 0.5f, 0.9f, 1.0f)); - ImGui::ProgressBar(frac, ImVec2(-1.0f, 8.0f), ""); - ImGui::PopStyleColor(); - ImGui::Spacing(); - } else { - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 14.0f); - ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out...").x) * 0.5f); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "Logging out..."); - ImGui::Spacing(); - } - - // Cancel button — only while countdown is still running - if (cd > 0.0f) { - float btnW = 100.0f; - ImGui::SetCursorPosX((W - btnW) * 0.5f); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f)); - if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { - gameHandler.cancelLogout(); - } - ImGui::PopStyleColor(2); - } - } - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} // ============================================================ // Death Screen // ============================================================ -void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { - if (!gameHandler.showDeathDialog()) { - deathTimerRunning_ = false; - deathElapsed_ = 0.0f; - return; - } - float dt = ImGui::GetIO().DeltaTime; - if (!deathTimerRunning_) { - deathElapsed_ = 0.0f; - deathTimerRunning_ = true; - } else { - deathElapsed_ += dt; - } - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - // Dark red overlay covering the whole screen - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.0f, 0.0f, 0.45f)); - ImGui::Begin("##DeathOverlay", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoInputs | - ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing); - ImGui::End(); - ImGui::PopStyleColor(); - - // "Release Spirit" dialog centered on screen - const bool hasSelfRes = gameHandler.canSelfRes(); - float dlgW = 280.0f; - // Extra height when self-res button is available; +20 for the "wait for res" hint - float dlgH = hasSelfRes ? 190.0f : 150.0f; - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.0f, 0.0f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.6f, 0.1f, 0.1f, 1.0f)); - - if (ImGui::Begin("##DeathDialog", nullptr, - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { - - ImGui::Spacing(); - // Center "You are dead." text - const char* deathText = "You are dead."; - float textW = ImGui::CalcTextSize(deathText).x; - ImGui::SetCursorPosX((dlgW - textW) / 2); - ImGui::TextColored(colors::kBrightRed, "%s", deathText); - - // Respawn timer: show how long until the server auto-releases the spirit - float timeLeft = kForcedReleaseSec - deathElapsed_; - if (timeLeft > 0.0f) { - int mins = static_cast(timeLeft) / 60; - int secs = static_cast(timeLeft) % 60; - char timerBuf[48]; - snprintf(timerBuf, sizeof(timerBuf), "Auto-release in %d:%02d", mins, secs); - float tw = ImGui::CalcTextSize(timerBuf).x; - ImGui::SetCursorPosX((dlgW - tw) / 2); - ImGui::TextColored(colors::kMediumGray, "%s", timerBuf); - } - - ImGui::Spacing(); - ImGui::Spacing(); - - // Self-resurrection button (Reincarnation / Twisting Nether / Deathpact) - if (hasSelfRes) { - float btnW2 = 220.0f; - ImGui::SetCursorPosX((dlgW - btnW2) / 2); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.55f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.5f, 0.75f, 1.0f)); - if (ImGui::Button("Use Self-Resurrection", ImVec2(btnW2, 30))) { - gameHandler.useSelfRes(); - } - ImGui::PopStyleColor(2); - ImGui::Spacing(); - } - - // Center the Release Spirit button - float btnW = 180.0f; - ImGui::SetCursorPosX((dlgW - btnW) / 2); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f)); - if (ImGui::Button("Release Spirit", ImVec2(btnW, 30))) { - gameHandler.releaseSpirit(); - } - ImGui::PopStyleColor(2); - - // Hint: player can stay dead and wait for another player to cast Resurrection - const char* resHint = "Or wait for a player to resurrect you."; - float hw = ImGui::CalcTextSize(resHint).x; - ImGui::SetCursorPosX((dlgW - hw) / 2); - ImGui::TextColored(ImVec4(0.5f, 0.6f, 0.5f, 0.85f), "%s", resHint); - } - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} - -void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { - if (!gameHandler.isPlayerGhost() || !gameHandler.canReclaimCorpse()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - float delaySec = gameHandler.getCorpseReclaimDelaySec(); - bool onDelay = (delaySec > 0.0f); - - float btnW = 220.0f, btnH = 36.0f; - float winH = btnH + 16.0f + (onDelay ? 20.0f : 0.0f); - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, winH), ImGuiCond_Always); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f)); - if (ImGui::Begin("##ReclaimCorpse", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoBringToFrontOnFocus)) { - if (onDelay) { - // Greyed-out button while PvP reclaim timer ticks down - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); - ImGui::BeginDisabled(true); - char delayLabel[64]; - snprintf(delayLabel, sizeof(delayLabel), "Resurrect from Corpse (%.0fs)", delaySec); - ImGui::Button(delayLabel, ImVec2(btnW, btnH)); - ImGui::EndDisabled(); - ImGui::PopStyleColor(2); - const char* waitMsg = "You cannot reclaim your corpse yet."; - float tw = ImGui::CalcTextSize(waitMsg).x; - ImGui::SetCursorPosX((btnW + 16.0f - tw) * 0.5f); - ImGui::TextColored(ImVec4(0.8f, 0.5f, 0.2f, 1.0f), "%s", waitMsg); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); - if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { - gameHandler.reclaimCorpse(); - } - ImGui::PopStyleColor(2); - float corpDist = gameHandler.getCorpseDistance(); - if (corpDist >= 0.0f) { - char distBuf[48]; - snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); - float dw = ImGui::CalcTextSize(distBuf).x; - ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); - ImGui::TextDisabled("%s", distBuf); - } - } - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(2); -} void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) { const auto& statuses = gameHandler.getNpcQuestStatuses(); @@ -13450,13 +6638,13 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { ImVec2 p = ImGui::GetCursorScreenPos(); ImVec2 sz(20.0f, 20.0f); if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) { - showSocialFrame_ = !showSocialFrame_; + socialPanel_.showSocialFrame_ = !socialPanel_.showSocialFrame_; } bool hovered = ImGui::IsItemHovered(); - ImU32 bg = showSocialFrame_ + ImU32 bg = socialPanel_.showSocialFrame_ ? IM_COL32(42, 100, 42, 230) : IM_COL32(38, 38, 38, 210); - if (hovered) bg = showSocialFrame_ ? IM_COL32(58, 130, 58, 230) : IM_COL32(65, 65, 65, 220); + 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), @@ -14069,1305 +7257,22 @@ void GameScreen::loadSettings() { // Mail Window // ============================================================ -void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isMailboxOpen()) return; - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 250, 80), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_Appearing); - - bool open = true; - if (ImGui::Begin("Mailbox", &open)) { - const auto& inbox = gameHandler.getMailInbox(); - - // Top bar: money + compose button - ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(gameHandler.getMoneyCopper()); - ImGui::SameLine(ImGui::GetWindowWidth() - 100); - if (ImGui::Button("Compose")) { - mailRecipientBuffer_[0] = '\0'; - mailSubjectBuffer_[0] = '\0'; - mailBodyBuffer_[0] = '\0'; - mailComposeMoney_[0] = 0; - mailComposeMoney_[1] = 0; - mailComposeMoney_[2] = 0; - gameHandler.openMailCompose(); - } - ImGui::Separator(); - - if (inbox.empty()) { - ImGui::TextDisabled("No mail."); - } else { - // Two-panel layout: left = mail list, right = selected mail detail - float listWidth = 220.0f; - - // Left panel - mail list - ImGui::BeginChild("MailList", ImVec2(listWidth, 0), true); - for (size_t i = 0; i < inbox.size(); ++i) { - const auto& mail = inbox[i]; - ImGui::PushID(static_cast(i)); - - bool selected = (gameHandler.getSelectedMailIndex() == static_cast(i)); - std::string label = mail.subject.empty() ? "(No Subject)" : mail.subject; - - // Unread indicator - if (!mail.read) { - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 0.5f, 1.0f)); - } - - if (ImGui::Selectable(label.c_str(), selected)) { - gameHandler.setSelectedMailIndex(static_cast(i)); - // Mark as read - if (!mail.read) { - gameHandler.mailMarkAsRead(mail.messageId); - } - } - - if (!mail.read) { - ImGui::PopStyleColor(); - } - - // Sub-info line - ImGui::TextColored(kColorGray, " From: %s", mail.senderName.c_str()); - if (mail.money > 0) { - ImGui::SameLine(); - ImGui::TextColored(colors::kWarmGold, " [G]"); - } - if (!mail.attachments.empty()) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), " [A]"); - } - // Expiry warning if within 3 days - if (mail.expirationTime > 0.0f) { - auto nowSec = static_cast(std::time(nullptr)); - float secsLeft = mail.expirationTime - nowSec; - if (secsLeft < 3.0f * 86400.0f && secsLeft > 0.0f) { - ImGui::SameLine(); - int daysLeft = static_cast(secsLeft / 86400.0f); - if (daysLeft == 0) { - ImGui::TextColored(colors::kBrightRed, " [expires today!]"); - } else { - ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), - " [expires in %dd]", daysLeft); - } - } - } - - ImGui::PopID(); - } - ImGui::EndChild(); - - ImGui::SameLine(); - - // Right panel - selected mail detail - ImGui::BeginChild("MailDetail", ImVec2(0, 0), true); - int sel = gameHandler.getSelectedMailIndex(); - if (sel >= 0 && sel < static_cast(inbox.size())) { - const auto& mail = inbox[sel]; - - ImGui::TextColored(colors::kWarmGold, "%s", - mail.subject.empty() ? "(No Subject)" : mail.subject.c_str()); - ImGui::Text("From: %s", mail.senderName.c_str()); - - if (mail.messageType == 2) { - ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.2f, 1.0f), "[Auction House]"); - } - - // Show expiry date in the detail panel - if (mail.expirationTime > 0.0f) { - auto nowSec = static_cast(std::time(nullptr)); - float secsLeft = mail.expirationTime - nowSec; - // Format absolute expiry as a date using struct tm - time_t expT = static_cast(mail.expirationTime); - struct tm* tmExp = std::localtime(&expT); - if (tmExp) { - const char* mname = kMonthAbbrev[tmExp->tm_mon]; - int daysLeft = static_cast(secsLeft / 86400.0f); - if (secsLeft <= 0.0f) { - ImGui::TextColored(kColorGray, - "Expired: %s %d, %d", mname, tmExp->tm_mday, 1900 + tmExp->tm_year); - } else if (secsLeft < 3.0f * 86400.0f) { - ImGui::TextColored(kColorRed, - "Expires: %s %d, %d (%d day%s!)", - mname, tmExp->tm_mday, 1900 + tmExp->tm_year, - daysLeft, daysLeft == 1 ? "" : "s"); - } else { - ImGui::TextDisabled("Expires: %s %d, %d", - mname, tmExp->tm_mday, 1900 + tmExp->tm_year); - } - } - } - ImGui::Separator(); - - // Body text - if (!mail.body.empty()) { - ImGui::TextWrapped("%s", mail.body.c_str()); - ImGui::Separator(); - } - - // Money - if (mail.money > 0) { - ImGui::TextDisabled("Money:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(mail.money); - ImGui::SameLine(); - if (ImGui::SmallButton("Take Money")) { - gameHandler.mailTakeMoney(mail.messageId); - } - } - - // COD warning - if (mail.cod > 0) { - uint64_t g = mail.cod / 10000; - uint64_t s = (mail.cod / 100) % 100; - uint64_t c = mail.cod % 100; - ImGui::TextColored(kColorRed, - "COD: %llug %llus %lluc (you pay this to take items)", - static_cast(g), - static_cast(s), - static_cast(c)); - } - - // Attachments - if (!mail.attachments.empty()) { - ImGui::Text("Attachments: %zu", mail.attachments.size()); - ImDrawList* mailDraw = ImGui::GetWindowDrawList(); - constexpr float MAIL_SLOT = 34.0f; - for (size_t j = 0; j < mail.attachments.size(); ++j) { - const auto& att = mail.attachments[j]; - ImGui::PushID(static_cast(j)); - - auto* info = gameHandler.getItemInfo(att.itemId); - game::ItemQuality quality = game::ItemQuality::COMMON; - std::string name = "Item " + std::to_string(att.itemId); - uint32_t displayInfoId = 0; - if (info && info->valid) { - quality = static_cast(info->quality); - name = info->name; - displayInfoId = info->displayInfoId; - } else { - gameHandler.ensureItemInfo(att.itemId); - } - ImVec4 qc = InventoryScreen::getQualityColor(quality); - ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); - - ImVec2 pos = ImGui::GetCursorScreenPos(); - VkDescriptorSet iconTex = displayInfoId - ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; - if (iconTex) { - mailDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, - ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT)); - mailDraw->AddRect(pos, ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), - borderCol, 0.0f, 0, 1.5f); - } else { - mailDraw->AddRectFilled(pos, - ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), - IM_COL32(40, 35, 30, 220)); - mailDraw->AddRect(pos, - ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), - borderCol, 0.0f, 0, 1.5f); - } - if (att.stackCount > 1) { - char cnt[16]; - snprintf(cnt, sizeof(cnt), "%u", att.stackCount); - float cw = ImGui::CalcTextSize(cnt).x; - mailDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), - IM_COL32(0, 0, 0, 200), cnt); - mailDraw->AddText( - ImVec2(pos.x + MAIL_SLOT - cw - 2.0f, pos.y + MAIL_SLOT - 14.0f), - IM_COL32(255, 255, 255, 220), cnt); - } - - ImGui::InvisibleButton("##mailatt", ImVec2(MAIL_SLOT, MAIL_SLOT)); - if (ImGui::IsItemHovered() && info && info->valid) - inventoryScreen.renderItemTooltip(*info); - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - ImGui::SameLine(); - ImGui::TextColored(qc, "%s", name.c_str()); - if (ImGui::IsItemHovered() && info && info->valid) - inventoryScreen.renderItemTooltip(*info); - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Take")) { - gameHandler.mailTakeItem(mail.messageId, att.itemGuidLow); - } - - ImGui::PopID(); - } - // "Take All" button when there are multiple attachments - if (mail.attachments.size() > 1) { - if (ImGui::SmallButton("Take All")) { - for (const auto& att2 : mail.attachments) { - gameHandler.mailTakeItem(mail.messageId, att2.itemGuidLow); - } - } - } - } - - ImGui::Spacing(); - ImGui::Separator(); - - // Action buttons - if (ImGui::Button("Delete")) { - gameHandler.mailDelete(mail.messageId); - } - ImGui::SameLine(); - if (mail.messageType == 0 && ImGui::Button("Reply")) { - // Pre-fill compose with sender as recipient - strncpy(mailRecipientBuffer_, mail.senderName.c_str(), sizeof(mailRecipientBuffer_) - 1); - mailRecipientBuffer_[sizeof(mailRecipientBuffer_) - 1] = '\0'; - std::string reSubject = "Re: " + mail.subject; - strncpy(mailSubjectBuffer_, reSubject.c_str(), sizeof(mailSubjectBuffer_) - 1); - mailSubjectBuffer_[sizeof(mailSubjectBuffer_) - 1] = '\0'; - mailBodyBuffer_[0] = '\0'; - mailComposeMoney_[0] = 0; - mailComposeMoney_[1] = 0; - mailComposeMoney_[2] = 0; - gameHandler.openMailCompose(); - } - } else { - ImGui::TextDisabled("Select a mail to read."); - } - ImGui::EndChild(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeMailbox(); - } -} - -void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isMailComposeOpen()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 250), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(400, 500), ImGuiCond_Appearing); - - bool open = true; - if (ImGui::Begin("Send Mail", &open)) { - ImGui::Text("To:"); - ImGui::SameLine(60); - ImGui::SetNextItemWidth(-1); - ImGui::InputText("##MailTo", mailRecipientBuffer_, sizeof(mailRecipientBuffer_)); - - ImGui::Text("Subject:"); - ImGui::SameLine(60); - ImGui::SetNextItemWidth(-1); - ImGui::InputText("##MailSubject", mailSubjectBuffer_, sizeof(mailSubjectBuffer_)); - - ImGui::Text("Body:"); - ImGui::InputTextMultiline("##MailBody", mailBodyBuffer_, sizeof(mailBodyBuffer_), - ImVec2(-1, 120)); - - // Attachments section - int attachCount = gameHandler.getMailAttachmentCount(); - ImGui::Text("Attachments (%d/12):", attachCount); - ImGui::SameLine(); - ImGui::TextColored(kColorGray, "Right-click items in bags to attach"); - - const auto& attachments = gameHandler.getMailAttachments(); - // Show attachment slots in a grid (6 per row) - for (int i = 0; i < game::GameHandler::MAIL_MAX_ATTACHMENTS; ++i) { - if (i % 6 != 0) ImGui::SameLine(); - ImGui::PushID(i + 5000); - const auto& att = attachments[i]; - if (att.occupied()) { - // Show item with quality color border - ImVec4 qualColor = ui::InventoryScreen::getQualityColor(att.item.quality); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qualColor.x * 0.3f, qualColor.y * 0.3f, qualColor.z * 0.3f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qualColor.x * 0.5f, qualColor.y * 0.5f, qualColor.z * 0.5f, 0.9f)); - - // Try to show icon - VkDescriptorSet icon = inventoryScreen.getItemIcon(att.item.displayInfoId); - bool clicked = false; - if (icon) { - clicked = ImGui::ImageButton("##att", (ImTextureID)icon, ImVec2(30, 30)); - } else { - // Truncate name to fit - std::string label = att.item.name.substr(0, 4); - clicked = ImGui::Button(label.c_str(), ImVec2(36, 36)); - } - ImGui::PopStyleColor(2); - - if (clicked) { - gameHandler.detachMailAttachment(i); - } - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qualColor, "%s", att.item.name.c_str()); - ImGui::TextColored(ui::colors::kLightGray, "Click to remove"); - ImGui::EndTooltip(); - } - } else { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.5f)); - ImGui::Button("##empty", ImVec2(36, 36)); - ImGui::PopStyleColor(); - } - ImGui::PopID(); - } - - ImGui::Spacing(); - ImGui::Text("Money:"); - ImGui::SameLine(60); - ImGui::SetNextItemWidth(60); - ImGui::InputInt("##MailGold", &mailComposeMoney_[0], 0, 0); - if (mailComposeMoney_[0] < 0) mailComposeMoney_[0] = 0; - ImGui::SameLine(); - ImGui::Text("g"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(40); - ImGui::InputInt("##MailSilver", &mailComposeMoney_[1], 0, 0); - if (mailComposeMoney_[1] < 0) mailComposeMoney_[1] = 0; - if (mailComposeMoney_[1] > 99) mailComposeMoney_[1] = 99; - ImGui::SameLine(); - ImGui::Text("s"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(40); - ImGui::InputInt("##MailCopper", &mailComposeMoney_[2], 0, 0); - if (mailComposeMoney_[2] < 0) mailComposeMoney_[2] = 0; - if (mailComposeMoney_[2] > 99) mailComposeMoney_[2] = 99; - ImGui::SameLine(); - ImGui::Text("c"); - - uint64_t totalMoney = static_cast(mailComposeMoney_[0]) * 10000 + - static_cast(mailComposeMoney_[1]) * 100 + - static_cast(mailComposeMoney_[2]); - - uint32_t sendCost = attachCount > 0 ? static_cast(30 * attachCount) : 30u; - ImGui::TextColored(kColorGray, "Sending cost: %uc", sendCost); - - ImGui::Spacing(); - bool canSend = (strlen(mailRecipientBuffer_) > 0); - if (!canSend) ImGui::BeginDisabled(); - if (ImGui::Button("Send", ImVec2(80, 0))) { - gameHandler.sendMail(mailRecipientBuffer_, mailSubjectBuffer_, - mailBodyBuffer_, totalMoney); - } - if (!canSend) ImGui::EndDisabled(); - - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(80, 0))) { - gameHandler.closeMailCompose(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.closeMailCompose(); - } -} // ============================================================ // Bank Window // ============================================================ -void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isBankOpen()) return; - - bool open = true; - ImGui::SetNextWindowSize(ImVec2(480, 420), ImGuiCond_FirstUseEver); - if (!ImGui::Begin("Bank", &open)) { - ImGui::End(); - if (!open) gameHandler.closeBank(); - return; - } - - auto& inv = gameHandler.getInventory(); - bool isHolding = inventoryScreen.isHoldingItem(); - constexpr float SLOT_SIZE = 42.0f; - static constexpr float kBankPickupHold = 0.10f; // seconds - // Persistent pickup tracking for bank (mirrors inventory_screen's pickupPending_) - static bool bankPickupPending = false; - static float bankPickupPressTime = 0.0f; - static int bankPickupType = 0; // 0=main bank, 1=bank bag slot, 2=bank bag equip slot - static int bankPickupIndex = -1; - static int bankPickupBagIndex = -1; - static int bankPickupBagSlotIndex = -1; - - // Helper: render a bank item slot with icon, click-and-hold pickup, drop, tooltip - auto renderBankItemSlot = [&](const game::ItemSlot& slot, int pickType, int mainIdx, - int bagIdx, int bagSlotIdx, uint8_t dstBag, uint8_t dstSlot) { - ImDrawList* drawList = ImGui::GetWindowDrawList(); - ImVec2 pos = ImGui::GetCursorScreenPos(); - - if (slot.empty()) { - ImU32 bgCol = IM_COL32(30, 30, 30, 200); - ImU32 borderCol = IM_COL32(60, 60, 60, 200); - if (isHolding) { - bgCol = IM_COL32(20, 50, 20, 200); - borderCol = IM_COL32(0, 180, 0, 200); - } - drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol); - drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), borderCol); - ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE)); - if (isHolding && ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot); - } - } else { - const auto& item = slot.item; - ImVec4 qc = InventoryScreen::getQualityColor(item.quality); - ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(item.displayInfoId); - - if (iconTex) { - drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos, - ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE)); - drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), - borderCol, 0.0f, 0, 2.0f); - } else { - ImU32 bgCol = IM_COL32(40, 35, 30, 220); - drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol); - drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), - borderCol, 0.0f, 0, 2.0f); - if (!item.name.empty()) { - char abbr[3] = { item.name[0], item.name.size() > 1 ? item.name[1] : '\0', '\0' }; - float tw = ImGui::CalcTextSize(abbr).x; - drawList->AddText(ImVec2(pos.x + (SLOT_SIZE - tw) * 0.5f, pos.y + 2.0f), - ImGui::ColorConvertFloat4ToU32(qc), abbr); - } - } - - if (item.stackCount > 1) { - char countStr[16]; - snprintf(countStr, sizeof(countStr), "%u", item.stackCount); - float cw = ImGui::CalcTextSize(countStr).x; - drawList->AddText(ImVec2(pos.x + SLOT_SIZE - cw - 2.0f, pos.y + SLOT_SIZE - 14.0f), - IM_COL32(255, 255, 255, 220), countStr); - } - - ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE)); - - if (!isHolding) { - // Start pickup tracking on mouse press - if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { - bankPickupPending = true; - bankPickupPressTime = ImGui::GetTime(); - bankPickupType = pickType; - bankPickupIndex = mainIdx; - bankPickupBagIndex = bagIdx; - bankPickupBagSlotIndex = bagSlotIdx; - } - // Check if held long enough to pick up - if (bankPickupPending && ImGui::IsMouseDown(ImGuiMouseButton_Left) && - (ImGui::GetTime() - bankPickupPressTime) >= kBankPickupHold) { - bool sameSlot = (bankPickupType == pickType); - if (pickType == 0) - sameSlot = sameSlot && (bankPickupIndex == mainIdx); - else if (pickType == 1) - sameSlot = sameSlot && (bankPickupBagIndex == bagIdx) && (bankPickupBagSlotIndex == bagSlotIdx); - else if (pickType == 2) - sameSlot = sameSlot && (bankPickupIndex == mainIdx); - - if (sameSlot && ImGui::IsItemHovered()) { - bankPickupPending = false; - if (pickType == 0) { - inventoryScreen.pickupFromBank(inv, mainIdx); - } else if (pickType == 1) { - inventoryScreen.pickupFromBankBag(inv, bagIdx, bagSlotIdx); - } else if (pickType == 2) { - inventoryScreen.pickupFromBankBagEquip(inv, mainIdx); - } - } - } - } else { - // Drop/swap on mouse release - if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot); - } - } - - // Tooltip - if (ImGui::IsItemHovered() && !isHolding) { - auto* info = gameHandler.getItemInfo(item.itemId); - if (info && info->valid) - inventoryScreen.renderItemTooltip(*info); - else { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", item.name.c_str()); - ImGui::EndTooltip(); - } - - // Shift-click to insert item link into chat - if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift - && !item.name.empty()) { - auto* info2 = gameHandler.getItemInfo(item.itemId); - uint8_t q = (info2 && info2->valid) - ? static_cast(info2->quality) - : static_cast(item.quality); - const std::string& lname = (info2 && info2->valid && !info2->name.empty()) - ? info2->name : item.name; - std::string link = buildItemChatLink(item.itemId, q, lname); - chatPanel_.insertChatLink(link); - } - } - } - }; - - // Main bank slots (24 for Classic, 28 for TBC/WotLK) - int bankSlotCount = gameHandler.getEffectiveBankSlots(); - int bankBagCount = gameHandler.getEffectiveBankBagSlots(); - ImGui::Text("Bank Slots"); - ImGui::Separator(); - for (int i = 0; i < bankSlotCount; i++) { - if (i % 7 != 0) ImGui::SameLine(); - ImGui::PushID(i + 1000); - renderBankItemSlot(inv.getBankSlot(i), 0, i, -1, -1, 0xFF, static_cast(39 + i)); - ImGui::PopID(); - } - - // Bank bag equip slots — show bag icon with pickup/drop, or "Buy Slot" - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Text("Bank Bags"); - uint8_t purchased = inv.getPurchasedBankBagSlots(); - for (int i = 0; i < bankBagCount; i++) { - if (i > 0) ImGui::SameLine(); - ImGui::PushID(i + 2000); - - int bagSize = inv.getBankBagSize(i); - if (i < purchased || bagSize > 0) { - const auto& bagSlot = inv.getBankBagItem(i); - // Render as an item slot: icon with pickup/drop (pickType=2 for bag equip) - renderBankItemSlot(bagSlot, 2, i, -1, -1, 0xFF, static_cast(67 + i)); - } else { - if (ImGui::Button("Buy Slot", ImVec2(50, 30))) { - gameHandler.buyBankSlot(); - } - } - ImGui::PopID(); - } - - // Show expanded bank bag contents - for (int bagIdx = 0; bagIdx < bankBagCount; bagIdx++) { - int bagSize = inv.getBankBagSize(bagIdx); - if (bagSize <= 0) continue; - - ImGui::Spacing(); - ImGui::Text("Bank Bag %d (%d slots)", bagIdx + 1, bagSize); - for (int s = 0; s < bagSize; s++) { - if (s % 7 != 0) ImGui::SameLine(); - ImGui::PushID(3000 + bagIdx * 100 + s); - renderBankItemSlot(inv.getBankBagSlot(bagIdx, s), 1, -1, bagIdx, s, - static_cast(67 + bagIdx), static_cast(s)); - ImGui::PopID(); - } - } - - ImGui::End(); - - if (!open) gameHandler.closeBank(); -} // ============================================================ // Guild Bank Window // ============================================================ -void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isGuildBankOpen()) return; - - bool open = true; - ImGui::SetNextWindowSize(ImVec2(520, 500), ImGuiCond_FirstUseEver); - if (!ImGui::Begin("Guild Bank", &open)) { - ImGui::End(); - if (!open) gameHandler.closeGuildBank(); - return; - } - - const auto& data = gameHandler.getGuildBankData(); - uint8_t activeTab = gameHandler.getGuildBankActiveTab(); - - // Money display - uint32_t gold = static_cast(data.money / 10000); - uint32_t silver = static_cast((data.money / 100) % 100); - uint32_t copper = static_cast(data.money % 100); - ImGui::TextDisabled("Guild Bank Money:"); ImGui::SameLine(0, 4); - renderCoinsText(gold, silver, copper); - - // Tab bar - if (!data.tabs.empty()) { - for (size_t i = 0; i < data.tabs.size(); i++) { - if (i > 0) ImGui::SameLine(); - bool selected = (i == activeTab); - if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f)); - std::string tabLabel = data.tabs[i].tabName.empty() ? ("Tab " + std::to_string(i + 1)) : data.tabs[i].tabName; - if (ImGui::Button(tabLabel.c_str())) { - gameHandler.queryGuildBankTab(static_cast(i)); - } - if (selected) ImGui::PopStyleColor(); - } - } - - // Buy tab button - if (data.tabs.size() < 6) { - ImGui::SameLine(); - if (ImGui::Button("Buy Tab")) { - gameHandler.buyGuildBankTab(); - } - } - - ImGui::Separator(); - - // Tab items (98 slots = 14 columns × 7 rows) - constexpr float GB_SLOT = 34.0f; - ImDrawList* gbDraw = ImGui::GetWindowDrawList(); - for (size_t i = 0; i < data.tabItems.size(); i++) { - if (i % 14 != 0) ImGui::SameLine(0.0f, 2.0f); - const auto& item = data.tabItems[i]; - ImGui::PushID(static_cast(i) + 5000); - - ImVec2 pos = ImGui::GetCursorScreenPos(); - - if (item.itemEntry == 0) { - gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), - IM_COL32(30, 30, 30, 200)); - gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), - IM_COL32(60, 60, 60, 180)); - ImGui::InvisibleButton("##gbempty", ImVec2(GB_SLOT, GB_SLOT)); - } else { - auto* info = gameHandler.getItemInfo(item.itemEntry); - game::ItemQuality quality = game::ItemQuality::COMMON; - std::string name = "Item " + std::to_string(item.itemEntry); - uint32_t displayInfoId = 0; - if (info) { - quality = static_cast(info->quality); - name = info->name; - displayInfoId = info->displayInfoId; - } - ImVec4 qc = InventoryScreen::getQualityColor(quality); - ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); - - VkDescriptorSet iconTex = displayInfoId ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; - if (iconTex) { - gbDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, - ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT)); - gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), - borderCol, 0.0f, 0, 1.5f); - } else { - gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), - IM_COL32(40, 35, 30, 220)); - gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), - borderCol, 0.0f, 0, 1.5f); - if (!name.empty() && name[0] != 'I') { - char abbr[3] = { name[0], name.size() > 1 ? name[1] : '\0', '\0' }; - float tw = ImGui::CalcTextSize(abbr).x; - gbDraw->AddText(ImVec2(pos.x + (GB_SLOT - tw) * 0.5f, pos.y + 2.0f), - borderCol, abbr); - } - } - - if (item.stackCount > 1) { - char cnt[16]; - snprintf(cnt, sizeof(cnt), "%u", item.stackCount); - float cw = ImGui::CalcTextSize(cnt).x; - gbDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), IM_COL32(0, 0, 0, 200), cnt); - gbDraw->AddText(ImVec2(pos.x + GB_SLOT - cw - 2.0f, pos.y + GB_SLOT - 14.0f), - IM_COL32(255, 255, 255, 220), cnt); - } - - ImGui::InvisibleButton("##gbslot", ImVec2(GB_SLOT, GB_SLOT)); - if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::GetIO().KeyShift) { - gameHandler.guildBankWithdrawItem(activeTab, item.slotId, 0xFF, 0); - } - if (ImGui::IsItemHovered()) { - if (info && info->valid) - inventoryScreen.renderItemTooltip(*info); - // Shift-click to insert item link into chat - if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift - && !name.empty() && item.itemEntry != 0) { - uint8_t q = static_cast(quality); - std::string link = buildItemChatLink(item.itemEntry, q, name); - chatPanel_.insertChatLink(link); - } - } - } - ImGui::PopID(); - } - - // Money deposit/withdraw - ImGui::Separator(); - ImGui::Text("Money:"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(60); - ImGui::InputInt("##gbg", &guildBankMoneyInput_[0], 0); ImGui::SameLine(); ImGui::Text("g"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(40); - ImGui::InputInt("##gbs", &guildBankMoneyInput_[1], 0); ImGui::SameLine(); ImGui::Text("s"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(40); - ImGui::InputInt("##gbc", &guildBankMoneyInput_[2], 0); ImGui::SameLine(); ImGui::Text("c"); - - ImGui::SameLine(); - if (ImGui::Button("Deposit")) { - uint32_t amount = guildBankMoneyInput_[0] * 10000 + guildBankMoneyInput_[1] * 100 + guildBankMoneyInput_[2]; - if (amount > 0) gameHandler.depositGuildBankMoney(amount); - } - ImGui::SameLine(); - if (ImGui::Button("Withdraw")) { - uint32_t amount = guildBankMoneyInput_[0] * 10000 + guildBankMoneyInput_[1] * 100 + guildBankMoneyInput_[2]; - if (amount > 0) gameHandler.withdrawGuildBankMoney(amount); - } - - if (data.withdrawAmount >= 0) { - ImGui::Text("Remaining withdrawals: %d", data.withdrawAmount); - } - - ImGui::End(); - - if (!open) gameHandler.closeGuildBank(); -} // ============================================================ // Auction House Window // ============================================================ -void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isAuctionHouseOpen()) return; - - bool open = true; - ImGui::SetNextWindowSize(ImVec2(650, 500), ImGuiCond_FirstUseEver); - if (!ImGui::Begin("Auction House", &open)) { - ImGui::End(); - if (!open) gameHandler.closeAuctionHouse(); - return; - } - - int tab = gameHandler.getAuctionActiveTab(); - - // Tab buttons - const char* tabNames[] = {"Browse", "Bids", "Auctions"}; - for (int i = 0; i < 3; i++) { - if (i > 0) ImGui::SameLine(); - bool selected = (tab == i); - if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f)); - if (ImGui::Button(tabNames[i], ImVec2(100, 0))) { - gameHandler.setAuctionActiveTab(i); - if (i == 1) gameHandler.auctionListBidderItems(); - else if (i == 2) gameHandler.auctionListOwnerItems(); - } - if (selected) ImGui::PopStyleColor(); - } - - ImGui::Separator(); - - if (tab == 0) { - // Browse tab - Search filters - - // --- Helper: resolve current UI filter state into wire-format search params --- - // WoW 3.3.5a item class IDs: - // 0=Consumable, 1=Container, 2=Weapon, 3=Gem, 4=Armor, - // 7=Projectile/TradeGoods, 9=Recipe, 11=Quiver, 15=Miscellaneous - struct AHClassMapping { const char* label; uint32_t classId; }; - static const AHClassMapping classMappings[] = { - {"All", 0xFFFFFFFF}, - {"Weapon", 2}, - {"Armor", 4}, - {"Container", 1}, - {"Consumable", 0}, - {"Trade Goods", 7}, - {"Gem", 3}, - {"Recipe", 9}, - {"Quiver", 11}, - {"Miscellaneous", 15}, - }; - static constexpr int NUM_CLASSES = 10; - - // Weapon subclass IDs (WoW 3.3.5a) - struct AHSubMapping { const char* label; uint32_t subId; }; - static const AHSubMapping weaponSubs[] = { - {"All", 0xFFFFFFFF}, {"Axe (1H)", 0}, {"Axe (2H)", 1}, {"Bow", 2}, - {"Gun", 3}, {"Mace (1H)", 4}, {"Mace (2H)", 5}, {"Polearm", 6}, - {"Sword (1H)", 7}, {"Sword (2H)", 8}, {"Staff", 10}, - {"Fist Weapon", 13}, {"Dagger", 15}, {"Thrown", 16}, - {"Crossbow", 18}, {"Wand", 19}, - }; - static constexpr int NUM_WEAPON_SUBS = 16; - - // Armor subclass IDs - static const AHSubMapping armorSubs[] = { - {"All", 0xFFFFFFFF}, {"Cloth", 1}, {"Leather", 2}, {"Mail", 3}, - {"Plate", 4}, {"Shield", 6}, {"Miscellaneous", 0}, - }; - static constexpr int NUM_ARMOR_SUBS = 7; - - auto getSearchClassId = [&]() -> uint32_t { - if (auctionItemClass_ < 0 || auctionItemClass_ >= NUM_CLASSES) return 0xFFFFFFFF; - return classMappings[auctionItemClass_].classId; - }; - - auto getSearchSubClassId = [&]() -> uint32_t { - if (auctionItemSubClass_ < 0) return 0xFFFFFFFF; - uint32_t cid = getSearchClassId(); - if (cid == 2 && auctionItemSubClass_ < NUM_WEAPON_SUBS) - return weaponSubs[auctionItemSubClass_].subId; - if (cid == 4 && auctionItemSubClass_ < NUM_ARMOR_SUBS) - return armorSubs[auctionItemSubClass_].subId; - return 0xFFFFFFFF; - }; - - auto doSearch = [&](uint32_t offset) { - auctionBrowseOffset_ = offset; - if (auctionLevelMin_ < 0) auctionLevelMin_ = 0; - if (auctionLevelMax_ < 0) auctionLevelMax_ = 0; - uint32_t q = auctionQuality_ > 0 ? static_cast(auctionQuality_ - 1) : 0xFFFFFFFF; - gameHandler.auctionSearch(auctionSearchName_, - static_cast(auctionLevelMin_), - static_cast(auctionLevelMax_), - q, getSearchClassId(), getSearchSubClassId(), 0, - auctionUsableOnly_ ? 1 : 0, offset); - }; - - // Row 1: Name + Level range - ImGui::SetNextItemWidth(200); - bool enterPressed = ImGui::InputText("Name", auctionSearchName_, sizeof(auctionSearchName_), - ImGuiInputTextFlags_EnterReturnsTrue); - ImGui::SameLine(); - ImGui::SetNextItemWidth(50); - ImGui::InputInt("Min Lv", &auctionLevelMin_, 0); - ImGui::SameLine(); - ImGui::SetNextItemWidth(50); - ImGui::InputInt("Max Lv", &auctionLevelMax_, 0); - - // Row 2: Quality + Category + Subcategory + Search button - const char* qualities[] = {"All", "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary"}; - ImGui::SetNextItemWidth(100); - ImGui::Combo("Quality", &auctionQuality_, qualities, 7); - - ImGui::SameLine(); - // Build class label list from mappings - const char* classLabels[NUM_CLASSES]; - for (int c = 0; c < NUM_CLASSES; c++) classLabels[c] = classMappings[c].label; - ImGui::SetNextItemWidth(120); - int classIdx = auctionItemClass_ < 0 ? 0 : auctionItemClass_; - if (ImGui::Combo("Category", &classIdx, classLabels, NUM_CLASSES)) { - if (classIdx != auctionItemClass_) auctionItemSubClass_ = -1; - auctionItemClass_ = classIdx; - } - - // Subcategory (only for Weapon and Armor) - uint32_t curClassId = getSearchClassId(); - if (curClassId == 2 || curClassId == 4) { - const AHSubMapping* subs = (curClassId == 2) ? weaponSubs : armorSubs; - int numSubs = (curClassId == 2) ? NUM_WEAPON_SUBS : NUM_ARMOR_SUBS; - const char* subLabels[20]; - for (int s = 0; s < numSubs && s < 20; s++) subLabels[s] = subs[s].label; - int subIdx = auctionItemSubClass_ + 1; // -1 → 0 ("All") - if (subIdx < 0 || subIdx >= numSubs) subIdx = 0; - ImGui::SameLine(); - ImGui::SetNextItemWidth(110); - if (ImGui::Combo("Subcat", &subIdx, subLabels, numSubs)) { - auctionItemSubClass_ = subIdx - 1; // 0 → -1 ("All") - } - } - - ImGui::SameLine(); - ImGui::Checkbox("Usable", &auctionUsableOnly_); - ImGui::SameLine(); - float delay = gameHandler.getAuctionSearchDelay(); - if (delay > 0.0f) { - char delayBuf[32]; - snprintf(delayBuf, sizeof(delayBuf), "Search (%.0fs)", delay); - ImGui::BeginDisabled(); - ImGui::Button(delayBuf); - ImGui::EndDisabled(); - } else { - if (ImGui::Button("Search") || enterPressed) { - doSearch(0); - } - } - - ImGui::Separator(); - - // Results table - const auto& results = gameHandler.getAuctionBrowseResults(); - constexpr uint32_t AH_PAGE_SIZE = 50; - ImGui::Text("%zu results (of %u total)", results.auctions.size(), results.totalCount); - - // Pagination - if (results.totalCount > AH_PAGE_SIZE) { - ImGui::SameLine(); - uint32_t page = auctionBrowseOffset_ / AH_PAGE_SIZE + 1; - uint32_t totalPages = (results.totalCount + AH_PAGE_SIZE - 1) / AH_PAGE_SIZE; - - if (auctionBrowseOffset_ == 0) ImGui::BeginDisabled(); - if (ImGui::SmallButton("< Prev")) { - uint32_t newOff = (auctionBrowseOffset_ >= AH_PAGE_SIZE) ? auctionBrowseOffset_ - AH_PAGE_SIZE : 0; - doSearch(newOff); - } - if (auctionBrowseOffset_ == 0) ImGui::EndDisabled(); - - ImGui::SameLine(); - ImGui::Text("Page %u/%u", page, totalPages); - - ImGui::SameLine(); - if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Next >")) { - doSearch(auctionBrowseOffset_ + AH_PAGE_SIZE); - } - if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::EndDisabled(); - } - - if (ImGui::BeginChild("AuctionResults", ImVec2(0, -110), true)) { - if (ImGui::BeginTable("AuctionTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { - ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); - ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60); - ImGui::TableSetupColumn("Bid", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60); - ImGui::TableHeadersRow(); - - for (size_t i = 0; i < results.auctions.size(); i++) { - const auto& auction = results.auctions[i]; - auto* info = gameHandler.getItemInfo(auction.itemEntry); - std::string name = info ? info->name : ("Item #" + std::to_string(auction.itemEntry)); - // Append random suffix name (e.g., "of the Eagle") if present - if (auction.randomPropertyId != 0) { - std::string suffix = gameHandler.getRandomPropertyName( - static_cast(auction.randomPropertyId)); - if (!suffix.empty()) name += " " + suffix; - } - game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; - ImVec4 qc = InventoryScreen::getQualityColor(quality); - - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - // Item icon - if (info && info->valid && info->displayInfoId != 0) { - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); - if (iconTex) { - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(16, 16)); - ImGui::SameLine(); - } - } - ImGui::TextColored(qc, "%s", name.c_str()); - // Item tooltip on hover; shift-click to insert chat link - if (ImGui::IsItemHovered() && info && info->valid) { - inventoryScreen.renderItemTooltip(*info); - } - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - - ImGui::TableSetColumnIndex(1); - ImGui::Text("%u", auction.stackCount); - - ImGui::TableSetColumnIndex(2); - // Time left display - uint32_t mins = auction.timeLeftMs / 60000; - if (mins > 720) ImGui::Text("Long"); - else if (mins > 120) ImGui::Text("Medium"); - else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short"); - - ImGui::TableSetColumnIndex(3); - { - uint32_t bid = auction.currentBid > 0 ? auction.currentBid : auction.startBid; - renderCoinsFromCopper(bid); - } - - ImGui::TableSetColumnIndex(4); - if (auction.buyoutPrice > 0) { - renderCoinsFromCopper(auction.buyoutPrice); - } else { - ImGui::TextDisabled("--"); - } - - ImGui::TableSetColumnIndex(5); - ImGui::PushID(static_cast(i) + 7000); - if (auction.buyoutPrice > 0 && ImGui::SmallButton("Buy")) { - gameHandler.auctionBuyout(auction.auctionId, auction.buyoutPrice); - } - if (auction.buyoutPrice > 0) ImGui::SameLine(); - if (ImGui::SmallButton("Bid")) { - uint32_t bidAmt = auction.currentBid > 0 - ? auction.currentBid + auction.minBidIncrement - : auction.startBid; - gameHandler.auctionPlaceBid(auction.auctionId, bidAmt); - } - ImGui::PopID(); - } - ImGui::EndTable(); - } - } - ImGui::EndChild(); - - // Sell section - ImGui::Separator(); - ImGui::Text("Sell Item:"); - - // Item picker from backpack - { - auto& inv = gameHandler.getInventory(); - // Build list of non-empty backpack slots - std::string preview = (auctionSellSlotIndex_ >= 0) - ? ([&]() -> std::string { - const auto& slot = inv.getBackpackSlot(auctionSellSlotIndex_); - if (!slot.empty()) { - std::string s = slot.item.name; - if (slot.item.stackCount > 1) s += " x" + std::to_string(slot.item.stackCount); - return s; - } - return "Select item..."; - })() - : "Select item..."; - - ImGui::SetNextItemWidth(250); - if (ImGui::BeginCombo("##sellitem", preview.c_str())) { - for (int i = 0; i < game::Inventory::BACKPACK_SLOTS; i++) { - const auto& slot = inv.getBackpackSlot(i); - if (slot.empty()) continue; - ImGui::PushID(i + 9000); - // Item icon - if (slot.item.displayInfoId != 0) { - VkDescriptorSet sIcon = inventoryScreen.getItemIcon(slot.item.displayInfoId); - if (sIcon) { - ImGui::Image((void*)(intptr_t)sIcon, ImVec2(16, 16)); - ImGui::SameLine(); - } - } - std::string label = slot.item.name; - if (slot.item.stackCount > 1) label += " x" + std::to_string(slot.item.stackCount); - ImVec4 iqc = InventoryScreen::getQualityColor(slot.item.quality); - ImGui::PushStyleColor(ImGuiCol_Text, iqc); - if (ImGui::Selectable(label.c_str(), auctionSellSlotIndex_ == i)) { - auctionSellSlotIndex_ = i; - } - ImGui::PopStyleColor(); - ImGui::PopID(); - } - ImGui::EndCombo(); - } - } - - ImGui::Text("Bid:"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(50); - ImGui::InputInt("##sbg", &auctionSellBid_[0], 0); ImGui::SameLine(); ImGui::Text("g"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(35); - ImGui::InputInt("##sbs", &auctionSellBid_[1], 0); ImGui::SameLine(); ImGui::Text("s"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(35); - ImGui::InputInt("##sbc", &auctionSellBid_[2], 0); ImGui::SameLine(); ImGui::Text("c"); - - ImGui::SameLine(0, 20); - ImGui::Text("Buyout:"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(50); - ImGui::InputInt("##sbog", &auctionSellBuyout_[0], 0); ImGui::SameLine(); ImGui::Text("g"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(35); - ImGui::InputInt("##sbos", &auctionSellBuyout_[1], 0); ImGui::SameLine(); ImGui::Text("s"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(35); - ImGui::InputInt("##sboc", &auctionSellBuyout_[2], 0); ImGui::SameLine(); ImGui::Text("c"); - - const char* durations[] = {"12 hours", "24 hours", "48 hours"}; - ImGui::SetNextItemWidth(90); - ImGui::Combo("##dur", &auctionSellDuration_, durations, 3); - ImGui::SameLine(); - - // Create Auction button - bool canCreate = auctionSellSlotIndex_ >= 0 && - !gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_).empty() && - (auctionSellBid_[0] > 0 || auctionSellBid_[1] > 0 || auctionSellBid_[2] > 0); - if (!canCreate) ImGui::BeginDisabled(); - if (ImGui::Button("Create Auction")) { - uint32_t bidCopper = static_cast(auctionSellBid_[0]) * 10000 - + static_cast(auctionSellBid_[1]) * 100 - + static_cast(auctionSellBid_[2]); - uint32_t buyoutCopper = static_cast(auctionSellBuyout_[0]) * 10000 - + static_cast(auctionSellBuyout_[1]) * 100 - + static_cast(auctionSellBuyout_[2]); - const uint32_t durationMins[] = {720, 1440, 2880}; - uint32_t dur = durationMins[auctionSellDuration_]; - uint64_t itemGuid = gameHandler.getBackpackItemGuid(auctionSellSlotIndex_); - const auto& slot = gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_); - uint32_t stackCount = slot.item.stackCount; - if (itemGuid != 0) { - gameHandler.auctionSellItem(itemGuid, stackCount, bidCopper, buyoutCopper, dur); - // Clear sell inputs - auctionSellSlotIndex_ = -1; - auctionSellBid_[0] = auctionSellBid_[1] = auctionSellBid_[2] = 0; - auctionSellBuyout_[0] = auctionSellBuyout_[1] = auctionSellBuyout_[2] = 0; - } - } - if (!canCreate) ImGui::EndDisabled(); - - } else if (tab == 1) { - // Bids tab - const auto& results = gameHandler.getAuctionBidderResults(); - ImGui::Text("Your Bids: %zu items", results.auctions.size()); - - if (ImGui::BeginTable("BidTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); - ImGui::TableSetupColumn("Your Bid", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60); - ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60); - ImGui::TableHeadersRow(); - - for (size_t bi = 0; bi < results.auctions.size(); bi++) { - const auto& a = results.auctions[bi]; - auto* info = gameHandler.getItemInfo(a.itemEntry); - std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); - if (a.randomPropertyId != 0) { - std::string suffix = gameHandler.getRandomPropertyName( - static_cast(a.randomPropertyId)); - if (!suffix.empty()) name += " " + suffix; - } - game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; - ImVec4 bqc = InventoryScreen::getQualityColor(quality); - - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - if (info && info->valid && info->displayInfoId != 0) { - VkDescriptorSet bIcon = inventoryScreen.getItemIcon(info->displayInfoId); - if (bIcon) { - ImGui::Image((void*)(intptr_t)bIcon, ImVec2(16, 16)); - ImGui::SameLine(); - } - } - // High bidder indicator - bool isHighBidder = (a.bidderGuid != 0 && a.bidderGuid == gameHandler.getPlayerGuid()); - if (isHighBidder) { - ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "[Winning]"); - ImGui::SameLine(); - } else if (a.bidderGuid != 0) { - ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[Outbid]"); - ImGui::SameLine(); - } - ImGui::TextColored(bqc, "%s", name.c_str()); - // Tooltip and shift-click - if (ImGui::IsItemHovered() && info && info->valid) - inventoryScreen.renderItemTooltip(*info); - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - ImGui::TableSetColumnIndex(1); - ImGui::Text("%u", a.stackCount); - ImGui::TableSetColumnIndex(2); - renderCoinsFromCopper(a.currentBid); - ImGui::TableSetColumnIndex(3); - if (a.buyoutPrice > 0) - renderCoinsFromCopper(a.buyoutPrice); - else - ImGui::TextDisabled("--"); - ImGui::TableSetColumnIndex(4); - uint32_t mins = a.timeLeftMs / 60000; - if (mins > 720) ImGui::Text("Long"); - else if (mins > 120) ImGui::Text("Medium"); - else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short"); - - ImGui::TableSetColumnIndex(5); - ImGui::PushID(static_cast(bi) + 7500); - if (a.buyoutPrice > 0 && ImGui::SmallButton("Buy")) { - gameHandler.auctionBuyout(a.auctionId, a.buyoutPrice); - } - if (a.buyoutPrice > 0) ImGui::SameLine(); - if (ImGui::SmallButton("Bid")) { - uint32_t bidAmt = a.currentBid > 0 - ? a.currentBid + a.minBidIncrement - : a.startBid; - gameHandler.auctionPlaceBid(a.auctionId, bidAmt); - } - ImGui::PopID(); - } - ImGui::EndTable(); - } - - } else if (tab == 2) { - // Auctions tab (your listings) - const auto& results = gameHandler.getAuctionOwnerResults(); - ImGui::Text("Your Auctions: %zu items", results.auctions.size()); - - if (ImGui::BeginTable("OwnerTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); - ImGui::TableSetupColumn("Bid", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("##cancel", ImGuiTableColumnFlags_WidthFixed, 60); - ImGui::TableHeadersRow(); - - for (size_t i = 0; i < results.auctions.size(); i++) { - const auto& a = results.auctions[i]; - auto* info = gameHandler.getItemInfo(a.itemEntry); - std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); - if (a.randomPropertyId != 0) { - std::string suffix = gameHandler.getRandomPropertyName( - static_cast(a.randomPropertyId)); - if (!suffix.empty()) name += " " + suffix; - } - game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; - - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImVec4 oqc = InventoryScreen::getQualityColor(quality); - if (info && info->valid && info->displayInfoId != 0) { - VkDescriptorSet oIcon = inventoryScreen.getItemIcon(info->displayInfoId); - if (oIcon) { - ImGui::Image((void*)(intptr_t)oIcon, ImVec2(16, 16)); - ImGui::SameLine(); - } - } - // Bid activity indicator for seller - if (a.bidderGuid != 0) { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "[Bid]"); - ImGui::SameLine(); - } - ImGui::TextColored(oqc, "%s", name.c_str()); - if (ImGui::IsItemHovered() && info && info->valid) - inventoryScreen.renderItemTooltip(*info); - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - ImGui::TableSetColumnIndex(1); - ImGui::Text("%u", a.stackCount); - ImGui::TableSetColumnIndex(2); - { - uint32_t bid = a.currentBid > 0 ? a.currentBid : a.startBid; - renderCoinsFromCopper(bid); - } - ImGui::TableSetColumnIndex(3); - if (a.buyoutPrice > 0) - renderCoinsFromCopper(a.buyoutPrice); - else - ImGui::TextDisabled("--"); - ImGui::TableSetColumnIndex(4); - ImGui::PushID(static_cast(i) + 8000); - if (ImGui::SmallButton("Cancel")) { - gameHandler.auctionCancelItem(a.auctionId); - } - ImGui::PopID(); - } - ImGui::EndTable(); - } - } - - ImGui::End(); - - if (!open) gameHandler.closeAuctionHouse(); -} // --------------------------------------------------------------------------- @@ -15503,2011 +7408,19 @@ void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) { // --------------------------------------------------------------------------- // Dungeon Finder window (toggle with hotkey or bag-bar button) // --------------------------------------------------------------------------- -void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { - // Toggle Dungeon Finder (customizable keybind) - if (!chatPanel_.isChatInputActive() && !ImGui::GetIO().WantTextInput && - KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { - showDungeonFinder_ = !showDungeonFinder_; - } - - if (!showDungeonFinder_) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW * 0.5f - 175.0f, screenH * 0.2f), - ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); - - bool open = true; - ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize; - if (!ImGui::Begin("Dungeon Finder", &open, flags)) { - ImGui::End(); - if (!open) showDungeonFinder_ = false; - return; - } - if (!open) { - ImGui::End(); - showDungeonFinder_ = false; - return; - } - - using LfgState = game::GameHandler::LfgState; - LfgState state = gameHandler.getLfgState(); - - // ---- Status banner ---- - switch (state) { - case LfgState::None: - ImGui::TextColored(kColorGray, "Status: Not queued"); - break; - case LfgState::RoleCheck: - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Status: Role check in progress..."); - break; - case LfgState::Queued: { - int32_t avgSec = gameHandler.getLfgAvgWaitSec(); - uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); - int qMin = static_cast(qMs / 60000); - int qSec = static_cast((qMs % 60000) / 1000); - std::string dName = gameHandler.getCurrentLfgDungeonName(); - if (!dName.empty()) - ImGui::TextColored(colors::kQueueGreen, - "Status: In queue for %s (%d:%02d)", dName.c_str(), qMin, qSec); - else - ImGui::TextColored(colors::kQueueGreen, "Status: In queue (%d:%02d)", qMin, qSec); - if (avgSec >= 0) { - int aMin = avgSec / 60; - int aSec = avgSec % 60; - ImGui::TextColored(colors::kSilver, - "Avg wait: %d:%02d", aMin, aSec); - } - break; - } - case LfgState::Proposal: { - std::string dName = gameHandler.getCurrentLfgDungeonName(); - if (!dName.empty()) - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found for %s!", dName.c_str()); - else - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); - break; - } - case LfgState::Boot: - ImGui::TextColored(kColorRed, "Status: Vote kick in progress"); - break; - case LfgState::InDungeon: { - std::string dName = gameHandler.getCurrentLfgDungeonName(); - if (!dName.empty()) - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon (%s)", dName.c_str()); - else - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); - break; - } - case LfgState::FinishedDungeon: { - std::string dName = gameHandler.getCurrentLfgDungeonName(); - if (!dName.empty()) - ImGui::TextColored(colors::kLightGreen, "Status: %s complete", dName.c_str()); - else - ImGui::TextColored(colors::kLightGreen, "Status: Dungeon complete"); - break; - } - case LfgState::RaidBrowser: - ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser"); - break; - } - - ImGui::Separator(); - - // ---- Proposal accept/decline ---- - if (state == LfgState::Proposal) { - std::string dName = gameHandler.getCurrentLfgDungeonName(); - if (!dName.empty()) - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), - "A group has been found for %s!", dName.c_str()); - else - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), - "A group has been found for your dungeon!"); - ImGui::Spacing(); - if (ImGui::Button("Accept", ImVec2(120, 0))) { - gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(120, 0))) { - gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); - } - ImGui::Separator(); - } - - // ---- Vote-to-kick buttons ---- - if (state == LfgState::Boot) { - ImGui::TextColored(kColorRed, "Vote to kick in progress:"); - const std::string& bootTarget = gameHandler.getLfgBootTargetName(); - const std::string& bootReason = gameHandler.getLfgBootReason(); - if (!bootTarget.empty()) { - ImGui::Text("Player: "); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", bootTarget.c_str()); - } - if (!bootReason.empty()) { - ImGui::Text("Reason: "); - ImGui::SameLine(); - ImGui::TextWrapped("%s", bootReason.c_str()); - } - uint32_t bootVotes = gameHandler.getLfgBootVotes(); - uint32_t bootTotal = gameHandler.getLfgBootTotal(); - uint32_t bootNeeded = gameHandler.getLfgBootNeeded(); - uint32_t bootTimeLeft= gameHandler.getLfgBootTimeLeft(); - if (bootNeeded > 0) { - ImGui::Text("Votes: %u / %u (need %u) %us left", - bootVotes, bootTotal, bootNeeded, bootTimeLeft); - } - ImGui::Spacing(); - if (ImGui::Button("Vote Yes (kick)", ImVec2(140, 0))) { - gameHandler.lfgSetBootVote(true); - } - ImGui::SameLine(); - if (ImGui::Button("Vote No (keep)", ImVec2(140, 0))) { - gameHandler.lfgSetBootVote(false); - } - ImGui::Separator(); - } - - // ---- Teleport button (in dungeon) ---- - if (state == LfgState::InDungeon) { - if (ImGui::Button("Teleport to Dungeon", ImVec2(-1, 0))) { - gameHandler.lfgTeleport(true); - } - ImGui::Separator(); - } - - // ---- Role selection (only when not queued/in dungeon) ---- - bool canConfigure = (state == LfgState::None || state == LfgState::FinishedDungeon); - - if (canConfigure) { - ImGui::Text("Role:"); - ImGui::SameLine(); - bool isTank = (lfgRoles_ & 0x02) != 0; - bool isHealer = (lfgRoles_ & 0x04) != 0; - bool isDps = (lfgRoles_ & 0x08) != 0; - if (ImGui::Checkbox("Tank", &isTank)) lfgRoles_ = (lfgRoles_ & ~0x02) | (isTank ? 0x02 : 0); - ImGui::SameLine(); - if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0); - ImGui::SameLine(); - if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0); - - ImGui::Spacing(); - - // ---- Dungeon selection ---- - ImGui::Text("Dungeon:"); - - struct DungeonEntry { uint32_t id; const char* name; }; - // Category 0=Random, 1=Classic, 2=TBC, 3=WotLK - struct DungeonEntryEx { uint32_t id; const char* name; uint8_t cat; }; - static const DungeonEntryEx kDungeons[] = { - { 861, "Random Dungeon", 0 }, - { 862, "Random Heroic", 0 }, - { 36, "Deadmines", 1 }, - { 43, "Ragefire Chasm", 1 }, - { 47, "Razorfen Kraul", 1 }, - { 48, "Blackfathom Deeps", 1 }, - { 52, "Uldaman", 1 }, - { 57, "Dire Maul: East", 1 }, - { 70, "Onyxia's Lair", 1 }, - { 264, "The Blood Furnace", 2 }, - { 269, "The Shattered Halls", 2 }, - { 576, "The Nexus", 3 }, - { 578, "The Oculus", 3 }, - { 595, "The Culling of Stratholme", 3 }, - { 599, "Halls of Stone", 3 }, - { 600, "Drak'Tharon Keep", 3 }, - { 601, "Azjol-Nerub", 3 }, - { 604, "Gundrak", 3 }, - { 608, "Violet Hold", 3 }, - { 619, "Ahn'kahet: Old Kingdom", 3 }, - { 623, "Halls of Lightning", 3 }, - { 632, "The Forge of Souls", 3 }, - { 650, "Trial of the Champion", 3 }, - { 658, "Pit of Saron", 3 }, - { 668, "Halls of Reflection", 3 }, - }; - static constexpr const char* kCatHeaders[] = { nullptr, "-- Classic --", "-- TBC --", "-- WotLK --" }; - - // Find current index - int curIdx = 0; - for (int i = 0; i < static_cast(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { - if (kDungeons[i].id == lfgSelectedDungeon_) { curIdx = i; break; } - } - - ImGui::SetNextItemWidth(-1); - if (ImGui::BeginCombo("##dungeon", kDungeons[curIdx].name)) { - uint8_t lastCat = 255; - for (int i = 0; i < static_cast(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { - if (kDungeons[i].cat != lastCat && kCatHeaders[kDungeons[i].cat]) { - if (lastCat != 255) ImGui::Separator(); - ImGui::TextDisabled("%s", kCatHeaders[kDungeons[i].cat]); - lastCat = kDungeons[i].cat; - } else if (kDungeons[i].cat != lastCat) { - lastCat = kDungeons[i].cat; - } - bool selected = (kDungeons[i].id == lfgSelectedDungeon_); - if (ImGui::Selectable(kDungeons[i].name, selected)) - lfgSelectedDungeon_ = kDungeons[i].id; - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - - ImGui::Spacing(); - - // ---- Join button ---- - bool rolesOk = (lfgRoles_ != 0); - if (!rolesOk) { - ImGui::BeginDisabled(); - } - if (ImGui::Button("Join Dungeon Finder", ImVec2(-1, 0))) { - gameHandler.lfgJoin(lfgSelectedDungeon_, lfgRoles_); - } - if (!rolesOk) { - ImGui::EndDisabled(); - ImGui::TextColored(colors::kSoftRed, "Select at least one role."); - } - } - - // ---- Leave button (when queued or role check) ---- - if (state == LfgState::Queued || state == LfgState::RoleCheck) { - if (ImGui::Button("Leave Queue", ImVec2(-1, 0))) { - gameHandler.lfgLeave(); - } - } - - ImGui::End(); -} - // ============================================================ // Instance Lockouts // ============================================================ -void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { - if (!showInstanceLockouts_) return; - ImGui::SetNextWindowSize(ImVec2(480, 0), ImGuiCond_Appearing); - ImGui::SetNextWindowPos( - ImVec2(ImGui::GetIO().DisplaySize.x / 2 - 240, 140), ImGuiCond_Appearing); - if (!ImGui::Begin("Instance Lockouts", &showInstanceLockouts_, - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::End(); - return; - } - - const auto& lockouts = gameHandler.getInstanceLockouts(); - - if (lockouts.empty()) { - ImGui::TextColored(kColorGray, "No active instance lockouts."); - } else { - auto difficultyLabel = [](uint32_t diff) -> const char* { - switch (diff) { - case 0: return "Normal"; - case 1: return "Heroic"; - case 2: return "25-Man"; - case 3: return "25-Man Heroic"; - default: return "Unknown"; - } - }; - - // Current UTC time for reset countdown - auto nowSec = static_cast(std::time(nullptr)); - - if (ImGui::BeginTable("lockouts", 4, - ImGuiTableFlags_SizingStretchProp | - ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter)) { - ImGui::TableSetupColumn("Instance", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Difficulty", ImGuiTableColumnFlags_WidthFixed, 110.0f); - ImGui::TableSetupColumn("Resets In", ImGuiTableColumnFlags_WidthFixed, 100.0f); - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 60.0f); - ImGui::TableHeadersRow(); - - for (const auto& lo : lockouts) { - ImGui::TableNextRow(); - - // Instance name — use GameHandler's Map.dbc cache (avoids duplicate DBC load) - ImGui::TableSetColumnIndex(0); - std::string mapName = gameHandler.getMapName(lo.mapId); - if (!mapName.empty()) { - ImGui::TextUnformatted(mapName.c_str()); - } else { - ImGui::Text("Map %u", lo.mapId); - } - - // Difficulty - ImGui::TableSetColumnIndex(1); - ImGui::TextUnformatted(difficultyLabel(lo.difficulty)); - - // Reset countdown - ImGui::TableSetColumnIndex(2); - if (lo.resetTime > nowSec) { - uint64_t remaining = lo.resetTime - nowSec; - uint64_t days = remaining / 86400; - uint64_t hours = (remaining % 86400) / 3600; - if (days > 0) { - ImGui::Text("%llud %lluh", - static_cast(days), - static_cast(hours)); - } else { - uint64_t mins = (remaining % 3600) / 60; - ImGui::Text("%lluh %llum", - static_cast(hours), - static_cast(mins)); - } - } else { - ImGui::TextColored(kColorDarkGray, "Expired"); - } - - // Locked / Extended status - ImGui::TableSetColumnIndex(3); - if (lo.extended) { - ImGui::TextColored(ImVec4(0.3f, 0.7f, 1.0f, 1.0f), "Ext"); - } else if (lo.locked) { - ImGui::TextColored(colors::kSoftRed, "Locked"); - } else { - ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "Open"); - } - } - - ImGui::EndTable(); - } - } - - ImGui::End(); -} - -// ============================================================================ -// Battleground score frame -// -// Displays the current score for the player's battleground using world states. -// Shown in the top-centre of the screen whenever SMSG_INIT_WORLD_STATES has -// been received for a known BG map. The layout adapts per battleground: -// -// WSG 489 – Alliance / Horde flag captures (max 3) -// AB 529 – Alliance / Horde resource scores (max 1600) -// AV 30 – Alliance / Horde reinforcements -// EotS 566 – Alliance / Horde resource scores (max 1600) -// ============================================================================ -void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { - // Only show when in a recognised battleground map - uint32_t mapId = gameHandler.getWorldStateMapId(); - - // World state key sets per battleground - // Keys from the WoW 3.3.5a WorldState.dbc / client source - struct BgScoreDef { - uint32_t mapId; - const char* name; - uint32_t allianceKey; // world state key for Alliance value - uint32_t hordeKey; // world state key for Horde value - uint32_t maxKey; // max score world state key (0 = use hardcoded) - uint32_t hardcodedMax; // used when maxKey == 0 - const char* unit; // suffix label (e.g. "flags", "resources") - }; - - static constexpr BgScoreDef kBgDefs[] = { - // Warsong Gulch: 3 flag captures wins - { 489, "Warsong Gulch", 1581, 1582, 0, 3, "flags" }, - // Arathi Basin: 1600 resources wins - { 529, "Arathi Basin", 1218, 1219, 0, 1600, "resources" }, - // Alterac Valley: reinforcements count down from 600 / 800 etc. - { 30, "Alterac Valley", 1322, 1323, 0, 600, "reinforcements" }, - // Eye of the Storm: 1600 resources wins - { 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" }, - // Strand of the Ancients (WotLK) - { 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" }, - // Isle of Conquest (WotLK): reinforcements (300 default) - { 628, "Isle of Conquest", 4221, 4222, 0, 300, "reinforcements" }, - }; - - const BgScoreDef* def = nullptr; - for (const auto& d : kBgDefs) { - if (d.mapId == mapId) { def = &d; break; } - } - if (!def) return; - - auto allianceOpt = gameHandler.getWorldState(def->allianceKey); - auto hordeOpt = gameHandler.getWorldState(def->hordeKey); - if (!allianceOpt && !hordeOpt) return; - - uint32_t allianceScore = allianceOpt.value_or(0); - uint32_t hordeScore = hordeOpt.value_or(0); - uint32_t maxScore = def->hardcodedMax; - if (def->maxKey != 0) { - if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv; - } - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - // Width scales with screen but stays reasonable - float frameW = 260.0f; - float frameH = 60.0f; - float posX = screenW / 2.0f - frameW / 2.0f; - float posY = 4.0f; - - ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(frameW, frameH), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.75f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); - - if (ImGui::Begin("##BGScore", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus | - ImGuiWindowFlags_NoSavedSettings)) { - - // BG name centred at top - float nameW = ImGui::CalcTextSize(def->name).x; - ImGui::SetCursorPosX((frameW - nameW) / 2.0f); - ImGui::TextColored(colors::kBrightGold, "%s", def->name); - - // Alliance score | separator | Horde score - float innerW = frameW - 12.0f; - float halfW = innerW / 2.0f - 4.0f; - - ImGui::SetCursorPosX(6.0f); - ImGui::BeginGroup(); - { - // Alliance (blue) - char aBuf[32]; - if (maxScore > 0 && strlen(def->unit) > 0) - snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u / %u", allianceScore, maxScore); - else - snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u", allianceScore); - ImGui::TextColored(colors::kLightBlue, "%s", aBuf); - } - ImGui::EndGroup(); - - ImGui::SameLine(halfW + 16.0f); - - ImGui::BeginGroup(); - { - // Horde (red) - char hBuf[32]; - if (maxScore > 0 && strlen(def->unit) > 0) - snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u / %u", hordeScore, maxScore); - else - snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u", hordeScore); - ImGui::TextColored(colors::kHostileRed, "%s", hBuf); - } - ImGui::EndGroup(); - } - ImGui::End(); - ImGui::PopStyleVar(2); -} - -// ─── Who Results Window ─────────────────────────────────────────────────────── -void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { - if (!showWhoWindow_) return; - - const auto& results = gameHandler.getWhoResults(); - - ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(200, 180), ImGuiCond_FirstUseEver); - - char title[64]; - uint32_t onlineCount = gameHandler.getWhoOnlineCount(); - if (onlineCount > 0) - snprintf(title, sizeof(title), "Players Online: %u###WhoWindow", onlineCount); - else - snprintf(title, sizeof(title), "Who###WhoWindow"); - - if (!ImGui::Begin(title, &showWhoWindow_)) { - ImGui::End(); - return; - } - - // Search bar with Send button - static char whoSearchBuf[64] = {}; - bool doSearch = false; - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 60.0f); - if (ImGui::InputTextWithHint("##whosearch", "Search players...", whoSearchBuf, sizeof(whoSearchBuf), - ImGuiInputTextFlags_EnterReturnsTrue)) - doSearch = true; - ImGui::SameLine(); - if (ImGui::Button("Search", ImVec2(-1, 0))) - doSearch = true; - if (doSearch) { - gameHandler.queryWho(std::string(whoSearchBuf)); - } - ImGui::Separator(); - - if (results.empty()) { - ImGui::TextDisabled("No results. Type a filter above or use /who [filter]."); - ImGui::End(); - return; - } - - // Table: Name | Guild | Level | Class | Zone - if (ImGui::BeginTable("##WhoTable", 5, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | - ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, - ImVec2(0, 0))) { - ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.22f); - ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthStretch, 0.20f); - ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); - ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 0.20f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 0.28f); - ImGui::TableHeadersRow(); - - for (size_t i = 0; i < results.size(); ++i) { - const auto& e = results[i]; - ImGui::TableNextRow(); - ImGui::PushID(static_cast(i)); - - // Name (class-colored if class is known) - ImGui::TableSetColumnIndex(0); - uint8_t cid = static_cast(e.classId); - ImVec4 nameCol = classColorVec4(cid); - ImGui::TextColored(nameCol, "%s", e.name.c_str()); - - // Right-click context menu on the name - if (ImGui::BeginPopupContextItem("##WhoCtx")) { - ImGui::TextDisabled("%s", e.name.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(e.name); - } - if (ImGui::MenuItem("Invite to Group")) - gameHandler.inviteToGroup(e.name); - if (ImGui::MenuItem("Add Friend")) - gameHandler.addFriend(e.name); - if (ImGui::MenuItem("Ignore")) - gameHandler.addIgnore(e.name); - ImGui::EndPopup(); - } - - // Guild - ImGui::TableSetColumnIndex(1); - if (!e.guildName.empty()) - ImGui::TextDisabled("<%s>", e.guildName.c_str()); - - // Level - ImGui::TableSetColumnIndex(2); - ImGui::Text("%u", e.level); - - // Class - ImGui::TableSetColumnIndex(3); - const char* className = game::getClassName(static_cast(e.classId)); - ImGui::TextColored(nameCol, "%s", className); - - // Zone - ImGui::TableSetColumnIndex(4); - if (e.zoneId != 0) { - std::string zoneName = gameHandler.getWhoAreaName(e.zoneId); - if (!zoneName.empty()) - ImGui::TextUnformatted(zoneName.c_str()); - else { - char zfb[32]; - snprintf(zfb, sizeof(zfb), "Zone #%u", e.zoneId); - ImGui::TextUnformatted(zfb); - } - } - - ImGui::PopID(); - } - - ImGui::EndTable(); - } - - ImGui::End(); -} - -// ─── Combat Log Window ──────────────────────────────────────────────────────── -void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { - if (!showCombatLog_) return; - - const auto& log = gameHandler.getCombatLog(); - - ImGui::SetNextWindowSize(ImVec2(520, 320), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(160, 200), ImGuiCond_FirstUseEver); - - char title[64]; - snprintf(title, sizeof(title), "Combat Log (%zu)###CombatLog", log.size()); - if (!ImGui::Begin(title, &showCombatLog_)) { - ImGui::End(); - return; - } - - // Filter toggles - static bool filterDamage = true; - static bool filterHeal = true; - static bool filterMisc = true; - static bool autoScroll = true; - - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 2)); - ImGui::Checkbox("Damage", &filterDamage); ImGui::SameLine(); - ImGui::Checkbox("Healing", &filterHeal); ImGui::SameLine(); - ImGui::Checkbox("Misc", &filterMisc); ImGui::SameLine(); - ImGui::Checkbox("Auto-scroll", &autoScroll); - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 40.0f); - if (ImGui::SmallButton("Clear")) - gameHandler.clearCombatLog(); - ImGui::PopStyleVar(); - ImGui::Separator(); - - // Helper: categorize entry - auto isDamageType = [](game::CombatTextEntry::Type t) { - using T = game::CombatTextEntry; - return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || - t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || - t == T::ENVIRONMENTAL || t == T::GLANCING || t == T::CRUSHING; - }; - auto isHealType = [](game::CombatTextEntry::Type t) { - using T = game::CombatTextEntry; - return t == T::HEAL || t == T::CRIT_HEAL || t == T::PERIODIC_HEAL; - }; - - // Two-column table: Time | Event description - ImGuiTableFlags tableFlags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | - ImGuiTableFlags_SizingFixedFit; - float availH = ImGui::GetContentRegionAvail().y; - if (ImGui::BeginTable("##CombatLogTable", 2, tableFlags, ImVec2(0.0f, availH))) { - ImGui::TableSetupScrollFreeze(0, 0); - ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 62.0f); - ImGui::TableSetupColumn("Event", ImGuiTableColumnFlags_WidthStretch); - - for (const auto& e : log) { - // Apply filters - bool isDmg = isDamageType(e.type); - bool isHeal = isHealType(e.type); - bool isMisc = !isDmg && !isHeal; - if (isDmg && !filterDamage) continue; - if (isHeal && !filterHeal) continue; - if (isMisc && !filterMisc) continue; - - // Format timestamp as HH:MM:SS - char timeBuf[10]; - { - struct tm* tm_info = std::localtime(&e.timestamp); - if (tm_info) - snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", - tm_info->tm_hour, tm_info->tm_min, tm_info->tm_sec); - else - snprintf(timeBuf, sizeof(timeBuf), "--:--:--"); - } - - // Build event description and choose color - char desc[256]; - ImVec4 color; - using T = game::CombatTextEntry; - const char* src = e.sourceName.empty() ? (e.isPlayerSource ? "You" : "?") : e.sourceName.c_str(); - const char* tgt = e.targetName.empty() ? "?" : e.targetName.c_str(); - const std::string& spellName = (e.spellId != 0) ? gameHandler.getSpellName(e.spellId) : std::string(); - const char* spell = spellName.empty() ? nullptr : spellName.c_str(); - - switch (e.type) { - case T::MELEE_DAMAGE: - snprintf(desc, sizeof(desc), "%s hits %s for %d", src, tgt, e.amount); - color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : colors::kSoftRed; - break; - case T::CRIT_DAMAGE: - snprintf(desc, sizeof(desc), "%s crits %s for %d!", src, tgt, e.amount); - color = e.isPlayerSource ? ImVec4(1.0f, 1.0f, 0.0f, 1.0f) : colors::kBrightRed; - break; - case T::SPELL_DAMAGE: - if (spell) - snprintf(desc, sizeof(desc), "%s's %s hits %s for %d", src, spell, tgt, e.amount); - else - snprintf(desc, sizeof(desc), "%s's spell hits %s for %d", src, tgt, e.amount); - color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : colors::kSoftRed; - break; - case T::PERIODIC_DAMAGE: - if (spell) - snprintf(desc, sizeof(desc), "%s's %s ticks %s for %d", src, spell, tgt, e.amount); - else - snprintf(desc, sizeof(desc), "%s's DoT ticks %s for %d", src, tgt, e.amount); - color = e.isPlayerSource ? ImVec4(0.9f, 0.7f, 0.3f, 1.0f) : ImVec4(0.9f, 0.3f, 0.3f, 1.0f); - break; - case T::HEAL: - if (spell) - snprintf(desc, sizeof(desc), "%s heals %s for %d (%s)", src, tgt, e.amount, spell); - else - snprintf(desc, sizeof(desc), "%s heals %s for %d", src, tgt, e.amount); - color = kColorGreen; - break; - case T::CRIT_HEAL: - if (spell) - snprintf(desc, sizeof(desc), "%s critically heals %s for %d! (%s)", src, tgt, e.amount, spell); - else - snprintf(desc, sizeof(desc), "%s critically heals %s for %d!", src, tgt, e.amount); - color = kColorBrightGreen; - break; - case T::PERIODIC_HEAL: - if (spell) - snprintf(desc, sizeof(desc), "%s's %s heals %s for %d", src, spell, tgt, e.amount); - else - snprintf(desc, sizeof(desc), "%s's HoT heals %s for %d", src, tgt, e.amount); - color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); - break; - case T::MISS: - if (spell) - snprintf(desc, sizeof(desc), "%s's %s misses %s", src, spell, tgt); - else - snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); - color = colors::kMediumGray; - break; - case T::DODGE: - if (spell) - snprintf(desc, sizeof(desc), "%s dodges %s's %s", tgt, src, spell); - else - snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); - color = colors::kMediumGray; - break; - case T::PARRY: - if (spell) - snprintf(desc, sizeof(desc), "%s parries %s's %s", tgt, src, spell); - else - snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); - color = colors::kMediumGray; - break; - case T::BLOCK: - if (spell) - snprintf(desc, sizeof(desc), "%s blocks %s's %s (%d blocked)", tgt, src, spell, e.amount); - else - snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); - color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); - break; - case T::EVADE: - if (spell) - snprintf(desc, sizeof(desc), "%s evades %s's %s", tgt, src, spell); - else - snprintf(desc, sizeof(desc), "%s evades %s's attack", tgt, src); - color = colors::kMediumGray; - break; - case T::IMMUNE: - if (spell) - snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); - else - snprintf(desc, sizeof(desc), "%s is immune", tgt); - color = colors::kSilver; - break; - case T::ABSORB: - if (spell && e.amount > 0) - snprintf(desc, sizeof(desc), "%s's %s absorbs %d", src, spell, e.amount); - else if (spell) - snprintf(desc, sizeof(desc), "%s absorbs %s", tgt, spell); - else if (e.amount > 0) - snprintf(desc, sizeof(desc), "%d absorbed", e.amount); - else - snprintf(desc, sizeof(desc), "Absorbed"); - color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); - break; - case T::RESIST: - if (spell && e.amount > 0) - snprintf(desc, sizeof(desc), "%s resists %s's %s (%d resisted)", tgt, src, spell, e.amount); - else if (spell) - snprintf(desc, sizeof(desc), "%s resists %s's %s", tgt, src, spell); - else if (e.amount > 0) - snprintf(desc, sizeof(desc), "%d resisted", e.amount); - else - snprintf(desc, sizeof(desc), "Resisted"); - color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); - break; - case T::DEFLECT: - if (spell) - snprintf(desc, sizeof(desc), "%s deflects %s's %s", tgt, src, spell); - else - snprintf(desc, sizeof(desc), "%s deflects %s's attack", tgt, src); - color = ImVec4(0.65f, 0.8f, 0.95f, 1.0f); - break; - case T::REFLECT: - if (spell) - snprintf(desc, sizeof(desc), "%s reflects %s's %s", tgt, src, spell); - else - snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src); - color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); - break; - case T::ENVIRONMENTAL: { - const char* envName = "Environmental"; - switch (e.powerType) { - case 0: envName = "Fatigue"; break; - case 1: envName = "Drowning"; break; - case 2: envName = "Falling"; break; - case 3: envName = "Lava"; break; - case 4: envName = "Slime"; break; - case 5: envName = "Fire"; break; - } - snprintf(desc, sizeof(desc), "%s damage: %d", envName, e.amount); - color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); - break; - } - case T::ENERGIZE: { - const char* pwrName = "power"; - switch (e.powerType) { - case 0: pwrName = "Mana"; break; - case 1: pwrName = "Rage"; break; - case 2: pwrName = "Focus"; break; - case 3: pwrName = "Energy"; break; - case 4: pwrName = "Happiness"; break; - case 6: pwrName = "Runic Power"; break; - } - if (spell) - snprintf(desc, sizeof(desc), "%s gains %d %s (%s)", tgt, e.amount, pwrName, spell); - else - snprintf(desc, sizeof(desc), "%s gains %d %s", tgt, e.amount, pwrName); - color = colors::kLightBlue; - break; - } - case T::POWER_DRAIN: { - const char* drainName = "power"; - switch (e.powerType) { - case 0: drainName = "Mana"; break; - case 1: drainName = "Rage"; break; - case 2: drainName = "Focus"; break; - case 3: drainName = "Energy"; break; - case 4: drainName = "Happiness"; break; - case 6: drainName = "Runic Power"; break; - } - if (spell) - snprintf(desc, sizeof(desc), "%s loses %d %s to %s's %s", tgt, e.amount, drainName, src, spell); - else - snprintf(desc, sizeof(desc), "%s loses %d %s", tgt, e.amount, drainName); - color = ImVec4(0.45f, 0.75f, 1.0f, 1.0f); - break; - } - case T::XP_GAIN: - snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); - color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); - break; - case T::PROC_TRIGGER: - if (spell) - snprintf(desc, sizeof(desc), "%s procs!", spell); - else - snprintf(desc, sizeof(desc), "Proc triggered"); - color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); - break; - case T::DISPEL: - if (spell && e.isPlayerSource) - snprintf(desc, sizeof(desc), "You dispel %s from %s", spell, tgt); - else if (spell) - snprintf(desc, sizeof(desc), "%s dispels %s from %s", src, spell, tgt); - else if (e.isPlayerSource) - snprintf(desc, sizeof(desc), "You dispel from %s", tgt); - else - snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); - color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); - break; - case T::STEAL: - if (spell && e.isPlayerSource) - snprintf(desc, sizeof(desc), "You steal %s from %s", spell, tgt); - else if (spell) - snprintf(desc, sizeof(desc), "%s steals %s from %s", src, spell, tgt); - else if (e.isPlayerSource) - snprintf(desc, sizeof(desc), "You steal from %s", tgt); - else - snprintf(desc, sizeof(desc), "%s steals from %s", src, tgt); - color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); - break; - case T::INTERRUPT: - if (spell && e.isPlayerSource) - snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); - else if (spell) - snprintf(desc, sizeof(desc), "%s interrupts %s's %s", src, tgt, spell); - else if (e.isPlayerSource) - snprintf(desc, sizeof(desc), "You interrupt %s", tgt); - else - snprintf(desc, sizeof(desc), "%s interrupted", tgt); - color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); - break; - case T::INSTAKILL: - if (spell && e.isPlayerSource) - snprintf(desc, sizeof(desc), "You instantly kill %s with %s", tgt, spell); - else if (spell) - snprintf(desc, sizeof(desc), "%s instantly kills %s with %s", src, tgt, spell); - else if (e.isPlayerSource) - snprintf(desc, sizeof(desc), "You instantly kill %s", tgt); - else - snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); - color = colors::kBrightRed; - break; - case T::HONOR_GAIN: - snprintf(desc, sizeof(desc), "You gain %d honor", e.amount); - color = colors::kBrightGold; - break; - case T::GLANCING: - snprintf(desc, sizeof(desc), "%s glances %s for %d", src, tgt, e.amount); - color = e.isPlayerSource ? ImVec4(0.75f, 0.75f, 0.5f, 1.0f) - : ImVec4(0.75f, 0.4f, 0.4f, 1.0f); - break; - case T::CRUSHING: - snprintf(desc, sizeof(desc), "%s crushes %s for %d!", src, tgt, e.amount); - color = e.isPlayerSource ? ImVec4(1.0f, 0.55f, 0.1f, 1.0f) - : ImVec4(1.0f, 0.15f, 0.15f, 1.0f); - break; - default: - snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", static_cast(e.type), e.amount); - color = ui::colors::kLightGray; - break; - } - - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::TextDisabled("%s", timeBuf); - ImGui::TableSetColumnIndex(1); - ImGui::TextColored(color, "%s", desc); - // Hover tooltip: show rich spell info for entries with a known spell - if (e.spellId != 0 && ImGui::IsItemHovered()) { - auto* assetMgrLog = core::Application::getInstance().getAssetManager(); - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog); - if (!richOk) { - ImGui::Text("%s", spellName.c_str()); - } - ImGui::EndTooltip(); - } - } - - // Auto-scroll to bottom - if (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) - ImGui::SetScrollHereY(1.0f); - - ImGui::EndTable(); - } - - ImGui::End(); -} - -// ─── Achievement Window ─────────────────────────────────────────────────────── -void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { - if (!showAchievementWindow_) return; - - ImGui::SetNextWindowSize(ImVec2(420, 480), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(200, 150), ImGuiCond_FirstUseEver); - - if (!ImGui::Begin("Achievements", &showAchievementWindow_)) { - ImGui::End(); - return; - } - - const auto& earned = gameHandler.getEarnedAchievements(); - const auto& criteria = gameHandler.getCriteriaProgress(); - - ImGui::SetNextItemWidth(180.0f); - ImGui::InputText("##achsearch", achievementSearchBuf_, sizeof(achievementSearchBuf_)); - ImGui::SameLine(); - if (ImGui::Button("Clear")) achievementSearchBuf_[0] = '\0'; - ImGui::Separator(); - - std::string filter(achievementSearchBuf_); - for (char& c : filter) c = static_cast(tolower(static_cast(c))); - - if (ImGui::BeginTabBar("##achtabs")) { - // --- Earned tab --- - char earnedLabel[32]; - snprintf(earnedLabel, sizeof(earnedLabel), "Earned (%u)###earned", static_cast(earned.size())); - if (ImGui::BeginTabItem(earnedLabel)) { - if (earned.empty()) { - ImGui::TextDisabled("No achievements earned yet."); - } else { - ImGui::BeginChild("##achlist", ImVec2(0, 0), false); - std::vector ids(earned.begin(), earned.end()); - std::sort(ids.begin(), ids.end()); - for (uint32_t id : ids) { - const std::string& name = gameHandler.getAchievementName(id); - const std::string& display = name.empty() ? std::to_string(id) : name; - if (!filter.empty()) { - std::string lower = display; - for (char& c : lower) c = static_cast(tolower(static_cast(c))); - if (lower.find(filter) == std::string::npos) continue; - } - ImGui::PushID(static_cast(id)); - ImGui::TextColored(colors::kBrightGold, "\xE2\x98\x85"); - ImGui::SameLine(); - ImGui::TextUnformatted(display.c_str()); - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - // Points badge - uint32_t pts = gameHandler.getAchievementPoints(id); - if (pts > 0) { - ImGui::TextColored(colors::kBrightGold, - "%u Achievement Point%s", pts, pts == 1 ? "" : "s"); - ImGui::Separator(); - } - // Description - const std::string& desc = gameHandler.getAchievementDescription(id); - if (!desc.empty()) { - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); - ImGui::TextUnformatted(desc.c_str()); - ImGui::PopTextWrapPos(); - ImGui::Spacing(); - } - // Earn date - uint32_t packed = gameHandler.getAchievementDate(id); - if (packed != 0) { - // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] - int minute = (packed >> 3) & 0x3F; - int hour = (packed >> 9) & 0x1F; - int day = (packed >> 17) & 0x1F; - int month = (packed >> 21) & 0x0F; - int year = ((packed >> 25) & 0x7F) + 2000; - const char* mname = (month >= 1 && month <= 12) ? kMonthAbbrev[month - 1] : "?"; - ImGui::TextDisabled("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); - } - ImGui::EndTooltip(); - } - ImGui::PopID(); - } - ImGui::EndChild(); - } - ImGui::EndTabItem(); - } - - // --- Criteria progress tab --- - char critLabel[32]; - snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", static_cast(criteria.size())); - if (ImGui::BeginTabItem(critLabel)) { - // Lazy-load AchievementCriteria.dbc for descriptions - struct CriteriaEntry { uint32_t achievementId; uint64_t quantity; std::string description; }; - static std::unordered_map s_criteriaData; - static bool s_criteriaDataLoaded = false; - if (!s_criteriaDataLoaded) { - s_criteriaDataLoaded = true; - auto* am = core::Application::getInstance().getAssetManager(); - if (am && am->isInitialized()) { - auto dbc = am->loadDBC("AchievementCriteria.dbc"); - if (dbc && dbc->isLoaded() && dbc->getFieldCount() >= 10) { - const auto* acL = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("AchievementCriteria") : nullptr; - uint32_t achField = acL ? acL->field("AchievementID") : 1u; - uint32_t qtyField = acL ? acL->field("Quantity") : 4u; - uint32_t descField = acL ? acL->field("Description") : 9u; - if (achField == 0xFFFFFFFF) achField = 1; - if (qtyField == 0xFFFFFFFF) qtyField = 4; - if (descField == 0xFFFFFFFF) descField = 9; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t cid = dbc->getUInt32(r, 0); - if (cid == 0) continue; - CriteriaEntry ce; - ce.achievementId = (achField < fc) ? dbc->getUInt32(r, achField) : 0; - ce.quantity = (qtyField < fc) ? dbc->getUInt32(r, qtyField) : 0; - ce.description = (descField < fc) ? dbc->getString(r, descField) : std::string{}; - s_criteriaData[cid] = std::move(ce); - } - } - } - } - - if (criteria.empty()) { - ImGui::TextDisabled("No criteria progress received yet."); - } else { - ImGui::BeginChild("##critlist", ImVec2(0, 0), false); - std::vector> clist(criteria.begin(), criteria.end()); - std::sort(clist.begin(), clist.end()); - for (const auto& [cid, cval] : clist) { - auto ceIt = s_criteriaData.find(cid); - - // Build display text for filtering - std::string display; - if (ceIt != s_criteriaData.end() && !ceIt->second.description.empty()) { - display = ceIt->second.description; - } else { - display = std::to_string(cid); - } - if (!filter.empty()) { - std::string lower = display; - for (char& c : lower) c = static_cast(tolower(static_cast(c))); - // Also allow filtering by achievement name - if (lower.find(filter) == std::string::npos && ceIt != s_criteriaData.end()) { - const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); - std::string achLower = achName; - for (char& c : achLower) c = static_cast(tolower(static_cast(c))); - if (achLower.find(filter) == std::string::npos) continue; - } else if (lower.find(filter) == std::string::npos) { - continue; - } - } - - ImGui::PushID(static_cast(cid)); - if (ceIt != s_criteriaData.end()) { - // Show achievement name as header (dim) - const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); - if (!achName.empty()) { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.8f), "%s", achName.c_str()); - ImGui::SameLine(); - ImGui::TextDisabled(">"); - ImGui::SameLine(); - } - if (!ceIt->second.description.empty()) { - ImGui::TextUnformatted(ceIt->second.description.c_str()); - } else { - ImGui::TextDisabled("Criteria %u", cid); - } - ImGui::SameLine(); - if (ceIt->second.quantity > 0) { - ImGui::TextColored(colors::kLightGreen, - "%llu/%llu", - static_cast(cval), - static_cast(ceIt->second.quantity)); - } else { - ImGui::TextColored(colors::kLightGreen, - "%llu", static_cast(cval)); - } - } else { - ImGui::TextDisabled("Criteria %u:", cid); - ImGui::SameLine(); - ImGui::Text("%llu", static_cast(cval)); - } - ImGui::PopID(); - } - ImGui::EndChild(); - } - ImGui::EndTabItem(); - } - ImGui::EndTabBar(); - } - - ImGui::End(); -} - -// ─── GM Ticket Window ───────────────────────────────────────────────────────── -void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { - // Fire a one-shot query when the window first becomes visible - if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) { - gameHandler.requestGmTicket(); - } - gmTicketWindowWasOpen_ = showGmTicketWindow_; - - if (!showGmTicketWindow_) return; - - ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); - - if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::End(); - return; - } - - // Show GM support availability - if (!gameHandler.isGmSupportAvailable()) { - ImGui::TextColored(colors::kSoftRed, "GM support is currently unavailable."); - ImGui::Spacing(); - } - - // Show existing open ticket if any - if (gameHandler.hasActiveGmTicket()) { - ImGui::TextColored(kColorGreen, "You have an open GM ticket."); - const std::string& existingText = gameHandler.getGmTicketText(); - if (!existingText.empty()) { - ImGui::TextWrapped("Current ticket: %s", existingText.c_str()); - } - float waitHours = gameHandler.getGmTicketWaitHours(); - if (waitHours > 0.0f) { - char waitBuf[64]; - std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf); - } - ImGui::Separator(); - ImGui::Spacing(); - } - - ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); - ImGui::Spacing(); - ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), - ImVec2(-1, 120)); - ImGui::Spacing(); - - bool hasText = (gmTicketBuf_[0] != '\0'); - if (!hasText) ImGui::BeginDisabled(); - if (ImGui::Button("Submit Ticket", ImVec2(160, 0))) { - gameHandler.submitGmTicket(gmTicketBuf_); - gmTicketBuf_[0] = '\0'; - showGmTicketWindow_ = false; - } - if (!hasText) ImGui::EndDisabled(); - - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(80, 0))) { - showGmTicketWindow_ = false; - } - ImGui::SameLine(); - if (gameHandler.hasActiveGmTicket()) { - if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) { - gameHandler.deleteGmTicket(); - showGmTicketWindow_ = false; - } - } - - ImGui::End(); -} // ─── Threat Window ──────────────────────────────────────────────────────────── -void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { - if (!showThreatWindow_) return; - - const auto* list = gameHandler.getTargetThreatList(); - - ImGui::SetNextWindowSize(ImVec2(280, 220), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(10, 300), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.85f); - - if (!ImGui::Begin("Threat###ThreatWin", &showThreatWindow_, - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::End(); - return; - } - - if (!list || list->empty()) { - ImGui::TextDisabled("No threat data for current target."); - ImGui::End(); - return; - } - - uint32_t maxThreat = list->front().threat; - - // Pre-scan to find the player's rank and threat percentage - uint64_t playerGuid = gameHandler.getPlayerGuid(); - int playerRank = 0; - float playerPct = 0.0f; - { - int scan = 0; - for (const auto& e : *list) { - ++scan; - if (e.victimGuid == playerGuid) { - playerRank = scan; - playerPct = (maxThreat > 0) ? static_cast(e.threat) / static_cast(maxThreat) : 0.0f; - break; - } - if (scan >= 10) break; - } - } - - // Status bar: aggro alert or rank summary - if (playerRank == 1) { - // Player has aggro — persistent red warning - ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.25f, 1.0f), "!! YOU HAVE AGGRO !!"); - } else if (playerRank > 1 && playerPct >= 0.8f) { - // Close to pulling — pulsing warning - float pulse = 0.55f + 0.45f * sinf(static_cast(ImGui::GetTime()) * 5.0f); - ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.1f, pulse), "! PULLING AGGRO (%.0f%%) !", playerPct * 100.0f); - } else if (playerRank > 0) { - ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "You: #%d %.0f%% threat", playerRank, playerPct * 100.0f); - } - - ImGui::TextDisabled("%-19s Threat", "Player"); - ImGui::Separator(); - - int rank = 0; - for (const auto& entry : *list) { - ++rank; - bool isPlayer = (entry.victimGuid == playerGuid); - - // Resolve name - std::string victimName; - auto entity = gameHandler.getEntityManager().getEntity(entry.victimGuid); - if (entity) { - if (entity->getType() == game::ObjectType::PLAYER) { - auto p = std::static_pointer_cast(entity); - victimName = p->getName().empty() ? "Player" : p->getName(); - } else if (entity->getType() == game::ObjectType::UNIT) { - auto u = std::static_pointer_cast(entity); - victimName = u->getName().empty() ? "NPC" : u->getName(); - } - } - if (victimName.empty()) - victimName = "0x" + [&](){ - char buf[20]; snprintf(buf, sizeof(buf), "%llX", - static_cast(entry.victimGuid)); return std::string(buf); }(); - - // Colour: gold for #1 (tank), red if player is highest, white otherwise - ImVec4 col = ui::colors::kWhite; - if (rank == 1) col = ui::colors::kTooltipGold; // gold - if (isPlayer && rank == 1) col = kColorRed; // red — you have aggro - - // Threat bar - float pct = (maxThreat > 0) ? static_cast(entry.threat) / static_cast(maxThreat) : 0.0f; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f)); - char barLabel[48]; - snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f); - ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel); - ImGui::PopStyleColor(); - ImGui::SameLine(); - - ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat); - - if (rank >= 10) break; // cap display at 10 entries - } - - ImGui::End(); -} - // ─── BG Scoreboard ──────────────────────────────────────────────────────────── -void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { - if (!showBgScoreboard_) return; - const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard(); - ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); - const char* title = data && data->isArena ? "Arena Score###BgScore" - : "Battleground Score###BgScore"; - if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { - ImGui::End(); - return; - } - if (!data) { - ImGui::TextDisabled("No score data yet."); - ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground or arena."); - ImGui::End(); - return; - } - // Arena team rating banner (shown only for arenas) - if (data->isArena) { - for (int t = 0; t < 2; ++t) { - const auto& at = data->arenaTeams[t]; - if (at.teamName.empty()) continue; - int32_t ratingDelta = static_cast(at.ratingChange); - ImVec4 teamCol = (t == 0) ? colors::kHostileRed // team 0: red - : colors::kLightBlue; // team 1: blue - ImGui::TextColored(teamCol, "%s", at.teamName.c_str()); - ImGui::SameLine(); - char ratingBuf[32]; - if (ratingDelta >= 0) - std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta); - else - std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta); - ImGui::TextDisabled("%s", ratingBuf); - } - ImGui::Separator(); - } - - // Winner banner - if (data->hasWinner) { - const char* winnerStr; - ImVec4 winnerColor; - if (data->isArena) { - // For arenas, winner byte 0/1 refers to team index in arenaTeams[] - const auto& winTeam = data->arenaTeams[data->winner & 1]; - winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str(); - winnerColor = (data->winner == 0) ? colors::kHostileRed - : colors::kLightBlue; - } else { - winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; - winnerColor = (data->winner == 1) ? colors::kLightBlue - : colors::kHostileRed; - } - float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; - ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); - ImGui::TextColored(winnerColor, "%s", winnerStr); - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kBrightGold, "Victory!"); - ImGui::Separator(); - } - - // Refresh button - if (ImGui::SmallButton("Refresh")) { - gameHandler.requestPvpLog(); - } - ImGui::SameLine(); - ImGui::TextDisabled("%zu players", data->players.size()); - - // Score table - constexpr ImGuiTableFlags kTableFlags = - ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | - ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV | - ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable; - - // Build dynamic column count based on what BG-specific stats are present - int numBgCols = 0; - std::vector bgColNames; - for (const auto& ps : data->players) { - for (const auto& [fieldName, val] : ps.bgStats) { - // Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps") - std::string shortName = fieldName; - auto dotPos = fieldName.rfind('.'); - if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); - bool found = false; - for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } } - if (!found) bgColNames.push_back(shortName); - } - } - numBgCols = static_cast(bgColNames.size()); - - // Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific - int totalCols = 6 + numBgCols; - float tableH = ImGui::GetContentRegionAvail().y; - if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) { - ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f); - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f); - ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f); - ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f); - ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f); - for (const auto& col : bgColNames) - ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f); - ImGui::TableHeadersRow(); - - // Sort: Alliance first, then Horde; within each team by KB desc - std::vector sorted; - sorted.reserve(data->players.size()); - for (const auto& ps : data->players) sorted.push_back(&ps); - std::stable_sort(sorted.begin(), sorted.end(), - [](const game::GameHandler::BgPlayerScore* a, - const game::GameHandler::BgPlayerScore* b) { - if (a->team != b->team) return a->team > b->team; // Alliance(1) first - return a->killingBlows > b->killingBlows; - }); - - uint64_t playerGuid = gameHandler.getPlayerGuid(); - for (const auto* ps : sorted) { - ImGui::TableNextRow(); - - // Team - ImGui::TableNextColumn(); - if (ps->team == 1) - ImGui::TextColored(colors::kLightBlue, "Alliance"); - else - ImGui::TextColored(colors::kHostileRed, "Horde"); - - // Name (highlight player's own row) - ImGui::TableNextColumn(); - bool isSelf = (ps->guid == playerGuid); - if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); - const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str(); - ImGui::TextUnformatted(nameStr); - if (isSelf) ImGui::PopStyleColor(); - - ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows); - ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths); - ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills); - ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor); - - for (const auto& col : bgColNames) { - ImGui::TableNextColumn(); - uint32_t val = 0; - for (const auto& [fieldName, fval] : ps->bgStats) { - std::string shortName = fieldName; - auto dotPos = fieldName.rfind('.'); - if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); - if (shortName == col) { val = fval; break; } - } - if (val > 0) ImGui::Text("%u", val); - else ImGui::TextDisabled("-"); - } - } - ImGui::EndTable(); - } - - ImGui::End(); -} - - - -// ─── Book / Scroll / Note Window ────────────────────────────────────────────── -void GameScreen::renderBookWindow(game::GameHandler& gameHandler) { - // Auto-open when new pages arrive - if (gameHandler.hasBookOpen() && !showBookWindow_) { - showBookWindow_ = true; - bookCurrentPage_ = 0; - } - if (!showBookWindow_) return; - - const auto& pages = gameHandler.getBookPages(); - if (pages.empty()) { showBookWindow_ = false; return; } - - // Clamp page index - if (bookCurrentPage_ < 0) bookCurrentPage_ = 0; - if (bookCurrentPage_ >= static_cast(pages.size())) - bookCurrentPage_ = static_cast(pages.size()) - 1; - - ImGui::SetNextWindowSize(ImVec2(420, 340), ImGuiCond_Appearing); - ImGui::SetNextWindowPos(ImVec2(400, 180), ImGuiCond_Appearing); - - bool open = showBookWindow_; - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.12f, 0.09f, 0.06f, 0.98f)); - ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.25f, 0.18f, 0.08f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.37f, 0.18f, 1.0f)); - - char title[64]; - if (pages.size() > 1) - snprintf(title, sizeof(title), "Page %d / %d###BookWin", - bookCurrentPage_ + 1, static_cast(pages.size())); - else - snprintf(title, sizeof(title), "###BookWin"); - - if (ImGui::Begin(title, &open, ImGuiWindowFlags_NoCollapse)) { - // Parchment text colour - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.85f, 0.78f, 0.62f, 1.0f)); - - const std::string& text = pages[bookCurrentPage_].text; - // Use a child region with word-wrap - ImGui::SetNextWindowContentSize(ImVec2(ImGui::GetContentRegionAvail().x, 0)); - if (ImGui::BeginChild("##BookText", - ImVec2(0, ImGui::GetContentRegionAvail().y - 34), - false, ImGuiWindowFlags_HorizontalScrollbar)) { - ImGui::SetNextItemWidth(-1); - ImGui::TextWrapped("%s", text.c_str()); - } - ImGui::EndChild(); - ImGui::PopStyleColor(); - - // Navigation row - ImGui::Separator(); - bool canPrev = (bookCurrentPage_ > 0); - bool canNext = (bookCurrentPage_ < static_cast(pages.size()) - 1); - - if (!canPrev) ImGui::BeginDisabled(); - if (ImGui::Button("< Prev", ImVec2(80, 0))) bookCurrentPage_--; - if (!canPrev) ImGui::EndDisabled(); - - ImGui::SameLine(); - if (!canNext) ImGui::BeginDisabled(); - if (ImGui::Button("Next >", ImVec2(80, 0))) bookCurrentPage_++; - if (!canNext) ImGui::EndDisabled(); - - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60); - if (ImGui::Button("Close", ImVec2(60, 0))) { - open = false; - } - } - ImGui::End(); - ImGui::PopStyleColor(3); - - if (!open) { - showBookWindow_ = false; - gameHandler.clearBook(); - } -} - -// ─── Inspect Window ─────────────────────────────────────────────────────────── -void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { - if (!showInspectWindow_) return; - - // Lazy-load SpellItemEnchantment.dbc for enchant name lookup - static std::unordered_map s_enchantNames; - static bool s_enchantDbLoaded = false; - auto* assetMgrEnchant = core::Application::getInstance().getAssetManager(); - if (!s_enchantDbLoaded && assetMgrEnchant && assetMgrEnchant->isInitialized()) { - s_enchantDbLoaded = true; - auto dbc = assetMgrEnchant->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") - : nullptr; - uint32_t idField = layout ? (*layout)["ID"] : 0; - uint32_t nameField = layout ? (*layout)["Name"] : 8; - for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { - uint32_t id = dbc->getUInt32(i, idField); - if (id == 0) continue; - std::string nm = dbc->getString(i, nameField); - if (!nm.empty()) s_enchantNames[id] = std::move(nm); - } - } - } - - // Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server) - static constexpr const char* kSlotNames[19] = { - "Head", "Neck", "Shoulder", "Shirt", "Chest", - "Waist", "Legs", "Feet", "Wrist", "Hands", - "Finger 1", "Finger 2", "Trinket 1", "Trinket 2", "Back", - "Main Hand", "Off Hand", "Ranged", "Tabard" - }; - - ImGui::SetNextWindowSize(ImVec2(360, 440), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(350, 120), ImGuiCond_FirstUseEver); - - const game::GameHandler::InspectResult* result = gameHandler.getInspectResult(); - - std::string title = result ? ("Inspect: " + result->playerName + "###InspectWin") - : "Inspect###InspectWin"; - if (!ImGui::Begin(title.c_str(), &showInspectWindow_, ImGuiWindowFlags_NoCollapse)) { - ImGui::End(); - return; - } - - if (!result) { - ImGui::TextDisabled("No inspect data yet. Target a player and use Inspect."); - ImGui::End(); - return; - } - - // Player name — class-colored if entity is loaded, else gold - { - auto ent = gameHandler.getEntityManager().getEntity(result->guid); - uint8_t cid = entityClassId(ent.get()); - ImVec4 nameColor = (cid != 0) ? classColorVec4(cid) : ui::colors::kTooltipGold; - ImGui::PushStyleColor(ImGuiCol_Text, nameColor); - ImGui::Text("%s", result->playerName.c_str()); - ImGui::PopStyleColor(); - if (cid != 0) { - ImGui::SameLine(); - ImGui::TextColored(classColorVec4(cid), "(%s)", classNameStr(cid)); - } - } - ImGui::SameLine(); - ImGui::TextDisabled(" %u talent pts", result->totalTalents); - if (result->unspentTalents > 0) { - ImGui::SameLine(); - ImGui::TextColored(colors::kSoftRed, "(%u unspent)", result->unspentTalents); - } - if (result->talentGroups > 1) { - ImGui::SameLine(); - ImGui::TextDisabled(" Dual spec (active %u)", static_cast(result->activeTalentGroup) + 1); - } - - ImGui::Separator(); - - // Equipment list - bool hasAnyGear = false; - for (int s = 0; s < 19; ++s) { - if (result->itemEntries[s] != 0) { hasAnyGear = true; break; } - } - - if (!hasAnyGear) { - ImGui::TextDisabled("Equipment data not yet available."); - ImGui::TextDisabled("(Gear loads after the player is inspected in-range)"); - } else { - // Average item level (only slots that have loaded info and are not shirt/tabard) - // Shirt=slot3, Tabard=slot18 — excluded from gear score by WoW convention - uint32_t iLevelSum = 0; - int iLevelCount = 0; - for (int s = 0; s < 19; ++s) { - if (s == 3 || s == 18) continue; // shirt, tabard - uint32_t entry = result->itemEntries[s]; - if (entry == 0) continue; - const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); - if (info && info->valid && info->itemLevel > 0) { - iLevelSum += info->itemLevel; - ++iLevelCount; - } - } - if (iLevelCount > 0) { - float avgIlvl = static_cast(iLevelSum) / static_cast(iLevelCount); - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Avg iLvl: %.1f", avgIlvl); - ImGui::SameLine(); - ImGui::TextDisabled("(%d/%d slots loaded)", iLevelCount, - [&]{ int c=0; for(int s=0;s<19;++s){if(s==3||s==18)continue;if(result->itemEntries[s])++c;} return c; }()); - } - if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { - constexpr float kIconSz = 28.0f; - for (int s = 0; s < 19; ++s) { - uint32_t entry = result->itemEntries[s]; - if (entry == 0) continue; - - const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); - if (!info) { - gameHandler.ensureItemInfo(entry); - ImGui::PushID(s); - ImGui::TextDisabled("[%s] (loading…)", kSlotNames[s]); - ImGui::PopID(); - continue; - } - - ImGui::PushID(s); - auto qColor = InventoryScreen::getQualityColor( - static_cast(info->quality)); - uint16_t enchantId = result->enchantIds[s]; - - // Item icon - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); - if (iconTex) { - ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(kIconSz, kIconSz), - ImVec2(0,0), ImVec2(1,1), - colors::kWhite, qColor); - } else { - ImGui::GetWindowDrawList()->AddRectFilled( - ImGui::GetCursorScreenPos(), - ImVec2(ImGui::GetCursorScreenPos().x + kIconSz, - ImGui::GetCursorScreenPos().y + kIconSz), - IM_COL32(40, 40, 50, 200)); - ImGui::Dummy(ImVec2(kIconSz, kIconSz)); - } - bool hovered = ImGui::IsItemHovered(); - - ImGui::SameLine(); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (kIconSz - ImGui::GetTextLineHeight()) * 0.5f); - ImGui::BeginGroup(); - ImGui::TextDisabled("%s", kSlotNames[s]); - ImGui::TextColored(qColor, "%s", info->name.c_str()); - // Enchant indicator on the same row as the name - if (enchantId != 0) { - auto enchIt = s_enchantNames.find(enchantId); - const std::string& enchName = (enchIt != s_enchantNames.end()) - ? enchIt->second : std::string{}; - ImGui::SameLine(); - if (!enchName.empty()) { - ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), - "\xe2\x9c\xa6 %s", enchName.c_str()); // UTF-8 ✦ - } else { - ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); - } - } - ImGui::EndGroup(); - hovered = hovered || ImGui::IsItemHovered(); - - if (hovered && info->valid) { - inventoryScreen.renderItemTooltip(*info); - } else if (hovered) { - ImGui::SetTooltip("%s", info->name.c_str()); - } - - ImGui::PopID(); - ImGui::Spacing(); - } - } - ImGui::EndChild(); - } - - // Arena teams (WotLK — from MSG_INSPECT_ARENA_TEAMS) - if (!result->arenaTeams.empty()) { - ImGui::Separator(); - ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Arena Teams"); - ImGui::Spacing(); - for (const auto& team : result->arenaTeams) { - const char* bracket = (team.type == 2) ? "2v2" - : (team.type == 3) ? "3v3" - : (team.type == 5) ? "5v5" : "?v?"; - ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), - "[%s] %s", bracket, team.name.c_str()); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f), - " Rating: %u", team.personalRating); - if (team.weekGames > 0 || team.seasonGames > 0) { - ImGui::TextDisabled(" Week: %u/%u Season: %u/%u", - team.weekWins, team.weekGames, - team.seasonWins, team.seasonGames); - } - } - } - - ImGui::End(); -} - -// ─── Titles Window ──────────────────────────────────────────────────────────── -void GameScreen::renderTitlesWindow(game::GameHandler& gameHandler) { - if (!showTitlesWindow_) return; - - ImGui::SetNextWindowSize(ImVec2(320, 400), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(240, 170), ImGuiCond_FirstUseEver); - - if (!ImGui::Begin("Titles", &showTitlesWindow_)) { - ImGui::End(); - return; - } - - const auto& knownBits = gameHandler.getKnownTitleBits(); - const int32_t chosen = gameHandler.getChosenTitleBit(); - - if (knownBits.empty()) { - ImGui::TextDisabled("No titles earned yet."); - ImGui::End(); - return; - } - - ImGui::TextUnformatted("Select a title to display:"); - ImGui::Separator(); - - // "No Title" option - bool noTitle = (chosen < 0); - if (ImGui::Selectable("(No Title)", noTitle)) { - if (!noTitle) gameHandler.sendSetTitle(-1); - } - if (noTitle) { - ImGui::SameLine(); - ImGui::TextColored(colors::kBrightGold, "<-- active"); - } - - ImGui::Separator(); - - // Sort known bits for stable display order - std::vector sortedBits(knownBits.begin(), knownBits.end()); - std::sort(sortedBits.begin(), sortedBits.end()); - - ImGui::BeginChild("##titlelist", ImVec2(0, 0), false); - for (uint32_t bit : sortedBits) { - const std::string title = gameHandler.getFormattedTitle(bit); - const std::string display = title.empty() - ? ("Title #" + std::to_string(bit)) : title; - - bool isActive = (chosen >= 0 && static_cast(chosen) == bit); - ImGui::PushID(static_cast(bit)); - - if (isActive) { - ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); - } - if (ImGui::Selectable(display.c_str(), isActive)) { - if (!isActive) gameHandler.sendSetTitle(static_cast(bit)); - } - if (isActive) { - ImGui::PopStyleColor(); - ImGui::SameLine(); - ImGui::TextDisabled("<-- active"); - } - - ImGui::PopID(); - } - ImGui::EndChild(); - - ImGui::End(); -} - -// ─── Equipment Set Manager Window ───────────────────────────────────────────── -void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) { - if (!showEquipSetWindow_) return; - - ImGui::SetNextWindowSize(ImVec2(280, 320), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(260, 180), ImGuiCond_FirstUseEver); - - if (!ImGui::Begin("Equipment Sets##equipsets", &showEquipSetWindow_)) { - ImGui::End(); - return; - } - - const auto& sets = gameHandler.getEquipmentSets(); - - if (sets.empty()) { - ImGui::TextDisabled("No equipment sets saved."); - ImGui::Spacing(); - ImGui::TextWrapped("Create equipment sets in-game using the default WoW equipment manager (Shift+click the Equipment Sets button)."); - ImGui::End(); - return; - } - - ImGui::TextUnformatted("Click a set to equip it:"); - ImGui::Separator(); - ImGui::Spacing(); - - ImGui::BeginChild("##equipsetlist", ImVec2(0, 0), false); - for (const auto& set : sets) { - ImGui::PushID(static_cast(set.setId)); - - // Icon placeholder (use a coloured square if no icon texture available) - ImVec2 iconSize(32.0f, 32.0f); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.20f, 0.10f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.30f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.60f, 0.45f, 0.20f, 1.0f)); - if (ImGui::Button("##icon", iconSize)) { - gameHandler.useEquipmentSet(set.setId); - } - ImGui::PopStyleColor(3); - - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Equip set: %s", set.name.c_str()); - } - - ImGui::SameLine(); - - // Name and equip button - ImGui::BeginGroup(); - ImGui::TextUnformatted(set.name.c_str()); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.35f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.50f, 0.22f, 1.0f)); - if (ImGui::SmallButton("Equip")) { - gameHandler.useEquipmentSet(set.setId); - } - ImGui::PopStyleColor(2); - ImGui::EndGroup(); - - ImGui::Spacing(); - ImGui::PopID(); - } - ImGui::EndChild(); - - ImGui::End(); -} - -void GameScreen::renderSkillsWindow(game::GameHandler& gameHandler) { - if (!showSkillsWindow_) return; - - ImGui::SetNextWindowSize(ImVec2(380, 480), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(220, 130), ImGuiCond_FirstUseEver); - - if (!ImGui::Begin("Skills & Professions", &showSkillsWindow_)) { - ImGui::End(); - return; - } - - const auto& skills = gameHandler.getPlayerSkills(); - if (skills.empty()) { - ImGui::TextDisabled("No skill data received yet."); - ImGui::End(); - return; - } - - // Organise skills by category - // WoW SkillLine.dbc categories: 6=Weapon, 7=Class, 8=Armor, 9=Secondary, 11=Professions, others=Misc - struct SkillEntry { - uint32_t skillId; - const game::PlayerSkill* skill; - }; - std::map> byCategory; - for (const auto& [id, sk] : skills) { - uint32_t cat = gameHandler.getSkillCategory(id); - byCategory[cat].push_back({id, &sk}); - } - - static constexpr struct { uint32_t cat; const char* label; } kCatOrder[] = { - {11, "Professions"}, - { 9, "Secondary Skills"}, - { 7, "Class Skills"}, - { 6, "Weapon Skills"}, - { 8, "Armor"}, - { 5, "Languages"}, - { 0, "Other"}, - }; - - // Collect handled categories to fall back to "Other" for unknowns - static const uint32_t kKnownCats[] = {11, 9, 7, 6, 8, 5}; - - // Redirect unknown categories into bucket 0 - for (auto& [cat, vec] : byCategory) { - bool known = false; - for (uint32_t kc : kKnownCats) if (cat == kc) { known = true; break; } - if (!known && cat != 0) { - auto& other = byCategory[0]; - other.insert(other.end(), vec.begin(), vec.end()); - vec.clear(); - } - } - - ImGui::BeginChild("##skillscroll", ImVec2(0, 0), false); - - for (const auto& [cat, label] : kCatOrder) { - auto it = byCategory.find(cat); - if (it == byCategory.end() || it->second.empty()) continue; - - auto& entries = it->second; - // Sort alphabetically within each category - std::sort(entries.begin(), entries.end(), [&](const SkillEntry& a, const SkillEntry& b) { - return gameHandler.getSkillName(a.skillId) < gameHandler.getSkillName(b.skillId); - }); - - if (ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen)) { - for (const auto& e : entries) { - const std::string& name = gameHandler.getSkillName(e.skillId); - const char* displayName = name.empty() ? "Unknown" : name.c_str(); - uint16_t val = e.skill->effectiveValue(); - uint16_t maxVal = e.skill->maxValue; - - ImGui::PushID(static_cast(e.skillId)); - - // Name column - ImGui::TextUnformatted(displayName); - ImGui::SameLine(170.0f); - - // Progress bar - float fraction = (maxVal > 0) ? static_cast(val) / static_cast(maxVal) : 0.0f; - char overlay[32]; - snprintf(overlay, sizeof(overlay), "%u / %u", val, maxVal); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.20f, 0.55f, 0.20f, 1.0f)); - ImGui::ProgressBar(fraction, ImVec2(160.0f, 14.0f), overlay); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::Text("%s", displayName); - ImGui::Separator(); - ImGui::Text("Base: %u", e.skill->value); - if (e.skill->bonusPerm > 0) - ImGui::Text("Permanent bonus: +%u", e.skill->bonusPerm); - if (e.skill->bonusTemp > 0) - ImGui::Text("Temporary bonus: +%u", e.skill->bonusTemp); - ImGui::Text("Max: %u", maxVal); - ImGui::EndTooltip(); - } - - ImGui::PopID(); - } - ImGui::Spacing(); - } - } - - ImGui::EndChild(); - ImGui::End(); -} }} // namespace wowee::ui diff --git a/src/ui/social_panel.cpp b/src/ui/social_panel.cpp new file mode 100644 index 00000000..6d0f0145 --- /dev/null +++ b/src/ui/social_panel.cpp @@ -0,0 +1,2626 @@ +// ============================================================ +// SocialPanel — extracted from GameScreen +// Owns all social/group-related UI rendering: party frames, +// boss frames, guild roster, social/friends frame, dungeon finder, +// who window, inspect window. +// ============================================================ +#include "ui/social_panel.hpp" +#include "ui/chat_panel.hpp" +#include "ui/spellbook_screen.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/ui_colors.hpp" +#include "core/application.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "ui/keybinding_manager.hpp" +#include "game/zone_manager.hpp" +#include +#include +#include +#include +#include +#include + +namespace { + 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; + + // 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); + } + + // 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); + } + + // 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. + uint8_t entityClassId(const wowee::game::Entity* entity) { + if (!entity) return 0; + using UF = wowee::game::UF; + uint32_t bytes0 = entity->getField(wowee::game::fieldIndex(UF::UNIT_FIELD_BYTES_0)); + return static_cast((bytes0 >> 8) & 0xFF); + } + + // Aura dispel-type names (indexed by dispelType 0-4) + constexpr const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; + + // 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" + }; + + // Alias for shared class name helper + const char* classNameStr(uint8_t classId) { + return wowee::game::getClassName(static_cast(classId)); + } +} // anonymous namespace + +namespace wowee { +namespace ui { + + +void SocialPanel::renderPartyFrames(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + SpellIconFn getSpellIcon) { + if (!gameHandler.isInGroup()) return; + + auto* assetMgr = core::Application::getInstance().getAssetManager(); + const auto& partyData = gameHandler.getPartyData(); + const bool isRaid = (partyData.groupType == 1); + float frameY = 120.0f; + + // ---- Raid frame layout ---- + if (isRaid) { + // Organize members by subgroup (0-7, up to 5 members each) + constexpr int MAX_SUBGROUPS = 8; + constexpr int MAX_PER_GROUP = 5; + std::vector subgroups[MAX_SUBGROUPS]; + for (const auto& m : partyData.members) { + int sg = m.subGroup < MAX_SUBGROUPS ? m.subGroup : 0; + if (static_cast(subgroups[sg].size()) < MAX_PER_GROUP) + subgroups[sg].push_back(&m); + } + + // Count non-empty subgroups to determine layout + int activeSgs = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) + if (!subgroups[sg].empty()) activeSgs++; + + // Compact raid cell: name + 2 narrow bars + constexpr float CELL_W = 90.0f; + constexpr float CELL_H = 42.0f; + constexpr float BAR_H = 7.0f; + constexpr float CELL_PAD = 3.0f; + + float winW = activeSgs * (CELL_W + CELL_PAD) + CELL_PAD + 8.0f; + float winH = MAX_PER_GROUP * (CELL_H + CELL_PAD) + CELL_PAD + 20.0f; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float raidX = (screenW - winW) / 2.0f; + float raidY = screenH - winH - 120.0f; // above action bar area + + ImGui::SetNextWindowPos(ImVec2(raidX, raidY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Always); + + ImGuiWindowFlags raidFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(CELL_PAD, CELL_PAD)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.07f, 0.07f, 0.1f, 0.85f)); + + if (ImGui::Begin("##RaidFrames", nullptr, raidFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 winPos = ImGui::GetWindowPos(); + + int colIdx = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { + if (subgroups[sg].empty()) continue; + + float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); + + for (int row = 0; row < static_cast(subgroups[sg].size()); row++) { + const auto& m = *subgroups[sg][row]; + float cellY = winPos.y + CELL_PAD + 14.0f + row * (CELL_H + CELL_PAD); + + ImVec2 cellMin(colX, cellY); + ImVec2 cellMax(colX + CELL_W, cellY + CELL_H); + + // Cell background + bool isTarget = (gameHandler.getTargetGuid() == m.guid); + ImU32 bg = isTarget ? IM_COL32(60, 80, 120, 200) : IM_COL32(30, 30, 40, 180); + draw->AddRectFilled(cellMin, cellMax, bg, 3.0f); + if (isTarget) + draw->AddRect(cellMin, cellMax, IM_COL32(100, 150, 255, 200), 3.0f); + + // Dead/ghost overlay + bool isOnline = (m.onlineStatus & 0x0001) != 0; + bool isDead = (m.onlineStatus & 0x0020) != 0; + bool isGhost = (m.onlineStatus & 0x0010) != 0; + + // Out-of-range check (40 yard threshold) + bool isOOR = false; + if (m.hasPartyStats && isOnline && !isDead && !isGhost && m.zoneId != 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt) { + float dx = playerEnt->getX() - static_cast(m.posX); + float dy = playerEnt->getY() - static_cast(m.posY); + isOOR = (dx * dx + dy * dy) > (40.0f * 40.0f); + } + } + // Dim cell overlay when out of range + if (isOOR) + draw->AddRectFilled(cellMin, cellMax, IM_COL32(0, 0, 0, 80), 3.0f); + + // Name text (truncated) — class color when alive+online, gray when dead/offline + char truncName[16]; + snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); + bool isMemberLeader = (m.guid == partyData.leaderGuid); + ImU32 nameCol; + if (!isOnline || isDead || isGhost) { + nameCol = IM_COL32(140, 140, 140, 200); // gray for dead/offline + } else { + // Default: gold for leader, light gray for others + nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : IM_COL32(220, 220, 220, 255); + // Override with WoW class color if entity is loaded + auto mEnt = gameHandler.getEntityManager().getEntity(m.guid); + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) nameCol = classColorU32(cid); + } + draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); + + // Leader crown star in top-right of cell + if (isMemberLeader) + draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*"); + + // Raid mark symbol — small, just to the left of the leader crown + { + static constexpr struct { const char* sym; ImU32 col; } kCellMarks[] = { + { "\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 rmk = gameHandler.getEntityRaidMark(m.guid); + if (rmk < game::GameHandler::kRaidMarkCount) { + ImFont* rmFont = ImGui::GetFont(); + ImVec2 rmsz = rmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kCellMarks[rmk].sym); + float rmX = cellMax.x - 10.0f - 2.0f - rmsz.x; + draw->AddText(rmFont, 9.0f, + ImVec2(rmX, cellMin.y + 2.0f), + kCellMarks[rmk].col, kCellMarks[rmk].sym); + } + } + + // LFG role badge in bottom-right corner of cell + if (m.roles & 0x02) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T"); + else if (m.roles & 0x04) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(60, 220, 80, 230), "H"); + else if (m.roles & 0x08) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D"); + + // Tactical role badge in bottom-left corner (flags from SMSG_GROUP_LIST / SMSG_REAL_GROUP_UPDATE) + // 0x01=Assistant, 0x02=Main Tank, 0x04=Main Assist + if (m.flags & 0x02) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(255, 140, 0, 230), "MT"); + else if (m.flags & 0x04) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(100, 180, 255, 230), "MA"); + else if (m.flags & 0x01) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(180, 215, 255, 180), "A"); + + // Health bar + uint32_t hp = m.hasPartyStats ? m.curHealth : 0; + uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + float barY = cellMin.y + 16.0f; + ImVec2 barBg(cellMin.x + 3.0f, barY); + ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H); + draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f); + ImVec2 barFill(barBg.x, barBg.y); + ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); + ImU32 hpCol = isOOR ? IM_COL32(100, 100, 100, 160) : + pct > 0.5f ? IM_COL32(60, 180, 60, 255) : + pct > 0.2f ? IM_COL32(200, 180, 50, 255) : + IM_COL32(200, 60, 60, 255); + draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); + // HP percentage or OOR text centered on bar + char hpPct[8]; + if (isOOR) + snprintf(hpPct, sizeof(hpPct), "OOR"); + else + snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(pct * 100.0f + 0.5f)); + ImVec2 ts = ImGui::CalcTextSize(hpPct); + float tx = (barBg.x + barBgEnd.x - ts.x) * 0.5f; + float ty = barBg.y + (BAR_H - ts.y) * 0.5f; + draw->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), hpPct); + draw->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 230), hpPct); + } + + // Power bar + if (m.hasPartyStats && m.maxPower > 0) { + float pct = static_cast(m.curPower) / static_cast(m.maxPower); + float barY = cellMin.y + 16.0f + BAR_H + 2.0f; + ImVec2 barBg(cellMin.x + 3.0f, barY); + ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H - 2.0f); + draw->AddRectFilled(barBg, barBgEnd, IM_COL32(30, 30, 40, 200), 2.0f); + ImVec2 barFill(barBg.x, barBg.y); + ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); + ImU32 pwrCol; + switch (m.powerType) { + case 0: pwrCol = IM_COL32(50, 80, 220, 255); break; // Mana + case 1: pwrCol = IM_COL32(200, 50, 50, 255); break; // Rage + case 3: pwrCol = IM_COL32(220, 210, 50, 255); break; // Energy + case 6: pwrCol = IM_COL32(180, 30, 50, 255); break; // Runic Power + default: pwrCol = IM_COL32(80, 120, 80, 255); break; + } + draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f); + } + + // Dispellable debuff dots at the bottom of the raid cell + // Mirrors party frame debuff indicators for healers in 25/40-man raids + if (!isDead && !isGhost) { + const std::vector* unitAuras = nullptr; + if (m.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (m.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(m.guid); + + if (unitAuras) { + bool shown[5] = {}; + float dotX = cellMin.x + 4.0f; + const float dotY = cellMax.y - 5.0f; + const float DOT_R = 3.5f; + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // debuffs only + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dc; + switch (dt) { + case 1: dc = ImVec4(0.25f, 0.50f, 1.00f, 0.90f); break; // Magic: blue + case 2: dc = ImVec4(0.70f, 0.15f, 0.90f, 0.90f); break; // Curse: purple + case 3: dc = ImVec4(0.65f, 0.45f, 0.10f, 0.90f); break; // Disease: brown + case 4: dc = ImVec4(0.10f, 0.75f, 0.10f, 0.90f); break; // Poison: green + default: continue; + } + ImU32 dotColU = ImGui::ColorConvertFloat4ToU32(dc); + draw->AddCircleFilled(ImVec2(dotX, dotY), DOT_R, dotColU); + draw->AddCircle(ImVec2(dotX, dotY), DOT_R + 0.5f, IM_COL32(0, 0, 0, 160), 8, 1.0f); + + float mdx = mouse.x - dotX, mdy = mouse.y - dotY; + if (mdx * mdx + mdy * mdy < (DOT_R + 4.0f) * (DOT_R + 4.0f)) { + ImGui::BeginTooltip(); + ImGui::TextColored(dc, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } + dotX += 9.0f; + } + } + } + + // Clickable invisible region over the whole cell + ImGui::SetCursorScreenPos(cellMin); + ImGui::PushID(static_cast(m.guid)); + if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { + gameHandler.setTarget(m.guid); + } + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(m.guid); + } + if (ImGui::BeginPopupContextItem("RaidMemberCtx")) { + ImGui::TextDisabled("%s", m.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(m.guid); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(m.guid); + if (ImGui::MenuItem("Whisper")) { + chatPanel.setWhisperTarget(m.name); + } + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(m.guid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(m.guid); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + bool isLeader = (partyData.leaderGuid == gameHandler.getPlayerGuid()); + if (isLeader) { + ImGui::Separator(); + if (ImGui::MenuItem("Kick from Raid")) + gameHandler.uninvitePlayer(m.name); + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(m.guid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(m.guid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + colIdx++; + } + + // Subgroup header row + colIdx = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { + if (subgroups[sg].empty()) continue; + float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); + char sgLabel[8]; + snprintf(sgLabel, sizeof(sgLabel), "G%d", sg + 1); + draw->AddText(ImVec2(colX + CELL_W / 2 - 8.0f, winPos.y + CELL_PAD), IM_COL32(160, 160, 180, 200), sgLabel); + colIdx++; + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); + return; + } + + // ---- Party frame layout (5-man) ---- + ImGui::SetNextWindowPos(ImVec2(10.0f, frameY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f)); + + if (ImGui::Begin("##PartyFrames", nullptr, flags)) { + const uint64_t leaderGuid = partyData.leaderGuid; + for (const auto& member : partyData.members) { + ImGui::PushID(static_cast(member.guid)); + + bool isLeader = (member.guid == leaderGuid); + + // Name with level and status info — leader gets a gold star prefix + std::string label = (isLeader ? "* " : " ") + member.name; + if (member.hasPartyStats && member.level > 0) { + label += " [" + std::to_string(member.level) + "]"; + } + if (member.hasPartyStats) { + bool isOnline = (member.onlineStatus & 0x0001) != 0; + bool isDead = (member.onlineStatus & 0x0020) != 0; + bool isGhost = (member.onlineStatus & 0x0010) != 0; + if (!isOnline) label += " (offline)"; + else if (isDead || isGhost) label += " (dead)"; + } + + // Clickable name to target — use WoW class colors when entity is loaded, + // fall back to gold for leader / light gray for others + ImVec4 nameColor = isLeader + ? colors::kBrightGold + : colors::kVeryLightGray; + { + auto memberEntity = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(memberEntity.get()); + if (cid != 0) nameColor = classColorVec4(cid); + } + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { + gameHandler.setTarget(member.guid); + } + // Set mouseover for [target=mouseover] macro conditionals + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(member.guid); + } + // Zone tooltip on name hover + if (ImGui::IsItemHovered() && member.hasPartyStats && member.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(member.zoneId); + if (!zoneName.empty()) + ImGui::SetTooltip("%s", zoneName.c_str()); + } + ImGui::PopStyleColor(); + + // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set + if (member.roles != 0) { + ImGui::SameLine(); + if (member.roles & 0x02) ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "[T]"); + if (member.roles & 0x04) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.3f, 1.0f), "[H]"); } + if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } + } + + // Tactical role badge (MT/MA/Asst) from group flags + if (member.flags & 0x02) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.0f, 0.9f), "[MT]"); + } else if (member.flags & 0x04) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 0.9f), "[MA]"); + } else if (member.flags & 0x01) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 0.7f), "[A]"); + } + + // Raid mark symbol — shown on same line as name when this party member has a mark + { + static constexpr struct { const char* sym; ImU32 col; } kPartyMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImGui::SameLine(); + ImGui::TextColored( + ImGui::ColorConvertU32ToFloat4(kPartyMarks[pmk].col), + "%s", kPartyMarks[pmk].sym); + } + } + + // Health bar: prefer party stats, fall back to entity + uint32_t hp = 0, maxHp = 0; + if (member.hasPartyStats && member.maxHealth > 0) { + hp = member.curHealth; + maxHp = member.maxHealth; + } else { + auto entity = gameHandler.getEntityManager().getEntity(member.guid); + if (entity && (entity->getType() == game::ObjectType::PLAYER || entity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(entity); + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + } + } + // Check dead/ghost state for health bar rendering + bool memberDead = false; + bool memberOffline = false; + if (member.hasPartyStats) { + bool isOnline2 = (member.onlineStatus & 0x0001) != 0; + bool isDead2 = (member.onlineStatus & 0x0020) != 0; + bool isGhost2 = (member.onlineStatus & 0x0010) != 0; + memberDead = isDead2 || isGhost2; + memberOffline = !isOnline2; + } + + // Out-of-range check: compare player position to member's reported position + // Range threshold: 40 yards (standard heal/spell range) + bool memberOutOfRange = false; + if (member.hasPartyStats && !memberOffline && !memberDead && + member.zoneId != 0) { + // Same map: use 2D Euclidean distance in WoW coordinates (yards) + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEntity) { + float dx = playerEntity->getX() - static_cast(member.posX); + float dy = playerEntity->getY() - static_cast(member.posY); + float distSq = dx * dx + dy * dy; + memberOutOfRange = (distSq > 40.0f * 40.0f); + } + } + + if (memberDead) { + // Gray "Dead" bar for fallen party members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Dead"); + ImGui::PopStyleColor(2); + } else if (memberOffline) { + // Dim bar for offline members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.25f, 0.25f, 0.25f, 0.6f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.1f, 0.1f, 0.1f, 0.6f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Offline"); + ImGui::PopStyleColor(2); + } else if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + // Out-of-range: desaturate health bar to gray + ImVec4 hpBarColor = memberOutOfRange + ? ImVec4(0.45f, 0.45f, 0.45f, 0.7f) + : (pct > 0.5f ? colors::kHealthGreen : + pct > 0.2f ? colors::kMidHealthYellow : + colors::kLowHealthRed); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpBarColor); + char hpText[32]; + if (memberOutOfRange) { + snprintf(hpText, sizeof(hpText), "OOR"); + } else if (maxHp >= 10000) { + snprintf(hpText, sizeof(hpText), "%dk/%dk", + static_cast(hp) / 1000, static_cast(maxHp) / 1000); + } else { + snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + } + ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); + ImGui::PopStyleColor(); + } + + // Power bar (mana/rage/energy) from party stats — hidden for dead/offline/OOR + if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) { + float powerPct = static_cast(member.curPower) / static_cast(member.maxPower); + ImVec4 powerColor; + switch (member.powerType) { + case 0: powerColor = colors::kManaBlue; break; // Mana (blue) + 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 = kColorDarkGray; break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); + ImGui::ProgressBar(powerPct, ImVec2(-1, 8), ""); + ImGui::PopStyleColor(); + } + + // Dispellable debuff indicators — small colored dots for party member debuffs + // Only show magic/curse/disease/poison (types 1-4); skip non-dispellable + if (!memberDead && !memberOffline) { + const std::vector* unitAuras = nullptr; + if (member.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (member.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(member.guid); + + if (unitAuras) { + bool anyDebuff = false; + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // only debuffs + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0) continue; // skip non-dispellable + anyDebuff = true; + break; + } + if (anyDebuff) { + // Render one dot per unique dispel type present + bool shown[5] = {}; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 1.0f)); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dotCol; + switch (dt) { + case 1: dotCol = ImVec4(0.25f, 0.50f, 1.00f, 1.0f); break; // Magic: blue + case 2: dotCol = ImVec4(0.70f, 0.15f, 0.90f, 1.0f); break; // Curse: purple + case 3: dotCol = ImVec4(0.65f, 0.45f, 0.10f, 1.0f); break; // Disease: brown + case 4: dotCol = ImVec4(0.10f, 0.75f, 0.10f, 1.0f); break; // Poison: green + default: break; + } + ImGui::PushStyleColor(ImGuiCol_Button, dotCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); + ImGui::Button("##d", ImVec2(8.0f, 8.0f)); + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) { + // Find spell name(s) of this dispel type + ImGui::BeginTooltip(); + ImGui::TextColored(dotCol, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } + ImGui::SameLine(); + } + ImGui::NewLine(); + ImGui::PopStyleVar(); + } + } + } + + // Party member cast bar — shows when the party member is casting + if (auto* cs = gameHandler.getUnitCastState(member.guid)) { + float castPct = (cs->timeTotal > 0.0f) + ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, colors::kMidHealthYellow); + char pcastLabel[48]; + const std::string& spellNm = gameHandler.getSpellName(cs->spellId); + if (!spellNm.empty()) + snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining); + else + snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining); + { + VkDescriptorSet pIcon = (cs->spellId != 0 && assetMgr) + ? getSpellIcon(cs->spellId, assetMgr) : VK_NULL_HANDLE; + if (pIcon) { + ImGui::Image((ImTextureID)(uintptr_t)pIcon, ImVec2(10, 10)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } + } + ImGui::PopStyleColor(); + } + + // Right-click context menu for party member actions + if (ImGui::BeginPopupContextItem("PartyMemberCtx")) { + ImGui::TextDisabled("%s", member.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) { + gameHandler.setTarget(member.guid); + } + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(member.guid); + } + if (ImGui::MenuItem("Whisper")) { + chatPanel.setWhisperTarget(member.name); + } + if (ImGui::MenuItem("Follow")) { + gameHandler.setTarget(member.guid); + gameHandler.followTarget(); + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(member.guid); + } + if (ImGui::MenuItem("Duel")) { + gameHandler.proposeDuel(member.guid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(member.guid); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (!member.name.empty()) { + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(member.name); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(member.name); + } + } + // Leader-only actions + bool isLeader = (gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()); + if (isLeader) { + ImGui::Separator(); + if (ImGui::MenuItem("Kick from Group")) { + gameHandler.uninvitePlayer(member.name); + } + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(member.guid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(member.guid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } + + ImGui::Separator(); + ImGui::PopID(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +void SocialPanel::renderBossFrames(game::GameHandler& gameHandler, + SpellbookScreen& spellbookScreen, + SpellIconFn getSpellIcon) { + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + // Collect active boss unit slots + struct BossSlot { uint32_t slot; uint64_t guid; }; + std::vector active; + for (uint32_t s = 0; s < game::GameHandler::kMaxEncounterSlots; ++s) { + uint64_t g = gameHandler.getEncounterUnitGuid(s); + if (g != 0) active.push_back({s, g}); + } + if (active.empty()) return; + + const float frameW = 200.0f; + const float startX = ImGui::GetIO().DisplaySize.x - frameW - 10.0f; + float frameY = 120.0f; + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.05f, 0.05f, 0.85f)); + + ImGui::SetNextWindowPos(ImVec2(startX, frameY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + if (ImGui::Begin("##BossFrames", nullptr, flags)) { + for (const auto& bs : active) { + ImGui::PushID(static_cast(bs.guid)); + + // Try to resolve name, health, and power from entity manager + std::string name = "Boss"; + uint32_t hp = 0, maxHp = 0; + uint8_t bossPowerType = 0; + uint32_t bossPower = 0, bossMaxPower = 0; + auto entity = gameHandler.getEntityManager().getEntity(bs.guid); + if (entity && (entity->getType() == game::ObjectType::UNIT || + entity->getType() == game::ObjectType::PLAYER)) { + auto unit = std::static_pointer_cast(entity); + const auto& n = unit->getName(); + if (!n.empty()) name = n; + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + bossPowerType = unit->getPowerType(); + bossPower = unit->getPower(); + bossMaxPower = unit->getMaxPower(); + } + + // Clickable name to target + if (ImGui::Selectable(name.c_str(), gameHandler.getTargetGuid() == bs.guid)) { + gameHandler.setTarget(bs.guid); + } + + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + // Boss health bar in red shades + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? colors::kLowHealthRed : + pct > 0.2f ? ImVec4(0.9f, 0.5f, 0.1f, 1.0f) : + ImVec4(1.0f, 0.8f, 0.1f, 1.0f)); + char label[32]; + std::snprintf(label, sizeof(label), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), label); + ImGui::PopStyleColor(); + } + + // Boss power bar — shown when boss has a non-zero power pool + // Energy bosses (type 3) are particularly important: full energy signals ability use + if (bossMaxPower > 0 && bossPower > 0) { + float bpPct = static_cast(bossPower) / static_cast(bossMaxPower); + ImVec4 bpColor; + switch (bossPowerType) { + case 0: bpColor = ImVec4(0.2f, 0.3f, 0.9f, 1.0f); break; // Mana: blue + case 1: bpColor = colors::kDarkRed; break; // Rage: red + case 2: bpColor = colors::kOrange; break; // Focus: orange + case 3: bpColor = ImVec4(0.9f, 0.9f, 0.1f, 1.0f); break; // Energy: yellow + default: bpColor = ImVec4(0.4f, 0.8f, 0.4f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bpColor); + char bpLabel[24]; + std::snprintf(bpLabel, sizeof(bpLabel), "%u", bossPower); + ImGui::ProgressBar(bpPct, ImVec2(-1, 6), bpLabel); + ImGui::PopStyleColor(); + } + + // Boss cast bar — shown when the boss is casting (critical for interrupt) + if (auto* cs = gameHandler.getUnitCastState(bs.guid)) { + float castPct = (cs->timeTotal > 0.0f) + ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; + uint32_t bspell = cs->spellId; + const std::string& bcastName = (bspell != 0) + ? gameHandler.getSpellName(bspell) : ""; + // Green = interruptible, Red = immune; pulse when > 80% complete + ImVec4 bcastColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + bcastColor = cs->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 { + bcastColor = cs->interruptible + ? colors::kCastGreen + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor); + char bcastLabel[72]; + if (!bcastName.empty()) + snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)", + bcastName.c_str(), cs->timeRemaining); + else + snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining); + { + VkDescriptorSet bIcon = (bspell != 0 && assetMgr) + ? getSpellIcon(bspell, assetMgr) : VK_NULL_HANDLE; + if (bIcon) { + ImGui::Image((ImTextureID)(uintptr_t)bIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } + } + ImGui::PopStyleColor(); + } + + // Boss aura row: debuffs first (player DoTs), then boss buffs + { + const std::vector* bossAuras = nullptr; + if (bs.guid == gameHandler.getTargetGuid()) + bossAuras = &gameHandler.getTargetAuras(); + else + bossAuras = gameHandler.getUnitAuras(bs.guid); + + if (bossAuras) { + int bossActive = 0; + for (const auto& a : *bossAuras) if (!a.isEmpty()) bossActive++; + if (bossActive > 0) { + constexpr float BA_ICON = 16.0f; + constexpr int BA_PER_ROW = 10; + + uint64_t baNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: player-applied debuffs first (most relevant), then others + const uint64_t pguid = gameHandler.getPlayerGuid(); + std::vector baIdx; + baIdx.reserve(bossAuras->size()); + for (size_t i = 0; i < bossAuras->size(); ++i) + if (!(*bossAuras)[i].isEmpty()) baIdx.push_back(i); + std::sort(baIdx.begin(), baIdx.end(), [&](size_t a, size_t b) { + const auto& aa = (*bossAuras)[a]; + const auto& ab = (*bossAuras)[b]; + bool aPlayerDot = (aa.flags & 0x80) != 0 && aa.casterGuid == pguid; + bool bPlayerDot = (ab.flags & 0x80) != 0 && ab.casterGuid == pguid; + if (aPlayerDot != bPlayerDot) return aPlayerDot > bPlayerDot; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; + int32_t ra = aa.getRemainingMs(baNowMs); + int32_t rb = ab.getRemainingMs(baNowMs); + 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 baShown = 0; + for (size_t si = 0; si < baIdx.size() && baShown < 20; ++si) { + const auto& aura = (*bossAuras)[baIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + bool isPlayerCast = (aura.casterGuid == pguid); + + if (baShown > 0 && baShown % BA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(baIdx[si]) + 7000); + + ImVec4 borderCol; + if (isBuff) { + // Boss buffs: gold for important enrage/shield types + borderCol = ImVec4(0.8f, 0.6f, 0.1f, 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 = isPlayerCast + ? ImVec4(0.90f, 0.30f, 0.10f, 0.9f) // player DoT: orange-red + : ImVec4(0.60f, 0.20f, 0.20f, 0.9f); // other debuff: dark red + break; + } + } + + VkDescriptorSet baIcon = assetMgr + ? getSpellIcon(aura.spellId, assetMgr) : VK_NULL_HANDLE; + if (baIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##baura", + (ImTextureID)(uintptr_t)baIcon, + ImVec2(BA_ICON - 2, BA_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(BA_ICON, BA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t baRemain = aura.getRemainingMs(baNowMs); + if (baRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + fmtDurationCompact(ts, sizeof(ts), (baRemain + 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); + } + + // Stack / charge count — upper-left corner (parity with target/focus frames) + if (aura.charges > 1) { + ImVec2 baMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 2, baMin.y + 2), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 1, baMin.y + 1), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (isPlayerCast && !isBuff) + ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "Your DoT"); + renderAuraRemaining(baRemain); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + baShown++; + } + ImGui::PopStyleVar(); + } + } + } + + ImGui::PopID(); + ImGui::Spacing(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +void SocialPanel::renderGuildRoster(game::GameHandler& gameHandler, + ChatPanel& chatPanel) { + // Guild Roster toggle (customizable keybind) + if (!chatPanel.isChatInputActive() && !ImGui::GetIO().WantTextInput && + !ImGui::GetIO().WantCaptureKeyboard && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { + showGuildRoster_ = !showGuildRoster_; + if (showGuildRoster_) { + // Open friends tab directly if not in guild + if (!gameHandler.isInGuild()) { + guildRosterTab_ = 2; // Friends tab + } else { + // Re-query guild name if we have guildId but no name yet + if (gameHandler.getGuildName().empty()) { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && ch->hasGuild()) { + gameHandler.queryGuildInfo(ch->guildId); + } + } + gameHandler.requestGuildRoster(); + gameHandler.requestGuildInfo(); + } + } + } + + // Petition creation dialog (shown when NPC sends SMSG_PETITION_SHOWLIST) + if (gameHandler.hasPetitionShowlist()) { + ImGui::OpenPopup("CreateGuildPetition"); + gameHandler.clearPetitionDialog(); + } + if (ImGui::BeginPopupModal("CreateGuildPetition", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Create Guild Charter"); + ImGui::Separator(); + uint32_t cost = gameHandler.getPetitionCost(); + ImGui::TextDisabled("Cost:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(cost); + ImGui::Spacing(); + ImGui::Text("Guild Name:"); + ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_)); + ImGui::Spacing(); + if (ImGui::Button("Create", ImVec2(120, 0))) { + if (petitionNameBuffer_[0] != '\0') { + gameHandler.buyPetition(gameHandler.getPetitionNpcGuid(), petitionNameBuffer_); + petitionNameBuffer_[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + petitionNameBuffer_[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + // Petition signatures window (shown when a petition item is used or offered) + if (gameHandler.hasPetitionSignaturesUI()) { + ImGui::OpenPopup("PetitionSignatures"); + gameHandler.clearPetitionSignaturesUI(); + } + if (ImGui::BeginPopupModal("PetitionSignatures", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& pInfo = gameHandler.getPetitionInfo(); + if (!pInfo.guildName.empty()) + ImGui::Text("Guild Charter: %s", pInfo.guildName.c_str()); + else + ImGui::Text("Guild Charter"); + ImGui::Separator(); + + ImGui::Text("Signatures: %u / %u", pInfo.signatureCount, pInfo.signaturesRequired); + ImGui::Spacing(); + + if (!pInfo.signatures.empty()) { + for (size_t i = 0; i < pInfo.signatures.size(); ++i) { + const auto& sig = pInfo.signatures[i]; + // Try to resolve name from entity manager + std::string sigName; + if (sig.playerGuid != 0) { + auto entity = gameHandler.getEntityManager().getEntity(sig.playerGuid); + if (entity) { + auto* unit = entity->isUnit() ? static_cast(entity.get()) : nullptr; + if (unit) sigName = unit->getName(); + } + } + if (sigName.empty()) + sigName = "Player " + std::to_string(i + 1); + ImGui::BulletText("%s", sigName.c_str()); + } + ImGui::Spacing(); + } + + // If we're not the owner, show Sign button + bool isOwner = (pInfo.ownerGuid == gameHandler.getPlayerGuid()); + if (!isOwner) { + if (ImGui::Button("Sign", ImVec2(120, 0))) { + gameHandler.signPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } else if (pInfo.signatureCount >= pInfo.signaturesRequired) { + // Owner with enough sigs — turn in + if (ImGui::Button("Turn In", ImVec2(120, 0))) { + gameHandler.turnInPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } + if (ImGui::Button("Close", ImVec2(120, 0))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + + if (!showGuildRoster_) return; + + // Get zone manager for name lookup + game::ZoneManager* zoneManager = nullptr; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + zoneManager = renderer->getZoneManager(); + } + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once); + + std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Social") : "Social"; + bool open = showGuildRoster_; + if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { + // Tab bar: Roster | Guild Info + if (ImGui::BeginTabBar("GuildTabs")) { + if (ImGui::BeginTabItem("Roster")) { + guildRosterTab_ = 0; + if (!gameHandler.hasGuildRoster()) { + ImGui::Text("Loading roster..."); + } else { + const auto& roster = gameHandler.getGuildRoster(); + + // MOTD + if (!roster.motd.empty()) { + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str()); + ImGui::Separator(); + } + + // Count online + int onlineCount = 0; + for (const auto& m : roster.members) { + if (m.online) ++onlineCount; + } + ImGui::Text("%d members (%d online)", static_cast(roster.members.size()), onlineCount); + ImGui::Separator(); + + const auto& rankNames = gameHandler.getGuildRankNames(); + + // Table + if (ImGui::BeginTable("GuildRoster", 7, + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_Sortable)) { + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); + ImGui::TableSetupColumn("Rank"); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Note"); + ImGui::TableSetupColumn("Officer Note"); + ImGui::TableHeadersRow(); + + // Online members first, then offline + auto sortedMembers = roster.members; + std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) { + if (a.online != b.online) return a.online > b.online; + return a.name < b.name; + }); + + for (const auto& m : sortedMembers) { + ImGui::TableNextRow(); + ImVec4 textColor = m.online ? ui::colors::kWhite + : kColorDarkGray; + ImVec4 nameColor = m.online ? classColorVec4(m.classId) : textColor; + + ImGui::TableNextColumn(); + ImGui::TextColored(nameColor, "%s", m.name.c_str()); + + // Right-click context menu + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + selectedGuildMember_ = m.name; + ImGui::OpenPopup("GuildMemberContext"); + } + + ImGui::TableNextColumn(); + // Show rank name instead of index + if (m.rankIndex < rankNames.size()) { + ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str()); + } else { + ImGui::TextColored(textColor, "Rank %u", m.rankIndex); + } + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%u", m.level); + + ImGui::TableNextColumn(); + const char* className = classNameStr(m.classId); + ImVec4 classCol = m.online ? classColorVec4(m.classId) : textColor; + ImGui::TextColored(classCol, "%s", className); + + ImGui::TableNextColumn(); + // Zone name lookup + if (zoneManager) { + const auto* zoneInfo = zoneManager->getZoneInfo(m.zoneId); + if (zoneInfo && !zoneInfo->name.empty()) { + ImGui::TextColored(textColor, "%s", zoneInfo->name.c_str()); + } else { + ImGui::TextColored(textColor, "%u", m.zoneId); + } + } else { + ImGui::TextColored(textColor, "%u", m.zoneId); + } + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.officerNote.c_str()); + } + ImGui::EndTable(); + } + + // Context menu popup + if (ImGui::BeginPopup("GuildMemberContext")) { + ImGui::TextDisabled("%s", selectedGuildMember_.c_str()); + ImGui::Separator(); + // Social actions — only for online members + bool memberOnline = false; + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { memberOnline = mem.online; break; } + } + if (memberOnline) { + if (ImGui::MenuItem("Whisper")) { + chatPanel.setWhisperTarget(selectedGuildMember_); + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(selectedGuildMember_); + } + ImGui::Separator(); + } + if (!selectedGuildMember_.empty()) { + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(selectedGuildMember_); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(selectedGuildMember_); + ImGui::Separator(); + } + if (ImGui::MenuItem("Promote")) { + gameHandler.promoteGuildMember(selectedGuildMember_); + } + if (ImGui::MenuItem("Demote")) { + gameHandler.demoteGuildMember(selectedGuildMember_); + } + if (ImGui::MenuItem("Kick")) { + gameHandler.kickGuildMember(selectedGuildMember_); + } + ImGui::Separator(); + if (ImGui::MenuItem("Set Public Note...")) { + showGuildNoteEdit_ = true; + editingOfficerNote_ = false; + guildNoteEditBuffer_[0] = '\0'; + // Pre-fill with existing note + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { + snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str()); + break; + } + } + } + if (ImGui::MenuItem("Set Officer Note...")) { + showGuildNoteEdit_ = true; + editingOfficerNote_ = true; + guildNoteEditBuffer_[0] = '\0'; + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { + snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str()); + break; + } + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Set as Leader")) { + gameHandler.setGuildLeader(selectedGuildMember_); + } + ImGui::EndPopup(); + } + + // Note edit modal + if (showGuildNoteEdit_) { + ImGui::OpenPopup("EditGuildNote"); + showGuildNoteEdit_ = false; + } + if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("%s %s for %s:", + editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str()); + ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_)); + if (ImGui::Button("Save")) { + if (editingOfficerNote_) { + gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_); + } else { + gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Guild Info")) { + guildRosterTab_ = 1; + const auto& infoData = gameHandler.getGuildInfoData(); + const auto& queryData = gameHandler.getGuildQueryData(); + const auto& roster = gameHandler.getGuildRoster(); + const auto& rankNames = gameHandler.getGuildRankNames(); + + // Guild name (large, gold) + ImGui::PushFont(nullptr); // default font + ImGui::TextColored(ui::colors::kTooltipGold, "<%s>", gameHandler.getGuildName().c_str()); + ImGui::PopFont(); + ImGui::Separator(); + + // Creation date + if (infoData.isValid()) { + ImGui::Text("Created: %u/%u/%u", infoData.creationDay, infoData.creationMonth, infoData.creationYear); + ImGui::Text("Members: %u | Accounts: %u", infoData.numMembers, infoData.numAccounts); + } + ImGui::Spacing(); + + // Guild description / info text + if (!roster.guildInfo.empty()) { + ImGui::TextColored(colors::kSilver, "Description:"); + ImGui::TextWrapped("%s", roster.guildInfo.c_str()); + } + ImGui::Spacing(); + + // MOTD with edit button + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD:"); + ImGui::SameLine(); + if (!roster.motd.empty()) { + ImGui::TextWrapped("%s", roster.motd.c_str()); + } else { + ImGui::TextColored(kColorDarkGray, "(not set)"); + } + if (ImGui::Button("Set MOTD")) { + showMotdEdit_ = true; + snprintf(guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_), "%s", roster.motd.c_str()); + } + ImGui::Spacing(); + + // MOTD edit modal + if (showMotdEdit_) { + ImGui::OpenPopup("EditMotd"); + showMotdEdit_ = false; + } + if (ImGui::BeginPopupModal("EditMotd", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Set Message of the Day:"); + ImGui::InputText("##motdinput", guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_)); + if (ImGui::Button("Save", ImVec2(120, 0))) { + gameHandler.setGuildMotd(guildMotdEditBuffer_); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + // Emblem info + if (queryData.isValid()) { + ImGui::Separator(); + ImGui::Text("Emblem: Style %u, Color %u | Border: Style %u, Color %u | BG: %u", + queryData.emblemStyle, queryData.emblemColor, + queryData.borderStyle, queryData.borderColor, queryData.backgroundColor); + } + + // Rank list + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "Ranks:"); + for (size_t i = 0; i < rankNames.size(); ++i) { + if (rankNames[i].empty()) continue; + // Show rank permission summary from roster data + if (i < roster.ranks.size()) { + uint32_t rights = roster.ranks[i].rights; + std::string perms; + if (rights & 0x01) perms += "Invite "; + if (rights & 0x02) perms += "Remove "; + if (rights & 0x40) perms += "Promote "; + if (rights & 0x80) perms += "Demote "; + if (rights & 0x04) perms += "OChat "; + if (rights & 0x10) perms += "MOTD "; + ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); + if (!perms.empty()) { + ImGui::SameLine(); + ImGui::TextColored(kColorDarkGray, "[%s]", perms.c_str()); + } + } else { + ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); + } + } + + // Rank management buttons + ImGui::Spacing(); + if (ImGui::Button("Add Rank")) { + showAddRankModal_ = true; + addRankNameBuffer_[0] = '\0'; + } + ImGui::SameLine(); + if (ImGui::Button("Delete Last Rank")) { + gameHandler.deleteGuildRank(); + } + + // Add rank modal + if (showAddRankModal_) { + ImGui::OpenPopup("AddGuildRank"); + showAddRankModal_ = false; + } + if (ImGui::BeginPopupModal("AddGuildRank", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("New Rank Name:"); + ImGui::InputText("##rankname", addRankNameBuffer_, sizeof(addRankNameBuffer_)); + if (ImGui::Button("Add", ImVec2(120, 0))) { + if (addRankNameBuffer_[0] != '\0') { + gameHandler.addGuildRank(addRankNameBuffer_); + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::EndTabItem(); + } + + // ---- Friends tab ---- + if (ImGui::BeginTabItem("Friends")) { + guildRosterTab_ = 2; + const auto& contacts = gameHandler.getContacts(); + + // Add Friend row + static char addFriendBuf[64] = {}; + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##addfriend", addFriendBuf, sizeof(addFriendBuf)); + ImGui::SameLine(); + if (ImGui::Button("Add Friend") && addFriendBuf[0] != '\0') { + gameHandler.addFriend(addFriendBuf); + addFriendBuf[0] = '\0'; + } + ImGui::Separator(); + + // Note-edit state + static std::string friendNoteTarget; + static char friendNoteBuf[256] = {}; + static bool openNotePopup = false; + + // Filter to friends only + int friendCount = 0; + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isFriend()) continue; + ++friendCount; + + ImGui::PushID(static_cast(ci)); + + // Status dot + ImU32 dotColor = c.isOnline() + ? IM_COL32(80, 200, 80, 255) + : IM_COL32(120, 120, 120, 255); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(cursor.x + 6.0f, cursor.y + 8.0f), 5.0f, dotColor); + ImGui::Dummy(ImVec2(14.0f, 0.0f)); + ImGui::SameLine(); + + // Name as Selectable for right-click context menu + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? ui::colors::kWhite + : colors::kInactiveGray; + ImGui::PushStyleColor(ImGuiCol_Text, nameCol); + ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap, ImVec2(130.0f, 0.0f)); + ImGui::PopStyleColor(); + + // Double-click to whisper + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) + && !c.name.empty()) { + chatPanel.setWhisperTarget(c.name); + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper") && !c.name.empty()) { + chatPanel.setWhisperTarget(c.name); + } + if (c.isOnline() && ImGui::MenuItem("Invite to Group") && !c.name.empty()) { + gameHandler.inviteToGroup(c.name); + } + if (ImGui::MenuItem("Edit Note")) { + friendNoteTarget = c.name; + strncpy(friendNoteBuf, c.note.c_str(), sizeof(friendNoteBuf) - 1); + friendNoteBuf[sizeof(friendNoteBuf) - 1] = '\0'; + openNotePopup = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Remove Friend")) { + gameHandler.removeFriend(c.name); + } + ImGui::EndPopup(); + } + + // Note tooltip on hover + if (ImGui::IsItemHovered() && !c.note.empty()) { + ImGui::BeginTooltip(); + ImGui::TextDisabled("Note: %s", c.note.c_str()); + ImGui::EndTooltip(); + } + + // Level, class, and status + if (c.isOnline()) { + ImGui::SameLine(150.0f); + const char* statusLabel = + (c.status == 2) ? " (AFK)" : + (c.status == 3) ? " (DND)" : ""; + // Class color for the level/class display + ImVec4 friendClassCol = classColorVec4(static_cast(c.classId)); + const char* friendClassName = classNameStr(static_cast(c.classId)); + if (c.level > 0 && c.classId > 0) { + ImGui::TextColored(friendClassCol, "Lv%u %s%s", c.level, friendClassName, statusLabel); + } else if (c.level > 0) { + ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); + } else if (*statusLabel) { + ImGui::TextDisabled("%s", statusLabel + 1); + } + + // Tooltip: zone info + if (ImGui::IsItemHovered() && c.areaId != 0) { + ImGui::BeginTooltip(); + if (zoneManager) { + const auto* zi = zoneManager->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) + ImGui::Text("Zone: %s", zi->name.c_str()); + else + ImGui::TextDisabled("Area ID: %u", c.areaId); + } else { + ImGui::TextDisabled("Area ID: %u", c.areaId); + } + ImGui::EndTooltip(); + } + } + + ImGui::PopID(); + } + + if (friendCount == 0) { + ImGui::TextDisabled("No friends found."); + } + + // Note edit modal + if (openNotePopup) { + ImGui::OpenPopup("EditFriendNote"); + openNotePopup = false; + } + if (ImGui::BeginPopupModal("EditFriendNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Note for %s:", friendNoteTarget.c_str()); + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##fnote", friendNoteBuf, sizeof(friendNoteBuf)); + if (ImGui::Button("Save", ImVec2(110, 0))) { + gameHandler.setFriendNote(friendNoteTarget, friendNoteBuf); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(110, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::EndTabItem(); + } + + // ---- Ignore List tab ---- + if (ImGui::BeginTabItem("Ignore")) { + guildRosterTab_ = 3; + const auto& contacts = gameHandler.getContacts(); + + // Add Ignore row + static char addIgnoreBuf[64] = {}; + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##addignore", addIgnoreBuf, sizeof(addIgnoreBuf)); + ImGui::SameLine(); + if (ImGui::Button("Ignore Player") && addIgnoreBuf[0] != '\0') { + gameHandler.addIgnore(addIgnoreBuf); + addIgnoreBuf[0] = '\0'; + } + ImGui::Separator(); + + int ignoreCount = 0; + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isIgnored()) continue; + ++ignoreCount; + + ImGui::PushID(static_cast(ci) + 10000); + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap); + if (ImGui::BeginPopupContextItem("IgnoreCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (ImGui::MenuItem("Remove Ignore")) { + gameHandler.removeIgnore(c.name); + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + + if (ignoreCount == 0) { + ImGui::TextDisabled("Ignore list is empty."); + } + + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } + } + ImGui::End(); + showGuildRoster_ = open; +} + +void SocialPanel::renderSocialFrame(game::GameHandler& gameHandler, + ChatPanel& chatPanel) { + if (!showSocialFrame_) return; + + const auto& contacts = gameHandler.getContacts(); + // Count online friends for early-out + int onlineCount = 0; + for (const auto& c : contacts) + if (c.isFriend() && c.isOnline()) ++onlineCount; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW - 230.0f, 240.0f), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(220.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f)); + + // State for "Set Note" inline editing + static int noteEditContactIdx = -1; + static char noteEditBuf[128] = {}; + + bool open = showSocialFrame_; + char socialTitle[32]; + snprintf(socialTitle, sizeof(socialTitle), "Social (%d online)##SocialFrame", onlineCount); + if (ImGui::Begin(socialTitle, &open, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { + + // Get zone manager for area name lookups + game::ZoneManager* socialZoneMgr = nullptr; + if (auto* rend = core::Application::getInstance().getRenderer()) + socialZoneMgr = rend->getZoneManager(); + + if (ImGui::BeginTabBar("##SocialTabs")) { + // ---- Friends tab ---- + if (ImGui::BeginTabItem("Friends")) { + ImGui::BeginChild("##FriendsList", ImVec2(200, 200), false); + + // Online friends first + int shown = 0; + for (int pass = 0; pass < 2; ++pass) { + bool wantOnline = (pass == 0); + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isFriend()) continue; + if (c.isOnline() != wantOnline) continue; + + ImGui::PushID(static_cast(ci)); + + // Status dot + ImU32 dotColor; + if (!c.isOnline()) dotColor = IM_COL32(100, 100, 100, 200); + else if (c.status == 2) dotColor = IM_COL32(255, 200, 50, 255); // AFK + else if (c.status == 3) dotColor = IM_COL32(255, 120, 50, 255); // DND + else dotColor = IM_COL32( 50, 220, 50, 255); // online + + ImVec2 dotMin = ImGui::GetCursorScreenPos(); + dotMin.y += 4.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotMin.x + 5.0f, dotMin.y + 5.0f), 4.5f, dotColor); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? classColorVec4(static_cast(c.classId)) + : kColorDarkGray; + ImGui::TextColored(nameCol, "%s", displayName); + + if (c.isOnline() && c.level > 0) { + ImGui::SameLine(); + // Show level and class name in class color + ImGui::TextColored(classColorVec4(static_cast(c.classId)), + "Lv%u %s", c.level, classNameStr(static_cast(c.classId))); + } + + // Tooltip: zone info and note + if (ImGui::IsItemHovered() || (c.isOnline() && ImGui::IsItemHovered())) { + if (c.isOnline() && (c.areaId != 0 || !c.note.empty())) { + ImGui::BeginTooltip(); + if (c.areaId != 0) { + const char* zoneName = nullptr; + if (socialZoneMgr) { + const auto* zi = socialZoneMgr->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) zoneName = zi->name.c_str(); + } + if (zoneName) + ImGui::Text("Zone: %s", zoneName); + else + ImGui::Text("Area ID: %u", c.areaId); + } + if (!c.note.empty()) + ImGui::TextDisabled("Note: %s", c.note.c_str()); + ImGui::EndTooltip(); + } + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (c.isOnline()) { + if (ImGui::MenuItem("Whisper")) { + showSocialFrame_ = false; + chatPanel.setWhisperTarget(c.name); + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(c.name); + if (c.guid != 0 && ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(c.guid); + } + if (ImGui::MenuItem("Set Note")) { + noteEditContactIdx = static_cast(ci); + strncpy(noteEditBuf, c.note.c_str(), sizeof(noteEditBuf) - 1); + noteEditBuf[sizeof(noteEditBuf) - 1] = '\0'; + ImGui::OpenPopup("##SetFriendNote"); + } + if (ImGui::MenuItem("Remove Friend")) + gameHandler.removeFriend(c.name); + ImGui::EndPopup(); + } + + ++shown; + ImGui::PopID(); + } + // Separator between online and offline if there are both + if (pass == 0 && shown > 0) { + ImGui::Separator(); + } + } + + if (shown == 0) { + ImGui::TextDisabled("No friends yet."); + } + + ImGui::EndChild(); + + // "Set Note" modal popup + if (ImGui::BeginPopup("##SetFriendNote")) { + const std::string& noteName = (noteEditContactIdx >= 0 && + noteEditContactIdx < static_cast(contacts.size())) + ? contacts[noteEditContactIdx].name : ""; + ImGui::TextDisabled("Note for %s:", noteName.c_str()); + ImGui::SetNextItemWidth(180.0f); + bool confirm = ImGui::InputText("##noteinput", noteEditBuf, sizeof(noteEditBuf), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (confirm || ImGui::Button("OK")) { + if (!noteName.empty()) + gameHandler.setFriendNote(noteName, noteEditBuf); + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::Separator(); + + // Add friend + static char addFriendBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_addfriend", addFriendBuf, sizeof(addFriendBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##addfriend") && addFriendBuf[0] != '\0') { + gameHandler.addFriend(addFriendBuf); + addFriendBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + + // ---- Ignore tab ---- + if (ImGui::BeginTabItem("Ignore")) { + const auto& ignores = gameHandler.getIgnoreCache(); + ImGui::BeginChild("##IgnoreList", ImVec2(200, 200), false); + + if (ignores.empty()) { + ImGui::TextDisabled("Ignore list is empty."); + } else { + for (const auto& kv : ignores) { + ImGui::PushID(kv.first.c_str()); + ImGui::TextUnformatted(kv.first.c_str()); + if (ImGui::BeginPopupContextItem("IgnoreCtx")) { + ImGui::TextDisabled("%s", kv.first.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Unignore")) + gameHandler.removeIgnore(kv.first); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } + + ImGui::EndChild(); + ImGui::Separator(); + + // Add ignore + static char addIgnBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_addignore", addIgnBuf, sizeof(addIgnBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##addignore") && addIgnBuf[0] != '\0') { + gameHandler.addIgnore(addIgnBuf); + addIgnBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + + // ---- Channels tab ---- + if (ImGui::BeginTabItem("Channels")) { + const auto& channels = gameHandler.getJoinedChannels(); + ImGui::BeginChild("##ChannelList", ImVec2(200, 200), false); + + if (channels.empty()) { + ImGui::TextDisabled("Not in any channels."); + } else { + for (size_t ci = 0; ci < channels.size(); ++ci) { + ImGui::PushID(static_cast(ci)); + ImGui::TextUnformatted(channels[ci].c_str()); + if (ImGui::BeginPopupContextItem("ChanCtx")) { + ImGui::TextDisabled("%s", channels[ci].c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Leave Channel")) + gameHandler.leaveChannel(channels[ci]); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } + + ImGui::EndChild(); + ImGui::Separator(); + + // Join a channel + static char joinChanBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_joinchan", joinChanBuf, sizeof(joinChanBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##joinchan") && joinChanBuf[0] != '\0') { + gameHandler.joinChannel(joinChanBuf); + joinChanBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + + // ---- Arena tab (WotLK: shows per-team rating/record + roster) ---- + const auto& arenaStats = gameHandler.getArenaTeamStats(); + if (!arenaStats.empty()) { + if (ImGui::BeginTabItem("Arena")) { + ImGui::BeginChild("##ArenaList", ImVec2(0, 0), false); + + for (size_t ai = 0; ai < arenaStats.size(); ++ai) { + const auto& ts = arenaStats[ai]; + ImGui::PushID(static_cast(ai)); + + // Team header: "2v2: Team Name" or fallback "Team #id" + std::string teamLabel; + if (ts.teamType > 0) + teamLabel = std::to_string(ts.teamType) + "v" + std::to_string(ts.teamType) + ": "; + if (!ts.teamName.empty()) + teamLabel += ts.teamName; + else + teamLabel += "Team #" + std::to_string(ts.teamId); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel.c_str()); + + ImGui::Indent(8.0f); + // Rating and rank + ImGui::Text("Rating: %u", ts.rating); + if (ts.rank > 0) { + ImGui::SameLine(0, 6); + ImGui::TextDisabled("(Rank #%u)", ts.rank); + } + + // Weekly record + uint32_t weekLosses = ts.weekGames > ts.weekWins + ? ts.weekGames - ts.weekWins : 0; + ImGui::Text("Week: %u W / %u L", ts.weekWins, weekLosses); + + // Season record + uint32_t seasLosses = ts.seasonGames > ts.seasonWins + ? ts.seasonGames - ts.seasonWins : 0; + ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); + + // Roster members (from SMSG_ARENA_TEAM_ROSTER) + const auto* roster = gameHandler.getArenaTeamRoster(ts.teamId); + if (roster && !roster->members.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("-- Roster (%zu members) --", + roster->members.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Refresh")) + gameHandler.requestArenaTeamRoster(ts.teamId); + + // Column headers + ImGui::Columns(4, "##arenaRosterCols", false); + ImGui::SetColumnWidth(0, 110.0f); + ImGui::SetColumnWidth(1, 60.0f); + ImGui::SetColumnWidth(2, 60.0f); + ImGui::SetColumnWidth(3, 60.0f); + ImGui::TextDisabled("Name"); ImGui::NextColumn(); + ImGui::TextDisabled("Rating"); ImGui::NextColumn(); + ImGui::TextDisabled("Week"); ImGui::NextColumn(); + ImGui::TextDisabled("Season"); ImGui::NextColumn(); + ImGui::Separator(); + + for (const auto& m : roster->members) { + // Name coloured green (online) or grey (offline) + if (m.online) + ImGui::TextColored(ImVec4(0.4f,1.0f,0.4f,1.0f), + "%s", m.name.c_str()); + else + ImGui::TextDisabled("%s", m.name.c_str()); + ImGui::NextColumn(); + + ImGui::Text("%u", m.personalRating); + ImGui::NextColumn(); + + uint32_t wL = m.weekGames > m.weekWins + ? m.weekGames - m.weekWins : 0; + ImGui::Text("%uW/%uL", m.weekWins, wL); + ImGui::NextColumn(); + + uint32_t sL = m.seasonGames > m.seasonWins + ? m.seasonGames - m.seasonWins : 0; + ImGui::Text("%uW/%uL", m.seasonWins, sL); + ImGui::NextColumn(); + } + ImGui::Columns(1); + } else { + ImGui::Spacing(); + if (ImGui::SmallButton("Load Roster")) + gameHandler.requestArenaTeamRoster(ts.teamId); + } + + ImGui::Unindent(8.0f); + + if (ai + 1 < arenaStats.size()) + ImGui::Separator(); + + ImGui::PopID(); + } + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + + ImGui::EndTabBar(); + } + } + ImGui::End(); + showSocialFrame_ = open; + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +void SocialPanel::renderDungeonFinderWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel) { + // Toggle Dungeon Finder (customizable keybind) + if (!chatPanel.isChatInputActive() && !ImGui::GetIO().WantTextInput && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { + showDungeonFinder_ = !showDungeonFinder_; + } + + if (!showDungeonFinder_) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW * 0.5f - 175.0f, screenH * 0.2f), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + bool open = true; + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize; + if (!ImGui::Begin("Dungeon Finder", &open, flags)) { + ImGui::End(); + if (!open) showDungeonFinder_ = false; + return; + } + if (!open) { + ImGui::End(); + showDungeonFinder_ = false; + return; + } + + using LfgState = game::GameHandler::LfgState; + LfgState state = gameHandler.getLfgState(); + + // ---- Status banner ---- + switch (state) { + case LfgState::None: + ImGui::TextColored(kColorGray, "Status: Not queued"); + break; + case LfgState::RoleCheck: + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Status: Role check in progress..."); + break; + case LfgState::Queued: { + int32_t avgSec = gameHandler.getLfgAvgWaitSec(); + uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); + int qMin = static_cast(qMs / 60000); + int qSec = static_cast((qMs % 60000) / 1000); + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(colors::kQueueGreen, + "Status: In queue for %s (%d:%02d)", dName.c_str(), qMin, qSec); + else + ImGui::TextColored(colors::kQueueGreen, "Status: In queue (%d:%02d)", qMin, qSec); + if (avgSec >= 0) { + int aMin = avgSec / 60; + int aSec = avgSec % 60; + ImGui::TextColored(colors::kSilver, + "Avg wait: %d:%02d", aMin, aSec); + } + break; + } + case LfgState::Proposal: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); + break; + } + case LfgState::Boot: + ImGui::TextColored(kColorRed, "Status: Vote kick in progress"); + break; + case LfgState::InDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon (%s)", dName.c_str()); + else + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); + break; + } + case LfgState::FinishedDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(colors::kLightGreen, "Status: %s complete", dName.c_str()); + else + ImGui::TextColored(colors::kLightGreen, "Status: Dungeon complete"); + break; + } + case LfgState::RaidBrowser: + ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser"); + break; + } + + ImGui::Separator(); + + // ---- Proposal accept/decline ---- + if (state == LfgState::Proposal) { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for your dungeon!"); + ImGui::Spacing(); + if (ImGui::Button("Accept", ImVec2(120, 0))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(120, 0))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); + } + ImGui::Separator(); + } + + // ---- Vote-to-kick buttons ---- + if (state == LfgState::Boot) { + ImGui::TextColored(kColorRed, "Vote to kick in progress:"); + const std::string& bootTarget = gameHandler.getLfgBootTargetName(); + const std::string& bootReason = gameHandler.getLfgBootReason(); + if (!bootTarget.empty()) { + ImGui::Text("Player: "); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", bootTarget.c_str()); + } + if (!bootReason.empty()) { + ImGui::Text("Reason: "); + ImGui::SameLine(); + ImGui::TextWrapped("%s", bootReason.c_str()); + } + uint32_t bootVotes = gameHandler.getLfgBootVotes(); + uint32_t bootTotal = gameHandler.getLfgBootTotal(); + uint32_t bootNeeded = gameHandler.getLfgBootNeeded(); + uint32_t bootTimeLeft= gameHandler.getLfgBootTimeLeft(); + if (bootNeeded > 0) { + ImGui::Text("Votes: %u / %u (need %u) %us left", + bootVotes, bootTotal, bootNeeded, bootTimeLeft); + } + ImGui::Spacing(); + if (ImGui::Button("Vote Yes (kick)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(true); + } + ImGui::SameLine(); + if (ImGui::Button("Vote No (keep)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(false); + } + ImGui::Separator(); + } + + // ---- Teleport button (in dungeon) ---- + if (state == LfgState::InDungeon) { + if (ImGui::Button("Teleport to Dungeon", ImVec2(-1, 0))) { + gameHandler.lfgTeleport(true); + } + ImGui::Separator(); + } + + // ---- Role selection (only when not queued/in dungeon) ---- + bool canConfigure = (state == LfgState::None || state == LfgState::FinishedDungeon); + + if (canConfigure) { + ImGui::Text("Role:"); + ImGui::SameLine(); + bool isTank = (lfgRoles_ & 0x02) != 0; + bool isHealer = (lfgRoles_ & 0x04) != 0; + bool isDps = (lfgRoles_ & 0x08) != 0; + if (ImGui::Checkbox("Tank", &isTank)) lfgRoles_ = (lfgRoles_ & ~0x02) | (isTank ? 0x02 : 0); + ImGui::SameLine(); + if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0); + ImGui::SameLine(); + if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0); + + ImGui::Spacing(); + + // ---- Dungeon selection ---- + ImGui::Text("Dungeon:"); + + struct DungeonEntry { uint32_t id; const char* name; }; + // Category 0=Random, 1=Classic, 2=TBC, 3=WotLK + struct DungeonEntryEx { uint32_t id; const char* name; uint8_t cat; }; + static const DungeonEntryEx kDungeons[] = { + { 861, "Random Dungeon", 0 }, + { 862, "Random Heroic", 0 }, + { 36, "Deadmines", 1 }, + { 43, "Ragefire Chasm", 1 }, + { 47, "Razorfen Kraul", 1 }, + { 48, "Blackfathom Deeps", 1 }, + { 52, "Uldaman", 1 }, + { 57, "Dire Maul: East", 1 }, + { 70, "Onyxia's Lair", 1 }, + { 264, "The Blood Furnace", 2 }, + { 269, "The Shattered Halls", 2 }, + { 576, "The Nexus", 3 }, + { 578, "The Oculus", 3 }, + { 595, "The Culling of Stratholme", 3 }, + { 599, "Halls of Stone", 3 }, + { 600, "Drak'Tharon Keep", 3 }, + { 601, "Azjol-Nerub", 3 }, + { 604, "Gundrak", 3 }, + { 608, "Violet Hold", 3 }, + { 619, "Ahn'kahet: Old Kingdom", 3 }, + { 623, "Halls of Lightning", 3 }, + { 632, "The Forge of Souls", 3 }, + { 650, "Trial of the Champion", 3 }, + { 658, "Pit of Saron", 3 }, + { 668, "Halls of Reflection", 3 }, + }; + static constexpr const char* kCatHeaders[] = { nullptr, "-- Classic --", "-- TBC --", "-- WotLK --" }; + + // Find current index + int curIdx = 0; + for (int i = 0; i < static_cast(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { + if (kDungeons[i].id == lfgSelectedDungeon_) { curIdx = i; break; } + } + + ImGui::SetNextItemWidth(-1); + if (ImGui::BeginCombo("##dungeon", kDungeons[curIdx].name)) { + uint8_t lastCat = 255; + for (int i = 0; i < static_cast(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { + if (kDungeons[i].cat != lastCat && kCatHeaders[kDungeons[i].cat]) { + if (lastCat != 255) ImGui::Separator(); + ImGui::TextDisabled("%s", kCatHeaders[kDungeons[i].cat]); + lastCat = kDungeons[i].cat; + } else if (kDungeons[i].cat != lastCat) { + lastCat = kDungeons[i].cat; + } + bool selected = (kDungeons[i].id == lfgSelectedDungeon_); + if (ImGui::Selectable(kDungeons[i].name, selected)) + lfgSelectedDungeon_ = kDungeons[i].id; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + ImGui::Spacing(); + + // ---- Join button ---- + bool rolesOk = (lfgRoles_ != 0); + if (!rolesOk) { + ImGui::BeginDisabled(); + } + if (ImGui::Button("Join Dungeon Finder", ImVec2(-1, 0))) { + gameHandler.lfgJoin(lfgSelectedDungeon_, lfgRoles_); + } + if (!rolesOk) { + ImGui::EndDisabled(); + ImGui::TextColored(colors::kSoftRed, "Select at least one role."); + } + } + + // ---- Leave button (when queued or role check) ---- + if (state == LfgState::Queued || state == LfgState::RoleCheck) { + if (ImGui::Button("Leave Queue", ImVec2(-1, 0))) { + gameHandler.lfgLeave(); + } + } + + ImGui::End(); +} + +void SocialPanel::renderWhoWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel) { + if (!showWhoWindow_) return; + + const auto& results = gameHandler.getWhoResults(); + + ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(200, 180), ImGuiCond_FirstUseEver); + + char title[64]; + uint32_t onlineCount = gameHandler.getWhoOnlineCount(); + if (onlineCount > 0) + snprintf(title, sizeof(title), "Players Online: %u###WhoWindow", onlineCount); + else + snprintf(title, sizeof(title), "Who###WhoWindow"); + + if (!ImGui::Begin(title, &showWhoWindow_)) { + ImGui::End(); + return; + } + + // Search bar with Send button + static char whoSearchBuf[64] = {}; + bool doSearch = false; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 60.0f); + if (ImGui::InputTextWithHint("##whosearch", "Search players...", whoSearchBuf, sizeof(whoSearchBuf), + ImGuiInputTextFlags_EnterReturnsTrue)) + doSearch = true; + ImGui::SameLine(); + if (ImGui::Button("Search", ImVec2(-1, 0))) + doSearch = true; + if (doSearch) { + gameHandler.queryWho(std::string(whoSearchBuf)); + } + ImGui::Separator(); + + if (results.empty()) { + ImGui::TextDisabled("No results. Type a filter above or use /who [filter]."); + ImGui::End(); + return; + } + + // Table: Name | Guild | Level | Class | Zone + if (ImGui::BeginTable("##WhoTable", 5, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, + ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.22f); + ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 0.28f); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < results.size(); ++i) { + const auto& e = results[i]; + ImGui::TableNextRow(); + ImGui::PushID(static_cast(i)); + + // Name (class-colored if class is known) + ImGui::TableSetColumnIndex(0); + uint8_t cid = static_cast(e.classId); + ImVec4 nameCol = classColorVec4(cid); + ImGui::TextColored(nameCol, "%s", e.name.c_str()); + + // Right-click context menu on the name + if (ImGui::BeginPopupContextItem("##WhoCtx")) { + ImGui::TextDisabled("%s", e.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + chatPanel.setWhisperTarget(e.name); + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(e.name); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(e.name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(e.name); + ImGui::EndPopup(); + } + + // Guild + ImGui::TableSetColumnIndex(1); + if (!e.guildName.empty()) + ImGui::TextDisabled("<%s>", e.guildName.c_str()); + + // Level + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", e.level); + + // Class + ImGui::TableSetColumnIndex(3); + const char* className = game::getClassName(static_cast(e.classId)); + ImGui::TextColored(nameCol, "%s", className); + + // Zone + ImGui::TableSetColumnIndex(4); + if (e.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(e.zoneId); + if (!zoneName.empty()) + ImGui::TextUnformatted(zoneName.c_str()); + else { + char zfb[32]; + snprintf(zfb, sizeof(zfb), "Zone #%u", e.zoneId); + ImGui::TextUnformatted(zfb); + } + } + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + + ImGui::End(); +} + +void SocialPanel::renderInspectWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen) { + if (!showInspectWindow_) return; + + // Lazy-load SpellItemEnchantment.dbc for enchant name lookup + static std::unordered_map s_enchantNames; + static bool s_enchantDbLoaded = false; + auto* assetMgrEnchant = core::Application::getInstance().getAssetManager(); + if (!s_enchantDbLoaded && assetMgrEnchant && assetMgrEnchant->isInitialized()) { + s_enchantDbLoaded = true; + auto dbc = assetMgrEnchant->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") + : nullptr; + uint32_t idField = layout ? (*layout)["ID"] : 0; + uint32_t nameField = layout ? (*layout)["Name"] : 8; + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, idField); + if (id == 0) continue; + std::string nm = dbc->getString(i, nameField); + if (!nm.empty()) s_enchantNames[id] = std::move(nm); + } + } + } + + // Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server) + static constexpr const char* kSlotNames[19] = { + "Head", "Neck", "Shoulder", "Shirt", "Chest", + "Waist", "Legs", "Feet", "Wrist", "Hands", + "Finger 1", "Finger 2", "Trinket 1", "Trinket 2", "Back", + "Main Hand", "Off Hand", "Ranged", "Tabard" + }; + + ImGui::SetNextWindowSize(ImVec2(360, 440), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(350, 120), ImGuiCond_FirstUseEver); + + const game::GameHandler::InspectResult* result = gameHandler.getInspectResult(); + + std::string title = result ? ("Inspect: " + result->playerName + "###InspectWin") + : "Inspect###InspectWin"; + if (!ImGui::Begin(title.c_str(), &showInspectWindow_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!result) { + ImGui::TextDisabled("No inspect data yet. Target a player and use Inspect."); + ImGui::End(); + return; + } + + // Player name — class-colored if entity is loaded, else gold + { + auto ent = gameHandler.getEntityManager().getEntity(result->guid); + uint8_t cid = entityClassId(ent.get()); + ImVec4 nameColor = (cid != 0) ? classColorVec4(cid) : ui::colors::kTooltipGold; + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::Text("%s", result->playerName.c_str()); + ImGui::PopStyleColor(); + if (cid != 0) { + ImGui::SameLine(); + ImGui::TextColored(classColorVec4(cid), "(%s)", classNameStr(cid)); + } + } + ImGui::SameLine(); + ImGui::TextDisabled(" %u talent pts", result->totalTalents); + if (result->unspentTalents > 0) { + ImGui::SameLine(); + ImGui::TextColored(colors::kSoftRed, "(%u unspent)", result->unspentTalents); + } + if (result->talentGroups > 1) { + ImGui::SameLine(); + ImGui::TextDisabled(" Dual spec (active %u)", static_cast(result->activeTalentGroup) + 1); + } + + ImGui::Separator(); + + // Equipment list + bool hasAnyGear = false; + for (int s = 0; s < 19; ++s) { + if (result->itemEntries[s] != 0) { hasAnyGear = true; break; } + } + + if (!hasAnyGear) { + ImGui::TextDisabled("Equipment data not yet available."); + ImGui::TextDisabled("(Gear loads after the player is inspected in-range)"); + } else { + // Average item level (only slots that have loaded info and are not shirt/tabard) + // Shirt=slot3, Tabard=slot18 — excluded from gear score by WoW convention + uint32_t iLevelSum = 0; + int iLevelCount = 0; + for (int s = 0; s < 19; ++s) { + if (s == 3 || s == 18) continue; // shirt, tabard + uint32_t entry = result->itemEntries[s]; + if (entry == 0) continue; + const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); + if (info && info->valid && info->itemLevel > 0) { + iLevelSum += info->itemLevel; + ++iLevelCount; + } + } + if (iLevelCount > 0) { + float avgIlvl = static_cast(iLevelSum) / static_cast(iLevelCount); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Avg iLvl: %.1f", avgIlvl); + ImGui::SameLine(); + ImGui::TextDisabled("(%d/%d slots loaded)", iLevelCount, + [&]{ int c=0; for(int s=0;s<19;++s){if(s==3||s==18)continue;if(result->itemEntries[s])++c;} return c; }()); + } + if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { + constexpr float kIconSz = 28.0f; + for (int s = 0; s < 19; ++s) { + uint32_t entry = result->itemEntries[s]; + if (entry == 0) continue; + + const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); + if (!info) { + gameHandler.ensureItemInfo(entry); + ImGui::PushID(s); + ImGui::TextDisabled("[%s] (loading…)", kSlotNames[s]); + ImGui::PopID(); + continue; + } + + ImGui::PushID(s); + auto qColor = InventoryScreen::getQualityColor( + static_cast(info->quality)); + uint16_t enchantId = result->enchantIds[s]; + + // Item icon + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(kIconSz, kIconSz), + ImVec2(0,0), ImVec2(1,1), + colors::kWhite, qColor); + } else { + ImGui::GetWindowDrawList()->AddRectFilled( + ImGui::GetCursorScreenPos(), + ImVec2(ImGui::GetCursorScreenPos().x + kIconSz, + ImGui::GetCursorScreenPos().y + kIconSz), + IM_COL32(40, 40, 50, 200)); + ImGui::Dummy(ImVec2(kIconSz, kIconSz)); + } + bool hovered = ImGui::IsItemHovered(); + + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (kIconSz - ImGui::GetTextLineHeight()) * 0.5f); + ImGui::BeginGroup(); + ImGui::TextDisabled("%s", kSlotNames[s]); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Enchant indicator on the same row as the name + if (enchantId != 0) { + auto enchIt = s_enchantNames.find(enchantId); + const std::string& enchName = (enchIt != s_enchantNames.end()) + ? enchIt->second : std::string{}; + ImGui::SameLine(); + if (!enchName.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), + "\xe2\x9c\xa6 %s", enchName.c_str()); // UTF-8 ✦ + } else { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); + } + } + ImGui::EndGroup(); + hovered = hovered || ImGui::IsItemHovered(); + + if (hovered && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered) { + ImGui::SetTooltip("%s", info->name.c_str()); + } + + ImGui::PopID(); + ImGui::Spacing(); + } + } + ImGui::EndChild(); + } + + // Arena teams (WotLK — from MSG_INSPECT_ARENA_TEAMS) + if (!result->arenaTeams.empty()) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Arena Teams"); + ImGui::Spacing(); + for (const auto& team : result->arenaTeams) { + const char* bracket = (team.type == 2) ? "2v2" + : (team.type == 3) ? "3v3" + : (team.type == 5) ? "5v5" : "?v?"; + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), + "[%s] %s", bracket, team.name.c_str()); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f), + " Rating: %u", team.personalRating); + if (team.weekGames > 0 || team.seasonGames > 0) { + ImGui::TextDisabled(" Week: %u/%u Season: %u/%u", + team.weekWins, team.weekGames, + team.seasonWins, team.seasonGames); + } + } + } + + ImGui::End(); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/window_manager.cpp b/src/ui/window_manager.cpp new file mode 100644 index 00000000..2091a8d9 --- /dev/null +++ b/src/ui/window_manager.cpp @@ -0,0 +1,4264 @@ +// ============================================================ +// WindowManager — extracted from GameScreen +// Owns all NPC interaction windows, popup dialogs, etc. +// ============================================================ +#include "ui/window_manager.hpp" +#include "ui/chat_panel.hpp" +#include "ui/settings_panel.hpp" +#include "ui/spellbook_screen.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/ui_colors.hpp" +#include "core/application.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "rendering/vk_context.hpp" +#include "core/window.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "audio/ui_sound_manager.hpp" +#include "audio/music_manager.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace { + using namespace wowee::ui::colors; + + // Abbreviated month names (indexed 0-11) + constexpr const char* kMonthAbbrev[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + + constexpr auto& kColorRed = kRed; + constexpr auto& kColorGreen = kGreen; + constexpr auto& kColorBrightGreen = kBrightGreen; + constexpr auto& kColorYellow = kYellow; + constexpr auto& kColorGray = kGray; + constexpr auto& kColorDarkGray = kDarkGray; + + // Render gold/silver/copper amounts in WoW-canonical colors + void renderGoldText(uint32_t totalCopper) { + uint32_t gold = totalCopper / 10000; + uint32_t silver = (totalCopper / 100) % 100; + uint32_t copper = totalCopper % 100; + bool shown = false; + if (gold > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%ug", gold); + shown = true; + } + if (silver > 0 || shown) { + if (shown) { ImGui::SameLine(0, 2); } + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), "%us", silver); + shown = true; + } + if (shown) { ImGui::SameLine(0, 2); } + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.2f, 1.0f), "%uc", copper); + } + + // Common ImGui window flags for popup dialogs + const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + // Build a WoW-format item link string for chat insertion. + 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; + } +} // anonymous namespace + +namespace wowee { +namespace ui { + +void WindowManager::renderLootWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.isLootWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + bool open = true; + if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& loot = gameHandler.getCurrentLoot(); + + // Gold (auto-looted on open; shown for feedback) + if (loot.gold > 0) { + ImGui::TextDisabled("Gold:"); + ImGui::SameLine(0, 4); + renderCoinsText(loot.getGold(), loot.getSilver(), loot.getCopper()); + ImGui::Separator(); + } + + // Items with icons and labels + constexpr float iconSize = 32.0f; + int lootSlotClicked = -1; // defer loot pickup to avoid iterator invalidation + for (const auto& item : loot.items) { + ImGui::PushID(item.slotIndex); + + // Get item info for name and quality + const auto* info = gameHandler.getItemInfo(item.itemId); + std::string itemName; + game::ItemQuality quality = game::ItemQuality::COMMON; + if (info && !info->name.empty()) { + itemName = info->name; + quality = static_cast(info->quality); + } else { + itemName = "Item #" + std::to_string(item.itemId); + } + ImVec4 qColor = InventoryScreen::getQualityColor(quality); + bool startsQuest = (info && info->startQuestId != 0); + + // Get item icon + uint32_t displayId = item.displayInfoId; + if (displayId == 0 && info) displayId = info->displayInfoId; + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(displayId); + + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float rowH = std::max(iconSize, ImGui::GetTextLineHeight() * 2.0f); + + // Invisible selectable for click handling + if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) { + if (ImGui::GetIO().KeyShift && info && !info->name.empty()) { + // Shift-click: insert item link into chat + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } else { + lootSlotClicked = item.slotIndex; + } + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + lootSlotClicked = item.slotIndex; + } + bool hovered = ImGui::IsItemHovered(); + + // Show item tooltip on hover + if (hovered && info && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered && info && !info->name.empty()) { + // Item info received but not yet fully valid — show name at minimum + ImGui::SetTooltip("%s", info->name.c_str()); + } + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + + // Draw hover highlight + if (hovered) { + drawList->AddRectFilled(cursor, + ImVec2(cursor.x + ImGui::GetContentRegionAvail().x + iconSize + 8.0f, + cursor.y + rowH), + IM_COL32(255, 255, 255, 30)); + } + + // Draw icon + if (iconTex) { + drawList->AddImage((ImTextureID)(uintptr_t)iconTex, + cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize)); + drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize), + ImGui::ColorConvertFloat4ToU32(qColor)); + } else { + drawList->AddRectFilled(cursor, + ImVec2(cursor.x + iconSize, cursor.y + iconSize), + IM_COL32(40, 40, 50, 200)); + drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize), + IM_COL32(80, 80, 80, 200)); + } + // Quest-starter: gold outer glow border + "!" badge on top-right corner + if (startsQuest) { + drawList->AddRect(ImVec2(cursor.x - 2.0f, cursor.y - 2.0f), + ImVec2(cursor.x + iconSize + 2.0f, cursor.y + iconSize + 2.0f), + IM_COL32(255, 210, 0, 210), 0.0f, 0, 2.0f); + drawList->AddText(ImVec2(cursor.x + iconSize - 10.0f, cursor.y + 1.0f), + IM_COL32(255, 210, 0, 255), "!"); + } + + // Draw item name + float textX = cursor.x + iconSize + 6.0f; + float textY = cursor.y + 2.0f; + drawList->AddText(ImVec2(textX, textY), + ImGui::ColorConvertFloat4ToU32(qColor), itemName.c_str()); + + // Draw count or "Begins a Quest" label on second line + float secondLineY = textY + ImGui::GetTextLineHeight(); + if (startsQuest) { + drawList->AddText(ImVec2(textX, secondLineY), + IM_COL32(255, 210, 0, 255), "Begins a Quest"); + } else if (item.count > 1) { + char countStr[32]; + snprintf(countStr, sizeof(countStr), "x%u", item.count); + drawList->AddText(ImVec2(textX, secondLineY), IM_COL32(200, 200, 200, 220), countStr); + } + + ImGui::PopID(); + } + + // Process deferred loot pickup (after loop to avoid iterator invalidation) + if (lootSlotClicked >= 0) { + if (gameHandler.hasMasterLootCandidates()) { + // Master looter: open popup to choose recipient + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", lootSlotClicked); + ImGui::OpenPopup(popupId); + } else { + gameHandler.lootItem(static_cast(lootSlotClicked)); + } + } + + // Master loot "Give to" popups + if (gameHandler.hasMasterLootCandidates()) { + for (const auto& item : loot.items) { + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", item.slotIndex); + if (ImGui::BeginPopup(popupId)) { + ImGui::TextDisabled("Give to:"); + ImGui::Separator(); + const auto& candidates = gameHandler.getMasterLootCandidates(); + for (uint64_t candidateGuid : candidates) { + auto entity = gameHandler.getEntityManager().getEntity(candidateGuid); + auto* unit = (entity && entity->isUnit()) ? static_cast(entity.get()) : nullptr; + const char* cName = unit ? unit->getName().c_str() : nullptr; + char nameBuf[64]; + if (!cName || cName[0] == '\0') { + snprintf(nameBuf, sizeof(nameBuf), "Player 0x%llx", + static_cast(candidateGuid)); + cName = nameBuf; + } + if (ImGui::MenuItem(cName)) { + gameHandler.lootMasterGive(item.slotIndex, candidateGuid); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } + } + + if (loot.items.empty() && loot.gold == 0) { + gameHandler.closeLoot(); + } + + ImGui::Spacing(); + bool hasItems = !loot.items.empty(); + if (hasItems) { + if (ImGui::Button("Loot All", ImVec2(-1, 0))) { + for (const auto& item : loot.items) { + gameHandler.lootItem(item.slotIndex); + } + } + ImGui::Spacing(); + } + if (ImGui::Button("Close", ImVec2(-1, 0))) { + gameHandler.closeLoot(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeLoot(); + } +} + +void WindowManager::renderGossipWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel) { + if (!gameHandler.isGossipWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always); + + bool open = true; + if (ImGui::Begin("NPC Dialog", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& gossip = gameHandler.getCurrentGossip(); + + // NPC name (from creature cache) + auto npcEntity = gameHandler.getEntityManager().getEntity(gossip.npcGuid); + if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(npcEntity); + if (!unit->getName().empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str()); + ImGui::Separator(); + } + } + + ImGui::Spacing(); + + // Gossip option icons - matches WoW GossipOptionIcon enum + static constexpr const char* gossipIcons[] = { + "[Chat]", // 0 = GOSSIP_ICON_CHAT + "[Vendor]", // 1 = GOSSIP_ICON_VENDOR + "[Taxi]", // 2 = GOSSIP_ICON_TAXI + "[Trainer]", // 3 = GOSSIP_ICON_TRAINER + "[Interact]", // 4 = GOSSIP_ICON_INTERACT_1 + "[Interact]", // 5 = GOSSIP_ICON_INTERACT_2 + "[Banker]", // 6 = GOSSIP_ICON_MONEY_BAG (banker) + "[Chat]", // 7 = GOSSIP_ICON_TALK + "[Tabard]", // 8 = GOSSIP_ICON_TABARD + "[Battlemaster]", // 9 = GOSSIP_ICON_BATTLE + "[Option]", // 10 = GOSSIP_ICON_DOT + }; + + // Default text for server-sent gossip option placeholders + static const std::unordered_map gossipPlaceholders = { + {"GOSSIP_OPTION_BANKER", "I would like to check my deposit box."}, + {"GOSSIP_OPTION_AUCTIONEER", "I'd like to browse your auctions."}, + {"GOSSIP_OPTION_VENDOR", "I want to browse your goods."}, + {"GOSSIP_OPTION_TAXIVENDOR", "I'd like to fly."}, + {"GOSSIP_OPTION_TRAINER", "I seek training."}, + {"GOSSIP_OPTION_INNKEEPER", "Make this inn your home."}, + {"GOSSIP_OPTION_SPIRITGUIDE", "Return me to life."}, + {"GOSSIP_OPTION_SPIRITHEALER", "Bring me back to life."}, + {"GOSSIP_OPTION_STABLEPET", "I'd like to stable my pet."}, + {"GOSSIP_OPTION_ARMORER", "I need to repair my equipment."}, + {"GOSSIP_OPTION_GOSSIP", "What can you tell me?"}, + {"GOSSIP_OPTION_BATTLEFIELD", "I'd like to go to the battleground."}, + {"GOSSIP_OPTION_TABARDDESIGNER", "I want to create a guild tabard."}, + {"GOSSIP_OPTION_PETITIONER", "I want to create a guild."}, + }; + + for (const auto& opt : gossip.options) { + ImGui::PushID(static_cast(opt.id)); + + // Determine icon label - use text-based detection for shared icons + const char* icon = (opt.icon < 11) ? gossipIcons[opt.icon] : "[Option]"; + if (opt.text == "GOSSIP_OPTION_AUCTIONEER") icon = "[Auctioneer]"; + else if (opt.text == "GOSSIP_OPTION_BANKER") icon = "[Banker]"; + else if (opt.text == "GOSSIP_OPTION_VENDOR") icon = "[Vendor]"; + else if (opt.text == "GOSSIP_OPTION_TRAINER") icon = "[Trainer]"; + else if (opt.text == "GOSSIP_OPTION_INNKEEPER") icon = "[Innkeeper]"; + else if (opt.text == "GOSSIP_OPTION_STABLEPET") icon = "[Stable Master]"; + else if (opt.text == "GOSSIP_OPTION_ARMORER") icon = "[Repair]"; + + // Resolve placeholder text from server + std::string displayText = opt.text; + auto placeholderIt = gossipPlaceholders.find(displayText); + if (placeholderIt != gossipPlaceholders.end()) { + displayText = placeholderIt->second; + } + + std::string processedText = chatPanel.replaceGenderPlaceholders(displayText, gameHandler); + std::string label = std::string(icon) + " " + processedText; + if (ImGui::Selectable(label.c_str())) { + if (opt.text == "GOSSIP_OPTION_ARMORER") { + gameHandler.setVendorCanRepair(true); + } + gameHandler.selectGossipOption(opt.id); + } + ImGui::PopID(); + } + + // Fallback: some spirit healers don't send gossip options. + if (gossip.options.empty() && gameHandler.isPlayerGhost()) { + bool isSpirit = false; + if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(npcEntity); + std::string name = unit->getName(); + std::transform(name.begin(), name.end(), name.begin(), + [](unsigned char c){ return static_cast(std::tolower(c)); }); + if (name.find("spirit healer") != std::string::npos || + name.find("spirit guide") != std::string::npos) { + isSpirit = true; + } + } + if (isSpirit) { + if (ImGui::Selectable("[Spiritguide] Return to Graveyard")) { + gameHandler.activateSpiritHealer(gossip.npcGuid); + gameHandler.closeGossip(); + } + } + } + + // Quest items + if (!gossip.quests.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(kColorYellow, "Quests:"); + for (size_t qi = 0; qi < gossip.quests.size(); qi++) { + const auto& quest = gossip.quests[qi]; + ImGui::PushID(static_cast(qi)); + + // Determine icon and color based on QuestGiverStatus stored in questIcon + // 5=INCOMPLETE (gray?), 6=REWARD_REP (yellow?), 7=AVAILABLE_LOW (gray!), + // 8=AVAILABLE (yellow!), 10=REWARD (yellow?) + const char* statusIcon = "!"; + ImVec4 statusColor = kColorYellow; // yellow + switch (quest.questIcon) { + case 5: // INCOMPLETE — in progress but not done + statusIcon = "?"; + statusColor = colors::kMediumGray; // gray + break; + case 6: // REWARD_REP — repeatable, ready to turn in + case 10: // REWARD — ready to turn in + statusIcon = "?"; + statusColor = kColorYellow; // yellow + break; + case 7: // AVAILABLE_LOW — available but gray (low-level) + statusIcon = "!"; + statusColor = colors::kMediumGray; // gray + break; + default: // AVAILABLE (8) and any others + statusIcon = "!"; + statusColor = kColorYellow; // yellow + break; + } + + // Render: colored icon glyph then [Lv] Title + ImGui::TextColored(statusColor, "%s", statusIcon); + ImGui::SameLine(0, 4); + char qlabel[256]; + snprintf(qlabel, sizeof(qlabel), "[%d] %s", quest.questLevel, quest.title.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, statusColor); + if (ImGui::Selectable(qlabel)) { + gameHandler.selectGossipQuest(quest.questId); + } + ImGui::PopStyleColor(); + ImGui::PopID(); + } + } + + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(-1, 0))) { + gameHandler.closeGossip(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeGossip(); + } +} + +void WindowManager::renderQuestDetailsWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen) { + if (!gameHandler.isQuestDetailsOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); + + bool open = true; + const auto& quest = gameHandler.getQuestDetails(); + std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + if (ImGui::Begin(processedTitle.c_str(), &open)) { + // Quest description + if (!quest.details.empty()) { + std::string processedDetails = chatPanel.replaceGenderPlaceholders(quest.details, gameHandler); + ImGui::TextWrapped("%s", processedDetails.c_str()); + } + + // Objectives + if (!quest.objectives.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "Objectives:"); + std::string processedObjectives = chatPanel.replaceGenderPlaceholders(quest.objectives, gameHandler); + ImGui::TextWrapped("%s", processedObjectives.c_str()); + } + + // Choice reward items (player picks one) + auto renderQuestRewardItem = [&](const game::QuestRewardItem& ri) { + gameHandler.ensureItemInfo(ri.itemId); + auto* info = gameHandler.getItemInfo(ri.itemId); + VkDescriptorSet iconTex = VK_NULL_HANDLE; + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); + + std::string label; + ImVec4 nameCol = ui::colors::kWhite; + if (info && info->valid && !info->name.empty()) { + label = info->name; + nameCol = InventoryScreen::getQualityColor(static_cast(info->quality)); + } else { + label = "Item " + std::to_string(ri.itemId); + } + if (ri.count > 1) label += " x" + std::to_string(ri.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + ImGui::SameLine(); + } + ImGui::TextColored(nameCol, " %s", label.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + }; + + if (!quest.rewardChoiceItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "Choose one reward:"); + for (const auto& ri : quest.rewardChoiceItems) { + renderQuestRewardItem(ri); + } + } + + // Fixed reward items (always given) + if (!quest.rewardItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "You will receive:"); + for (const auto& ri : quest.rewardItems) { + renderQuestRewardItem(ri); + } + } + + // XP and money rewards + if (quest.rewardXp > 0 || quest.rewardMoney > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "Rewards:"); + if (quest.rewardXp > 0) { + ImGui::Text(" %u experience", quest.rewardXp); + } + if (quest.rewardMoney > 0) { + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(quest.rewardMoney); + } + } + + if (quest.suggestedPlayers > 1) { + ImGui::TextColored(ui::colors::kLightGray, + "Suggested players: %u", quest.suggestedPlayers); + } + + // Accept / Decline buttons + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (ImGui::Button("Accept", ImVec2(buttonW, 0))) { + gameHandler.acceptQuest(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(buttonW, 0))) { + gameHandler.declineQuest(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.declineQuest(); + } +} + +void WindowManager::renderQuestRequestItemsWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen) { + if (!gameHandler.isQuestRequestItemsOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(450, 350), ImGuiCond_Appearing); + + bool open = true; + const auto& quest = gameHandler.getQuestRequestItems(); + auto countItemInInventory = [&](uint32_t itemId) -> uint32_t { + const auto& inv = gameHandler.getInventory(); + uint32_t total = 0; + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& slot = inv.getBackpackSlot(i); + if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount; + } + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; ++bag) { + int bagSize = inv.getBagSize(bag); + for (int s = 0; s < bagSize; ++s) { + const auto& slot = inv.getBagSlot(bag, s); + if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount; + } + } + return total; + }; + + std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { + if (!quest.completionText.empty()) { + std::string processedCompletionText = chatPanel.replaceGenderPlaceholders(quest.completionText, gameHandler); + ImGui::TextWrapped("%s", processedCompletionText.c_str()); + } + + // Required items + if (!quest.requiredItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "Required Items:"); + for (const auto& item : quest.requiredItems) { + uint32_t have = countItemInInventory(item.itemId); + bool enough = have >= item.count; + ImVec4 textCol = enough ? colors::kLightGreen : ImVec4(1.0f, 0.6f, 0.6f, 1.0f); + auto* info = gameHandler.getItemInfo(item.itemId); + const char* name = (info && info->valid) ? info->name.c_str() : nullptr; + + // Show icon if display info is available + uint32_t dispId = item.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + ImGui::SameLine(); + } + } + if (name && *name) { + ImGui::TextColored(textCol, "%s %u/%u", name, have, item.count); + } else { + ImGui::TextColored(textCol, "Item %u %u/%u", item.itemId, have, item.count); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + } + } + + if (quest.requiredMoney > 0) { + ImGui::Spacing(); + ImGui::TextDisabled("Required money:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(quest.requiredMoney); + } + + // Complete / Cancel buttons + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) { + gameHandler.completeQuest(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) { + gameHandler.closeQuestRequestItems(); + } + + if (!quest.isCompletable()) { + ImGui::TextDisabled("Server flagged this quest as incomplete; completion will be server-validated."); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeQuestRequestItems(); + } +} + +void WindowManager::renderQuestOfferRewardWindow(game::GameHandler& gameHandler, + ChatPanel& chatPanel, + InventoryScreen& inventoryScreen) { + if (!gameHandler.isQuestOfferRewardOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); + + bool open = true; + const auto& quest = gameHandler.getQuestOfferReward(); + static int selectedChoice = -1; + + // Auto-select if only one choice reward + if (quest.choiceRewards.size() == 1 && selectedChoice == -1) { + selectedChoice = 0; + } + + std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { + if (!quest.rewardText.empty()) { + std::string processedRewardText = chatPanel.replaceGenderPlaceholders(quest.rewardText, gameHandler); + ImGui::TextWrapped("%s", processedRewardText.c_str()); + } + + // Choice rewards (pick one) + // Trigger item info fetch for all reward items + for (const auto& item : quest.choiceRewards) gameHandler.ensureItemInfo(item.itemId); + for (const auto& item : quest.fixedRewards) gameHandler.ensureItemInfo(item.itemId); + + // Helper: resolve icon tex + quality color for a reward item + auto resolveRewardItemVis = [&](const game::QuestRewardItem& ri) + -> std::pair + { + auto* info = gameHandler.getItemInfo(ri.itemId); + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + ImVec4 col = (info && info->valid) + ? InventoryScreen::getQualityColor(static_cast(info->quality)) + : ui::colors::kWhite; + return {iconTex, col}; + }; + + // Helper: show full item tooltip (reuses InventoryScreen's rich tooltip) + auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 /*nameCol*/) { + auto* info = gameHandler.getItemInfo(ri.itemId); + if (!info || !info->valid) { + ImGui::BeginTooltip(); + ImGui::TextDisabled("Loading item data..."); + ImGui::EndTooltip(); + return; + } + inventoryScreen.renderItemTooltip(*info); + }; + + if (!quest.choiceRewards.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "Choose a reward:"); + + for (size_t i = 0; i < quest.choiceRewards.size(); ++i) { + const auto& item = quest.choiceRewards[i]; + auto* info = gameHandler.getItemInfo(item.itemId); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); + + bool selected = (selectedChoice == static_cast(i)); + ImGui::PushID(static_cast(i)); + + // Icon then selectable on same line + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(20, 20)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); + } + ImGui::PushStyleColor(ImGuiCol_Text, qualityColor); + if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) { + if (ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } else { + selectedChoice = static_cast(i); + } + } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + + ImGui::PopID(); + } + } + + // Fixed rewards (always given) + if (!quest.fixedRewards.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "You will also receive:"); + for (const auto& item : quest.fixedRewards) { + auto* info = gameHandler.getItemInfo(item.itemId); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); + } + ImGui::TextColored(qualityColor, " %s", label.c_str()); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + } + } + + // Money / XP rewards + if (quest.rewardXp > 0 || quest.rewardMoney > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ui::colors::kTooltipGold, "Rewards:"); + if (quest.rewardXp > 0) + ImGui::Text(" %u experience", quest.rewardXp); + if (quest.rewardMoney > 0) { + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(quest.rewardMoney); + } + } + + // Complete button + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + + bool canComplete = quest.choiceRewards.empty() || selectedChoice >= 0; + if (!canComplete) ImGui::BeginDisabled(); + if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) { + uint32_t rewardIdx = 0; + if (!quest.choiceRewards.empty() && selectedChoice >= 0 && + selectedChoice < static_cast(quest.choiceRewards.size())) { + // Server expects the original slot index from its fixed-size reward array. + rewardIdx = quest.choiceRewards[static_cast(selectedChoice)].choiceSlot; + } + gameHandler.chooseQuestReward(rewardIdx); + selectedChoice = -1; + } + if (!canComplete) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) { + gameHandler.closeQuestOfferReward(); + selectedChoice = -1; + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeQuestOfferReward(); + selectedChoice = -1; + } +} + +void WindowManager::loadExtendedCostDBC() { + if (extendedCostDbLoaded_) return; + extendedCostDbLoaded_ = true; + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + auto dbc = am->loadDBC("ItemExtendedCost.dbc"); + if (!dbc || !dbc->isLoaded()) return; + // WotLK ItemExtendedCost.dbc: field 0=ID, 1=honorPoints, 2=arenaPoints, + // 3=arenaSlotRestrictions, 4-8=itemId[5], 9-13=itemCount[5], 14=reqRating, 15=purchaseGroup + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + if (id == 0) continue; + ExtendedCostEntry e; + e.honorPoints = dbc->getUInt32(i, 1); + e.arenaPoints = dbc->getUInt32(i, 2); + for (int j = 0; j < 5; ++j) { + e.itemId[j] = dbc->getUInt32(i, 4 + j); + e.itemCount[j] = dbc->getUInt32(i, 9 + j); + } + extendedCostCache_[id] = e; + } + LOG_INFO("ItemExtendedCost.dbc: loaded ", extendedCostCache_.size(), " entries"); +} + +std::string WindowManager::formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler) { + loadExtendedCostDBC(); + auto it = extendedCostCache_.find(extendedCostId); + if (it == extendedCostCache_.end()) return "[Tokens]"; + const auto& e = it->second; + std::string result; + if (e.honorPoints > 0) { + result += std::to_string(e.honorPoints) + " Honor"; + } + if (e.arenaPoints > 0) { + if (!result.empty()) result += ", "; + result += std::to_string(e.arenaPoints) + " Arena"; + } + for (int j = 0; j < 5; ++j) { + if (e.itemId[j] == 0 || e.itemCount[j] == 0) continue; + if (!result.empty()) result += ", "; + gameHandler.ensureItemInfo(e.itemId[j]); // query if not cached + const auto* itemInfo = gameHandler.getItemInfo(e.itemId[j]); + if (itemInfo && itemInfo->valid && !itemInfo->name.empty()) { + result += std::to_string(e.itemCount[j]) + "x " + itemInfo->name; + } else { + result += std::to_string(e.itemCount[j]) + "x Item#" + std::to_string(e.itemId[j]); + } + } + return result.empty() ? "[Tokens]" : result; +} + +void WindowManager::renderVendorWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.isVendorWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 100), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing); + + bool open = true; + if (ImGui::Begin("Vendor", &open)) { + const auto& vendor = gameHandler.getVendorItems(); + + // Show player money + uint64_t money = gameHandler.getMoneyCopper(); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(money); + + if (vendor.canRepair) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.0f); + if (ImGui::SmallButton("Repair All")) { + gameHandler.repairAll(vendor.vendorGuid, false); + } + if (ImGui::IsItemHovered()) { + // Show durability summary of all equipment + const auto& inv = gameHandler.getInventory(); + int damagedCount = 0; + int brokenCount = 0; + for (int s = 0; s < static_cast(game::EquipSlot::BAG1); s++) { + const auto& slot = inv.getEquipSlot(static_cast(s)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) brokenCount++; + else if (slot.item.curDurability < slot.item.maxDurability) damagedCount++; + } + if (brokenCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d damaged, %d broken", damagedCount, brokenCount); + else if (damagedCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d item%s need repair", damagedCount, damagedCount > 1 ? "s" : ""); + else + ImGui::SetTooltip("All equipment is in good condition"); + } + if (gameHandler.isInGuild()) { + ImGui::SameLine(); + if (ImGui::SmallButton("Repair (Guild)")) { + gameHandler.repairAll(vendor.vendorGuid, true); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Repair all equipped items using guild bank funds"); + } + } + } + ImGui::Separator(); + + ImGui::TextColored(ui::colors::kLightGray, "Right-click bag items to sell"); + + // Count grey (POOR quality) sellable items across backpack and bags + const auto& inv = gameHandler.getInventory(); + int junkCount = 0; + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& sl = inv.getBackpackSlot(i); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + ++junkCount; + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < inv.getBagSize(b); ++s) { + const auto& sl = inv.getBagSlot(b, s); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + ++junkCount; + } + } + if (junkCount > 0) { + char junkLabel[64]; + snprintf(junkLabel, sizeof(junkLabel), "Sell All Junk (%d item%s)", + junkCount, junkCount == 1 ? "" : "s"); + if (ImGui::Button(junkLabel, ImVec2(-1, 0))) { + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& sl = inv.getBackpackSlot(i); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + gameHandler.sellItemBySlot(i); + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < inv.getBagSize(b); ++s) { + const auto& sl = inv.getBagSlot(b, s); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + gameHandler.sellItemInBag(b, s); + } + } + } + } + ImGui::Separator(); + + const auto& buyback = gameHandler.getBuybackItems(); + if (!buyback.empty()) { + ImGui::TextColored(ui::colors::kTooltipGold, "Buy Back"); + if (ImGui::BeginTable("BuybackTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); + ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableHeadersRow(); + // Show all buyback items (most recently sold first) + for (int i = 0; i < static_cast(buyback.size()); ++i) { + const auto& entry = buyback[i]; + gameHandler.ensureItemInfo(entry.item.itemId); + auto* bbInfo = gameHandler.getItemInfo(entry.item.itemId); + uint32_t sellPrice = entry.item.sellPrice; + if (sellPrice == 0) { + if (bbInfo && bbInfo->valid) sellPrice = bbInfo->sellPrice; + } + uint64_t price = static_cast(sellPrice) * + static_cast(entry.count > 0 ? entry.count : 1); + uint32_t g = static_cast(price / 10000); + uint32_t s = static_cast((price / 100) % 100); + uint32_t c = static_cast(price % 100); + bool canAfford = money >= price; + + ImGui::TableNextRow(); + ImGui::PushID(8000 + i); + ImGui::TableSetColumnIndex(0); + { + uint32_t dispId = entry.item.displayInfoId; + if (bbInfo && bbInfo->valid && bbInfo->displayInfoId != 0) dispId = bbInfo->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + } + } + ImGui::TableSetColumnIndex(1); + game::ItemQuality bbQuality = entry.item.quality; + if (bbInfo && bbInfo->valid) bbQuality = static_cast(bbInfo->quality); + ImVec4 bbQc = InventoryScreen::getQualityColor(bbQuality); + const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); + if (entry.count > 1) { + ImGui::TextColored(bbQc, "%s x%u", name, entry.count); + } else { + ImGui::TextColored(bbQc, "%s", name); + } + if (ImGui::IsItemHovered() && bbInfo && bbInfo->valid) + inventoryScreen.renderItemTooltip(*bbInfo); + ImGui::TableSetColumnIndex(2); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(kColorRed, "%ug %us %uc", g, s, c); + } + ImGui::TableSetColumnIndex(3); + if (!canAfford) ImGui::BeginDisabled(); + char bbLabel[32]; + snprintf(bbLabel, sizeof(bbLabel), "Buy Back##bb%d", i); + if (ImGui::SmallButton(bbLabel)) { + gameHandler.buyBackItem(static_cast(i)); + } + if (!canAfford) ImGui::EndDisabled(); + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::Separator(); + } + + if (vendor.items.empty()) { + ImGui::TextDisabled("This vendor has nothing for sale."); + } else { + // Search + quantity controls on one row + ImGui::SetNextItemWidth(200.0f); + ImGui::InputTextWithHint("##VendorSearch", "Search...", vendorSearchFilter_, sizeof(vendorSearchFilter_)); + ImGui::SameLine(); + ImGui::Text("Qty:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60.0f); + static int vendorBuyQty = 1; + ImGui::InputInt("##VendorQty", &vendorBuyQty, 1, 5); + if (vendorBuyQty < 1) vendorBuyQty = 1; + if (vendorBuyQty > 99) vendorBuyQty = 99; + ImGui::Spacing(); + + if (ImGui::BeginTable("VendorTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); + ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Stock", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f); + ImGui::TableHeadersRow(); + + std::string vendorFilter(vendorSearchFilter_); + // Lowercase filter for case-insensitive match + for (char& c : vendorFilter) c = static_cast(std::tolower(static_cast(c))); + + for (int vi = 0; vi < static_cast(vendor.items.size()); ++vi) { + const auto& item = vendor.items[vi]; + + // Proactively ensure vendor item info is loaded + gameHandler.ensureItemInfo(item.itemId); + auto* info = gameHandler.getItemInfo(item.itemId); + + // Apply search filter + if (!vendorFilter.empty()) { + std::string nameLC = info && info->valid ? info->name : ("Item " + std::to_string(item.itemId)); + for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); + if (nameLC.find(vendorFilter) == std::string::npos) { + ImGui::PushID(vi); + ImGui::PopID(); + continue; + } + } + + ImGui::TableNextRow(); + ImGui::PushID(vi); + + // Icon column + ImGui::TableSetColumnIndex(0); + { + uint32_t dispId = item.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + } + } + + // Name column + ImGui::TableSetColumnIndex(1); + if (info && info->valid) { + ImVec4 qc = InventoryScreen::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qc, "%s", info->name.c_str()); + if (ImGui::IsItemHovered()) { + inventoryScreen.renderItemTooltip(*info, &gameHandler.getInventory()); + } + // Shift-click: insert item link into chat + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + } else { + ImGui::Text("Item %u", item.itemId); + } + + ImGui::TableSetColumnIndex(2); + if (item.buyPrice == 0 && item.extendedCost != 0) { + // Token-only item — show detailed cost from ItemExtendedCost.dbc + std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "%s", costStr.c_str()); + } else { + uint32_t g = item.buyPrice / 10000; + uint32_t s = (item.buyPrice / 100) % 100; + uint32_t c = item.buyPrice % 100; + bool canAfford = money >= item.buyPrice; + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(kColorRed, "%ug %us %uc", g, s, c); + } + // Show additional token cost if both gold and tokens are required + if (item.extendedCost != 0) { + std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); + if (costStr != "[Tokens]") { + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 0.8f), "+ %s", costStr.c_str()); + } + } + } + + ImGui::TableSetColumnIndex(3); + if (item.maxCount < 0) { + ImGui::TextDisabled("Inf"); + } else if (item.maxCount == 0) { + ImGui::TextColored(kColorRed, "Out"); + } else if (item.maxCount <= 5) { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), "%d", item.maxCount); + } else { + ImGui::Text("%d", item.maxCount); + } + + ImGui::TableSetColumnIndex(4); + bool outOfStock = (item.maxCount == 0); + if (outOfStock) ImGui::BeginDisabled(); + std::string buyBtnId = "Buy##vendor_" + std::to_string(vi); + if (ImGui::SmallButton(buyBtnId.c_str())) { + int qty = vendorBuyQty; + if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount; + uint32_t totalCost = item.buyPrice * static_cast(qty); + if (totalCost >= 10000) { // >= 1 gold: confirm + vendorConfirmOpen_ = true; + vendorConfirmGuid_ = vendor.vendorGuid; + vendorConfirmItemId_ = item.itemId; + vendorConfirmSlot_ = item.slot; + vendorConfirmQty_ = static_cast(qty); + vendorConfirmPrice_ = totalCost; + vendorConfirmItemName_ = (info && info->valid) ? info->name : "Item"; + } else { + gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, + static_cast(qty)); + } + } + if (outOfStock) ImGui::EndDisabled(); + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeVendor(); + } + + // Vendor purchase confirmation popup for expensive items + if (vendorConfirmOpen_) { + ImGui::OpenPopup("Confirm Purchase##vendor"); + vendorConfirmOpen_ = false; + } + if (ImGui::BeginPopupModal("Confirm Purchase##vendor", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + ImGui::Text("Buy %s", vendorConfirmItemName_.c_str()); + if (vendorConfirmQty_ > 1) + ImGui::Text("Quantity: %u", vendorConfirmQty_); + uint32_t g = vendorConfirmPrice_ / 10000; + uint32_t s = (vendorConfirmPrice_ / 100) % 100; + uint32_t c = vendorConfirmPrice_ % 100; + ImGui::Text("Cost: %ug %us %uc", g, s, c); + ImGui::Spacing(); + if (ImGui::Button("Buy", ImVec2(80, 0))) { + gameHandler.buyItem(vendorConfirmGuid_, vendorConfirmItemId_, + vendorConfirmSlot_, vendorConfirmQty_); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } +} + +void WindowManager::renderTrainerWindow(game::GameHandler& gameHandler, + SpellIconFn getSpellIcon) { + if (!gameHandler.isTrainerWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, 100), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(500, 450), ImGuiCond_Appearing); + + bool open = true; + if (ImGui::Begin("Trainer", &open)) { + // If user clicked window close, short-circuit before rendering large trainer tables. + if (!open) { + ImGui::End(); + gameHandler.closeTrainer(); + return; + } + + const auto& trainer = gameHandler.getTrainerSpells(); + const bool isProfessionTrainer = (trainer.trainerType == 2); + + // NPC name + auto npcEntity = gameHandler.getEntityManager().getEntity(trainer.trainerGuid); + if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(npcEntity); + if (!unit->getName().empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str()); + } + } + + // Greeting + if (!trainer.greeting.empty()) { + ImGui::TextWrapped("%s", trainer.greeting.c_str()); + } + ImGui::Separator(); + + // Player money + uint64_t money = gameHandler.getMoneyCopper(); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(money); + + // Filter controls + static bool showUnavailable = false; + ImGui::Checkbox("Show unavailable spells", &showUnavailable); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##TrainerSearch", "Search...", trainerSearchFilter_, sizeof(trainerSearchFilter_)); + ImGui::Separator(); + + if (trainer.spells.empty()) { + ImGui::TextDisabled("This trainer has nothing to teach you."); + } else { + // Known spells for checking + const auto& knownSpells = gameHandler.getKnownSpells(); + auto isKnown = [&](uint32_t id) { + if (id == 0) return true; + // Check if spell is in knownSpells list + bool found = knownSpells.count(id); + if (found) return true; + + // Also check if spell is in trainer list with state=2 (explicitly known) + // state=0 means unavailable (could be no prereqs, wrong level, etc.) - don't count as known + for (const auto& ts : trainer.spells) { + if (ts.spellId == id && ts.state == 2) { + return true; + } + } + return false; + }; + uint32_t playerLevel = gameHandler.getPlayerLevel(); + + // Renders spell rows into the current table + auto renderSpellRows = [&](const std::vector& spells) { + for (const auto* spell : spells) { + // Check prerequisites client-side first + bool prereq1Met = isKnown(spell->chainNode1); + bool prereq2Met = isKnown(spell->chainNode2); + bool prereq3Met = isKnown(spell->chainNode3); + bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; + bool levelMet = (spell->reqLevel == 0 || playerLevel >= spell->reqLevel); + bool alreadyKnown = isKnown(spell->spellId); + + // Dynamically determine effective state based on current prerequisites + // Server sends state, but we override if prerequisites are now met + uint8_t effectiveState = spell->state; + if (spell->state == 1 && prereqsMet && levelMet) { + // Server said unavailable, but we now meet all requirements + effectiveState = 0; // Treat as available + } + + // Filter: skip unavailable spells if checkbox is unchecked + // Use effectiveState so spells with newly met prereqs aren't filtered + if (!showUnavailable && effectiveState == 1) { + continue; + } + + // Apply text search filter + if (trainerSearchFilter_[0] != '\0') { + std::string trainerFilter(trainerSearchFilter_); + for (char& c : trainerFilter) c = static_cast(std::tolower(static_cast(c))); + const std::string& spellName = gameHandler.getSpellName(spell->spellId); + std::string nameLC = spellName.empty() ? std::to_string(spell->spellId) : spellName; + for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); + if (nameLC.find(trainerFilter) == std::string::npos) { + ImGui::PushID(static_cast(spell->spellId)); + ImGui::PopID(); + continue; + } + } + + ImGui::TableNextRow(); + ImGui::PushID(static_cast(spell->spellId)); + + ImVec4 color; + const char* statusLabel; + // WotLK trainer states: 0=available, 1=unavailable, 2=known + if (effectiveState == 2 || alreadyKnown) { + color = colors::kQueueGreen; + statusLabel = "Known"; + } else if (effectiveState == 0) { + color = ui::colors::kWhite; + statusLabel = "Available"; + } else { + color = ImVec4(0.6f, 0.3f, 0.3f, 1.0f); + statusLabel = "Unavailable"; + } + + // Icon column + ImGui::TableSetColumnIndex(0); + { + VkDescriptorSet spellIcon = getSpellIcon(spell->spellId, assetMgr); + if (spellIcon) { + if (effectiveState == 1 && !alreadyKnown) { + ImGui::ImageWithBg((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18), + ImVec2(0, 0), ImVec2(1, 1), + ImVec4(0, 0, 0, 0), ImVec4(0.5f, 0.5f, 0.5f, 0.6f)); + } else { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18)); + } + } + } + + // Spell name + ImGui::TableSetColumnIndex(1); + const std::string& name = gameHandler.getSpellName(spell->spellId); + const std::string& rank = gameHandler.getSpellRank(spell->spellId); + if (!name.empty()) { + if (!rank.empty()) + ImGui::TextColored(color, "%s (%s)", name.c_str(), rank.c_str()); + else + ImGui::TextColored(color, "%s", name.c_str()); + } else { + ImGui::TextColored(color, "Spell #%u", spell->spellId); + } + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + if (!name.empty()) { + ImGui::TextColored(kColorYellow, "%s", name.c_str()); + if (!rank.empty()) ImGui::TextColored(kColorGray, "%s", rank.c_str()); + } + const std::string& spDesc = gameHandler.getSpellDescription(spell->spellId); + if (!spDesc.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextWrapped("%s", spDesc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + ImGui::TextDisabled("Status: %s", statusLabel); + if (spell->reqLevel > 0) { + ImVec4 lvlColor = levelMet ? ui::colors::kLightGray : kColorRed; + ImGui::TextColored(lvlColor, "Required Level: %u", spell->reqLevel); + } + if (spell->reqSkill > 0) ImGui::Text("Required Skill: %u (value %u)", spell->reqSkill, spell->reqSkillValue); + auto showPrereq = [&](uint32_t node) { + if (node == 0) return; + bool met = isKnown(node); + const std::string& pname = gameHandler.getSpellName(node); + ImVec4 pcolor = met ? colors::kQueueGreen : kColorRed; + if (!pname.empty()) + ImGui::TextColored(pcolor, "Requires: %s%s", pname.c_str(), met ? " (known)" : ""); + else + ImGui::TextColored(pcolor, "Requires: Spell #%u%s", node, met ? " (known)" : ""); + }; + showPrereq(spell->chainNode1); + showPrereq(spell->chainNode2); + showPrereq(spell->chainNode3); + ImGui::EndTooltip(); + } + + // Level + ImGui::TableSetColumnIndex(2); + ImGui::TextColored(color, "%u", spell->reqLevel); + + // Cost + ImGui::TableSetColumnIndex(3); + if (spell->spellCost > 0) { + uint32_t g = spell->spellCost / 10000; + uint32_t s = (spell->spellCost / 100) % 100; + uint32_t c = spell->spellCost % 100; + bool canAfford = money >= spell->spellCost; + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(kColorRed, "%ug %us %uc", g, s, c); + } + } else { + ImGui::TextColored(color, "Free"); + } + + // Train button - only enabled if available, affordable, prereqs met + ImGui::TableSetColumnIndex(4); + // Use effectiveState so newly available spells (after learning prereqs) can be trained + bool canTrain = !alreadyKnown && effectiveState == 0 + && prereqsMet && levelMet + && (money >= spell->spellCost); + + // Debug logging for first 3 spells to see why buttons are disabled + static int logCount = 0; + static uint64_t lastTrainerGuid = 0; + if (trainer.trainerGuid != lastTrainerGuid) { + logCount = 0; + lastTrainerGuid = trainer.trainerGuid; + } + if (logCount < 3) { + LOG_INFO("Trainer button debug: spellId=", spell->spellId, + " alreadyKnown=", alreadyKnown, " state=", static_cast(spell->state), + " prereqsMet=", prereqsMet, " (", prereq1Met, ",", prereq2Met, ",", prereq3Met, ")", + " levelMet=", levelMet, + " reqLevel=", spell->reqLevel, " playerLevel=", playerLevel, + " chain1=", spell->chainNode1, " chain2=", spell->chainNode2, " chain3=", spell->chainNode3, + " canAfford=", (money >= spell->spellCost), + " canTrain=", canTrain); + logCount++; + } + + if (isProfessionTrainer && alreadyKnown) { + // Profession trainer: known recipes show "Create" button to craft + bool isCasting = gameHandler.isCasting(); + if (isCasting) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Create")) { + gameHandler.castSpell(spell->spellId, 0); + } + if (isCasting) ImGui::EndDisabled(); + } else { + if (!canTrain) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Train")) { + gameHandler.trainSpell(spell->spellId); + } + if (!canTrain) ImGui::EndDisabled(); + } + + ImGui::PopID(); + } + }; + + auto renderSpellTable = [&](const char* tableId, const std::vector& spells) { + if (ImGui::BeginTable(tableId, 5, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); + ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableHeadersRow(); + renderSpellRows(spells); + ImGui::EndTable(); + } + }; + + const auto& tabs = gameHandler.getTrainerTabs(); + if (tabs.size() > 1) { + // Multiple tabs - show tab bar + if (ImGui::BeginTabBar("TrainerTabs")) { + for (size_t i = 0; i < tabs.size(); i++) { + char tabLabel[64]; + snprintf(tabLabel, sizeof(tabLabel), "%s (%zu)", + tabs[i].name.c_str(), tabs[i].spells.size()); + + if (ImGui::BeginTabItem(tabLabel)) { + char tableId[32]; + snprintf(tableId, sizeof(tableId), "TT%zu", i); + renderSpellTable(tableId, tabs[i].spells); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + } else { + // Single tab or no categorization - flat list + std::vector allSpells; + allSpells.reserve(trainer.spells.size()); + for (const auto& spell : trainer.spells) { + allSpells.push_back(&spell); + } + renderSpellTable("TrainerTable", allSpells); + } + + // Count how many spells are trainable right now + int trainableCount = 0; + uint64_t totalCost = 0; + for (const auto& spell : trainer.spells) { + bool prereq1Met = isKnown(spell.chainNode1); + bool prereq2Met = isKnown(spell.chainNode2); + bool prereq3Met = isKnown(spell.chainNode3); + bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; + bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); + bool alreadyKnown = isKnown(spell.spellId); + uint8_t effectiveState = spell.state; + if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; + bool canTrain = !alreadyKnown && effectiveState == 0 + && prereqsMet && levelMet + && (money >= spell.spellCost); + if (canTrain) { + ++trainableCount; + totalCost += spell.spellCost; + } + } + + ImGui::Separator(); + bool canAffordAll = (money >= totalCost); + bool hasTrainable = (trainableCount > 0) && canAffordAll; + if (!hasTrainable) ImGui::BeginDisabled(); + uint32_t tag = static_cast(totalCost / 10000); + uint32_t tas = static_cast((totalCost / 100) % 100); + uint32_t tac = static_cast(totalCost % 100); + char trainAllLabel[80]; + if (trainableCount == 0) { + snprintf(trainAllLabel, sizeof(trainAllLabel), "Train All Available (none)"); + } else { + snprintf(trainAllLabel, sizeof(trainAllLabel), + "Train All Available (%d spell%s, %ug %us %uc)", + trainableCount, trainableCount == 1 ? "" : "s", + tag, tas, tac); + } + if (ImGui::Button(trainAllLabel, ImVec2(-1.0f, 0.0f))) { + for (const auto& spell : trainer.spells) { + bool prereq1Met = isKnown(spell.chainNode1); + bool prereq2Met = isKnown(spell.chainNode2); + bool prereq3Met = isKnown(spell.chainNode3); + bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; + bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); + bool alreadyKnown = isKnown(spell.spellId); + uint8_t effectiveState = spell.state; + if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; + bool canTrain = !alreadyKnown && effectiveState == 0 + && prereqsMet && levelMet + && (money >= spell.spellCost); + if (canTrain) { + gameHandler.trainSpell(spell.spellId); + } + } + } + if (!hasTrainable) ImGui::EndDisabled(); + + // Profession trainer: craft quantity controls + if (isProfessionTrainer) { + ImGui::Separator(); + static int craftQuantity = 1; + static uint32_t selectedCraftSpell = 0; + + // Show craft queue status if active + int queueRemaining = gameHandler.getCraftQueueRemaining(); + if (queueRemaining > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), + "Crafting... %d remaining", queueRemaining); + ImGui::SameLine(); + if (ImGui::SmallButton("Stop")) { + gameHandler.cancelCraftQueue(); + gameHandler.cancelCast(); + } + } else { + // Spell selector + quantity input + // Build list of known (craftable) spells + std::vector craftable; + for (const auto& spell : trainer.spells) { + if (isKnown(spell.spellId)) { + craftable.push_back(&spell); + } + } + if (!craftable.empty()) { + // Combo box for recipe selection + const char* previewName = "Select recipe..."; + for (const auto* sp : craftable) { + if (sp->spellId == selectedCraftSpell) { + const std::string& n = gameHandler.getSpellName(sp->spellId); + if (!n.empty()) previewName = n.c_str(); + break; + } + } + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f); + if (ImGui::BeginCombo("##CraftSelect", previewName)) { + for (const auto* sp : craftable) { + const std::string& n = gameHandler.getSpellName(sp->spellId); + const std::string& r = gameHandler.getSpellRank(sp->spellId); + char label[128]; + if (!r.empty()) + snprintf(label, sizeof(label), "%s (%s)##%u", + n.empty() ? "???" : n.c_str(), r.c_str(), sp->spellId); + else + snprintf(label, sizeof(label), "%s##%u", + n.empty() ? "???" : n.c_str(), sp->spellId); + if (ImGui::Selectable(label, sp->spellId == selectedCraftSpell)) { + selectedCraftSpell = sp->spellId; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(50.0f); + ImGui::InputInt("##CraftQty", &craftQuantity, 0, 0); + if (craftQuantity < 1) craftQuantity = 1; + if (craftQuantity > 99) craftQuantity = 99; + ImGui::SameLine(); + bool canCraft = selectedCraftSpell != 0 && !gameHandler.isCasting(); + if (!canCraft) ImGui::BeginDisabled(); + if (ImGui::Button("Create")) { + if (craftQuantity == 1) { + gameHandler.castSpell(selectedCraftSpell, 0); + } else { + gameHandler.startCraftQueue(selectedCraftSpell, craftQuantity); + } + } + ImGui::SameLine(); + if (ImGui::Button("Create All")) { + // Queue a large count — server stops the queue automatically + // when materials run out (sends SPELL_FAILED_REAGENTS). + gameHandler.startCraftQueue(selectedCraftSpell, 999); + } + if (!canCraft) ImGui::EndDisabled(); + } + } + } + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeTrainer(); + } +} + +void WindowManager::renderEscapeMenu(SettingsPanel& settingsPanel) { + if (!showEscapeMenu) return; + + ImGuiIO& io = ImGui::GetIO(); + float screenW = io.DisplaySize.x; + float screenH = io.DisplaySize.y; + ImVec2 size(260.0f, 248.0f); + ImVec2 pos((screenW - size.x) * 0.5f, (screenH - size.y) * 0.5f); + + ImGui::SetNextWindowPos(pos, ImGuiCond_Always); + ImGui::SetNextWindowSize(size, ImGuiCond_Always); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar; + + if (ImGui::Begin("##EscapeMenu", nullptr, flags)) { + ImGui::Text("Game Menu"); + ImGui::Separator(); + + if (ImGui::Button("Logout", ImVec2(-1, 0))) { + core::Application::getInstance().logoutToLogin(); + showEscapeMenu = false; + settingsPanel.showEscapeSettingsNotice = false; + } + if (ImGui::Button("Quit", ImVec2(-1, 0))) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + if (auto* music = renderer->getMusicManager()) { + music->stopMusic(0.0f); + } + } + core::Application::getInstance().shutdown(); + } + if (ImGui::Button("Settings", ImVec2(-1, 0))) { + settingsPanel.showEscapeSettingsNotice = false; + settingsPanel.showSettingsWindow = true; + settingsPanel.settingsInit = false; + showEscapeMenu = false; + } + if (ImGui::Button("Instance Lockouts", ImVec2(-1, 0))) { + showInstanceLockouts_ = true; + showEscapeMenu = false; + } + if (ImGui::Button("Help / GM Ticket", ImVec2(-1, 0))) { + showGmTicketWindow_ = true; + showEscapeMenu = false; + } + + ImGui::Spacing(); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); + if (ImGui::Button("Back to Game", ImVec2(-1, 0))) { + showEscapeMenu = false; + settingsPanel.showEscapeSettingsNotice = false; + } + ImGui::PopStyleVar(); + } + ImGui::End(); +} + +void WindowManager::renderBarberShopWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isBarberShopOpen()) { + barberInitialized_ = false; + return; + } + + const auto* ch = gameHandler.getActiveCharacter(); + if (!ch) return; + + uint8_t race = static_cast(ch->race); + game::Gender gender = ch->gender; + game::Race raceEnum = ch->race; + + // Initialize sliders from current appearance + if (!barberInitialized_) { + barberOrigHairStyle_ = static_cast((ch->appearanceBytes >> 16) & 0xFF); + barberOrigHairColor_ = static_cast((ch->appearanceBytes >> 24) & 0xFF); + barberOrigFacialHair_ = static_cast(ch->facialFeatures); + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + barberInitialized_ = true; + } + + int maxHairStyle = static_cast(game::getMaxHairStyle(raceEnum, gender)); + int maxHairColor = static_cast(game::getMaxHairColor(raceEnum, gender)); + int maxFacialHair = static_cast(game::getMaxFacialFeature(raceEnum, gender)); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float winW = 300.0f; + float winH = 220.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - winW) / 2.0f, (screenH - winH) / 2.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Appearing); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + bool open = true; + if (ImGui::Begin("Barber Shop", &open, flags)) { + ImGui::Text("Choose your new look:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushItemWidth(-1); + + // Hair Style + ImGui::Text("Hair Style"); + ImGui::SliderInt("##HairStyle", &barberHairStyle_, 0, maxHairStyle, + "%d"); + + // Hair Color + ImGui::Text("Hair Color"); + ImGui::SliderInt("##HairColor", &barberHairColor_, 0, maxHairColor, + "%d"); + + // Facial Hair / Piercings / Markings + const char* facialLabel = (gender == game::Gender::FEMALE) ? "Piercings" : "Facial Hair"; + // Some races use "Markings" or "Tusks" etc. + if (race == 8 || race == 6) facialLabel = "Features"; // Trolls, Tauren + ImGui::Text("%s", facialLabel); + ImGui::SliderInt("##FacialHair", &barberFacialHair_, 0, maxFacialHair, + "%d"); + + ImGui::PopItemWidth(); + + ImGui::Spacing(); + ImGui::Separator(); + + // Show whether anything changed + bool changed = (barberHairStyle_ != barberOrigHairStyle_ || + barberHairColor_ != barberOrigHairColor_ || + barberFacialHair_ != barberOrigFacialHair_); + + // OK / Reset / Cancel buttons + float btnW = 80.0f; + float totalW = btnW * 3 + ImGui::GetStyle().ItemSpacing.x * 2; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) / 2.0f); + + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("OK", ImVec2(btnW, 0))) { + gameHandler.sendAlterAppearance( + static_cast(barberHairStyle_), + static_cast(barberHairColor_), + static_cast(barberFacialHair_)); + // Keep window open — server will respond with SMSG_BARBER_SHOP_RESULT + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("Reset", ImVec2(btnW, 0))) { + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.closeBarberShop(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeBarberShop(); + } +} + +void WindowManager::renderStableWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isStableWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), + ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); + + bool open = true; + if (!ImGui::Begin("Pet Stable", &open, + kDialogFlags)) { + ImGui::End(); + if (!open) { + // User closed the window; clear stable state + gameHandler.closeStableWindow(); + } + return; + } + + const auto& pets = gameHandler.getStabledPets(); + uint8_t numSlots = gameHandler.getStableSlots(); + + ImGui::TextDisabled("Stable slots: %u", static_cast(numSlots)); + ImGui::Separator(); + + // Active pets section + bool hasActivePets = false; + for (const auto& p : pets) { + if (p.isActive) { hasActivePets = true; break; } + } + + if (hasActivePets) { + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Active / Summoned"); + for (const auto& p : pets) { + if (!p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber) * -1 - 1); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u)", displayName.c_str(), p.level); + ImGui::SameLine(); + ImGui::TextDisabled("[Active]"); + + // Offer to stable the active pet if there are free slots + uint8_t usedSlots = 0; + for (const auto& sp : pets) { if (!sp.isActive) ++usedSlots; } + if (usedSlots < numSlots) { + ImGui::SameLine(); + if (ImGui::SmallButton("Store in stable")) { + // Slot 1 is first stable slot; server handles free slot assignment. + gameHandler.stablePet(1); + } + } + ImGui::PopID(); + } + ImGui::Separator(); + } + + // Stabled pets section + ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f), "Stabled Pets"); + + bool hasStabledPets = false; + for (const auto& p : pets) { + if (!p.isActive) { hasStabledPets = true; break; } + } + + if (!hasStabledPets) { + ImGui::TextDisabled(" (No pets in stable)"); + } else { + for (const auto& p : pets) { + if (p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber)); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u, Entry %u)", + displayName.c_str(), p.level, p.entry); + ImGui::SameLine(); + if (ImGui::SmallButton("Retrieve")) { + gameHandler.unstablePet(p.petNumber); + } + ImGui::PopID(); + } + } + + // Empty slots + uint8_t usedStableSlots = 0; + for (const auto& p : pets) { if (!p.isActive) ++usedStableSlots; } + if (usedStableSlots < numSlots) { + ImGui::TextDisabled(" %u empty slot(s) available", + static_cast(numSlots - usedStableSlots)); + } + + ImGui::Separator(); + if (ImGui::Button("Refresh")) { + gameHandler.requestStabledPetList(); + } + ImGui::SameLine(); + if (ImGui::Button("Close")) { + gameHandler.closeStableWindow(); + } + + ImGui::End(); + if (!open) { + gameHandler.closeStableWindow(); + } +} + +void WindowManager::renderTaxiWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isTaxiWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always); + + bool open = true; + if (ImGui::Begin("Flight Master", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& taxiData = gameHandler.getTaxiData(); + const auto& nodes = gameHandler.getTaxiNodes(); + uint32_t currentNode = gameHandler.getTaxiCurrentNode(); + + // Get current node's map to filter destinations + uint32_t currentMapId = 0; + auto curIt = nodes.find(currentNode); + if (curIt != nodes.end()) { + currentMapId = curIt->second.mapId; + ImGui::TextColored(colors::kActiveGreen, "Current: %s", curIt->second.name.c_str()); + ImGui::Separator(); + } + + ImGui::Text("Select a destination:"); + ImGui::Spacing(); + + static uint32_t selectedNodeId = 0; + int destCount = 0; + if (ImGui::BeginTable("TaxiNodes", 3, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Destination", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableHeadersRow(); + + for (const auto& [nodeId, node] : nodes) { + if (nodeId == currentNode) continue; + if (node.mapId != currentMapId) continue; + if (!taxiData.isNodeKnown(nodeId)) continue; + + uint32_t costCopper = gameHandler.getTaxiCostTo(nodeId); + uint32_t gold = costCopper / 10000; + uint32_t silver = (costCopper / 100) % 100; + uint32_t copper = costCopper % 100; + + ImGui::PushID(static_cast(nodeId)); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + bool isSelected = (selectedNodeId == nodeId); + if (ImGui::Selectable(node.name.c_str(), isSelected, + ImGuiSelectableFlags_SpanAllColumns | + ImGuiSelectableFlags_AllowDoubleClick)) { + selectedNodeId = nodeId; + LOG_INFO("Taxi UI: Selected dest=", nodeId); + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + LOG_INFO("Taxi UI: Double-click activate dest=", nodeId); + gameHandler.activateTaxi(nodeId); + } + } + + ImGui::TableSetColumnIndex(1); + renderCoinsText(gold, silver, copper); + + ImGui::TableSetColumnIndex(2); + if (ImGui::SmallButton("Fly")) { + selectedNodeId = nodeId; + LOG_INFO("Taxi UI: Fly clicked dest=", nodeId); + gameHandler.activateTaxi(nodeId); + } + + ImGui::PopID(); + destCount++; + } + ImGui::EndTable(); + } + + if (destCount == 0) { + ImGui::TextColored(ui::colors::kLightGray, "No destinations available."); + } + + ImGui::Spacing(); + ImGui::Separator(); + if (selectedNodeId != 0 && ImGui::Button("Fly Selected", ImVec2(-1, 0))) { + LOG_INFO("Taxi UI: Fly Selected dest=", selectedNodeId); + gameHandler.activateTaxi(selectedNodeId); + } + if (ImGui::Button("Close", ImVec2(-1, 0))) { + gameHandler.closeTaxi(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeTaxi(); + } +} + +void WindowManager::renderLogoutCountdown(game::GameHandler& gameHandler) { + if (!gameHandler.isLoggingOut()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + constexpr float W = 280.0f; + constexpr float H = 80.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - W) * 0.5f, screenH * 0.5f - H * 0.5f - 60.0f), + ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(W, H), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.88f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.8f, 1.0f)); + + if (ImGui::Begin("##LogoutCountdown", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) { + + float cd = gameHandler.getLogoutCountdown(); + if (cd > 0.0f) { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 6.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out in 20s...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), + "Logging out in %ds...", static_cast(std::ceil(cd))); + + // Progress bar (20 second countdown) + float frac = 1.0f - std::min(cd / 20.0f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.5f, 0.5f, 0.9f, 1.0f)); + ImGui::ProgressBar(frac, ImVec2(-1.0f, 8.0f), ""); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } else { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 14.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "Logging out..."); + ImGui::Spacing(); + } + + // Cancel button — only while countdown is still running + if (cd > 0.0f) { + float btnW = 100.0f; + ImGui::SetCursorPosX((W - btnW) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f)); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.cancelLogout(); + } + ImGui::PopStyleColor(2); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void WindowManager::renderDeathScreen(game::GameHandler& gameHandler) { + if (!gameHandler.showDeathDialog()) { + deathTimerRunning_ = false; + deathElapsed_ = 0.0f; + return; + } + float dt = ImGui::GetIO().DeltaTime; + if (!deathTimerRunning_) { + deathElapsed_ = 0.0f; + deathTimerRunning_ = true; + } else { + deathElapsed_ += dt; + } + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Dark red overlay covering the whole screen + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.0f, 0.0f, 0.45f)); + ImGui::Begin("##DeathOverlay", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing); + ImGui::End(); + ImGui::PopStyleColor(); + + // "Release Spirit" dialog centered on screen + const bool hasSelfRes = gameHandler.canSelfRes(); + float dlgW = 280.0f; + // Extra height when self-res button is available; +20 for the "wait for res" hint + float dlgH = hasSelfRes ? 190.0f : 150.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.0f, 0.0f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.6f, 0.1f, 0.1f, 1.0f)); + + if (ImGui::Begin("##DeathDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + // Center "You are dead." text + const char* deathText = "You are dead."; + float textW = ImGui::CalcTextSize(deathText).x; + ImGui::SetCursorPosX((dlgW - textW) / 2); + ImGui::TextColored(colors::kBrightRed, "%s", deathText); + + // Respawn timer: show how long until the server auto-releases the spirit + float timeLeft = kForcedReleaseSec - deathElapsed_; + if (timeLeft > 0.0f) { + int mins = static_cast(timeLeft) / 60; + int secs = static_cast(timeLeft) % 60; + char timerBuf[48]; + snprintf(timerBuf, sizeof(timerBuf), "Auto-release in %d:%02d", mins, secs); + float tw = ImGui::CalcTextSize(timerBuf).x; + ImGui::SetCursorPosX((dlgW - tw) / 2); + ImGui::TextColored(colors::kMediumGray, "%s", timerBuf); + } + + ImGui::Spacing(); + ImGui::Spacing(); + + // Self-resurrection button (Reincarnation / Twisting Nether / Deathpact) + if (hasSelfRes) { + float btnW2 = 220.0f; + ImGui::SetCursorPosX((dlgW - btnW2) / 2); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.55f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.5f, 0.75f, 1.0f)); + if (ImGui::Button("Use Self-Resurrection", ImVec2(btnW2, 30))) { + gameHandler.useSelfRes(); + } + ImGui::PopStyleColor(2); + ImGui::Spacing(); + } + + // Center the Release Spirit button + float btnW = 180.0f; + ImGui::SetCursorPosX((dlgW - btnW) / 2); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f)); + if (ImGui::Button("Release Spirit", ImVec2(btnW, 30))) { + gameHandler.releaseSpirit(); + } + ImGui::PopStyleColor(2); + + // Hint: player can stay dead and wait for another player to cast Resurrection + const char* resHint = "Or wait for a player to resurrect you."; + float hw = ImGui::CalcTextSize(resHint).x; + ImGui::SetCursorPosX((dlgW - hw) / 2); + ImGui::TextColored(ImVec4(0.5f, 0.6f, 0.5f, 0.85f), "%s", resHint); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void WindowManager::renderReclaimCorpseButton(game::GameHandler& gameHandler) { + if (!gameHandler.isPlayerGhost() || !gameHandler.canReclaimCorpse()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float delaySec = gameHandler.getCorpseReclaimDelaySec(); + bool onDelay = (delaySec > 0.0f); + + float btnW = 220.0f, btnH = 36.0f; + float winH = btnH + 16.0f + (onDelay ? 20.0f : 0.0f); + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, winH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f)); + if (ImGui::Begin("##ReclaimCorpse", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoBringToFrontOnFocus)) { + if (onDelay) { + // Greyed-out button while PvP reclaim timer ticks down + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::BeginDisabled(true); + char delayLabel[64]; + snprintf(delayLabel, sizeof(delayLabel), "Resurrect from Corpse (%.0fs)", delaySec); + ImGui::Button(delayLabel, ImVec2(btnW, btnH)); + ImGui::EndDisabled(); + ImGui::PopStyleColor(2); + const char* waitMsg = "You cannot reclaim your corpse yet."; + float tw = ImGui::CalcTextSize(waitMsg).x; + ImGui::SetCursorPosX((btnW + 16.0f - tw) * 0.5f); + ImGui::TextColored(ImVec4(0.8f, 0.5f, 0.2f, 1.0f), "%s", waitMsg); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); + if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { + gameHandler.reclaimCorpse(); + } + ImGui::PopStyleColor(2); + float corpDist = gameHandler.getCorpseDistance(); + if (corpDist >= 0.0f) { + char distBuf[48]; + snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); + float dw = ImGui::CalcTextSize(distBuf).x; + ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); + ImGui::TextDisabled("%s", distBuf); + } + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); +} + +void WindowManager::renderMailWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.isMailboxOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 250, 80), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_Appearing); + + bool open = true; + if (ImGui::Begin("Mailbox", &open)) { + const auto& inbox = gameHandler.getMailInbox(); + + // Top bar: money + compose button + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(gameHandler.getMoneyCopper()); + ImGui::SameLine(ImGui::GetWindowWidth() - 100); + if (ImGui::Button("Compose")) { + mailRecipientBuffer_[0] = '\0'; + mailSubjectBuffer_[0] = '\0'; + mailBodyBuffer_[0] = '\0'; + mailComposeMoney_[0] = 0; + mailComposeMoney_[1] = 0; + mailComposeMoney_[2] = 0; + gameHandler.openMailCompose(); + } + ImGui::Separator(); + + if (inbox.empty()) { + ImGui::TextDisabled("No mail."); + } else { + // Two-panel layout: left = mail list, right = selected mail detail + float listWidth = 220.0f; + + // Left panel - mail list + ImGui::BeginChild("MailList", ImVec2(listWidth, 0), true); + for (size_t i = 0; i < inbox.size(); ++i) { + const auto& mail = inbox[i]; + ImGui::PushID(static_cast(i)); + + bool selected = (gameHandler.getSelectedMailIndex() == static_cast(i)); + std::string label = mail.subject.empty() ? "(No Subject)" : mail.subject; + + // Unread indicator + if (!mail.read) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 0.5f, 1.0f)); + } + + if (ImGui::Selectable(label.c_str(), selected)) { + gameHandler.setSelectedMailIndex(static_cast(i)); + // Mark as read + if (!mail.read) { + gameHandler.mailMarkAsRead(mail.messageId); + } + } + + if (!mail.read) { + ImGui::PopStyleColor(); + } + + // Sub-info line + ImGui::TextColored(kColorGray, " From: %s", mail.senderName.c_str()); + if (mail.money > 0) { + ImGui::SameLine(); + ImGui::TextColored(colors::kWarmGold, " [G]"); + } + if (!mail.attachments.empty()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), " [A]"); + } + // Expiry warning if within 3 days + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + if (secsLeft < 3.0f * 86400.0f && secsLeft > 0.0f) { + ImGui::SameLine(); + int daysLeft = static_cast(secsLeft / 86400.0f); + if (daysLeft == 0) { + ImGui::TextColored(colors::kBrightRed, " [expires today!]"); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), + " [expires in %dd]", daysLeft); + } + } + } + + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::SameLine(); + + // Right panel - selected mail detail + ImGui::BeginChild("MailDetail", ImVec2(0, 0), true); + int sel = gameHandler.getSelectedMailIndex(); + if (sel >= 0 && sel < static_cast(inbox.size())) { + const auto& mail = inbox[sel]; + + ImGui::TextColored(colors::kWarmGold, "%s", + mail.subject.empty() ? "(No Subject)" : mail.subject.c_str()); + ImGui::Text("From: %s", mail.senderName.c_str()); + + if (mail.messageType == 2) { + ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.2f, 1.0f), "[Auction House]"); + } + + // Show expiry date in the detail panel + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + // Format absolute expiry as a date using struct tm + time_t expT = static_cast(mail.expirationTime); + struct tm* tmExp = std::localtime(&expT); + if (tmExp) { + const char* mname = kMonthAbbrev[tmExp->tm_mon]; + int daysLeft = static_cast(secsLeft / 86400.0f); + if (secsLeft <= 0.0f) { + ImGui::TextColored(kColorGray, + "Expired: %s %d, %d", mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } else if (secsLeft < 3.0f * 86400.0f) { + ImGui::TextColored(kColorRed, + "Expires: %s %d, %d (%d day%s!)", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year, + daysLeft, daysLeft == 1 ? "" : "s"); + } else { + ImGui::TextDisabled("Expires: %s %d, %d", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } + } + } + ImGui::Separator(); + + // Body text + if (!mail.body.empty()) { + ImGui::TextWrapped("%s", mail.body.c_str()); + ImGui::Separator(); + } + + // Money + if (mail.money > 0) { + ImGui::TextDisabled("Money:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(mail.money); + ImGui::SameLine(); + if (ImGui::SmallButton("Take Money")) { + gameHandler.mailTakeMoney(mail.messageId); + } + } + + // COD warning + if (mail.cod > 0) { + uint64_t g = mail.cod / 10000; + uint64_t s = (mail.cod / 100) % 100; + uint64_t c = mail.cod % 100; + ImGui::TextColored(kColorRed, + "COD: %llug %llus %lluc (you pay this to take items)", + static_cast(g), + static_cast(s), + static_cast(c)); + } + + // Attachments + if (!mail.attachments.empty()) { + ImGui::Text("Attachments: %zu", mail.attachments.size()); + ImDrawList* mailDraw = ImGui::GetWindowDrawList(); + constexpr float MAIL_SLOT = 34.0f; + for (size_t j = 0; j < mail.attachments.size(); ++j) { + const auto& att = mail.attachments[j]; + ImGui::PushID(static_cast(j)); + + auto* info = gameHandler.getItemInfo(att.itemId); + game::ItemQuality quality = game::ItemQuality::COMMON; + std::string name = "Item " + std::to_string(att.itemId); + uint32_t displayInfoId = 0; + if (info && info->valid) { + quality = static_cast(info->quality); + name = info->name; + displayInfoId = info->displayInfoId; + } else { + gameHandler.ensureItemInfo(att.itemId); + } + ImVec4 qc = InventoryScreen::getQualityColor(quality); + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + + ImVec2 pos = ImGui::GetCursorScreenPos(); + VkDescriptorSet iconTex = displayInfoId + ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; + if (iconTex) { + mailDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT)); + mailDraw->AddRect(pos, ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + borderCol, 0.0f, 0, 1.5f); + } else { + mailDraw->AddRectFilled(pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + IM_COL32(40, 35, 30, 220)); + mailDraw->AddRect(pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + borderCol, 0.0f, 0, 1.5f); + } + if (att.stackCount > 1) { + char cnt[16]; + snprintf(cnt, sizeof(cnt), "%u", att.stackCount); + float cw = ImGui::CalcTextSize(cnt).x; + mailDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), + IM_COL32(0, 0, 0, 200), cnt); + mailDraw->AddText( + ImVec2(pos.x + MAIL_SLOT - cw - 2.0f, pos.y + MAIL_SLOT - 14.0f), + IM_COL32(255, 255, 255, 220), cnt); + } + + ImGui::InvisibleButton("##mailatt", ImVec2(MAIL_SLOT, MAIL_SLOT)); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + ImGui::SameLine(); + ImGui::TextColored(qc, "%s", name.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Take")) { + gameHandler.mailTakeItem(mail.messageId, att.itemGuidLow); + } + + ImGui::PopID(); + } + // "Take All" button when there are multiple attachments + if (mail.attachments.size() > 1) { + if (ImGui::SmallButton("Take All")) { + for (const auto& att2 : mail.attachments) { + gameHandler.mailTakeItem(mail.messageId, att2.itemGuidLow); + } + } + } + } + + ImGui::Spacing(); + ImGui::Separator(); + + // Action buttons + if (ImGui::Button("Delete")) { + gameHandler.mailDelete(mail.messageId); + } + ImGui::SameLine(); + if (mail.messageType == 0 && ImGui::Button("Reply")) { + // Pre-fill compose with sender as recipient + strncpy(mailRecipientBuffer_, mail.senderName.c_str(), sizeof(mailRecipientBuffer_) - 1); + mailRecipientBuffer_[sizeof(mailRecipientBuffer_) - 1] = '\0'; + std::string reSubject = "Re: " + mail.subject; + strncpy(mailSubjectBuffer_, reSubject.c_str(), sizeof(mailSubjectBuffer_) - 1); + mailSubjectBuffer_[sizeof(mailSubjectBuffer_) - 1] = '\0'; + mailBodyBuffer_[0] = '\0'; + mailComposeMoney_[0] = 0; + mailComposeMoney_[1] = 0; + mailComposeMoney_[2] = 0; + gameHandler.openMailCompose(); + } + } else { + ImGui::TextDisabled("Select a mail to read."); + } + ImGui::EndChild(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeMailbox(); + } +} + +void WindowManager::renderMailComposeWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen) { + if (!gameHandler.isMailComposeOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 250), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(400, 500), ImGuiCond_Appearing); + + bool open = true; + if (ImGui::Begin("Send Mail", &open)) { + ImGui::Text("To:"); + ImGui::SameLine(60); + ImGui::SetNextItemWidth(-1); + ImGui::InputText("##MailTo", mailRecipientBuffer_, sizeof(mailRecipientBuffer_)); + + ImGui::Text("Subject:"); + ImGui::SameLine(60); + ImGui::SetNextItemWidth(-1); + ImGui::InputText("##MailSubject", mailSubjectBuffer_, sizeof(mailSubjectBuffer_)); + + ImGui::Text("Body:"); + ImGui::InputTextMultiline("##MailBody", mailBodyBuffer_, sizeof(mailBodyBuffer_), + ImVec2(-1, 120)); + + // Attachments section + int attachCount = gameHandler.getMailAttachmentCount(); + ImGui::Text("Attachments (%d/12):", attachCount); + ImGui::SameLine(); + ImGui::TextColored(kColorGray, "Right-click items in bags to attach"); + + const auto& attachments = gameHandler.getMailAttachments(); + // Show attachment slots in a grid (6 per row) + for (int i = 0; i < game::GameHandler::MAIL_MAX_ATTACHMENTS; ++i) { + if (i % 6 != 0) ImGui::SameLine(); + ImGui::PushID(i + 5000); + const auto& att = attachments[i]; + if (att.occupied()) { + // Show item with quality color border + ImVec4 qualColor = ui::InventoryScreen::getQualityColor(att.item.quality); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qualColor.x * 0.3f, qualColor.y * 0.3f, qualColor.z * 0.3f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qualColor.x * 0.5f, qualColor.y * 0.5f, qualColor.z * 0.5f, 0.9f)); + + // Try to show icon + VkDescriptorSet icon = inventoryScreen.getItemIcon(att.item.displayInfoId); + bool clicked = false; + if (icon) { + clicked = ImGui::ImageButton("##att", (ImTextureID)icon, ImVec2(30, 30)); + } else { + // Truncate name to fit + std::string label = att.item.name.substr(0, 4); + clicked = ImGui::Button(label.c_str(), ImVec2(36, 36)); + } + ImGui::PopStyleColor(2); + + if (clicked) { + gameHandler.detachMailAttachment(i); + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextColored(qualColor, "%s", att.item.name.c_str()); + ImGui::TextColored(ui::colors::kLightGray, "Click to remove"); + ImGui::EndTooltip(); + } + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.5f)); + ImGui::Button("##empty", ImVec2(36, 36)); + ImGui::PopStyleColor(); + } + ImGui::PopID(); + } + + ImGui::Spacing(); + ImGui::Text("Money:"); + ImGui::SameLine(60); + ImGui::SetNextItemWidth(60); + ImGui::InputInt("##MailGold", &mailComposeMoney_[0], 0, 0); + if (mailComposeMoney_[0] < 0) mailComposeMoney_[0] = 0; + ImGui::SameLine(); + ImGui::Text("g"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(40); + ImGui::InputInt("##MailSilver", &mailComposeMoney_[1], 0, 0); + if (mailComposeMoney_[1] < 0) mailComposeMoney_[1] = 0; + if (mailComposeMoney_[1] > 99) mailComposeMoney_[1] = 99; + ImGui::SameLine(); + ImGui::Text("s"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(40); + ImGui::InputInt("##MailCopper", &mailComposeMoney_[2], 0, 0); + if (mailComposeMoney_[2] < 0) mailComposeMoney_[2] = 0; + if (mailComposeMoney_[2] > 99) mailComposeMoney_[2] = 99; + ImGui::SameLine(); + ImGui::Text("c"); + + uint64_t totalMoney = static_cast(mailComposeMoney_[0]) * 10000 + + static_cast(mailComposeMoney_[1]) * 100 + + static_cast(mailComposeMoney_[2]); + + uint32_t sendCost = attachCount > 0 ? static_cast(30 * attachCount) : 30u; + ImGui::TextColored(kColorGray, "Sending cost: %uc", sendCost); + + ImGui::Spacing(); + bool canSend = (strlen(mailRecipientBuffer_) > 0); + if (!canSend) ImGui::BeginDisabled(); + if (ImGui::Button("Send", ImVec2(80, 0))) { + gameHandler.sendMail(mailRecipientBuffer_, mailSubjectBuffer_, + mailBodyBuffer_, totalMoney); + } + if (!canSend) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + gameHandler.closeMailCompose(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeMailCompose(); + } +} + +void WindowManager::renderBankWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.isBankOpen()) return; + + bool open = true; + ImGui::SetNextWindowSize(ImVec2(480, 420), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Bank", &open)) { + ImGui::End(); + if (!open) gameHandler.closeBank(); + return; + } + + auto& inv = gameHandler.getInventory(); + bool isHolding = inventoryScreen.isHoldingItem(); + constexpr float SLOT_SIZE = 42.0f; + static constexpr float kBankPickupHold = 0.10f; // seconds + // Persistent pickup tracking for bank (mirrors inventory_screen's pickupPending_) + static bool bankPickupPending = false; + static float bankPickupPressTime = 0.0f; + static int bankPickupType = 0; // 0=main bank, 1=bank bag slot, 2=bank bag equip slot + static int bankPickupIndex = -1; + static int bankPickupBagIndex = -1; + static int bankPickupBagSlotIndex = -1; + + // Helper: render a bank item slot with icon, click-and-hold pickup, drop, tooltip + auto renderBankItemSlot = [&](const game::ItemSlot& slot, int pickType, int mainIdx, + int bagIdx, int bagSlotIdx, uint8_t dstBag, uint8_t dstSlot) { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + ImVec2 pos = ImGui::GetCursorScreenPos(); + + if (slot.empty()) { + ImU32 bgCol = IM_COL32(30, 30, 30, 200); + ImU32 borderCol = IM_COL32(60, 60, 60, 200); + if (isHolding) { + bgCol = IM_COL32(20, 50, 20, 200); + borderCol = IM_COL32(0, 180, 0, 200); + } + drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol); + drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), borderCol); + ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE)); + if (isHolding && ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot); + } + } else { + const auto& item = slot.item; + ImVec4 qc = InventoryScreen::getQualityColor(item.quality); + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(item.displayInfoId); + + if (iconTex) { + drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE)); + drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), + borderCol, 0.0f, 0, 2.0f); + } else { + ImU32 bgCol = IM_COL32(40, 35, 30, 220); + drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol); + drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), + borderCol, 0.0f, 0, 2.0f); + if (!item.name.empty()) { + char abbr[3] = { item.name[0], item.name.size() > 1 ? item.name[1] : '\0', '\0' }; + float tw = ImGui::CalcTextSize(abbr).x; + drawList->AddText(ImVec2(pos.x + (SLOT_SIZE - tw) * 0.5f, pos.y + 2.0f), + ImGui::ColorConvertFloat4ToU32(qc), abbr); + } + } + + if (item.stackCount > 1) { + char countStr[16]; + snprintf(countStr, sizeof(countStr), "%u", item.stackCount); + float cw = ImGui::CalcTextSize(countStr).x; + drawList->AddText(ImVec2(pos.x + SLOT_SIZE - cw - 2.0f, pos.y + SLOT_SIZE - 14.0f), + IM_COL32(255, 255, 255, 220), countStr); + } + + ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE)); + + if (!isHolding) { + // Start pickup tracking on mouse press + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + bankPickupPending = true; + bankPickupPressTime = ImGui::GetTime(); + bankPickupType = pickType; + bankPickupIndex = mainIdx; + bankPickupBagIndex = bagIdx; + bankPickupBagSlotIndex = bagSlotIdx; + } + // Check if held long enough to pick up + if (bankPickupPending && ImGui::IsMouseDown(ImGuiMouseButton_Left) && + (ImGui::GetTime() - bankPickupPressTime) >= kBankPickupHold) { + bool sameSlot = (bankPickupType == pickType); + if (pickType == 0) + sameSlot = sameSlot && (bankPickupIndex == mainIdx); + else if (pickType == 1) + sameSlot = sameSlot && (bankPickupBagIndex == bagIdx) && (bankPickupBagSlotIndex == bagSlotIdx); + else if (pickType == 2) + sameSlot = sameSlot && (bankPickupIndex == mainIdx); + + if (sameSlot && ImGui::IsItemHovered()) { + bankPickupPending = false; + if (pickType == 0) { + inventoryScreen.pickupFromBank(inv, mainIdx); + } else if (pickType == 1) { + inventoryScreen.pickupFromBankBag(inv, bagIdx, bagSlotIdx); + } else if (pickType == 2) { + inventoryScreen.pickupFromBankBagEquip(inv, mainIdx); + } + } + } + } else { + // Drop/swap on mouse release + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot); + } + } + + // Tooltip + if (ImGui::IsItemHovered() && !isHolding) { + auto* info = gameHandler.getItemInfo(item.itemId); + if (info && info->valid) + inventoryScreen.renderItemTooltip(*info); + else { + ImGui::BeginTooltip(); + ImGui::TextColored(qc, "%s", item.name.c_str()); + ImGui::EndTooltip(); + } + + // Shift-click to insert item link into chat + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift + && !item.name.empty()) { + auto* info2 = gameHandler.getItemInfo(item.itemId); + uint8_t q = (info2 && info2->valid) + ? static_cast(info2->quality) + : static_cast(item.quality); + const std::string& lname = (info2 && info2->valid && !info2->name.empty()) + ? info2->name : item.name; + std::string link = buildItemChatLink(item.itemId, q, lname); + chatPanel.insertChatLink(link); + } + } + } + }; + + // Main bank slots (24 for Classic, 28 for TBC/WotLK) + int bankSlotCount = gameHandler.getEffectiveBankSlots(); + int bankBagCount = gameHandler.getEffectiveBankBagSlots(); + ImGui::Text("Bank Slots"); + ImGui::Separator(); + for (int i = 0; i < bankSlotCount; i++) { + if (i % 7 != 0) ImGui::SameLine(); + ImGui::PushID(i + 1000); + renderBankItemSlot(inv.getBankSlot(i), 0, i, -1, -1, 0xFF, static_cast(39 + i)); + ImGui::PopID(); + } + + // Bank bag equip slots — show bag icon with pickup/drop, or "Buy Slot" + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Text("Bank Bags"); + uint8_t purchased = inv.getPurchasedBankBagSlots(); + for (int i = 0; i < bankBagCount; i++) { + if (i > 0) ImGui::SameLine(); + ImGui::PushID(i + 2000); + + int bagSize = inv.getBankBagSize(i); + if (i < purchased || bagSize > 0) { + const auto& bagSlot = inv.getBankBagItem(i); + // Render as an item slot: icon with pickup/drop (pickType=2 for bag equip) + renderBankItemSlot(bagSlot, 2, i, -1, -1, 0xFF, static_cast(67 + i)); + } else { + if (ImGui::Button("Buy Slot", ImVec2(50, 30))) { + gameHandler.buyBankSlot(); + } + } + ImGui::PopID(); + } + + // Show expanded bank bag contents + for (int bagIdx = 0; bagIdx < bankBagCount; bagIdx++) { + int bagSize = inv.getBankBagSize(bagIdx); + if (bagSize <= 0) continue; + + ImGui::Spacing(); + ImGui::Text("Bank Bag %d (%d slots)", bagIdx + 1, bagSize); + for (int s = 0; s < bagSize; s++) { + if (s % 7 != 0) ImGui::SameLine(); + ImGui::PushID(3000 + bagIdx * 100 + s); + renderBankItemSlot(inv.getBankBagSlot(bagIdx, s), 1, -1, bagIdx, s, + static_cast(67 + bagIdx), static_cast(s)); + ImGui::PopID(); + } + } + + ImGui::End(); + + if (!open) gameHandler.closeBank(); +} + +void WindowManager::renderGuildBankWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.isGuildBankOpen()) return; + + bool open = true; + ImGui::SetNextWindowSize(ImVec2(520, 500), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Guild Bank", &open)) { + ImGui::End(); + if (!open) gameHandler.closeGuildBank(); + return; + } + + const auto& data = gameHandler.getGuildBankData(); + uint8_t activeTab = gameHandler.getGuildBankActiveTab(); + + // Money display + uint32_t gold = static_cast(data.money / 10000); + uint32_t silver = static_cast((data.money / 100) % 100); + uint32_t copper = static_cast(data.money % 100); + ImGui::TextDisabled("Guild Bank Money:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); + + // Tab bar + if (!data.tabs.empty()) { + for (size_t i = 0; i < data.tabs.size(); i++) { + if (i > 0) ImGui::SameLine(); + bool selected = (i == activeTab); + if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f)); + std::string tabLabel = data.tabs[i].tabName.empty() ? ("Tab " + std::to_string(i + 1)) : data.tabs[i].tabName; + if (ImGui::Button(tabLabel.c_str())) { + gameHandler.queryGuildBankTab(static_cast(i)); + } + if (selected) ImGui::PopStyleColor(); + } + } + + // Buy tab button + if (data.tabs.size() < 6) { + ImGui::SameLine(); + if (ImGui::Button("Buy Tab")) { + gameHandler.buyGuildBankTab(); + } + } + + ImGui::Separator(); + + // Tab items (98 slots = 14 columns × 7 rows) + constexpr float GB_SLOT = 34.0f; + ImDrawList* gbDraw = ImGui::GetWindowDrawList(); + for (size_t i = 0; i < data.tabItems.size(); i++) { + if (i % 14 != 0) ImGui::SameLine(0.0f, 2.0f); + const auto& item = data.tabItems[i]; + ImGui::PushID(static_cast(i) + 5000); + + ImVec2 pos = ImGui::GetCursorScreenPos(); + + if (item.itemEntry == 0) { + gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(30, 30, 30, 200)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(60, 60, 60, 180)); + ImGui::InvisibleButton("##gbempty", ImVec2(GB_SLOT, GB_SLOT)); + } else { + auto* info = gameHandler.getItemInfo(item.itemEntry); + game::ItemQuality quality = game::ItemQuality::COMMON; + std::string name = "Item " + std::to_string(item.itemEntry); + uint32_t displayInfoId = 0; + if (info) { + quality = static_cast(info->quality); + name = info->name; + displayInfoId = info->displayInfoId; + } + ImVec4 qc = InventoryScreen::getQualityColor(quality); + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + + VkDescriptorSet iconTex = displayInfoId ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; + if (iconTex) { + gbDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + borderCol, 0.0f, 0, 1.5f); + } else { + gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(40, 35, 30, 220)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + borderCol, 0.0f, 0, 1.5f); + if (!name.empty() && name[0] != 'I') { + char abbr[3] = { name[0], name.size() > 1 ? name[1] : '\0', '\0' }; + float tw = ImGui::CalcTextSize(abbr).x; + gbDraw->AddText(ImVec2(pos.x + (GB_SLOT - tw) * 0.5f, pos.y + 2.0f), + borderCol, abbr); + } + } + + if (item.stackCount > 1) { + char cnt[16]; + snprintf(cnt, sizeof(cnt), "%u", item.stackCount); + float cw = ImGui::CalcTextSize(cnt).x; + gbDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), IM_COL32(0, 0, 0, 200), cnt); + gbDraw->AddText(ImVec2(pos.x + GB_SLOT - cw - 2.0f, pos.y + GB_SLOT - 14.0f), + IM_COL32(255, 255, 255, 220), cnt); + } + + ImGui::InvisibleButton("##gbslot", ImVec2(GB_SLOT, GB_SLOT)); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::GetIO().KeyShift) { + gameHandler.guildBankWithdrawItem(activeTab, item.slotId, 0xFF, 0); + } + if (ImGui::IsItemHovered()) { + if (info && info->valid) + inventoryScreen.renderItemTooltip(*info); + // Shift-click to insert item link into chat + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift + && !name.empty() && item.itemEntry != 0) { + uint8_t q = static_cast(quality); + std::string link = buildItemChatLink(item.itemEntry, q, name); + chatPanel.insertChatLink(link); + } + } + } + ImGui::PopID(); + } + + // Money deposit/withdraw + ImGui::Separator(); + ImGui::Text("Money:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + ImGui::InputInt("##gbg", &guildBankMoneyInput_[0], 0); ImGui::SameLine(); ImGui::Text("g"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(40); + ImGui::InputInt("##gbs", &guildBankMoneyInput_[1], 0); ImGui::SameLine(); ImGui::Text("s"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(40); + ImGui::InputInt("##gbc", &guildBankMoneyInput_[2], 0); ImGui::SameLine(); ImGui::Text("c"); + + ImGui::SameLine(); + if (ImGui::Button("Deposit")) { + uint32_t amount = guildBankMoneyInput_[0] * 10000 + guildBankMoneyInput_[1] * 100 + guildBankMoneyInput_[2]; + if (amount > 0) gameHandler.depositGuildBankMoney(amount); + } + ImGui::SameLine(); + if (ImGui::Button("Withdraw")) { + uint32_t amount = guildBankMoneyInput_[0] * 10000 + guildBankMoneyInput_[1] * 100 + guildBankMoneyInput_[2]; + if (amount > 0) gameHandler.withdrawGuildBankMoney(amount); + } + + if (data.withdrawAmount >= 0) { + ImGui::Text("Remaining withdrawals: %d", data.withdrawAmount); + } + + ImGui::End(); + + if (!open) gameHandler.closeGuildBank(); +} + +void WindowManager::renderAuctionHouseWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.isAuctionHouseOpen()) return; + + bool open = true; + ImGui::SetNextWindowSize(ImVec2(650, 500), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Auction House", &open)) { + ImGui::End(); + if (!open) gameHandler.closeAuctionHouse(); + return; + } + + int tab = gameHandler.getAuctionActiveTab(); + + // Tab buttons + const char* tabNames[] = {"Browse", "Bids", "Auctions"}; + for (int i = 0; i < 3; i++) { + if (i > 0) ImGui::SameLine(); + bool selected = (tab == i); + if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f)); + if (ImGui::Button(tabNames[i], ImVec2(100, 0))) { + gameHandler.setAuctionActiveTab(i); + if (i == 1) gameHandler.auctionListBidderItems(); + else if (i == 2) gameHandler.auctionListOwnerItems(); + } + if (selected) ImGui::PopStyleColor(); + } + + ImGui::Separator(); + + if (tab == 0) { + // Browse tab - Search filters + + // --- Helper: resolve current UI filter state into wire-format search params --- + // WoW 3.3.5a item class IDs: + // 0=Consumable, 1=Container, 2=Weapon, 3=Gem, 4=Armor, + // 7=Projectile/TradeGoods, 9=Recipe, 11=Quiver, 15=Miscellaneous + struct AHClassMapping { const char* label; uint32_t classId; }; + static const AHClassMapping classMappings[] = { + {"All", 0xFFFFFFFF}, + {"Weapon", 2}, + {"Armor", 4}, + {"Container", 1}, + {"Consumable", 0}, + {"Trade Goods", 7}, + {"Gem", 3}, + {"Recipe", 9}, + {"Quiver", 11}, + {"Miscellaneous", 15}, + }; + static constexpr int NUM_CLASSES = 10; + + // Weapon subclass IDs (WoW 3.3.5a) + struct AHSubMapping { const char* label; uint32_t subId; }; + static const AHSubMapping weaponSubs[] = { + {"All", 0xFFFFFFFF}, {"Axe (1H)", 0}, {"Axe (2H)", 1}, {"Bow", 2}, + {"Gun", 3}, {"Mace (1H)", 4}, {"Mace (2H)", 5}, {"Polearm", 6}, + {"Sword (1H)", 7}, {"Sword (2H)", 8}, {"Staff", 10}, + {"Fist Weapon", 13}, {"Dagger", 15}, {"Thrown", 16}, + {"Crossbow", 18}, {"Wand", 19}, + }; + static constexpr int NUM_WEAPON_SUBS = 16; + + // Armor subclass IDs + static const AHSubMapping armorSubs[] = { + {"All", 0xFFFFFFFF}, {"Cloth", 1}, {"Leather", 2}, {"Mail", 3}, + {"Plate", 4}, {"Shield", 6}, {"Miscellaneous", 0}, + }; + static constexpr int NUM_ARMOR_SUBS = 7; + + auto getSearchClassId = [&]() -> uint32_t { + if (auctionItemClass_ < 0 || auctionItemClass_ >= NUM_CLASSES) return 0xFFFFFFFF; + return classMappings[auctionItemClass_].classId; + }; + + auto getSearchSubClassId = [&]() -> uint32_t { + if (auctionItemSubClass_ < 0) return 0xFFFFFFFF; + uint32_t cid = getSearchClassId(); + if (cid == 2 && auctionItemSubClass_ < NUM_WEAPON_SUBS) + return weaponSubs[auctionItemSubClass_].subId; + if (cid == 4 && auctionItemSubClass_ < NUM_ARMOR_SUBS) + return armorSubs[auctionItemSubClass_].subId; + return 0xFFFFFFFF; + }; + + auto doSearch = [&](uint32_t offset) { + auctionBrowseOffset_ = offset; + if (auctionLevelMin_ < 0) auctionLevelMin_ = 0; + if (auctionLevelMax_ < 0) auctionLevelMax_ = 0; + uint32_t q = auctionQuality_ > 0 ? static_cast(auctionQuality_ - 1) : 0xFFFFFFFF; + gameHandler.auctionSearch(auctionSearchName_, + static_cast(auctionLevelMin_), + static_cast(auctionLevelMax_), + q, getSearchClassId(), getSearchSubClassId(), 0, + auctionUsableOnly_ ? 1 : 0, offset); + }; + + // Row 1: Name + Level range + ImGui::SetNextItemWidth(200); + bool enterPressed = ImGui::InputText("Name", auctionSearchName_, sizeof(auctionSearchName_), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + ImGui::SetNextItemWidth(50); + ImGui::InputInt("Min Lv", &auctionLevelMin_, 0); + ImGui::SameLine(); + ImGui::SetNextItemWidth(50); + ImGui::InputInt("Max Lv", &auctionLevelMax_, 0); + + // Row 2: Quality + Category + Subcategory + Search button + const char* qualities[] = {"All", "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary"}; + ImGui::SetNextItemWidth(100); + ImGui::Combo("Quality", &auctionQuality_, qualities, 7); + + ImGui::SameLine(); + // Build class label list from mappings + const char* classLabels[NUM_CLASSES]; + for (int c = 0; c < NUM_CLASSES; c++) classLabels[c] = classMappings[c].label; + ImGui::SetNextItemWidth(120); + int classIdx = auctionItemClass_ < 0 ? 0 : auctionItemClass_; + if (ImGui::Combo("Category", &classIdx, classLabels, NUM_CLASSES)) { + if (classIdx != auctionItemClass_) auctionItemSubClass_ = -1; + auctionItemClass_ = classIdx; + } + + // Subcategory (only for Weapon and Armor) + uint32_t curClassId = getSearchClassId(); + if (curClassId == 2 || curClassId == 4) { + const AHSubMapping* subs = (curClassId == 2) ? weaponSubs : armorSubs; + int numSubs = (curClassId == 2) ? NUM_WEAPON_SUBS : NUM_ARMOR_SUBS; + const char* subLabels[20]; + for (int s = 0; s < numSubs && s < 20; s++) subLabels[s] = subs[s].label; + int subIdx = auctionItemSubClass_ + 1; // -1 → 0 ("All") + if (subIdx < 0 || subIdx >= numSubs) subIdx = 0; + ImGui::SameLine(); + ImGui::SetNextItemWidth(110); + if (ImGui::Combo("Subcat", &subIdx, subLabels, numSubs)) { + auctionItemSubClass_ = subIdx - 1; // 0 → -1 ("All") + } + } + + ImGui::SameLine(); + ImGui::Checkbox("Usable", &auctionUsableOnly_); + ImGui::SameLine(); + float delay = gameHandler.getAuctionSearchDelay(); + if (delay > 0.0f) { + char delayBuf[32]; + snprintf(delayBuf, sizeof(delayBuf), "Search (%.0fs)", delay); + ImGui::BeginDisabled(); + ImGui::Button(delayBuf); + ImGui::EndDisabled(); + } else { + if (ImGui::Button("Search") || enterPressed) { + doSearch(0); + } + } + + ImGui::Separator(); + + // Results table + const auto& results = gameHandler.getAuctionBrowseResults(); + constexpr uint32_t AH_PAGE_SIZE = 50; + ImGui::Text("%zu results (of %u total)", results.auctions.size(), results.totalCount); + + // Pagination + if (results.totalCount > AH_PAGE_SIZE) { + ImGui::SameLine(); + uint32_t page = auctionBrowseOffset_ / AH_PAGE_SIZE + 1; + uint32_t totalPages = (results.totalCount + AH_PAGE_SIZE - 1) / AH_PAGE_SIZE; + + if (auctionBrowseOffset_ == 0) ImGui::BeginDisabled(); + if (ImGui::SmallButton("< Prev")) { + uint32_t newOff = (auctionBrowseOffset_ >= AH_PAGE_SIZE) ? auctionBrowseOffset_ - AH_PAGE_SIZE : 0; + doSearch(newOff); + } + if (auctionBrowseOffset_ == 0) ImGui::EndDisabled(); + + ImGui::SameLine(); + ImGui::Text("Page %u/%u", page, totalPages); + + ImGui::SameLine(); + if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Next >")) { + doSearch(auctionBrowseOffset_ + AH_PAGE_SIZE); + } + if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::EndDisabled(); + } + + if (ImGui::BeginChild("AuctionResults", ImVec2(0, -110), true)) { + if (ImGui::BeginTable("AuctionTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableSetupColumn("Bid", ImGuiTableColumnFlags_WidthFixed, 90); + ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); + ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < results.auctions.size(); i++) { + const auto& auction = results.auctions[i]; + auto* info = gameHandler.getItemInfo(auction.itemEntry); + std::string name = info ? info->name : ("Item #" + std::to_string(auction.itemEntry)); + // Append random suffix name (e.g., "of the Eagle") if present + if (auction.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(auction.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } + game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; + ImVec4 qc = InventoryScreen::getQualityColor(quality); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + // Item icon + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(qc, "%s", name.c_str()); + // Item tooltip on hover; shift-click to insert chat link + if (ImGui::IsItemHovered() && info && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + + ImGui::TableSetColumnIndex(1); + ImGui::Text("%u", auction.stackCount); + + ImGui::TableSetColumnIndex(2); + // Time left display + uint32_t mins = auction.timeLeftMs / 60000; + if (mins > 720) ImGui::Text("Long"); + else if (mins > 120) ImGui::Text("Medium"); + else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short"); + + ImGui::TableSetColumnIndex(3); + { + uint32_t bid = auction.currentBid > 0 ? auction.currentBid : auction.startBid; + renderCoinsFromCopper(bid); + } + + ImGui::TableSetColumnIndex(4); + if (auction.buyoutPrice > 0) { + renderCoinsFromCopper(auction.buyoutPrice); + } else { + ImGui::TextDisabled("--"); + } + + ImGui::TableSetColumnIndex(5); + ImGui::PushID(static_cast(i) + 7000); + if (auction.buyoutPrice > 0 && ImGui::SmallButton("Buy")) { + gameHandler.auctionBuyout(auction.auctionId, auction.buyoutPrice); + } + if (auction.buyoutPrice > 0) ImGui::SameLine(); + if (ImGui::SmallButton("Bid")) { + uint32_t bidAmt = auction.currentBid > 0 + ? auction.currentBid + auction.minBidIncrement + : auction.startBid; + gameHandler.auctionPlaceBid(auction.auctionId, bidAmt); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + } + ImGui::EndChild(); + + // Sell section + ImGui::Separator(); + ImGui::Text("Sell Item:"); + + // Item picker from backpack + { + auto& inv = gameHandler.getInventory(); + // Build list of non-empty backpack slots + std::string preview = (auctionSellSlotIndex_ >= 0) + ? ([&]() -> std::string { + const auto& slot = inv.getBackpackSlot(auctionSellSlotIndex_); + if (!slot.empty()) { + std::string s = slot.item.name; + if (slot.item.stackCount > 1) s += " x" + std::to_string(slot.item.stackCount); + return s; + } + return "Select item..."; + })() + : "Select item..."; + + ImGui::SetNextItemWidth(250); + if (ImGui::BeginCombo("##sellitem", preview.c_str())) { + for (int i = 0; i < game::Inventory::BACKPACK_SLOTS; i++) { + const auto& slot = inv.getBackpackSlot(i); + if (slot.empty()) continue; + ImGui::PushID(i + 9000); + // Item icon + if (slot.item.displayInfoId != 0) { + VkDescriptorSet sIcon = inventoryScreen.getItemIcon(slot.item.displayInfoId); + if (sIcon) { + ImGui::Image((void*)(intptr_t)sIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + std::string label = slot.item.name; + if (slot.item.stackCount > 1) label += " x" + std::to_string(slot.item.stackCount); + ImVec4 iqc = InventoryScreen::getQualityColor(slot.item.quality); + ImGui::PushStyleColor(ImGuiCol_Text, iqc); + if (ImGui::Selectable(label.c_str(), auctionSellSlotIndex_ == i)) { + auctionSellSlotIndex_ = i; + } + ImGui::PopStyleColor(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + } + + ImGui::Text("Bid:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(50); + ImGui::InputInt("##sbg", &auctionSellBid_[0], 0); ImGui::SameLine(); ImGui::Text("g"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(35); + ImGui::InputInt("##sbs", &auctionSellBid_[1], 0); ImGui::SameLine(); ImGui::Text("s"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(35); + ImGui::InputInt("##sbc", &auctionSellBid_[2], 0); ImGui::SameLine(); ImGui::Text("c"); + + ImGui::SameLine(0, 20); + ImGui::Text("Buyout:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(50); + ImGui::InputInt("##sbog", &auctionSellBuyout_[0], 0); ImGui::SameLine(); ImGui::Text("g"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(35); + ImGui::InputInt("##sbos", &auctionSellBuyout_[1], 0); ImGui::SameLine(); ImGui::Text("s"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(35); + ImGui::InputInt("##sboc", &auctionSellBuyout_[2], 0); ImGui::SameLine(); ImGui::Text("c"); + + const char* durations[] = {"12 hours", "24 hours", "48 hours"}; + ImGui::SetNextItemWidth(90); + ImGui::Combo("##dur", &auctionSellDuration_, durations, 3); + ImGui::SameLine(); + + // Create Auction button + bool canCreate = auctionSellSlotIndex_ >= 0 && + !gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_).empty() && + (auctionSellBid_[0] > 0 || auctionSellBid_[1] > 0 || auctionSellBid_[2] > 0); + if (!canCreate) ImGui::BeginDisabled(); + if (ImGui::Button("Create Auction")) { + uint32_t bidCopper = static_cast(auctionSellBid_[0]) * 10000 + + static_cast(auctionSellBid_[1]) * 100 + + static_cast(auctionSellBid_[2]); + uint32_t buyoutCopper = static_cast(auctionSellBuyout_[0]) * 10000 + + static_cast(auctionSellBuyout_[1]) * 100 + + static_cast(auctionSellBuyout_[2]); + const uint32_t durationMins[] = {720, 1440, 2880}; + uint32_t dur = durationMins[auctionSellDuration_]; + uint64_t itemGuid = gameHandler.getBackpackItemGuid(auctionSellSlotIndex_); + const auto& slot = gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_); + uint32_t stackCount = slot.item.stackCount; + if (itemGuid != 0) { + gameHandler.auctionSellItem(itemGuid, stackCount, bidCopper, buyoutCopper, dur); + // Clear sell inputs + auctionSellSlotIndex_ = -1; + auctionSellBid_[0] = auctionSellBid_[1] = auctionSellBid_[2] = 0; + auctionSellBuyout_[0] = auctionSellBuyout_[1] = auctionSellBuyout_[2] = 0; + } + } + if (!canCreate) ImGui::EndDisabled(); + + } else if (tab == 1) { + // Bids tab + const auto& results = gameHandler.getAuctionBidderResults(); + ImGui::Text("Your Bids: %zu items", results.auctions.size()); + + if (ImGui::BeginTable("BidTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); + ImGui::TableSetupColumn("Your Bid", ImGuiTableColumnFlags_WidthFixed, 90); + ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableHeadersRow(); + + for (size_t bi = 0; bi < results.auctions.size(); bi++) { + const auto& a = results.auctions[bi]; + auto* info = gameHandler.getItemInfo(a.itemEntry); + std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); + if (a.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(a.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } + game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; + ImVec4 bqc = InventoryScreen::getQualityColor(quality); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet bIcon = inventoryScreen.getItemIcon(info->displayInfoId); + if (bIcon) { + ImGui::Image((void*)(intptr_t)bIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + // High bidder indicator + bool isHighBidder = (a.bidderGuid != 0 && a.bidderGuid == gameHandler.getPlayerGuid()); + if (isHighBidder) { + ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "[Winning]"); + ImGui::SameLine(); + } else if (a.bidderGuid != 0) { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[Outbid]"); + ImGui::SameLine(); + } + ImGui::TextColored(bqc, "%s", name.c_str()); + // Tooltip and shift-click + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + ImGui::TableSetColumnIndex(1); + ImGui::Text("%u", a.stackCount); + ImGui::TableSetColumnIndex(2); + renderCoinsFromCopper(a.currentBid); + ImGui::TableSetColumnIndex(3); + if (a.buyoutPrice > 0) + renderCoinsFromCopper(a.buyoutPrice); + else + ImGui::TextDisabled("--"); + ImGui::TableSetColumnIndex(4); + uint32_t mins = a.timeLeftMs / 60000; + if (mins > 720) ImGui::Text("Long"); + else if (mins > 120) ImGui::Text("Medium"); + else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short"); + + ImGui::TableSetColumnIndex(5); + ImGui::PushID(static_cast(bi) + 7500); + if (a.buyoutPrice > 0 && ImGui::SmallButton("Buy")) { + gameHandler.auctionBuyout(a.auctionId, a.buyoutPrice); + } + if (a.buyoutPrice > 0) ImGui::SameLine(); + if (ImGui::SmallButton("Bid")) { + uint32_t bidAmt = a.currentBid > 0 + ? a.currentBid + a.minBidIncrement + : a.startBid; + gameHandler.auctionPlaceBid(a.auctionId, bidAmt); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + + } else if (tab == 2) { + // Auctions tab (your listings) + const auto& results = gameHandler.getAuctionOwnerResults(); + ImGui::Text("Your Auctions: %zu items", results.auctions.size()); + + if (ImGui::BeginTable("OwnerTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); + ImGui::TableSetupColumn("Bid", ImGuiTableColumnFlags_WidthFixed, 90); + ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); + ImGui::TableSetupColumn("##cancel", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < results.auctions.size(); i++) { + const auto& a = results.auctions[i]; + auto* info = gameHandler.getItemInfo(a.itemEntry); + std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); + if (a.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(a.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } + game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImVec4 oqc = InventoryScreen::getQualityColor(quality); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet oIcon = inventoryScreen.getItemIcon(info->displayInfoId); + if (oIcon) { + ImGui::Image((void*)(intptr_t)oIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + // Bid activity indicator for seller + if (a.bidderGuid != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "[Bid]"); + ImGui::SameLine(); + } + ImGui::TextColored(oqc, "%s", name.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + ImGui::TableSetColumnIndex(1); + ImGui::Text("%u", a.stackCount); + ImGui::TableSetColumnIndex(2); + { + uint32_t bid = a.currentBid > 0 ? a.currentBid : a.startBid; + renderCoinsFromCopper(bid); + } + ImGui::TableSetColumnIndex(3); + if (a.buyoutPrice > 0) + renderCoinsFromCopper(a.buyoutPrice); + else + ImGui::TextDisabled("--"); + ImGui::TableSetColumnIndex(4); + ImGui::PushID(static_cast(i) + 8000); + if (ImGui::SmallButton("Cancel")) { + gameHandler.auctionCancelItem(a.auctionId); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + } + + ImGui::End(); + + if (!open) gameHandler.closeAuctionHouse(); +} + +void WindowManager::renderInstanceLockouts(game::GameHandler& gameHandler) { + if (!showInstanceLockouts_) return; + + ImGui::SetNextWindowSize(ImVec2(480, 0), ImGuiCond_Appearing); + ImGui::SetNextWindowPos( + ImVec2(ImGui::GetIO().DisplaySize.x / 2 - 240, 140), ImGuiCond_Appearing); + + if (!ImGui::Begin("Instance Lockouts", &showInstanceLockouts_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + const auto& lockouts = gameHandler.getInstanceLockouts(); + + if (lockouts.empty()) { + ImGui::TextColored(kColorGray, "No active instance lockouts."); + } else { + auto difficultyLabel = [](uint32_t diff) -> const char* { + switch (diff) { + case 0: return "Normal"; + case 1: return "Heroic"; + case 2: return "25-Man"; + case 3: return "25-Man Heroic"; + default: return "Unknown"; + } + }; + + // Current UTC time for reset countdown + auto nowSec = static_cast(std::time(nullptr)); + + if (ImGui::BeginTable("lockouts", 4, + ImGuiTableFlags_SizingStretchProp | + ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter)) { + ImGui::TableSetupColumn("Instance", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Difficulty", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Resets In", ImGuiTableColumnFlags_WidthFixed, 100.0f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableHeadersRow(); + + for (const auto& lo : lockouts) { + ImGui::TableNextRow(); + + // Instance name — use GameHandler's Map.dbc cache (avoids duplicate DBC load) + ImGui::TableSetColumnIndex(0); + std::string mapName = gameHandler.getMapName(lo.mapId); + if (!mapName.empty()) { + ImGui::TextUnformatted(mapName.c_str()); + } else { + ImGui::Text("Map %u", lo.mapId); + } + + // Difficulty + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(difficultyLabel(lo.difficulty)); + + // Reset countdown + ImGui::TableSetColumnIndex(2); + if (lo.resetTime > nowSec) { + uint64_t remaining = lo.resetTime - nowSec; + uint64_t days = remaining / 86400; + uint64_t hours = (remaining % 86400) / 3600; + if (days > 0) { + ImGui::Text("%llud %lluh", + static_cast(days), + static_cast(hours)); + } else { + uint64_t mins = (remaining % 3600) / 60; + ImGui::Text("%lluh %llum", + static_cast(hours), + static_cast(mins)); + } + } else { + ImGui::TextColored(kColorDarkGray, "Expired"); + } + + // Locked / Extended status + ImGui::TableSetColumnIndex(3); + if (lo.extended) { + ImGui::TextColored(ImVec4(0.3f, 0.7f, 1.0f, 1.0f), "Ext"); + } else if (lo.locked) { + ImGui::TextColored(colors::kSoftRed, "Locked"); + } else { + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "Open"); + } + } + + ImGui::EndTable(); + } + } + + ImGui::End(); +} + +// ============================================================================ +// Battleground score frame +// +// Displays the current score for the player's battleground using world states. +// Shown in the top-centre of the screen whenever SMSG_INIT_WORLD_STATES has +// been received for a known BG map. The layout adapts per battleground: +// +// WSG 489 – Alliance / Horde flag captures (max 3) +// AB 529 – Alliance / Horde resource scores (max 1600) +// AV 30 – Alliance / Horde reinforcements +// EotS 566 – Alliance / Horde resource scores (max 1600) +// ============================================================================ +// ─── Who Results Window ─────────────────────────────────────────────────────── +// ─── Combat Log Window ──────────────────────────────────────────────────────── +// ─── Achievement Window ─────────────────────────────────────────────────────── +void WindowManager::renderAchievementWindow(game::GameHandler& gameHandler) { + if (!showAchievementWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(420, 480), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(200, 150), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Achievements", &showAchievementWindow_)) { + ImGui::End(); + return; + } + + const auto& earned = gameHandler.getEarnedAchievements(); + const auto& criteria = gameHandler.getCriteriaProgress(); + + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##achsearch", achievementSearchBuf_, sizeof(achievementSearchBuf_)); + ImGui::SameLine(); + if (ImGui::Button("Clear")) achievementSearchBuf_[0] = '\0'; + ImGui::Separator(); + + std::string filter(achievementSearchBuf_); + for (char& c : filter) c = static_cast(tolower(static_cast(c))); + + if (ImGui::BeginTabBar("##achtabs")) { + // --- Earned tab --- + char earnedLabel[32]; + snprintf(earnedLabel, sizeof(earnedLabel), "Earned (%u)###earned", static_cast(earned.size())); + if (ImGui::BeginTabItem(earnedLabel)) { + if (earned.empty()) { + ImGui::TextDisabled("No achievements earned yet."); + } else { + ImGui::BeginChild("##achlist", ImVec2(0, 0), false); + std::vector ids(earned.begin(), earned.end()); + std::sort(ids.begin(), ids.end()); + for (uint32_t id : ids) { + const std::string& name = gameHandler.getAchievementName(id); + const std::string& display = name.empty() ? std::to_string(id) : name; + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + if (lower.find(filter) == std::string::npos) continue; + } + ImGui::PushID(static_cast(id)); + ImGui::TextColored(colors::kBrightGold, "\xE2\x98\x85"); + ImGui::SameLine(); + ImGui::TextUnformatted(display.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + // Points badge + uint32_t pts = gameHandler.getAchievementPoints(id); + if (pts > 0) { + ImGui::TextColored(colors::kBrightGold, + "%u Achievement Point%s", pts, pts == 1 ? "" : "s"); + ImGui::Separator(); + } + // Description + const std::string& desc = gameHandler.getAchievementDescription(id); + if (!desc.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); + ImGui::TextUnformatted(desc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + // Earn date + uint32_t packed = gameHandler.getAchievementDate(id); + if (packed != 0) { + // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] + int minute = (packed >> 3) & 0x3F; + int hour = (packed >> 9) & 0x1F; + int day = (packed >> 17) & 0x1F; + int month = (packed >> 21) & 0x0F; + int year = ((packed >> 25) & 0x7F) + 2000; + const char* mname = (month >= 1 && month <= 12) ? kMonthAbbrev[month - 1] : "?"; + ImGui::TextDisabled("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); + } + ImGui::EndTooltip(); + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + + // --- Criteria progress tab --- + char critLabel[32]; + snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", static_cast(criteria.size())); + if (ImGui::BeginTabItem(critLabel)) { + // Lazy-load AchievementCriteria.dbc for descriptions + struct CriteriaEntry { uint32_t achievementId; uint64_t quantity; std::string description; }; + static std::unordered_map s_criteriaData; + static bool s_criteriaDataLoaded = false; + if (!s_criteriaDataLoaded) { + s_criteriaDataLoaded = true; + auto* am = core::Application::getInstance().getAssetManager(); + if (am && am->isInitialized()) { + auto dbc = am->loadDBC("AchievementCriteria.dbc"); + if (dbc && dbc->isLoaded() && dbc->getFieldCount() >= 10) { + const auto* acL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("AchievementCriteria") : nullptr; + uint32_t achField = acL ? acL->field("AchievementID") : 1u; + uint32_t qtyField = acL ? acL->field("Quantity") : 4u; + uint32_t descField = acL ? acL->field("Description") : 9u; + if (achField == 0xFFFFFFFF) achField = 1; + if (qtyField == 0xFFFFFFFF) qtyField = 4; + if (descField == 0xFFFFFFFF) descField = 9; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t cid = dbc->getUInt32(r, 0); + if (cid == 0) continue; + CriteriaEntry ce; + ce.achievementId = (achField < fc) ? dbc->getUInt32(r, achField) : 0; + ce.quantity = (qtyField < fc) ? dbc->getUInt32(r, qtyField) : 0; + ce.description = (descField < fc) ? dbc->getString(r, descField) : std::string{}; + s_criteriaData[cid] = std::move(ce); + } + } + } + } + + if (criteria.empty()) { + ImGui::TextDisabled("No criteria progress received yet."); + } else { + ImGui::BeginChild("##critlist", ImVec2(0, 0), false); + std::vector> clist(criteria.begin(), criteria.end()); + std::sort(clist.begin(), clist.end()); + for (const auto& [cid, cval] : clist) { + auto ceIt = s_criteriaData.find(cid); + + // Build display text for filtering + std::string display; + if (ceIt != s_criteriaData.end() && !ceIt->second.description.empty()) { + display = ceIt->second.description; + } else { + display = std::to_string(cid); + } + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + // Also allow filtering by achievement name + if (lower.find(filter) == std::string::npos && ceIt != s_criteriaData.end()) { + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + std::string achLower = achName; + for (char& c : achLower) c = static_cast(tolower(static_cast(c))); + if (achLower.find(filter) == std::string::npos) continue; + } else if (lower.find(filter) == std::string::npos) { + continue; + } + } + + ImGui::PushID(static_cast(cid)); + if (ceIt != s_criteriaData.end()) { + // Show achievement name as header (dim) + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + if (!achName.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.8f), "%s", achName.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled(">"); + ImGui::SameLine(); + } + if (!ceIt->second.description.empty()) { + ImGui::TextUnformatted(ceIt->second.description.c_str()); + } else { + ImGui::TextDisabled("Criteria %u", cid); + } + ImGui::SameLine(); + if (ceIt->second.quantity > 0) { + ImGui::TextColored(colors::kLightGreen, + "%llu/%llu", + static_cast(cval), + static_cast(ceIt->second.quantity)); + } else { + ImGui::TextColored(colors::kLightGreen, + "%llu", static_cast(cval)); + } + } else { + ImGui::TextDisabled("Criteria %u:", cid); + ImGui::SameLine(); + ImGui::Text("%llu", static_cast(cval)); + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + ImGui::End(); +} + +// ─── GM Ticket Window ───────────────────────────────────────────────────────── +void WindowManager::renderGmTicketWindow(game::GameHandler& gameHandler) { + // Fire a one-shot query when the window first becomes visible + if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) { + gameHandler.requestGmTicket(); + } + gmTicketWindowWasOpen_ = showGmTicketWindow_; + + if (!showGmTicketWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + // Show GM support availability + if (!gameHandler.isGmSupportAvailable()) { + ImGui::TextColored(colors::kSoftRed, "GM support is currently unavailable."); + ImGui::Spacing(); + } + + // Show existing open ticket if any + if (gameHandler.hasActiveGmTicket()) { + ImGui::TextColored(kColorGreen, "You have an open GM ticket."); + const std::string& existingText = gameHandler.getGmTicketText(); + if (!existingText.empty()) { + ImGui::TextWrapped("Current ticket: %s", existingText.c_str()); + } + float waitHours = gameHandler.getGmTicketWaitHours(); + if (waitHours > 0.0f) { + char waitBuf[64]; + std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf); + } + ImGui::Separator(); + ImGui::Spacing(); + } + + ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); + ImGui::Spacing(); + ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), + ImVec2(-1, 120)); + ImGui::Spacing(); + + bool hasText = (gmTicketBuf_[0] != '\0'); + if (!hasText) ImGui::BeginDisabled(); + if (ImGui::Button("Submit Ticket", ImVec2(160, 0))) { + gameHandler.submitGmTicket(gmTicketBuf_); + gmTicketBuf_[0] = '\0'; + showGmTicketWindow_ = false; + } + if (!hasText) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + showGmTicketWindow_ = false; + } + ImGui::SameLine(); + if (gameHandler.hasActiveGmTicket()) { + if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) { + gameHandler.deleteGmTicket(); + showGmTicketWindow_ = false; + } + } + + ImGui::End(); +} + +// ─── Book / Scroll / Note Window ────────────────────────────────────────────── +void WindowManager::renderBookWindow(game::GameHandler& gameHandler) { + // Auto-open when new pages arrive + if (gameHandler.hasBookOpen() && !showBookWindow_) { + showBookWindow_ = true; + bookCurrentPage_ = 0; + } + if (!showBookWindow_) return; + + const auto& pages = gameHandler.getBookPages(); + if (pages.empty()) { showBookWindow_ = false; return; } + + // Clamp page index + if (bookCurrentPage_ < 0) bookCurrentPage_ = 0; + if (bookCurrentPage_ >= static_cast(pages.size())) + bookCurrentPage_ = static_cast(pages.size()) - 1; + + ImGui::SetNextWindowSize(ImVec2(420, 340), ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImVec2(400, 180), ImGuiCond_Appearing); + + bool open = showBookWindow_; + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.12f, 0.09f, 0.06f, 0.98f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.25f, 0.18f, 0.08f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.37f, 0.18f, 1.0f)); + + char title[64]; + if (pages.size() > 1) + snprintf(title, sizeof(title), "Page %d / %d###BookWin", + bookCurrentPage_ + 1, static_cast(pages.size())); + else + snprintf(title, sizeof(title), "###BookWin"); + + if (ImGui::Begin(title, &open, ImGuiWindowFlags_NoCollapse)) { + // Parchment text colour + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.85f, 0.78f, 0.62f, 1.0f)); + + const std::string& text = pages[bookCurrentPage_].text; + // Use a child region with word-wrap + ImGui::SetNextWindowContentSize(ImVec2(ImGui::GetContentRegionAvail().x, 0)); + if (ImGui::BeginChild("##BookText", + ImVec2(0, ImGui::GetContentRegionAvail().y - 34), + false, ImGuiWindowFlags_HorizontalScrollbar)) { + ImGui::SetNextItemWidth(-1); + ImGui::TextWrapped("%s", text.c_str()); + } + ImGui::EndChild(); + ImGui::PopStyleColor(); + + // Navigation row + ImGui::Separator(); + bool canPrev = (bookCurrentPage_ > 0); + bool canNext = (bookCurrentPage_ < static_cast(pages.size()) - 1); + + if (!canPrev) ImGui::BeginDisabled(); + if (ImGui::Button("< Prev", ImVec2(80, 0))) bookCurrentPage_--; + if (!canPrev) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!canNext) ImGui::BeginDisabled(); + if (ImGui::Button("Next >", ImVec2(80, 0))) bookCurrentPage_++; + if (!canNext) ImGui::EndDisabled(); + + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60); + if (ImGui::Button("Close", ImVec2(60, 0))) { + open = false; + } + } + ImGui::End(); + ImGui::PopStyleColor(3); + + if (!open) { + showBookWindow_ = false; + gameHandler.clearBook(); + } +} + +// ─── Inspect Window ─────────────────────────────────────────────────────────── +// ─── Titles Window ──────────────────────────────────────────────────────────── +void WindowManager::renderTitlesWindow(game::GameHandler& gameHandler) { + if (!showTitlesWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(320, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(240, 170), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Titles", &showTitlesWindow_)) { + ImGui::End(); + return; + } + + const auto& knownBits = gameHandler.getKnownTitleBits(); + const int32_t chosen = gameHandler.getChosenTitleBit(); + + if (knownBits.empty()) { + ImGui::TextDisabled("No titles earned yet."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Select a title to display:"); + ImGui::Separator(); + + // "No Title" option + bool noTitle = (chosen < 0); + if (ImGui::Selectable("(No Title)", noTitle)) { + if (!noTitle) gameHandler.sendSetTitle(-1); + } + if (noTitle) { + ImGui::SameLine(); + ImGui::TextColored(colors::kBrightGold, "<-- active"); + } + + ImGui::Separator(); + + // Sort known bits for stable display order + std::vector sortedBits(knownBits.begin(), knownBits.end()); + std::sort(sortedBits.begin(), sortedBits.end()); + + ImGui::BeginChild("##titlelist", ImVec2(0, 0), false); + for (uint32_t bit : sortedBits) { + const std::string title = gameHandler.getFormattedTitle(bit); + const std::string display = title.empty() + ? ("Title #" + std::to_string(bit)) : title; + + bool isActive = (chosen >= 0 && static_cast(chosen) == bit); + ImGui::PushID(static_cast(bit)); + + if (isActive) { + ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); + } + if (ImGui::Selectable(display.c_str(), isActive)) { + if (!isActive) gameHandler.sendSetTitle(static_cast(bit)); + } + if (isActive) { + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled("<-- active"); + } + + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::End(); +} + +// ─── Equipment Set Manager Window ───────────────────────────────────────────── +void WindowManager::renderEquipSetWindow(game::GameHandler& gameHandler) { + if (!showEquipSetWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(280, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(260, 180), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Equipment Sets##equipsets", &showEquipSetWindow_)) { + ImGui::End(); + return; + } + + const auto& sets = gameHandler.getEquipmentSets(); + + if (sets.empty()) { + ImGui::TextDisabled("No equipment sets saved."); + ImGui::Spacing(); + ImGui::TextWrapped("Create equipment sets in-game using the default WoW equipment manager (Shift+click the Equipment Sets button)."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Click a set to equip it:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::BeginChild("##equipsetlist", ImVec2(0, 0), false); + for (const auto& set : sets) { + ImGui::PushID(static_cast(set.setId)); + + // Icon placeholder (use a coloured square if no icon texture available) + ImVec2 iconSize(32.0f, 32.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.20f, 0.10f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.30f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.60f, 0.45f, 0.20f, 1.0f)); + if (ImGui::Button("##icon", iconSize)) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(3); + + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Equip set: %s", set.name.c_str()); + } + + ImGui::SameLine(); + + // Name and equip button + ImGui::BeginGroup(); + ImGui::TextUnformatted(set.name.c_str()); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.50f, 0.22f, 1.0f)); + if (ImGui::SmallButton("Equip")) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(2); + ImGui::EndGroup(); + + ImGui::Spacing(); + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::End(); +} + +void WindowManager::renderSkillsWindow(game::GameHandler& gameHandler) { + if (!showSkillsWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(380, 480), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(220, 130), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Skills & Professions", &showSkillsWindow_)) { + ImGui::End(); + return; + } + + const auto& skills = gameHandler.getPlayerSkills(); + if (skills.empty()) { + ImGui::TextDisabled("No skill data received yet."); + ImGui::End(); + return; + } + + // Organise skills by category + // WoW SkillLine.dbc categories: 6=Weapon, 7=Class, 8=Armor, 9=Secondary, 11=Professions, others=Misc + struct SkillEntry { + uint32_t skillId; + const game::PlayerSkill* skill; + }; + std::map> byCategory; + for (const auto& [id, sk] : skills) { + uint32_t cat = gameHandler.getSkillCategory(id); + byCategory[cat].push_back({id, &sk}); + } + + static constexpr struct { uint32_t cat; const char* label; } kCatOrder[] = { + {11, "Professions"}, + { 9, "Secondary Skills"}, + { 7, "Class Skills"}, + { 6, "Weapon Skills"}, + { 8, "Armor"}, + { 5, "Languages"}, + { 0, "Other"}, + }; + + // Collect handled categories to fall back to "Other" for unknowns + static const uint32_t kKnownCats[] = {11, 9, 7, 6, 8, 5}; + + // Redirect unknown categories into bucket 0 + for (auto& [cat, vec] : byCategory) { + bool known = false; + for (uint32_t kc : kKnownCats) if (cat == kc) { known = true; break; } + if (!known && cat != 0) { + auto& other = byCategory[0]; + other.insert(other.end(), vec.begin(), vec.end()); + vec.clear(); + } + } + + ImGui::BeginChild("##skillscroll", ImVec2(0, 0), false); + + for (const auto& [cat, label] : kCatOrder) { + auto it = byCategory.find(cat); + if (it == byCategory.end() || it->second.empty()) continue; + + auto& entries = it->second; + // Sort alphabetically within each category + std::sort(entries.begin(), entries.end(), [&](const SkillEntry& a, const SkillEntry& b) { + return gameHandler.getSkillName(a.skillId) < gameHandler.getSkillName(b.skillId); + }); + + if (ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen)) { + for (const auto& e : entries) { + const std::string& name = gameHandler.getSkillName(e.skillId); + const char* displayName = name.empty() ? "Unknown" : name.c_str(); + uint16_t val = e.skill->effectiveValue(); + uint16_t maxVal = e.skill->maxValue; + + ImGui::PushID(static_cast(e.skillId)); + + // Name column + ImGui::TextUnformatted(displayName); + ImGui::SameLine(170.0f); + + // Progress bar + float fraction = (maxVal > 0) ? static_cast(val) / static_cast(maxVal) : 0.0f; + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", val, maxVal); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.20f, 0.55f, 0.20f, 1.0f)); + ImGui::ProgressBar(fraction, ImVec2(160.0f, 14.0f), overlay); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("%s", displayName); + ImGui::Separator(); + ImGui::Text("Base: %u", e.skill->value); + if (e.skill->bonusPerm > 0) + ImGui::Text("Permanent bonus: +%u", e.skill->bonusPerm); + if (e.skill->bonusTemp > 0) + ImGui::Text("Temporary bonus: +%u", e.skill->bonusTemp); + ImGui::Text("Max: %u", maxVal); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + ImGui::Spacing(); + } + } + + ImGui::EndChild(); + ImGui::End(); +} + + +} // namespace ui +} // namespace wowee