From 0f1cd5fe9ac274a2e6b88169c45ec86ac1f58c3d Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 31 Mar 2026 08:53:14 +0300 Subject: [PATCH 1/5] `chore(game-ui): extract chat panel into dedicated UI module` - moved chat panel logic out of `game_screen` into `chat_panel` - added chat_panel.hpp and chat_panel.cpp - updated game_screen.hpp and game_screen.cpp to integrate new `ChatPanel` component - updated build config in CMakeLists.txt to include new UI module sources --- CMakeLists.txt | 1 + include/ui/chat_panel.hpp | 194 ++ include/ui/game_screen.hpp | 97 +- src/ui/chat_panel.cpp | 4898 ++++++++++++++++++++++++++++++++++ src/ui/game_screen.cpp | 5060 +----------------------------------- 5 files changed, 5227 insertions(+), 5023 deletions(-) create mode 100644 include/ui/chat_panel.hpp create mode 100644 src/ui/chat_panel.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5489cbe3..06a4727e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -556,6 +556,7 @@ set(WOWEE_SOURCES src/ui/character_create_screen.cpp src/ui/character_screen.cpp src/ui/game_screen.cpp + src/ui/chat_panel.cpp src/ui/inventory_screen.cpp src/ui/quest_log_screen.cpp src/ui/spellbook_screen.cpp diff --git a/include/ui/chat_panel.hpp b/include/ui/chat_panel.hpp new file mode 100644 index 00000000..50dac84b --- /dev/null +++ b/include/ui/chat_panel.hpp @@ -0,0 +1,194 @@ +#pragma once + +#include "game/game_handler.hpp" +#include +#include +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } +namespace rendering { class Renderer; } +namespace ui { + +class InventoryScreen; +class SpellbookScreen; +class QuestLogScreen; + +/** + * Self-contained chat UI panel extracted from GameScreen. + * + * Owns all chat state: input buffer, sent-history, tab filtering, + * slash-command parsing, chat bubbles, and chat-related settings. + */ +class ChatPanel { +public: + ChatPanel(); + + // ---- Main entry points (called by GameScreen) ---- + + /** + * Render the chat window (tabs, history, input, etc.) + */ + void render(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen); + + /** + * Render 3D-projected chat bubbles above entities. + */ + void renderBubbles(game::GameHandler& gameHandler); + + /** + * Register one-shot callbacks on GameHandler (call once per session). + * Sets up the chat-bubble callback. + */ + void setupCallbacks(game::GameHandler& gameHandler); + + // ---- Input helpers (called by GameScreen keybind handling) ---- + + bool isChatInputActive() const { return chatInputActive_; } + + /** Insert a spell / item link into the chat input buffer (shift-click). */ + void insertChatLink(const std::string& link); + + /** Activate the input field with a leading '/' (slash key). */ + void activateSlashInput(); + + /** Activate (focus) the input field (Enter key). */ + void activateInput(); + + /** Request that the chat input be focused next frame. */ + void requestRefocus() { refocusChatInput_ = true; } + + /** Set up a whisper to the given player name and focus input. */ + void setWhisperTarget(const std::string& name); + + /** Execute a macro body (one line per 'click'). */ + void executeMacroText(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen, + const std::string& macroText); + + // ---- Slash-command side-effects ---- + // GameScreen reads these each frame, then clears them. + + struct SlashCommands { + bool showInspect = false; + bool toggleThreat = false; + bool showBgScore = false; + bool showGmTicket = false; + bool showWho = false; + bool toggleCombatLog = false; + bool takeScreenshot = false; + }; + + /** Return accumulated slash-command flags and reset them. */ + SlashCommands consumeSlashCommands(); + + // ---- Chat settings (read/written by GameScreen save/load & settings tab) ---- + + bool chatShowTimestamps = false; + int chatFontSize = 1; // 0=small, 1=medium, 2=large + bool chatAutoJoinGeneral = true; + bool chatAutoJoinTrade = true; + bool chatAutoJoinLocalDefense = true; + bool chatAutoJoinLFG = true; + bool chatAutoJoinLocal = true; + int activeChatTab = 0; + + /** Spell icon lookup callback — set by GameScreen each frame before render(). */ + std::function getSpellIcon; + + /** Render the "Chat" tab inside the Settings window. */ + void renderSettingsTab(std::function saveSettingsFn); + + /** Reset all chat settings to defaults. */ + void restoreDefaults(); + + /** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */ + std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); + +private: + // ---- Chat input state ---- + char chatInputBuffer_[512] = ""; + char whisperTargetBuffer_[256] = ""; + bool chatInputActive_ = false; + int selectedChatType_ = 0; // 0=SAY .. 10=CHANNEL + int lastChatType_ = 0; + int selectedChannelIdx_ = 0; + bool chatInputMoveCursorToEnd_ = false; + bool refocusChatInput_ = false; + + // Sent-message history (Up/Down arrow recall) + std::vector chatSentHistory_; + int chatHistoryIdx_ = -1; + + // Macro stop flag + bool macroStopped_ = false; + + // Tab-completion state + std::string chatTabPrefix_; + std::vector chatTabMatches_; + int chatTabMatchIdx_ = -1; + + // Mention notification + size_t chatMentionSeenCount_ = 0; + + // ---- Chat tabs ---- + struct ChatTab { + std::string name; + uint64_t typeMask; + }; + std::vector chatTabs_; + std::vector chatTabUnread_; + size_t chatTabSeenCount_ = 0; + + void initChatTabs(); + bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; + + // ---- Chat window visual state ---- + bool chatScrolledUp_ = false; + bool chatForceScrollToBottom_ = false; + bool chatWindowLocked_ = true; + ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); + bool chatWindowPosInit_ = false; + + // ---- Chat bubbles ---- + struct ChatBubble { + uint64_t senderGuid = 0; + std::string message; + float timeRemaining = 0.0f; + float totalDuration = 0.0f; + bool isYell = false; + }; + std::vector chatBubbles_; + bool chatBubbleCallbackSet_ = false; + + // ---- Whisper toast state (populated in render, rendered by GameScreen/ToastManager) ---- + // Whisper scanning lives here because it's tightly coupled to chat history iteration. + size_t whisperSeenCount_ = 0; + + // ---- Helpers ---- + void sendChatMessage(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen); + const char* getChatTypeName(game::ChatType type) const; + ImVec4 getChatTypeColor(game::ChatType type) const; + + // Cached game handler for input callback (set each frame in render) + game::GameHandler* cachedGameHandler_ = nullptr; + + // Join channel input buffer + char joinChannelBuffer_[128] = ""; + + // Slash command flags (accumulated, consumed by GameScreen) + SlashCommands slashCmds_; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 49697eab..b6dd4d27 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -9,6 +9,7 @@ #include "ui/spellbook_screen.hpp" #include "ui/talent_screen.hpp" #include "ui/keybinding_manager.hpp" +#include "ui/chat_panel.hpp" #include #include #include @@ -37,56 +38,20 @@ public: /** * Check if chat input is active */ - bool isChatInputActive() const { return chatInputActive; } + bool isChatInputActive() const { return chatPanel_.isChatInputActive(); } void saveSettings(); void loadSettings(); void applyAudioVolumes(rendering::Renderer* renderer); private: - // Chat state - char chatInputBuffer[512] = ""; - char whisperTargetBuffer[256] = ""; - bool chatInputActive = false; - int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER, ..., 10=CHANNEL - int lastChatType = 0; // Track chat type changes - int selectedChannelIdx = 0; // Index into joinedChannels_ when selectedChatType==10 - bool chatInputMoveCursorToEnd = false; - - // Chat sent-message history (Up/Down arrow recall) - std::vector chatSentHistory_; - int chatHistoryIdx_ = -1; // -1 = not browsing history - - // Set to true by /stopmacro; checked in executeMacroText to halt remaining commands. - bool macroStopped_ = false; + // Chat panel (extracted from GameScreen — owns all chat state and rendering) + ChatPanel chatPanel_; // 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_; - // Cached game handler for input callbacks (set each frame in render) - game::GameHandler* cachedGameHandler_ = nullptr; - - // Tab-completion state for slash commands and player names - std::string chatTabPrefix_; // prefix captured on first Tab press - std::vector chatTabMatches_; // matching command list - int chatTabMatchIdx_ = -1; // active match index (-1 = inactive) - - // Mention notification: plays a sound when the player's name appears in chat - size_t chatMentionSeenCount_ = 0; // how many messages have been scanned for mentions - - // Chat tabs - int activeChatTab_ = 0; - struct ChatTab { - std::string name; - uint64_t typeMask; // bitmask of ChatType values to show (64-bit: types go up to 84) - }; - std::vector chatTabs_; - std::vector chatTabUnread_; // unread message count per tab (0 = none) - size_t chatTabSeenCount_ = 0; // how many history messages have been processed - void initChatTabs(); - bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; - // UI state bool showEntityWindow = false; bool showChatWindow = true; @@ -165,13 +130,7 @@ private: char petitionNameBuffer_[64] = {0}; char addRankNameBuffer_[64] = {0}; bool showAddRankModal_ = false; - bool refocusChatInput = false; bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session - bool chatScrolledUp_ = false; // true when user has scrolled above the latest messages - bool chatForceScrollToBottom_ = false; // set to true to jump to bottom next frame - bool chatWindowLocked = true; - ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); - bool chatWindowPosInit_ = false; 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 @@ -286,27 +245,6 @@ private: */ void renderEntityList(game::GameHandler& gameHandler); - /** - * Render chat window - */ - void renderChatWindow(game::GameHandler& gameHandler); - - /** - * Send chat message - */ - void sendChatMessage(game::GameHandler& gameHandler); - void executeMacroText(game::GameHandler& gameHandler, const std::string& macroText); - - /** - * Get chat type name - */ - const char* getChatTypeName(game::ChatType type) const; - - /** - * Get chat type color - */ - ImVec4 getChatTypeColor(game::ChatType type) const; - /** * Render player unit frame (top-left) */ @@ -385,7 +323,6 @@ private: void renderEscapeMenu(); void renderSettingsWindow(); void renderSettingsAudioTab(); - void renderSettingsChatTab(); void renderSettingsAboutTab(); void renderSettingsInterfaceTab(); void renderSettingsGameplayTab(); @@ -402,7 +339,6 @@ private: void renderBfMgrInvitePopup(game::GameHandler& gameHandler); void renderLfgProposalPopup(game::GameHandler& gameHandler); void renderLfgRoleCheckPopup(game::GameHandler& gameHandler); - void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); void renderBankWindow(game::GameHandler& gameHandler); @@ -527,33 +463,8 @@ private: uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) - // Chat settings - bool chatShowTimestamps_ = false; - int chatFontSize_ = 1; // 0=small, 1=medium, 2=large - bool chatAutoJoinGeneral_ = true; - bool chatAutoJoinTrade_ = true; - bool chatAutoJoinLocalDefense_ = true; - bool chatAutoJoinLFG_ = true; - bool chatAutoJoinLocal_ = true; - - // Join channel input buffer - char joinChannelBuffer_[128] = ""; - static std::string getSettingsPath(); - // Gender placeholder replacement - std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); - - // Chat bubbles - struct ChatBubble { - uint64_t senderGuid = 0; - std::string message; - float timeRemaining = 0.0f; - float totalDuration = 0.0f; - bool isYell = false; - }; - std::vector chatBubbles_; - bool chatBubbleCallbackSet_ = false; bool levelUpCallbackSet_ = false; bool achievementCallbackSet_ = false; diff --git a/src/ui/chat_panel.cpp b/src/ui/chat_panel.cpp new file mode 100644 index 00000000..eb5f68dc --- /dev/null +++ b/src/ui/chat_panel.cpp @@ -0,0 +1,4898 @@ +#include "ui/chat_panel.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/spellbook_screen.hpp" +#include "ui/quest_log_screen.hpp" +#include "ui/ui_colors.hpp" +#include "rendering/vk_context.hpp" +#include "core/application.hpp" +#include "addons/addon_manager.hpp" +#include "core/coordinates.hpp" +#include "core/input.hpp" +#include "rendering/renderer.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "audio/audio_engine.hpp" +#include "audio/ui_sound_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" +#include "game/expansion_profile.hpp" +#include "game/character.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + // Common ImGui colors (aliases) + 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; + + // Common ImGui window flags for popup dialogs + const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + std::string trim(const std::string& s) { + size_t first = s.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return ""; + size_t last = s.find_last_not_of(" \t\r\n"); + return s.substr(first, last - first + 1); + } + + // Format a duration in seconds as compact text: "2h", "3:05", "42" + void fmtDurationCompact(char* buf, size_t sz, int secs) { + if (secs >= 3600) snprintf(buf, sz, "%dh", secs / 3600); + else if (secs >= 60) snprintf(buf, sz, "%d:%02d", secs / 60, secs % 60); + else snprintf(buf, sz, "%d", secs); + } + + // Render "Remaining: Xs" or "Remaining: Xm Ys" in a tooltip (light gray) + void renderAuraRemaining(int remainMs) { + if (remainMs <= 0) return; + int s = remainMs / 1000; + char buf[32]; + if (s < 60) snprintf(buf, sizeof(buf), "Remaining: %ds", s); + else snprintf(buf, sizeof(buf), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(kLightGray, "%s", buf); + } + + std::string toLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return s; + } + + bool isPortBotTarget(const std::string& target) { + std::string t = toLower(trim(target)); + return t == "portbot" || t == "gmbot" || t == "telebot"; + } + + std::string buildPortBotCommand(const std::string& rawInput) { + std::string input = trim(rawInput); + if (input.empty()) return ""; + + std::string lower = toLower(input); + if (lower == "help" || lower == "?") { + return "__help__"; + } + + if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) { + return input; + } + + if (lower.rfind("xyz ", 0) == 0) { + return ".go " + input; + } + + if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; + if (lower == "if" || lower == "ironforge") return ".tele ironforge"; + if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; + if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; + if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; + if (lower == "uc" || lower == "undercity") return ".tele undercity"; + if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; + if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; + + return ".tele " + input; + } + + std::string getEntityName(const std::shared_ptr& entity) { + if (entity->getType() == wowee::game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == wowee::game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); + } + return "Unknown"; + } +} + +namespace wowee { namespace ui { + +ChatPanel::ChatPanel() { + initChatTabs(); +} + +void ChatPanel::initChatTabs() { + chatTabs_.clear(); + // General tab: shows everything + chatTabs_.push_back({"General", ~0ULL}); + // Combat tab: system, loot, skills, achievements, and NPC speech/emotes + chatTabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | + (1ULL << static_cast(game::ChatType::LOOT)) | + (1ULL << static_cast(game::ChatType::SKILL)) | + (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | + (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | + (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | + (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | + (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); + // Whispers tab + chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | + (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); + // Guild tab: guild and officer chat + chatTabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | + (1ULL << static_cast(game::ChatType::OFFICER)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); + // Trade/LFG tab: channel messages + chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); + // Reset unread counts to match new tab list + chatTabUnread_.assign(chatTabs_.size(), 0); + chatTabSeenCount_ = 0; +} + +bool ChatPanel::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { + if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; + const auto& tab = chatTabs_[tabIndex]; + if (tab.typeMask == ~0ULL) return true; // General tab shows all + + uint64_t typeBit = 1ULL << static_cast(msg.type); + + // For Trade/LFG tab (now index 4), also filter by channel name + if (tabIndex == 4 && msg.type == game::ChatType::CHANNEL) { + const std::string& ch = msg.channelName; + if (ch.find("Trade") == std::string::npos && + ch.find("General") == std::string::npos && + ch.find("LookingForGroup") == std::string::npos && + ch.find("Local") == std::string::npos) { + return false; + } + return true; + } + + return (tab.typeMask & typeBit) != 0; +} + + +// Forward declaration — defined below +static std::string firstMacroCommand(const std::string& macroText); +static std::vector allMacroCommands(const std::string& macroText); +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); +static std::string getMacroShowtooltipArg(const std::string& macroText); + +void ChatPanel::render(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen) { + auto* window = core::Application::getInstance().getWindow(); + auto* assetMgr = core::Application::getInstance().getAssetManager(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float chatW = std::min(500.0f, screenW * 0.4f); + float chatH = 220.0f; + float chatX = 8.0f; + float chatY = screenH - chatH - 80.0f; // Above action bar + if (chatWindowLocked_) { + // Always recompute position from current window size when locked + chatWindowPos_ = ImVec2(chatX, chatY); + ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_Always); + ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_Always); + } else { + if (!chatWindowPosInit_) { + chatWindowPos_ = ImVec2(chatX, chatY); + chatWindowPosInit_ = true; + } + ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_FirstUseEver); + } + ImGuiWindowFlags flags = kDialogFlags; + if (chatWindowLocked_) { + flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar; + } + ImGui::Begin("Chat", nullptr, flags); + + if (!chatWindowLocked_) { + chatWindowPos_ = ImGui::GetWindowPos(); + } + + // Update unread counts: scan any new messages since last frame + { + const auto& history = gameHandler.getChatHistory(); + // Ensure unread array is sized correctly (guards against late init) + if (chatTabUnread_.size() != chatTabs_.size()) + chatTabUnread_.assign(chatTabs_.size(), 0); + // If history shrank (e.g. cleared), reset + if (chatTabSeenCount_ > history.size()) chatTabSeenCount_ = 0; + for (size_t mi = chatTabSeenCount_; mi < history.size(); ++mi) { + const auto& msg = history[mi]; + // For each non-General (non-0) tab that isn't currently active, check visibility + for (int ti = 1; ti < static_cast(chatTabs_.size()); ++ti) { + if (ti == activeChatTab) continue; + if (shouldShowMessage(msg, ti)) { + chatTabUnread_[ti]++; + } + } + } + chatTabSeenCount_ = history.size(); + } + + // Chat tabs + if (ImGui::BeginTabBar("ChatTabs")) { + for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { + // Build label with unread count suffix for non-General tabs + std::string tabLabel = chatTabs_[i].name; + if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { + tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; + } + // Flash tab text color when unread messages exist + bool hasUnread = (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0); + if (hasUnread) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f * pulse, 0.2f * pulse, 1.0f)); + } + if (ImGui::BeginTabItem(tabLabel.c_str())) { + if (activeChatTab != i) { + activeChatTab = i; + // Clear unread count when tab becomes active + if (i < static_cast(chatTabUnread_.size())) + chatTabUnread_[i] = 0; + } + ImGui::EndTabItem(); + } + if (hasUnread) ImGui::PopStyleColor(); + } + ImGui::EndTabBar(); + } + + // Chat history + const auto& chatHistory = gameHandler.getChatHistory(); + + // Apply chat font size scaling + float chatScale = chatFontSize == 0 ? 0.85f : (chatFontSize == 2 ? 1.2f : 1.0f); + ImGui::SetWindowFontScale(chatScale); + + ImGui::BeginChild("ChatHistory", ImVec2(0, -70), true, ImGuiWindowFlags_HorizontalScrollbar); + bool chatHistoryHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + + // Helper: parse WoW color code |cAARRGGBB → ImVec4 + auto parseWowColor = [](const std::string& text, size_t pos) -> ImVec4 { + // |cAARRGGBB (10 chars total: |c + 8 hex) + if (pos + 10 > text.size()) return colors::kWhite; + auto hexByte = [&](size_t offset) -> float { + const char* s = text.c_str() + pos + offset; + char buf[3] = {s[0], s[1], '\0'}; + return static_cast(strtol(buf, nullptr, 16)) / 255.0f; + }; + float a = hexByte(2); + float r = hexByte(4); + float g = hexByte(6); + float b = hexByte(8); + return ImVec4(r, g, b, a); + }; + + // Helper: render an item tooltip from ItemQueryResponseData + auto renderItemLinkTooltip = [&](uint32_t itemEntry) { + const auto* info = gameHandler.getItemInfo(itemEntry); + if (!info || !info->valid) return; + auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { + using ES = game::EquipSlot; + const auto& inv = gameHandler.getInventory(); + auto slotPtr = [&](ES slot) -> const game::ItemSlot* { + const auto& s = inv.getEquipSlot(slot); + return s.empty() ? nullptr : &s; + }; + switch (inventoryType) { + case 1: return slotPtr(ES::HEAD); + case 2: return slotPtr(ES::NECK); + case 3: return slotPtr(ES::SHOULDERS); + case 4: return slotPtr(ES::SHIRT); + case 5: + case 20: return slotPtr(ES::CHEST); + case 6: return slotPtr(ES::WAIST); + case 7: return slotPtr(ES::LEGS); + case 8: return slotPtr(ES::FEET); + case 9: return slotPtr(ES::WRISTS); + case 10: return slotPtr(ES::HANDS); + case 11: { + if (auto* s = slotPtr(ES::RING1)) return s; + return slotPtr(ES::RING2); + } + case 12: { + if (auto* s = slotPtr(ES::TRINKET1)) return s; + return slotPtr(ES::TRINKET2); + } + case 13: + if (auto* s = slotPtr(ES::MAIN_HAND)) return s; + return slotPtr(ES::OFF_HAND); + case 14: + case 22: + case 23: return slotPtr(ES::OFF_HAND); + case 15: + case 25: + case 26: return slotPtr(ES::RANGED); + case 16: return slotPtr(ES::BACK); + case 17: + case 21: return slotPtr(ES::MAIN_HAND); + case 18: + for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { + auto slot = static_cast(static_cast(ES::BAG1) + i); + if (auto* s = slotPtr(slot)) return s; + } + return nullptr; + case 19: return slotPtr(ES::TABARD); + default: return nullptr; + } + }; + + ImGui::BeginTooltip(); + // Quality color for name + auto qColor = ui::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + + // Heroic indicator (green, matches WoW tooltip style) + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (info->itemFlags & kFlagHeroic) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + + // Bind type (appears right under name in WoW) + switch (info->bindType) { + case 1: ImGui::TextDisabled("Binds when picked up"); break; + case 2: ImGui::TextDisabled("Binds when equipped"); break; + case 3: ImGui::TextDisabled("Binds when used"); break; + case 4: ImGui::TextDisabled("Quest Item"); break; + } + // Unique / Unique-Equipped + if (info->maxCount == 1) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); + else if (info->itemFlags & kFlagUniqueEquipped) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); + + // Slot type + if (info->inventoryType > 0) { + const char* slotName = ui::getInventorySlotName(info->inventoryType); + if (slotName[0]) { + if (!info->subclassName.empty()) + ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); + else + ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); + } + } + auto isWeaponInventoryType = [](uint32_t invType) { + switch (invType) { + case 13: // One-Hand + case 15: // Ranged + case 17: // Two-Hand + case 21: // Main Hand + case 25: // Thrown + case 26: // Ranged Right + return true; + default: + return false; + } + }; + const bool isWeapon = isWeaponInventoryType(info->inventoryType); + + // Item level (after slot/subclass) + if (info->itemLevel > 0) + ImGui::TextDisabled("Item Level %u", info->itemLevel); + + if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; + // WoW-style: "22 - 41 Damage" with speed right-aligned on same row + char dmgBuf[64], spdBuf[32]; + std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", + static_cast(info->damageMin), static_cast(info->damageMax)); + std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); + float spdW = ImGui::CalcTextSize(spdBuf).x; + ImGui::Text("%s", dmgBuf); + ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); + ImGui::Text("%s", spdBuf); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " "; + out += shortName; + }; + std::string bonusLine; + appendBonus(bonusLine, info->strength, "Str"); + appendBonus(bonusLine, info->agility, "Agi"); + appendBonus(bonusLine, info->stamina, "Sta"); + appendBonus(bonusLine, info->intellect, "Int"); + appendBonus(bonusLine, info->spirit, "Spi"); + if (!bonusLine.empty()) { + ImGui::TextColored(green, "%s", bonusLine.c_str()); + } + if (info->armor > 0) { + ImGui::Text("%d Armor", info->armor); + } + // Elemental resistances (fire resist gear, nature resist gear, etc.) + { + const int32_t resVals[6] = { + info->holyRes, info->fireRes, info->natureRes, + info->frostRes, info->shadowRes, info->arcaneRes + }; + static constexpr const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int ri = 0; ri < 6; ++ri) + if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); + } + // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) + if (!info->extraStats.empty()) { + auto statName = [](uint32_t t) -> const char* { + switch (t) { + case 12: return "Defense Rating"; + case 13: return "Dodge Rating"; + case 14: return "Parry Rating"; + case 15: return "Block Rating"; + case 16: case 17: case 18: case 31: return "Hit Rating"; + case 19: case 20: case 21: case 32: return "Critical Strike Rating"; + case 28: case 29: case 30: case 35: return "Haste Rating"; + case 34: return "Resilience Rating"; + case 36: return "Expertise Rating"; + case 37: return "Attack Power"; + case 38: return "Ranged Attack Power"; + case 45: return "Spell Power"; + case 46: return "Healing Power"; + case 47: return "Spell Damage"; + case 49: return "Mana per 5 sec."; + case 43: return "Spell Penetration"; + case 44: return "Block Value"; + default: return nullptr; + } + }; + for (const auto& es : info->extraStats) { + const char* nm = statName(es.statType); + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + // Gem sockets (WotLK only — socketColor != 0 means socket present) + // socketColor bitmask: 1=Meta, 2=Red, 4=Yellow, 8=Blue + { + const auto& kSocketTypes = ui::kSocketTypes; + bool hasSocket = false; + for (int s = 0; s < 3; ++s) { + if (info->socketColor[s] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info->socketColor[s] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info->socketBonus != 0) { + // Socket bonus ID maps to SpellItemEnchantment.dbc — lazy-load names + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetMgr) { + s_enchantNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNames[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNames.find(info->socketBonus); + if (enchIt != s_enchantNames.end()) + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); + } + } + // Item set membership + if (info->itemSetId != 0) { + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetMgr) { + s_setDataLoaded = true; + auto dbc = assetMgr->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + const auto& itemKeys = ui::kItemSetItemKeys; + const auto& spellKeys = ui::kItemSetSpellKeys; + const auto& thrKeys = ui::kItemSetThresholdKeys; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); + } + s_setData[id] = std::move(e); + } + } + } + ImGui::Spacing(); + const auto& inv = gameHandler.getInventory(); + auto setIt = s_setData.find(info->itemSetId); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { + const auto& eq = inv.getEquipSlot(static_cast(sl)); + if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + if (total > 0) + ImGui::TextColored(ui::colors::kTooltipGold, + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + else if (!se.name.empty()) + ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); + } + } + // Item spell effects (Use / Equip / Chance on Hit / Teaches) + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* triggerLabel = nullptr; + switch (sp.spellTrigger) { + case 0: triggerLabel = "Use"; break; + case 1: triggerLabel = "Equip"; break; + case 2: triggerLabel = "Chance on Hit"; break; + case 5: triggerLabel = "Teaches"; break; + } + if (!triggerLabel) continue; + // Use full spell description if available (matches inventory tooltip style) + const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); + const std::string& spText = !spDesc.empty() ? spDesc + : gameHandler.getSpellName(sp.spellId); + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextColored(colors::kCyan, + "%s: %s", triggerLabel, spText.c_str()); + ImGui::PopTextWrapPos(); + } + } + // Required level + if (info->requiredLevel > 1) + ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Required skill (e.g. "Requires Blacksmithing (300)") + if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetMgr) { + s_skillNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 2u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler.getPlayerSkills(); + auto skPit = skills.find(info->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + auto skIt = s_skillNames.find(info->requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); + } + // Required reputation (e.g. "Requires Exalted with Argent Dawn") + if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetMgr) { + s_factionNamesLoaded = true; + auto dbc = assetMgr->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 20u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static constexpr const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info->requiredReputationRank < 8) + ? kRepRankNames[info->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction (e.g. "Classes: Paladin, Warrior") + if (info->allowableClass != 0) { + const auto& kClasses = ui::kClassMasks; + int matchCount = 0; + for (const auto& kc : kClasses) + if (info->allowableClass & kc.mask) ++matchCount; + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info->allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + uint8_t pc = gameHandler.getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Race restriction (e.g. "Races: Night Elf, Human") + if (info->allowableRace != 0) { + const auto& kRaces = ui::kRaceMasks; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + if ((info->allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info->allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info->allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + uint8_t pr = gameHandler.getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // Flavor / lore text (shown in gold italic in WoW, use a yellow-ish dim color here) + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(300.0f); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); + ImGui::PopTextWrapPos(); + } + if (info->sellPrice > 0) { + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(info->sellPrice); + } + + if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { + if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { + ImGui::Separator(); + ImGui::TextDisabled("Equipped:"); + VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); + if (eqIcon) { + ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); + ImGui::SameLine(); + } + ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + if (isWeaponInventoryType(eq->item.inventoryType) && + eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { + float speed = static_cast(eq->item.delayMs) / 1000.0f; + float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; + char eqDmg[64], eqSpd[32]; + std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", + static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); + std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); + float eqSpdW = ImGui::CalcTextSize(eqSpd).x; + ImGui::Text("%s", eqDmg); + ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); + ImGui::Text("%s", eqSpd); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + if (eq->item.armor > 0) { + ImGui::Text("%d Armor", eq->item.armor); + } + std::string eqBonusLine; + appendBonus(eqBonusLine, eq->item.strength, "Str"); + appendBonus(eqBonusLine, eq->item.agility, "Agi"); + appendBonus(eqBonusLine, eq->item.stamina, "Sta"); + appendBonus(eqBonusLine, eq->item.intellect, "Int"); + appendBonus(eqBonusLine, eq->item.spirit, "Spi"); + if (!eqBonusLine.empty()) { + ImGui::TextColored(green, "%s", eqBonusLine.c_str()); + } + // Extra stats for the equipped item + for (const auto& es : eq->item.extraStats) { + const char* nm = nullptr; + switch (es.statType) { + case 12: nm = "Defense Rating"; break; + case 13: nm = "Dodge Rating"; break; + case 14: nm = "Parry Rating"; break; + case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; + case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; + case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; + case 34: nm = "Resilience Rating"; break; + case 36: nm = "Expertise Rating"; break; + case 37: nm = "Attack Power"; break; + case 38: nm = "Ranged Attack Power"; break; + case 45: nm = "Spell Power"; break; + case 46: nm = "Healing Power"; break; + case 49: nm = "Mana per 5 sec."; break; + default: break; + } + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + } + ImGui::EndTooltip(); + }; + + // Helper: render text with clickable URLs and WoW item links + auto renderTextWithLinks = [&](const std::string& text, const ImVec4& color) { + size_t pos = 0; + while (pos < text.size()) { + // Find next special element: URL or WoW link + size_t urlStart = text.find("https://", pos); + + // Find next WoW link (may be colored with |c prefix or bare |H) + size_t linkStart = text.find("|c", pos); + // Also handle bare |H links without color prefix + size_t bareItem = text.find("|Hitem:", pos); + size_t bareSpell = text.find("|Hspell:", pos); + size_t bareQuest = text.find("|Hquest:", pos); + size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); + + // Determine which comes first + size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart}); + + if (nextSpecial == std::string::npos) { + // No more special elements, render remaining text + std::string remaining = text.substr(pos); + if (!remaining.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::TextWrapped("%s", remaining.c_str()); + ImGui::PopStyleColor(); + } + break; + } + + // Render plain text before special element + if (nextSpecial > pos) { + std::string before = text.substr(pos, nextSpecial - pos); + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::TextWrapped("%s", before.c_str()); + ImGui::PopStyleColor(); + ImGui::SameLine(0, 0); + } + + // Handle WoW item link + if (nextSpecial == linkStart || nextSpecial == bareLinkStart) { + ImVec4 linkColor = color; + size_t hStart = std::string::npos; + + if (nextSpecial == linkStart && text.size() > linkStart + 10) { + // Parse |cAARRGGBB color + linkColor = parseWowColor(text, linkStart); + // Find the nearest |H link of any supported type + size_t hItem = text.find("|Hitem:", linkStart + 10); + size_t hSpell = text.find("|Hspell:", linkStart + 10); + size_t hQuest = text.find("|Hquest:", linkStart + 10); + size_t hAch = text.find("|Hachievement:", linkStart + 10); + hStart = std::min({hItem, hSpell, hQuest, hAch}); + } else if (nextSpecial == bareLinkStart) { + hStart = bareLinkStart; + } + + if (hStart != std::string::npos) { + // Determine link type + const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); + const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); + const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); + // Default: item link + + // Parse the first numeric ID after |Htype: + size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); + size_t entryStart = hStart + idOffset; + size_t entryEnd = text.find(':', entryStart); + uint32_t linkId = 0; + if (entryEnd != std::string::npos) { + linkId = static_cast(strtoul( + text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10)); + } + + // Find display name: |h[Name]|h + size_t nameTagStart = text.find("|h[", hStart); + size_t nameTagEnd = (nameTagStart != std::string::npos) + ? text.find("]|h", nameTagStart + 3) : std::string::npos; + + std::string linkName = isSpellLink ? "Unknown Spell" + : isQuestLink ? "Unknown Quest" + : isAchievLink ? "Unknown Achievement" + : "Unknown Item"; + if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { + linkName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); + } + + // Find end of entire link sequence (|r or after ]|h) + size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + idOffset; + size_t resetPos = text.find("|r", linkEnd); + if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { + linkEnd = resetPos + 2; + } + + if (!isSpellLink && !isQuestLink && !isAchievLink) { + // --- Item link --- + uint32_t itemEntry = linkId; + if (itemEntry > 0) { + gameHandler.ensureItemInfo(itemEntry); + } + + // Show small icon before item link if available + if (itemEntry > 0) { + const auto* chatInfo = gameHandler.getItemInfo(itemEntry); + if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) { + VkDescriptorSet chatIcon = inventoryScreen.getItemIcon(chatInfo->displayInfoId); + if (chatIcon) { + ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + renderItemLinkTooltip(itemEntry); + } + ImGui::SameLine(0, 2); + } + } + } + + // Render bracketed item name in quality color + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (itemEntry > 0) { + renderItemLinkTooltip(itemEntry); + } + } + } else if (isSpellLink) { + // --- Spell link: |Hspell:SPELLID:RANK|h[Name]|h --- + // Small icon (use spell icon cache if available) + VkDescriptorSet spellIcon = (linkId > 0) ? getSpellIcon(linkId, assetMgr) : VK_NULL_HANDLE; + if (spellIcon) { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + ImGui::SameLine(0, 2); + } + + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (linkId > 0) { + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + } + } else if (isQuestLink) { + // --- Quest link: |Hquest:QUESTID:QUESTLEVEL|h[Name]|h --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, colors::kWarmGold); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::BeginTooltip(); + ImGui::TextColored(colors::kWarmGold, "%s", linkName.c_str()); + // Parse quest level (second field after questId) + if (entryEnd != std::string::npos) { + size_t lvlEnd = text.find(':', entryEnd + 1); + if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); + if (lvlEnd != std::string::npos) { + uint32_t qLvl = static_cast(strtoul( + text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); + if (qLvl > 0) ImGui::TextDisabled("Level %u Quest", qLvl); + } + } + ImGui::TextDisabled("Click quest log to view details"); + ImGui::EndTooltip(); + } + // Click: open quest log and select this quest if we have it + if (ImGui::IsItemClicked() && linkId > 0) { + questLogScreen.openAndSelectQuest(linkId); + } + } else { + // --- Achievement link --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Achievement: %s", linkName.c_str()); + } + } + + // Shift-click: insert entire link back into chat input + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { + std::string linkText = text.substr(nextSpecial, linkEnd - nextSpecial); + size_t curLen = strlen(chatInputBuffer_); + if (curLen + linkText.size() + 1 < sizeof(chatInputBuffer_)) { + strncat(chatInputBuffer_, linkText.c_str(), sizeof(chatInputBuffer_) - curLen - 1); + chatInputMoveCursorToEnd_ = true; + } + } + + pos = linkEnd; + continue; + } + + // Not an item link — treat as colored text: |cAARRGGBB...text...|r + if (nextSpecial == linkStart && text.size() > linkStart + 10) { + ImVec4 cColor = parseWowColor(text, linkStart); + size_t textStart = linkStart + 10; // after |cAARRGGBB + size_t resetPos2 = text.find("|r", textStart); + std::string coloredText; + if (resetPos2 != std::string::npos) { + coloredText = text.substr(textStart, resetPos2 - textStart); + pos = resetPos2 + 2; // skip |r + } else { + coloredText = text.substr(textStart); + pos = text.size(); + } + // Strip any remaining WoW markup from the colored segment + // (e.g. |H...|h pairs that aren't item links) + std::string clean; + for (size_t i = 0; i < coloredText.size(); i++) { + if (coloredText[i] == '|' && i + 1 < coloredText.size()) { + char next = coloredText[i + 1]; + if (next == 'H') { + // Skip |H...|h + size_t hEnd = coloredText.find("|h", i + 2); + if (hEnd != std::string::npos) { i = hEnd + 1; continue; } + } else if (next == 'h') { + i += 1; continue; // skip |h + } else if (next == 'r') { + i += 1; continue; // skip |r + } + } + clean += coloredText[i]; + } + if (!clean.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, cColor); + ImGui::TextWrapped("%s", clean.c_str()); + ImGui::PopStyleColor(); + ImGui::SameLine(0, 0); + } + } else { + // Bare |c without enough chars for color — render literally + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::TextWrapped("|c"); + ImGui::PopStyleColor(); + ImGui::SameLine(0, 0); + pos = nextSpecial + 2; + } + continue; + } + + // Handle URL + if (nextSpecial == urlStart) { + size_t urlEnd = text.find_first_of(" \t\n\r", urlStart); + if (urlEnd == std::string::npos) urlEnd = text.size(); + std::string url = text.substr(urlStart, urlEnd - urlStart); + + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.7f, 1.0f, 1.0f)); + ImGui::TextWrapped("%s", url.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Open: %s", url.c_str()); + } + if (ImGui::IsItemClicked()) { + std::string cmd = "xdg-open '" + url + "' &"; + [[maybe_unused]] int result = system(cmd.c_str()); + } + ImGui::PopStyleColor(); + + pos = urlEnd; + continue; + } + } + }; + + // Determine local player name for mention detection (case-insensitive) + std::string selfNameLower; + { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && !ch->name.empty()) { + selfNameLower = ch->name; + for (auto& c : selfNameLower) c = static_cast(std::tolower(static_cast(c))); + } + } + + // Scan NEW messages (beyond chatMentionSeenCount_) for mentions and play notification sound + if (!selfNameLower.empty() && chatHistory.size() > chatMentionSeenCount_) { + for (size_t mi = chatMentionSeenCount_; mi < chatHistory.size(); ++mi) { + const auto& mMsg = chatHistory[mi]; + // Skip outgoing whispers, system, and monster messages + if (mMsg.type == game::ChatType::WHISPER_INFORM || + mMsg.type == game::ChatType::SYSTEM) continue; + // Case-insensitive search in message body + std::string bodyLower = mMsg.message; + for (auto& c : bodyLower) c = static_cast(std::tolower(static_cast(c))); + if (bodyLower.find(selfNameLower) != std::string::npos) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } + break; // play at most once per scan pass + } + } + chatMentionSeenCount_ = chatHistory.size(); + } else if (chatHistory.size() <= chatMentionSeenCount_) { + chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared + } + + // Whisper toast scanning left in GameScreen (will move to ToastManager later) + + int chatMsgIdx = 0; + for (const auto& msg : chatHistory) { + if (!shouldShowMessage(msg, activeChatTab)) continue; + std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); + + // Resolve sender name at render time in case it wasn't available at parse time. + // This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns. + const std::string& resolvedSenderName = [&]() -> const std::string& { + if (!msg.senderName.empty()) return msg.senderName; + if (msg.senderGuid == 0) return msg.senderName; + const std::string& cached = gameHandler.lookupName(msg.senderGuid); + if (!cached.empty()) return cached; + return msg.senderName; + }(); + + ImVec4 color = getChatTypeColor(msg.type); + + // Optional timestamp prefix + std::string tsPrefix; + if (chatShowTimestamps) { + auto tt = std::chrono::system_clock::to_time_t(msg.timestamp); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &tt); +#else + localtime_r(&tt, &tm); +#endif + char tsBuf[16]; + snprintf(tsBuf, sizeof(tsBuf), "[%02d:%02d] ", tm.tm_hour, tm.tm_min); + tsPrefix = tsBuf; + } + + // Build chat tag prefix: , , from chatTag bitmask + std::string tagPrefix; + if (msg.chatTag & 0x04) tagPrefix = " "; + else if (msg.chatTag & 0x01) tagPrefix = " "; + else if (msg.chatTag & 0x02) tagPrefix = " "; + + // Build full message string for this entry + std::string fullMsg; + if (msg.type == game::ChatType::SYSTEM || msg.type == game::ChatType::TEXT_EMOTE) { + fullMsg = tsPrefix + processedMessage; + } else if (!resolvedSenderName.empty()) { + if (msg.type == game::ChatType::SAY || + msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; + } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; + } else if (msg.type == game::ChatType::WHISPER || + msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; + } else if (msg.type == game::ChatType::WHISPER_INFORM) { + const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; + fullMsg = tsPrefix + "To " + target + ": " + processedMessage; + } else if (msg.type == game::ChatType::EMOTE || + msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; + } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { + int chIdx = gameHandler.getChannelIndex(msg.channelName); + std::string chDisplay = chIdx > 0 + ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" + : "[" + msg.channelName + "]"; + fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; + } else { + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; + } + } else { + bool isGroupType = + msg.type == game::ChatType::PARTY || + msg.type == game::ChatType::GUILD || + msg.type == game::ChatType::OFFICER || + msg.type == game::ChatType::RAID || + msg.type == game::ChatType::RAID_LEADER || + msg.type == game::ChatType::RAID_WARNING || + msg.type == game::ChatType::BATTLEGROUND || + msg.type == game::ChatType::BATTLEGROUND_LEADER; + if (isGroupType) { + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; + } else { + fullMsg = tsPrefix + processedMessage; + } + } + + // Detect mention: does this message contain the local player's name? + bool isMention = false; + if (!selfNameLower.empty() && + msg.type != game::ChatType::WHISPER_INFORM && + msg.type != game::ChatType::SYSTEM) { + std::string msgLower = fullMsg; + for (auto& c : msgLower) c = static_cast(std::tolower(static_cast(c))); + isMention = (msgLower.find(selfNameLower) != std::string::npos); + } + + // Render message in a group so we can attach a right-click context menu + ImGui::PushID(chatMsgIdx++); + ImGui::BeginGroup(); + renderTextWithLinks(fullMsg, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : color); + ImGui::EndGroup(); + if (isMention) { + // Draw highlight AFTER rendering so the rect covers all wrapped lines, + // not just the first. Previously used a pre-render single-lineH rect. + ImVec2 rMin = ImGui::GetItemRectMin(); + ImVec2 rMax = ImGui::GetItemRectMax(); + float availW = ImGui::GetContentRegionAvail().x + ImGui::GetCursorScreenPos().x - rMin.x; + ImGui::GetWindowDrawList()->AddRectFilled( + rMin, ImVec2(rMin.x + availW, rMax.y), + IM_COL32(255, 200, 50, 45)); // soft golden tint + } + + // Right-click context menu (only for player messages with a sender) + bool isPlayerMsg = !resolvedSenderName.empty() && + msg.type != game::ChatType::SYSTEM && + msg.type != game::ChatType::TEXT_EMOTE && + msg.type != game::ChatType::MONSTER_SAY && + msg.type != game::ChatType::MONSTER_YELL && + msg.type != game::ChatType::MONSTER_WHISPER && + msg.type != game::ChatType::MONSTER_EMOTE && + msg.type != game::ChatType::MONSTER_PARTY && + msg.type != game::ChatType::RAID_BOSS_WHISPER && + msg.type != game::ChatType::RAID_BOSS_EMOTE; + + if (isPlayerMsg && ImGui::BeginPopupContextItem("ChatMsgCtx")) { + ImGui::TextDisabled("%s", resolvedSenderName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType_ = 4; // WHISPER + strncpy(whisperTargetBuffer_, resolvedSenderName.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + refocusChatInput_ = true; + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(resolvedSenderName); + } + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(resolvedSenderName); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(resolvedSenderName); + } + ImGui::EndPopup(); + } + + ImGui::PopID(); + } + + // Auto-scroll to bottom; track whether user has scrolled up + { + float scrollY = ImGui::GetScrollY(); + float scrollMaxY = ImGui::GetScrollMaxY(); + bool atBottom = (scrollMaxY <= 0.0f) || (scrollY >= scrollMaxY - 2.0f); + if (atBottom || chatForceScrollToBottom_) { + ImGui::SetScrollHereY(1.0f); + chatScrolledUp_ = false; + chatForceScrollToBottom_ = false; + } else { + chatScrolledUp_ = true; + } + } + + ImGui::EndChild(); + + // Reset font scale after chat history + ImGui::SetWindowFontScale(1.0f); + + // "Jump to bottom" indicator when scrolled up + if (chatScrolledUp_) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.35f, 0.7f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); + if (ImGui::SmallButton(" v New messages ")) { + chatForceScrollToBottom_ = true; + } + ImGui::PopStyleColor(2); + ImGui::SameLine(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + // Lock toggle + ImGui::Checkbox("Lock", &chatWindowLocked_); + ImGui::SameLine(); + ImGui::TextDisabled(chatWindowLocked_ ? "(locked)" : "(movable)"); + + // Chat input + ImGui::Text("Type:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(100); + const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE", "CHANNEL" }; + ImGui::Combo("##ChatType", &selectedChatType_, chatTypes, 11); + + // Auto-fill whisper target when switching to WHISPER mode + if (selectedChatType_ == 4 && lastChatType_ != 4) { + // Just switched to WHISPER mode + if (gameHandler.hasTarget()) { + auto target = gameHandler.getTarget(); + if (target && target->getType() == game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(target); + if (!player->getName().empty()) { + strncpy(whisperTargetBuffer_, player->getName().c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + } + } + } + } + lastChatType_ = selectedChatType_; + + // Show whisper target field if WHISPER is selected + if (selectedChatType_ == 4) { + ImGui::SameLine(); + ImGui::Text("To:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(120); + ImGui::InputText("##WhisperTarget", whisperTargetBuffer_, sizeof(whisperTargetBuffer_)); + } + + // Show channel picker if CHANNEL is selected + if (selectedChatType_ == 10) { + const auto& channels = gameHandler.getJoinedChannels(); + if (channels.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(no channels joined)"); + } else { + ImGui::SameLine(); + if (selectedChannelIdx_ >= static_cast(channels.size())) selectedChannelIdx_ = 0; + ImGui::SetNextItemWidth(140); + if (ImGui::BeginCombo("##ChannelPicker", channels[selectedChannelIdx_].c_str())) { + for (int ci = 0; ci < static_cast(channels.size()); ++ci) { + bool selected = (ci == selectedChannelIdx_); + if (ImGui::Selectable(channels[ci].c_str(), selected)) selectedChannelIdx_ = ci; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + } + + ImGui::SameLine(); + ImGui::Text("Message:"); + ImGui::SameLine(); + + ImGui::SetNextItemWidth(-1); + if (refocusChatInput_) { + ImGui::SetKeyboardFocusHere(); + refocusChatInput_ = false; + } + + // Detect chat channel prefix as user types and switch the dropdown + { + std::string buf(chatInputBuffer_); + if (buf.size() >= 2 && buf[0] == '/') { + // Find the command and check if there's a space after it + size_t sp = buf.find(' ', 1); + if (sp != std::string::npos) { + std::string cmd = buf.substr(1, sp - 1); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + int detected = -1; + bool isReply = false; + if (cmd == "s" || cmd == "say") detected = 0; + else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; + else if (cmd == "p" || cmd == "party") detected = 2; + else if (cmd == "g" || cmd == "guild") detected = 3; + else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; + else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } + else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; + else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; + else if (cmd == "bg" || cmd == "battleground") detected = 7; + else if (cmd == "rw" || cmd == "raidwarning") detected = 8; + else if (cmd == "i" || cmd == "instance") detected = 9; + else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. + if (detected >= 0 && (selectedChatType_ != detected || detected == 10 || isReply)) { + // For channel shortcuts, also update selectedChannelIdx_ + if (detected == 10) { + int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. + const auto& chans = gameHandler.getJoinedChannels(); + if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { + selectedChannelIdx_ = chanIdx; + } + } + selectedChatType_ = detected; + // Strip the prefix, keep only the message part + std::string remaining = buf.substr(sp + 1); + // /r reply: pre-fill whisper target from last whisper sender + if (detected == 4 && isReply) { + std::string lastSender = gameHandler.getLastWhisperSender(); + if (!lastSender.empty()) { + strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + } + // remaining is the message — don't extract a target from it + } else if (detected == 4) { + // For whisper, first word after /w is the target + size_t msgStart = remaining.find(' '); + if (msgStart != std::string::npos) { + std::string wTarget = remaining.substr(0, msgStart); + strncpy(whisperTargetBuffer_, wTarget.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + remaining = remaining.substr(msgStart + 1); + } else { + // Just the target name so far, no message yet + strncpy(whisperTargetBuffer_, remaining.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + remaining = ""; + } + } + strncpy(chatInputBuffer_, remaining.c_str(), sizeof(chatInputBuffer_) - 1); + chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; + chatInputMoveCursorToEnd_ = true; + } + } + } + } + + // Color the input text based on current chat type + ImVec4 inputColor; + switch (selectedChatType_) { + case 1: inputColor = kColorRed; break; // YELL - red + case 2: inputColor = colors::kLightBlue; break; // PARTY - blue + case 3: inputColor = kColorBrightGreen; break; // GUILD - green + case 4: inputColor = ImVec4(1.0f, 0.5f, 1.0f, 1.0f); break; // WHISPER - pink + case 5: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // RAID - orange + case 6: inputColor = kColorBrightGreen; break; // OFFICER - green + case 7: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // BG - orange + case 8: inputColor = ImVec4(1.0f, 0.3f, 0.0f, 1.0f); break; // RAID WARNING - red-orange + case 9: inputColor = colors::kLightBlue; break; // INSTANCE - blue + case 10: inputColor = ImVec4(0.3f, 0.9f, 0.9f, 1.0f); break; // CHANNEL - cyan + default: inputColor = ui::colors::kWhite; break; // SAY - white + } + ImGui::PushStyleColor(ImGuiCol_Text, inputColor); + + auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { + auto* self = static_cast(data->UserData); + if (!self) return 0; + + // Cursor-to-end after channel switch + if (self->chatInputMoveCursorToEnd_) { + int len = static_cast(std::strlen(data->Buf)); + data->CursorPos = len; + data->SelectionStart = len; + data->SelectionEnd = len; + self->chatInputMoveCursorToEnd_ = false; + } + + // Tab: slash-command autocomplete + if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { + if (data->BufTextLen > 0 && data->Buf[0] == '/') { + // Split buffer into command word and trailing args + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + // Normalize to lowercase for matching + std::string lowerWord = word; + for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); + + static const std::vector kCmds = { + "/afk", "/assist", "/away", + "/cancelaura", "/cancelform", "/cancellogout", "/cancelshapeshift", + "/cast", "/castsequence", "/chathelp", "/clear", "/clearfocus", + "/clearmainassist", "/clearmaintank", "/cleartarget", "/cloak", + "/combatlog", "/dance", "/difficulty", "/dismount", "/dnd", "/do", "/duel", "/dump", + "/e", "/emote", "/equip", "/equipset", "/exit", + "/focus", "/follow", "/forfeit", "/friend", + "/g", "/gdemote", "/ginvite", "/gkick", "/gleader", "/gmotd", + "/gmticket", "/gpromote", "/gquit", "/grouploot", "/groster", + "/guild", "/guildinfo", + "/helm", "/help", + "/i", "/ignore", "/inspect", "/instance", "/invite", + "/j", "/join", "/kick", "/kneel", + "/l", "/leave", "/leaveparty", "/loc", "/local", "/logout", + "/lootmethod", "/lootthreshold", + "/macrohelp", "/mainassist", "/maintank", "/mark", "/me", + "/notready", + "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", + "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", + "/played", "/pvp", + "/quit", + "/r", "/raid", "/raidconvert", "/raidinfo", "/raidwarning", "/random", "/ready", + "/readycheck", "/reload", "/reloadui", "/removefriend", + "/reply", "/rl", "/roll", "/run", + "/s", "/say", "/score", "/screenshot", "/script", "/setloot", + "/shout", "/sit", "/stand", + "/startattack", "/stopattack", "/stopcasting", "/stopfollow", "/stopmacro", + "/t", "/target", "/targetenemy", "/targetfriend", "/targetlast", + "/threat", "/ticket", "/time", "/trade", + "/unignore", "/uninvite", "/unstuck", "/use", + "/w", "/whisper", "/who", "/wts", "/wtb", + "/y", "/yell", "/zone" + }; + + // New session if prefix changed + if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) { + self->chatTabPrefix_ = lowerWord; + self->chatTabMatches_.clear(); + for (const auto& cmd : kCmds) { + if (cmd.size() >= lowerWord.size() && + cmd.compare(0, lowerWord.size(), lowerWord) == 0) + self->chatTabMatches_.push_back(cmd); + } + self->chatTabMatchIdx_ = 0; + } else { + // Cycle forward through matches + ++self->chatTabMatchIdx_; + if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) + self->chatTabMatchIdx_ = 0; + } + + if (!self->chatTabMatches_.empty()) { + std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + // Append trailing space when match is unambiguous + if (self->chatTabMatches_.size() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } else if (data->BufTextLen > 0) { + // Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel + // Also works for plain text (completes nearby player names) + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + bool isNameCommand = false; + std::string namePrefix; + size_t replaceStart = 0; + + if (fullBuf[0] == '/' && spacePos != std::string::npos) { + std::string cmd = fullBuf.substr(0, spacePos); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + // Commands that take a player name as the first argument after the command + if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || + cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || + cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || + cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || + cmd == "/t" || cmd == "/target" || cmd == "/kick" || + cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { + // Extract the partial name after the space + namePrefix = fullBuf.substr(spacePos + 1); + // Only complete the first word after the command + size_t nameSpace = namePrefix.find(' '); + if (nameSpace == std::string::npos) { + isNameCommand = true; + replaceStart = spacePos + 1; + } + } + } + + if (isNameCommand && !namePrefix.empty()) { + std::string lowerPrefix = namePrefix; + for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); + + if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerPrefix) { + self->chatTabPrefix_ = lowerPrefix; + self->chatTabMatches_.clear(); + // Search player name cache and nearby entities + auto* gh = self->cachedGameHandler_; + // Party/raid members + for (const auto& m : gh->getPartyData().members) { + if (m.name.empty()) continue; + std::string lname = m.name; + for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) + self->chatTabMatches_.push_back(m.name); + } + // Friends + for (const auto& c : gh->getContacts()) { + if (!c.isFriend() || c.name.empty()) continue; + std::string lname = c.name; + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + // Avoid duplicates from party + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == c.name) { dup = true; break; } + if (!dup) self->chatTabMatches_.push_back(c.name); + } + } + // Nearby visible players + for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) continue; + std::string lname = player->getName(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == player->getName()) { dup = true; break; } + if (!dup) self->chatTabMatches_.push_back(player->getName()); + } + } + // Last whisper sender + if (!gh->getLastWhisperSender().empty()) { + std::string lname = gh->getLastWhisperSender(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == gh->getLastWhisperSender()) { dup = true; break; } + if (!dup) self->chatTabMatches_.insert(self->chatTabMatches_.begin(), gh->getLastWhisperSender()); + } + } + self->chatTabMatchIdx_ = 0; + } else { + ++self->chatTabMatchIdx_; + if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) + self->chatTabMatchIdx_ = 0; + } + + if (!self->chatTabMatches_.empty()) { + std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + std::string prefix = fullBuf.substr(0, replaceStart); + std::string newBuf = prefix + match; + if (self->chatTabMatches_.size() == 1) newBuf += ' '; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } + } + return 0; + } + + // Up/Down arrow: cycle through sent message history + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + // Any history navigation resets autocomplete + self->chatTabMatchIdx_ = -1; + self->chatTabMatches_.clear(); + + const int histSize = static_cast(self->chatSentHistory_.size()); + if (histSize == 0) return 0; + + if (data->EventKey == ImGuiKey_UpArrow) { + // Go back in history + if (self->chatHistoryIdx_ == -1) + self->chatHistoryIdx_ = histSize - 1; + else if (self->chatHistoryIdx_ > 0) + --self->chatHistoryIdx_; + } else if (data->EventKey == ImGuiKey_DownArrow) { + if (self->chatHistoryIdx_ == -1) return 0; + ++self->chatHistoryIdx_; + if (self->chatHistoryIdx_ >= histSize) { + self->chatHistoryIdx_ = -1; + data->DeleteChars(0, data->BufTextLen); + return 0; + } + } + + if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { + const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, entry.c_str()); + } + } + return 0; + }; + + ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_CallbackAlways | + ImGuiInputTextFlags_CallbackHistory | + ImGuiInputTextFlags_CallbackCompletion; + if (ImGui::InputText("##ChatInput", chatInputBuffer_, sizeof(chatInputBuffer_), inputFlags, inputCallback, this)) { + sendChatMessage(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); + // Close chat input on send so movement keys work immediately. + refocusChatInput_ = false; + ImGui::ClearActiveID(); + } + ImGui::PopStyleColor(); + + if (ImGui::IsItemActive()) { + chatInputActive_ = true; + } else { + chatInputActive_ = false; + } + + // Click in chat history area (received messages) → focus input. + { + if (chatHistoryHovered && ImGui::IsMouseClicked(0)) { + refocusChatInput_ = true; + } + } + + ImGui::End(); +} + + +static std::string firstMacroCommand(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 start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + return line; + if (nl == std::string::npos) break; + pos = nl + 1; + } + return {}; +} + +// Collect all non-comment, non-empty lines from a macro body. +static 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: +// "#showtooltip Spell" → "Spell" +// "#showtooltip" → "__auto__" (derive from first /cast) +// (none) → "" +static 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 {}; +} + +// --------------------------------------------------------------------------- +// WoW macro conditional evaluator +// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell +// Returns the first matching alternative's argument, or "" if none matches. +// targetOverride is set to a specific GUID if [target=X] was in the conditions, +// or left as UINT64_MAX to mean "use the normal target". +// --------------------------------------------------------------------------- +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride) { + targetOverride = static_cast(-1); + + auto& input = core::Input::getInstance(); + + const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || + input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || + input.isKeyPressed(SDL_SCANCODE_RCTRL); + const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || + input.isKeyPressed(SDL_SCANCODE_RALT); + const bool anyMod = shiftHeld || ctrlHeld || altHeld; + + // Split rawArg on ';' → alternatives + std::vector alts; + { + std::string cur; + for (char c : rawArg) { + if (c == ';') { alts.push_back(cur); cur.clear(); } + else cur += c; + } + alts.push_back(cur); + } + + // Evaluate a single comma-separated condition token. + // tgt is updated if a target= or @ specifier is found. + auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { + std::string c = raw; + // trim + size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; + size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); + if (c.empty()) return true; + + // @target specifiers: @player, @focus, @pet, @mouseover, @target + if (!c.empty() && c[0] == '@') { + std::string spec = c.substr(1); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + // target=X specifiers + if (c.rfind("target=", 0) == 0) { + std::string spec = c.substr(7); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + + // mod / nomod + if (c == "nomod" || c == "mod:none") return !anyMod; + if (c.rfind("mod:", 0) == 0) { + std::string mods = c.substr(4); + bool ok = true; + if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; + if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; + if (mods.find("alt") != std::string::npos && !altHeld) ok = false; + return ok; + } + + // combat / nocombat + if (c == "combat") return gameHandler.isInCombat(); + if (c == "nocombat") return !gameHandler.isInCombat(); + + // Helper to get the effective target entity + auto effTarget = [&]() -> std::shared_ptr { + if (tgt != static_cast(-1) && tgt != 0) + return gameHandler.getEntityManager().getEntity(tgt); + return gameHandler.getTarget(); + }; + + // exists / noexists + if (c == "exists") return effTarget() != nullptr; + if (c == "noexists") return effTarget() == nullptr; + + // dead / nodead + if (c == "dead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() == 0; + } + if (c == "nodead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() > 0; + } + + // help (friendly) / harm (hostile) and their no- variants + auto unitHostile = [&](const std::shared_ptr& t) -> bool { + if (!t) return false; + auto u = std::dynamic_pointer_cast(t); + return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); + }; + if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } + if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } + + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // swimming / noswimming + if (c == "swimming") return gameHandler.isSwimming(); + if (c == "noswimming") return !gameHandler.isSwimming(); + + // flying / noflying (CAN_FLY + FLYING flags active) + if (c == "flying") return gameHandler.isPlayerFlying(); + if (c == "noflying") return !gameHandler.isPlayerFlying(); + + // channeling / nochanneling + if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling(); + if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling()); + + // stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING) + auto isStealthedFn = [&]() -> bool { + auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (!pe) return false; + auto pu = std::dynamic_pointer_cast(pe); + return pu && (pu->getUnitFlags() & 0x02000000u) != 0; + }; + if (c == "stealthed") return isStealthedFn(); + if (c == "nostealthed") return !isStealthedFn(); + + // pet / nopet — player has an active pet (hunters, warlocks, DKs) + if (c == "pet") return gameHandler.hasPet(); + if (c == "nopet") return !gameHandler.hasPet(); + + // indoors / outdoors — WMO interior detection (affects mount type selection) + if (c == "indoors" || c == "nooutdoors") { + auto* r = core::Application::getInstance().getRenderer(); + return r && r->isPlayerIndoors(); + } + if (c == "outdoors" || c == "noindoors") { + auto* r = core::Application::getInstance().getRenderer(); + return !r || !r->isPlayerIndoors(); + } + + // group / nogroup — player is in a party or raid + if (c == "group" || c == "party") return gameHandler.isInGroup(); + if (c == "nogroup") return !gameHandler.isInGroup(); + + // raid / noraid — player is in a raid group (groupType == 1) + if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1; + if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1; + + // spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary) + if (c.rfind("spec:", 0) == 0) { + uint8_t wantSpec = 0; + try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} + return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1); + } + + // noform / nostance — player is NOT in a shapeshift/stance + if (c == "noform" || c == "nostance") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + // form:0 same as noform + if (c == "form:0" || c == "stance:0") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + + // buff:SpellName / nobuff:SpellName — check if the effective target (or player + // if no target specified) has a buff with the given name. + // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). + auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, + bool negate) -> bool { + // Determine which aura list to check: effective target or player + const std::vector* auras = nullptr; + if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { + // Check target's auras + auras = &gameHandler.getTargetAuras(); + } else { + auras = &gameHandler.getPlayerAuras(); + } + std::string nameLow = spellName; + for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); + for (const auto& a : *auras) { + if (a.isEmpty() || a.spellId == 0) continue; + // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type + bool isDebuff = (a.flags & 0x80) != 0; + if (wantDebuff ? !isDebuff : isDebuff) continue; + std::string sn = gameHandler.getSpellName(a.spellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + if (sn == nameLow) return !negate; + } + return negate; + }; + if (c.rfind("buff:", 0) == 0 && c.size() > 5) + return checkAuraByName(c.substr(5), false, false); + if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), false, true); + if (c.rfind("debuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), true, false); + if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) + return checkAuraByName(c.substr(9), true, true); + + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // group (any group) / nogroup / raid + if (c == "group") return !gameHandler.getPartyData().isEmpty(); + if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); + if (c == "raid") { + const auto& pd = gameHandler.getPartyData(); + return pd.groupType >= 1; // groupType 1 = raid, 0 = party + } + + // channeling:SpellName — player is currently channeling that spell + if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { + if (!gameHandler.isChanneling()) return false; + std::string want = c.substr(11); + for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); + uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); + std::string sn = gameHandler.getSpellName(castSpellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + return sn == want; + } + if (c == "channeling") return gameHandler.isChanneling(); + if (c == "nochanneling") return !gameHandler.isChanneling(); + + // casting (any active cast or channel) + if (c == "casting") return gameHandler.isCasting(); + if (c == "nocasting") return !gameHandler.isCasting(); + + // vehicle / novehicle (WotLK) + if (c == "vehicle") return gameHandler.getVehicleId() != 0; + if (c == "novehicle") return gameHandler.getVehicleId() == 0; + + // Unknown → permissive (don't block) + return true; + }; + + for (auto& alt : alts) { + // trim + size_t fs = alt.find_first_not_of(" \t"); + if (fs == std::string::npos) continue; + alt = alt.substr(fs); + size_t ls = alt.find_last_not_of(" \t"); + if (ls != std::string::npos) alt.resize(ls + 1); + + if (!alt.empty() && alt[0] == '[') { + size_t close = alt.find(']'); + if (close == std::string::npos) continue; + std::string condStr = alt.substr(1, close - 1); + std::string argPart = alt.substr(close + 1); + // Trim argPart + size_t as = argPart.find_first_not_of(" \t"); + argPart = (as != std::string::npos) ? argPart.substr(as) : ""; + + // Evaluate comma-separated conditions + uint64_t tgt = static_cast(-1); + bool pass = true; + size_t cp = 0; + while (pass) { + size_t comma = condStr.find(',', cp); + std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); + if (!evalCond(tok, tgt)) { pass = false; break; } + if (comma == std::string::npos) break; + cp = comma + 1; + } + if (pass) { + if (tgt != static_cast(-1)) targetOverride = tgt; + return argPart; + } + } else { + // No condition block — default fallback always matches + return alt; + } + } + return {}; +} + +// Execute all non-comment lines of a macro body in sequence. +// In WoW, every line executes per click; the server enforces spell-cast limits. +// /stopmacro (with optional conditionals) halts the remaining commands early. + +void ChatPanel::executeMacroText(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen, + const std::string& macroText) { + macroStopped_ = false; + for (const auto& cmd : allMacroCommands(macroText)) { + strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); + chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; + sendChatMessage(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); + if (macroStopped_) break; + } + macroStopped_ = false; +} + +// /castsequence persistent state — shared across all macros using the same spell list. +// Keyed by the normalized (lowercase, comma-joined) spell sequence string. +namespace { +struct CastSeqState { + size_t index = 0; + float lastPressSec = 0.0f; + uint64_t lastTargetGuid = 0; + bool lastInCombat = false; +}; +std::unordered_map s_castSeqStates; +} // namespace + + +void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen) { + if (strlen(chatInputBuffer_) > 0) { + std::string input(chatInputBuffer_); + + // Save to sent-message history (skip pure whitespace, cap at 50 entries) + { + bool allSpace = true; + for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } + if (!allSpace) { + // Remove duplicate of last entry if identical + if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { + chatSentHistory_.push_back(input); + if (chatSentHistory_.size() > 50) + chatSentHistory_.erase(chatSentHistory_.begin()); + } + } + } + chatHistoryIdx_ = -1; // reset browsing position after send + + game::ChatType type = game::ChatType::SAY; + std::string message = input; + std::string target; + + // Track if a channel shortcut should change the chat type dropdown + int switchChatType = -1; + + // Check for slash commands + if (input.size() > 1 && input[0] == '/') { + std::string command = input.substr(1); + size_t spacePos = command.find(' '); + std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; + + // Convert command to lowercase for comparison + std::string cmdLower = cmd; + for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); + + // /run — execute Lua script via addon system + if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { + std::string luaCode = command.substr(spacePos + 1); + auto* am = core::Application::getInstance().getAddonManager(); + if (am) { + am->runScript(luaCode); + } else { + gameHandler.addUIError("Addon system not initialized."); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /dump — evaluate Lua expression and print result + if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { + std::string expr = command.substr(spacePos + 1); + auto* am = core::Application::getInstance().getAddonManager(); + if (am && am->isInitialized()) { + // Wrap expression in print(tostring(...)) to display the value + std::string wrapped = "local __v = " + expr + + "; if type(__v) == 'table' then " + " local parts = {} " + " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " + " print('{' .. table.concat(parts, ', ') .. '}') " + "else print(tostring(__v)) end"; + am->runScript(wrapped); + } else { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "Addon system not initialized."; + gameHandler.addLocalChatMessage(errMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Check addon slash commands (SlashCmdList) before built-in commands + { + auto* am = core::Application::getInstance().getAddonManager(); + if (am && am->isInitialized()) { + std::string slashCmd = "/" + cmdLower; + std::string slashArgs; + if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); + if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { + chatInputBuffer_[0] = '\0'; + return; + } + } + } + + // Special commands + if (cmdLower == "logout") { + core::Application::getInstance().logoutToLogin(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clear") { + gameHandler.clearChatHistory(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files) + if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") { + auto* am = core::Application::getInstance().getAddonManager(); + if (am) { + am->reload(); + am->fireEvent("VARIABLES_LOADED"); + am->fireEvent("PLAYER_LOGIN"); + am->fireEvent("PLAYER_ENTERING_WORLD"); + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Interface reloaded."; + gameHandler.addLocalChatMessage(rlMsg); + } else { + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Addon system not available."; + gameHandler.addLocalChatMessage(rlMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /stopmacro [conditions] + // Halts execution of the current macro (remaining lines are skipped). + // With a condition block, only stops if the conditions evaluate to true. + // /stopmacro → always stops + // /stopmacro [combat] → stops only while in combat + // /stopmacro [nocombat] → stops only when not in combat + if (cmdLower == "stopmacro") { + bool shouldStop = true; + if (spacePos != std::string::npos) { + std::string condArg = command.substr(spacePos + 1); + while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); + if (!condArg.empty() && condArg.front() == '[') { + // Append a sentinel action so evaluateMacroConditionals can signal a match. + uint64_t tgtOver = static_cast(-1); + std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); + shouldStop = !hit.empty(); + } + } + if (shouldStop) macroStopped_ = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /invite command + if (cmdLower == "invite" && spacePos != std::string::npos) { + std::string targetName = command.substr(spacePos + 1); + gameHandler.inviteToGroup(targetName); + chatInputBuffer_[0] = '\0'; + return; + } + + // /inspect command + if (cmdLower == "inspect") { + gameHandler.inspectTarget(); + slashCmds_.showInspect = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /threat command + if (cmdLower == "threat") { + slashCmds_.toggleThreat = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /score command — BG scoreboard + if (cmdLower == "score") { + gameHandler.requestPvpLog(); + slashCmds_.showBgScore = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /time command + if (cmdLower == "time") { + gameHandler.queryServerTime(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /loc command — print player coordinates and zone name + if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { + const auto& pmi = gameHandler.getMovementInfo(); + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + char buf[256]; + snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", + pmi.x, pmi.y, pmi.z, + zoneName.empty() ? "" : " — ", + zoneName.c_str()); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = buf; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /screenshot command — capture current frame to PNG + if (cmdLower == "screenshot" || cmdLower == "ss") { + slashCmds_.takeScreenshot = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /zone command — print current zone name + if (cmdLower == "zone") { + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /played command + if (cmdLower == "played") { + gameHandler.requestPlayedTime(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ticket command — open GM ticket window + if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { + slashCmds_.showGmTicket = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /chathelp command — list chat-channel slash commands + if (cmdLower == "chathelp") { + static constexpr const char* kChatHelp[] = { + "--- Chat Channel Commands ---", + "/s [msg] Say to nearby players", + "/y [msg] Yell to a wider area", + "/w [msg] Whisper to player", + "/r [msg] Reply to last whisper", + "/p [msg] Party chat", + "/g [msg] Guild chat", + "/o [msg] Guild officer chat", + "/raid [msg] Raid chat", + "/rw [msg] Raid warning", + "/bg [msg] Battleground chat", + "/1 [msg] General channel", + "/2 [msg] Trade channel (also /wts /wtb)", + "/ [msg] Channel by number", + "/join Join a channel", + "/leave Leave a channel", + "/afk [msg] Set AFK status", + "/dnd [msg] Set Do Not Disturb", + }; + for (const char* line : kChatHelp) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /macrohelp command — list available macro conditionals + if (cmdLower == "macrohelp") { + static constexpr const char* kMacroHelp[] = { + "--- Macro Conditionals ---", + "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", + "State: [combat] [mounted] [swimming] [flying] [stealthed]", + " [channeling] [pet] [group] [raid] [indoors] [outdoors]", + "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", + " (prefix no- to negate any condition)", + "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", + " [target=focus] [target=pet] [target=mouseover] [target=player]", + " (also: @focus, @pet, @mouseover, @player, @target)", + "Form: [noform] [nostance] [form:0]", + "Keys: [mod:shift] [mod:ctrl] [mod:alt]", + "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", + "Other: #showtooltip, /stopmacro [cond], /castsequence", + }; + for (const char* line : kMacroHelp) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = line; + gameHandler.addLocalChatMessage(m); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /help command — list available slash commands + if (cmdLower == "help" || cmdLower == "?") { + static constexpr const char* kHelpLines[] = { + "--- Wowee Slash Commands ---", + "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", + "Social: /who /friend add/remove /ignore /unignore", + "Party: /invite /uninvite /leave /readycheck /mark /roll", + " /maintank /mainassist /raidconvert /raidinfo", + " /lootmethod /lootthreshold", + "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", + " /gleader /groster /ginfo /gcreate /gdisband", + "Combat: /cast /castsequence /use /startattack /stopattack", + " /stopcasting /duel /forfeit /pvp /assist", + " /follow /stopfollow /threat /combatlog", + "Items: /use /equip /equipset [name]", + "Target: /target /cleartarget /focus /clearfocus /inspect", + "Movement: /sit /stand /kneel /dismount", + "Misc: /played /time /zone /loc /afk /dnd /helm /cloak", + " /trade /score /unstuck /logout /quit /exit /ticket", + " /screenshot /difficulty", + " /macrohelp /chathelp /help", + }; + for (const char* line : kHelpLines) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /who commands + if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { + std::string query; + if (spacePos != std::string::npos) { + query = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + size_t first = query.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + query.clear(); + } else { + size_t last = query.find_last_not_of(" \t\r\n"); + query = query.substr(first, last - first + 1); + } + } + + if ((cmdLower == "whois") && query.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /whois "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "who" && (query == "help" || query == "?")) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Who commands: /who [name/filter], /whois , /online"; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + gameHandler.queryWho(query); + slashCmds_.showWho = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /combatlog command + if (cmdLower == "combatlog" || cmdLower == "cl") { + slashCmds_.toggleCombatLog = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /roll command + if (cmdLower == "roll" || cmdLower == "random" || cmdLower == "rnd") { + uint32_t minRoll = 1; + uint32_t maxRoll = 100; + + if (spacePos != std::string::npos) { + std::string args = command.substr(spacePos + 1); + size_t dashPos = args.find('-'); + size_t spacePos2 = args.find(' '); + + if (dashPos != std::string::npos) { + // Format: /roll 1-100 + try { + minRoll = std::stoul(args.substr(0, dashPos)); + maxRoll = std::stoul(args.substr(dashPos + 1)); + } catch (...) {} + } else if (spacePos2 != std::string::npos) { + // Format: /roll 1 100 + try { + minRoll = std::stoul(args.substr(0, spacePos2)); + maxRoll = std::stoul(args.substr(spacePos2 + 1)); + } catch (...) {} + } else { + // Format: /roll 100 (means 1-100) + try { + maxRoll = std::stoul(args); + } catch (...) {} + } + } + + gameHandler.randomRoll(minRoll, maxRoll); + chatInputBuffer_[0] = '\0'; + return; + } + + // /friend or /addfriend command + if (cmdLower == "friend" || cmdLower == "addfriend") { + if (spacePos != std::string::npos) { + std::string args = command.substr(spacePos + 1); + size_t subCmdSpace = args.find(' '); + + if (cmdLower == "friend" && subCmdSpace != std::string::npos) { + std::string subCmd = args.substr(0, subCmdSpace); + std::transform(subCmd.begin(), subCmd.end(), subCmd.begin(), ::tolower); + + if (subCmd == "add") { + std::string playerName = args.substr(subCmdSpace + 1); + gameHandler.addFriend(playerName); + chatInputBuffer_[0] = '\0'; + return; + } else if (subCmd == "remove" || subCmd == "delete" || subCmd == "rem") { + std::string playerName = args.substr(subCmdSpace + 1); + gameHandler.removeFriend(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + } else { + // /addfriend name or /friend name (assume add) + gameHandler.addFriend(args); + chatInputBuffer_[0] = '\0'; + return; + } + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /friend add or /friend remove "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /removefriend or /delfriend command + if (cmdLower == "removefriend" || cmdLower == "delfriend" || cmdLower == "remfriend") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.removeFriend(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /removefriend "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ignore command + if (cmdLower == "ignore") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.addIgnore(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /ignore "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /unignore command + if (cmdLower == "unignore") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.removeIgnore(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /unignore "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /dismount command + if (cmdLower == "dismount") { + gameHandler.dismount(); + chatInputBuffer_[0] = '\0'; + return; + } + + // Pet control commands (common macro use) + // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive + if (cmdLower == "petattack") { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendPetAction(5, target); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petfollow") { + gameHandler.sendPetAction(2, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petstay" || cmdLower == "pethalt") { + gameHandler.sendPetAction(3, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petpassive") { + gameHandler.sendPetAction(1, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petdefensive") { + gameHandler.sendPetAction(4, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petaggressive") { + gameHandler.sendPetAction(6, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petdismiss") { + gameHandler.dismissPet(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /cancelform / /cancelshapeshift — leave current shapeshift/stance + if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { + // Cancel the first permanent shapeshift aura the player has + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + // Permanent shapeshift auras have the permanent flag (0x20) set + if (aura.flags & 0x20) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /cancelaura — cancel a specific buff by name or ID + if (cmdLower == "cancelaura" && spacePos != std::string::npos) { + std::string auraArg = command.substr(spacePos + 1); + while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); + while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); + // Try numeric ID first + { + std::string numStr = auraArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNum = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNum) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId) gameHandler.cancelAura(spellId); + chatInputBuffer_[0] = '\0'; + return; + } + } + // Name match against player auras + std::string argLow = auraArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + std::string sn = gameHandler.getSpellName(aura.spellId); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == argLow) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /sit command + if (cmdLower == "sit") { + gameHandler.setStandState(1); // 1 = sit + chatInputBuffer_[0] = '\0'; + return; + } + + // /stand command + if (cmdLower == "stand") { + gameHandler.setStandState(0); // 0 = stand + chatInputBuffer_[0] = '\0'; + return; + } + + // /kneel command + if (cmdLower == "kneel") { + gameHandler.setStandState(8); // 8 = kneel + chatInputBuffer_[0] = '\0'; + return; + } + + // /logout command (also /camp, /quit, /exit) + if (cmdLower == "logout" || cmdLower == "camp" || cmdLower == "quit" || cmdLower == "exit") { + gameHandler.requestLogout(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /cancellogout command + if (cmdLower == "cancellogout") { + gameHandler.cancelLogout(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /difficulty command — set dungeon/raid difficulty (WotLK) + if (cmdLower == "difficulty") { + std::string arg; + if (spacePos != std::string::npos) { + arg = command.substr(spacePos + 1); + // Trim whitespace + size_t first = arg.find_first_not_of(" \t"); + size_t last = arg.find_last_not_of(" \t"); + if (first != std::string::npos) + arg = arg.substr(first, last - first + 1); + else + arg.clear(); + for (auto& ch : arg) ch = static_cast(std::tolower(static_cast(ch))); + } + + uint32_t diff = 0; + bool valid = true; + if (arg == "normal" || arg == "0") diff = 0; + else if (arg == "heroic" || arg == "1") diff = 1; + else if (arg == "25" || arg == "25normal" || arg == "25man" || arg == "2") + diff = 2; + else if (arg == "25heroic" || arg == "25manheroic" || arg == "3") + diff = 3; + else valid = false; + + if (!valid || arg.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /difficulty normal|heroic|25|25heroic (0-3)"; + gameHandler.addLocalChatMessage(msg); + } else { + static constexpr const char* kDiffNames[] = { + "Normal (5-man)", "Heroic (5-man)", "Normal (25-man)", "Heroic (25-man)" + }; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Setting difficulty to: ") + kDiffNames[diff]; + gameHandler.addLocalChatMessage(msg); + gameHandler.sendSetDifficulty(diff); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /helm command + if (cmdLower == "helm" || cmdLower == "helmet" || cmdLower == "showhelm") { + gameHandler.toggleHelm(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /cloak command + if (cmdLower == "cloak" || cmdLower == "showcloak") { + gameHandler.toggleCloak(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /follow command + if (cmdLower == "follow" || cmdLower == "f") { + gameHandler.followTarget(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /stopfollow command + if (cmdLower == "stopfollow") { + gameHandler.cancelFollow(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /assist command + if (cmdLower == "assist") { + // /assist → assist current target (use their target) + // /assist PlayerName → find PlayerName, target their target + // /assist [target=X] → evaluate conditional, target that entity's target + auto assistEntityTarget = [&](uint64_t srcGuid) { + auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); + if (!srcEnt) { gameHandler.assistTarget(); return; } + uint64_t atkGuid = 0; + const auto& flds = srcEnt->getFields(); + auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (iLo != flds.end()) { + atkGuid = iLo->second; + auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); + } + if (atkGuid != 0) { + gameHandler.setTarget(atkGuid); + } else { + std::string sn = getEntityName(srcEnt); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = (sn.empty() ? "Target" : sn) + " has no target."; + gameHandler.addLocalChatMessage(msg); + } + }; + + if (spacePos != std::string::npos) { + std::string assistArg = command.substr(spacePos + 1); + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + + // Evaluate conditionals if present + uint64_t assistOver = static_cast(-1); + if (!assistArg.empty() && assistArg.front() == '[') { + assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); + if (assistArg.empty() && assistOver == static_cast(-1)) { + chatInputBuffer_[0] = '\0'; return; // no condition matched + } + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); + } + + if (assistOver != static_cast(-1) && assistOver != 0) { + assistEntityTarget(assistOver); + } else if (!assistArg.empty()) { + // Name search + std::string argLow = assistArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { + if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; + std::string nm = getEntityName(ent); + std::string nml = nm; + for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); + if (nml.find(argLow) != 0) continue; + float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) + + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); + if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } + } + if (bestGuid) assistEntityTarget(bestGuid); + else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + assistArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } else { + gameHandler.assistTarget(); + } + } else { + gameHandler.assistTarget(); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /pvp command + if (cmdLower == "pvp") { + gameHandler.togglePvp(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ginfo command + if (cmdLower == "ginfo" || cmdLower == "guildinfo") { + gameHandler.requestGuildInfo(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /groster command + if (cmdLower == "groster" || cmdLower == "guildroster") { + gameHandler.requestGuildRoster(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gmotd command + if (cmdLower == "gmotd" || cmdLower == "guildmotd") { + if (spacePos != std::string::npos) { + std::string motd = command.substr(spacePos + 1); + gameHandler.setGuildMotd(motd); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gmotd "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gpromote command + if (cmdLower == "gpromote" || cmdLower == "guildpromote") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.promoteGuildMember(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gpromote "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gdemote command + if (cmdLower == "gdemote" || cmdLower == "guilddemote") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.demoteGuildMember(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gdemote "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gquit command + if (cmdLower == "gquit" || cmdLower == "guildquit" || cmdLower == "leaveguild") { + gameHandler.leaveGuild(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ginvite command + if (cmdLower == "ginvite" || cmdLower == "guildinvite") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.inviteToGuild(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /ginvite "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gkick command + if (cmdLower == "gkick" || cmdLower == "guildkick") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.kickGuildMember(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gkick "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gcreate command + if (cmdLower == "gcreate" || cmdLower == "guildcreate") { + if (spacePos != std::string::npos) { + std::string guildName = command.substr(spacePos + 1); + gameHandler.createGuild(guildName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gcreate "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gdisband command + if (cmdLower == "gdisband" || cmdLower == "guilddisband") { + gameHandler.disbandGuild(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gleader command + if (cmdLower == "gleader" || cmdLower == "guildleader") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.setGuildLeader(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gleader "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /readycheck command + if (cmdLower == "readycheck" || cmdLower == "rc") { + gameHandler.initiateReadyCheck(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ready command (respond yes to ready check) + if (cmdLower == "ready") { + gameHandler.respondToReadyCheck(true); + chatInputBuffer_[0] = '\0'; + return; + } + + // /notready command (respond no to ready check) + if (cmdLower == "notready" || cmdLower == "nr") { + gameHandler.respondToReadyCheck(false); + chatInputBuffer_[0] = '\0'; + return; + } + + // /yield or /forfeit command + if (cmdLower == "yield" || cmdLower == "forfeit" || cmdLower == "surrender") { + gameHandler.forfeitDuel(); + chatInputBuffer_[0] = '\0'; + return; + } + + // AFK command + if (cmdLower == "afk" || cmdLower == "away") { + std::string afkMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + gameHandler.toggleAfk(afkMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // DND command + if (cmdLower == "dnd" || cmdLower == "busy") { + std::string dndMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + gameHandler.toggleDnd(dndMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Reply command + if (cmdLower == "r" || cmdLower == "reply") { + std::string lastSender = gameHandler.getLastWhisperSender(); + if (lastSender.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "No one has whispered you yet."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer_[0] = '\0'; + return; + } + // Set whisper target to last whisper sender + strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + if (spacePos != std::string::npos) { + // /r message — send reply immediately + std::string replyMsg = command.substr(spacePos + 1); + gameHandler.sendChatMessage(game::ChatType::WHISPER, replyMsg, lastSender); + } + // Switch to whisper tab + selectedChatType_ = 4; + chatInputBuffer_[0] = '\0'; + return; + } + + // Party/Raid management commands + if (cmdLower == "uninvite" || cmdLower == "kick") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.uninvitePlayer(playerName); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /uninvite "; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "leave" || cmdLower == "leaveparty") { + gameHandler.leaveParty(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "maintank" || cmdLower == "mt") { + if (gameHandler.hasTarget()) { + gameHandler.setMainTank(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to set as main tank."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "mainassist" || cmdLower == "ma") { + if (gameHandler.hasTarget()) { + gameHandler.setMainAssist(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to set as main assist."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clearmaintank") { + gameHandler.clearMainTank(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clearmainassist") { + gameHandler.clearMainAssist(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "raidinfo") { + gameHandler.requestRaidInfo(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "raidconvert") { + gameHandler.convertToRaid(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /lootmethod (or /grouploot, /setloot) — set party/raid loot method + if (cmdLower == "lootmethod" || cmdLower == "grouploot" || cmdLower == "setloot") { + if (!gameHandler.isInGroup()) { + gameHandler.addUIError("You are not in a group."); + } else if (spacePos == std::string::npos) { + // No argument — show current method and usage + static constexpr const char* kMethodNames[] = { + "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" + }; + const auto& pd = gameHandler.getPartyData(); + const char* cur = (pd.lootMethod < 5) ? kMethodNames[pd.lootMethod] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot method: ") + cur; + gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootmethod ffa|roundrobin|master|group|needbeforegreed"; + gameHandler.addLocalChatMessage(msg); + } else { + std::string arg = command.substr(spacePos + 1); + // Lowercase the argument + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + uint32_t method = 0xFFFFFFFF; + if (arg == "ffa" || arg == "freeforall") method = 0; + else if (arg == "roundrobin" || arg == "rr") method = 1; + else if (arg == "master" || arg == "masterloot") method = 2; + else if (arg == "group" || arg == "grouploot") method = 3; + else if (arg == "needbeforegreed" || arg == "nbg" || arg == "need") method = 4; + + if (method == 0xFFFFFFFF) { + gameHandler.addUIError("Unknown loot method. Use: ffa, roundrobin, master, group, needbeforegreed"); + } else { + const auto& pd = gameHandler.getPartyData(); + // Master loot uses player guid as master looter; otherwise 0 + uint64_t masterGuid = (method == 2) ? gameHandler.getPlayerGuid() : 0; + gameHandler.sendSetLootMethod(method, pd.lootThreshold, masterGuid); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /lootthreshold — set minimum item quality for group loot rolls + if (cmdLower == "lootthreshold") { + if (!gameHandler.isInGroup()) { + gameHandler.addUIError("You are not in a group."); + } else if (spacePos == std::string::npos) { + const auto& pd = gameHandler.getPartyData(); + static constexpr const char* kQualityNames[] = { + "Poor (grey)", "Common (white)", "Uncommon (green)", + "Rare (blue)", "Epic (purple)", "Legendary (orange)" + }; + const char* cur = (pd.lootThreshold < 6) ? kQualityNames[pd.lootThreshold] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot threshold: ") + cur; + gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootthreshold <0-5> (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary)"; + gameHandler.addLocalChatMessage(msg); + } else { + std::string arg = command.substr(spacePos + 1); + // Trim whitespace + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + uint32_t threshold = 0xFFFFFFFF; + if (arg.size() == 1 && arg[0] >= '0' && arg[0] <= '5') { + threshold = static_cast(arg[0] - '0'); + } else { + // Accept quality names + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + if (arg == "poor" || arg == "grey" || arg == "gray") threshold = 0; + else if (arg == "common" || arg == "white") threshold = 1; + else if (arg == "uncommon" || arg == "green") threshold = 2; + else if (arg == "rare" || arg == "blue") threshold = 3; + else if (arg == "epic" || arg == "purple") threshold = 4; + else if (arg == "legendary" || arg == "orange") threshold = 5; + } + + if (threshold == 0xFFFFFFFF) { + gameHandler.addUIError("Invalid threshold. Use 0-5 or: poor, common, uncommon, rare, epic, legendary"); + } else { + const auto& pd = gameHandler.getPartyData(); + uint64_t masterGuid = (pd.lootMethod == 2) ? gameHandler.getPlayerGuid() : 0; + gameHandler.sendSetLootMethod(pd.lootMethod, threshold, masterGuid); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /mark [icon] — set or clear a raid target mark on the current target. + // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull + // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) + // /mark — no arg marks with skull (icon 7) + if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { + if (!gameHandler.hasTarget()) { + game::MessageChatData noTgt; + noTgt.type = game::ChatType::SYSTEM; + noTgt.language = game::ChatLanguage::UNIVERSAL; + noTgt.message = "No target selected."; + gameHandler.addLocalChatMessage(noTgt); + chatInputBuffer_[0] = '\0'; + return; + } + static constexpr const char* kMarkWords[] = { + "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" + }; + uint8_t icon = 7; // default: skull + if (spacePos != std::string::npos) { + std::string arg = command.substr(spacePos + 1); + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + std::string argLow = arg; + for (auto& c : argLow) c = static_cast(std::tolower(c)); + if (argLow == "clear" || argLow == "0" || argLow == "none") { + gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); + chatInputBuffer_[0] = '\0'; + return; + } + bool found = false; + for (int mi = 0; mi < 8; ++mi) { + if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } + } + if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { + icon = static_cast(argLow[0] - '1'); + found = true; + } + if (!found) { + game::MessageChatData badArg; + badArg.type = game::ChatType::SYSTEM; + badArg.language = game::ChatLanguage::UNIVERSAL; + badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; + gameHandler.addLocalChatMessage(badArg); + chatInputBuffer_[0] = '\0'; + return; + } + } + gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); + chatInputBuffer_[0] = '\0'; + return; + } + + // Combat and Trade commands + if (cmdLower == "duel") { + if (gameHandler.hasTarget()) { + gameHandler.proposeDuel(gameHandler.getTargetGuid()); + } else if (spacePos != std::string::npos) { + // Target player by name (would need name-to-GUID lookup) + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to challenge to a duel."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to challenge to a duel."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "trade") { + if (gameHandler.hasTarget()) { + gameHandler.initiateTrade(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to trade with."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "startattack") { + // Support macro conditionals: /startattack [harm,nodead] + bool condPass = true; + uint64_t saOverride = static_cast(-1); + if (spacePos != std::string::npos) { + std::string saArg = command.substr(spacePos + 1); + while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); + if (!saArg.empty() && saArg.front() == '[') { + std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); + condPass = !(result.empty() && saOverride == static_cast(-1)); + } + } + if (condPass) { + uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) + ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + if (atkTarget != 0) { + gameHandler.startAutoAttack(atkTarget); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You have no target."; + gameHandler.addLocalChatMessage(msg); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "stopattack") { + gameHandler.stopAutoAttack(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "stopcasting") { + gameHandler.stopCasting(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "cancelqueuedspell" || cmdLower == "stopspellqueue") { + gameHandler.cancelQueuedSpell(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) + // /equipset — list available sets in chat + if (cmdLower == "equipset") { + const auto& sets = gameHandler.getEquipmentSets(); + auto sysSay = [&](const std::string& msg) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = msg; + gameHandler.addLocalChatMessage(m); + }; + if (spacePos == std::string::npos) { + // No argument: list available sets + if (sets.empty()) { + sysSay("[System] No equipment sets saved."); + } else { + sysSay("[System] Equipment sets:"); + for (const auto& es : sets) + sysSay(" " + es.name); + } + } else { + std::string setName = command.substr(spacePos + 1); + while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); + while (!setName.empty() && setName.back() == ' ') setName.pop_back(); + // Case-insensitive prefix match + std::string setLower = setName; + std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); + const game::GameHandler::EquipmentSetInfo* found = nullptr; + for (const auto& es : sets) { + std::string nameLow = es.name; + std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); + if (nameLow == setLower || nameLow.find(setLower) == 0) { + found = &es; + break; + } + } + if (found) { + gameHandler.useEquipmentSet(found->setId); + } else { + sysSay("[System] No equipment set matching '" + setName + "'."); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... + // Cycles through the spell list on successive presses; resets per the reset= spec. + if (cmdLower == "castsequence" && spacePos != std::string::npos) { + std::string seqArg = command.substr(spacePos + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + + // Macro conditionals + uint64_t seqTgtOver = static_cast(-1); + if (!seqArg.empty() && seqArg.front() == '[') { + seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); + if (seqArg.empty() && seqTgtOver == static_cast(-1)) { + chatInputBuffer_[0] = '\0'; return; + } + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); + } + + // Optional reset= spec (may contain slash-separated conditions: reset=5/target) + std::string resetSpec; + if (seqArg.rfind("reset=", 0) == 0) { + size_t spAfter = seqArg.find(' '); + if (spAfter != std::string::npos) { + resetSpec = seqArg.substr(6, spAfter - 6); + seqArg = seqArg.substr(spAfter + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + } + } + + // Parse comma-separated spell list + std::vector seqSpells; + { + std::string cur; + for (char c : seqArg) { + if (c == ',') { + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + cur.clear(); + } else { cur += c; } + } + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + } + if (seqSpells.empty()) { chatInputBuffer_[0] = '\0'; return; } + + // Build stable key from lowercase spell list + std::string seqKey; + for (size_t k = 0; k < seqSpells.size(); ++k) { + if (k) seqKey += ','; + std::string sl = seqSpells[k]; + for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); + seqKey += sl; + } + + auto& seqState = s_castSeqStates[seqKey]; + + // Check reset conditions (slash-separated: e.g. "5/target") + float nowSec = static_cast(ImGui::GetTime()); + bool shouldReset = false; + if (!resetSpec.empty()) { + size_t rpos = 0; + while (rpos <= resetSpec.size()) { + size_t slash = resetSpec.find('/', rpos); + std::string part = (slash != std::string::npos) + ? resetSpec.substr(rpos, slash - rpos) + : resetSpec.substr(rpos); + std::string plow = part; + for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); + bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), + [](unsigned char c){ return std::isdigit(c) || c == '.'; }); + if (isNum) { + float rSec = 0.0f; + try { rSec = std::stof(plow); } catch (...) {} + if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; + } else if (plow == "target") { + if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; + } else if (plow == "combat") { + if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; + } + if (slash == std::string::npos) break; + rpos = slash + 1; + } + } + if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; + + const std::string& seqSpell = seqSpells[seqState.index]; + seqState.index = (seqState.index + 1) % seqSpells.size(); + seqState.lastPressSec = nowSec; + seqState.lastTargetGuid = gameHandler.getTargetGuid(); + seqState.lastInCombat = gameHandler.isInCombat(); + + // Cast the selected spell — mirrors /cast spell lookup + std::string ssLow = seqSpell; + for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); + if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); + + uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) + ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + + // Numeric ID + if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); + bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (ssNumeric) { + uint32_t ssId = 0; + try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} + if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); + } else { + uint32_t ssBest = 0; int ssBestRank = -1; + 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 != ssLow) continue; + int sRnk = 0; + const std::string& rk = gameHandler.getSpellRank(sid); + if (!rk.empty()) { + std::string rkl = rk; + for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); + if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } + } + if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } + } + if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "cast" && spacePos != std::string::npos) { + std::string spellArg = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + + // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal + uint64_t castTargetOverride = static_cast(-1); + if (!spellArg.empty() && spellArg.front() == '[') { + spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); + if (spellArg.empty()) { + chatInputBuffer_[0] = '\0'; + return; // No conditional matched — skip cast + } + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + } + + // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) + if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); + + // Support numeric spell ID: /cast 133 or /cast #133 + { + std::string numStr = spellArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId != 0) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + gameHandler.castSpell(spellId, targetGuid); + } + chatInputBuffer_[0] = '\0'; + return; + } + } + + // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" + int requestedRank = -1; // -1 = highest rank + std::string spellName = spellArg; + { + auto rankPos = spellArg.find('('); + if (rankPos != std::string::npos) { + std::string rankStr = spellArg.substr(rankPos + 1); + // Strip closing paren and whitespace + auto closePos = rankStr.find(')'); + if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); + for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); + // Expect "rank N" + if (rankStr.rfind("rank ", 0) == 0) { + try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} + } + spellName = spellArg.substr(0, rankPos); + while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); + } + } + + std::string spellNameLower = spellName; + for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); + + // Search known spells for a name match; pick highest rank (or specific rank) + uint32_t bestSpellId = 0; + int bestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sName = gameHandler.getSpellName(sid); + if (sName.empty()) continue; + std::string sNameLower = sName; + for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); + if (sNameLower != spellNameLower) continue; + + // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) + int sRank = 0; + const std::string& rankStr = gameHandler.getSpellRank(sid); + if (!rankStr.empty()) { + std::string rLow = rankStr; + for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); + if (rLow.rfind("rank ", 0) == 0) { + try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} + } + } + + if (requestedRank >= 0) { + if (sRank == requestedRank) { bestSpellId = sid; break; } + } else { + if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } + } + } + + if (bestSpellId) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + gameHandler.castSpell(bestSpellId, targetGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = requestedRank >= 0 + ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." + : "Unknown spell: '" + spellName + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /use + // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, + // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) + if (cmdLower == "use" && spacePos != std::string::npos) { + std::string useArg = command.substr(spacePos + 1); + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + + // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem + if (!useArg.empty() && useArg.front() == '[') { + uint64_t dummy = static_cast(-1); + useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); + if (useArg.empty()) { chatInputBuffer_[0] = '\0'; return; } + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + } + + // Check for bag/slot notation: two numbers separated by whitespace + { + std::istringstream iss(useArg); + int bagNum = -1, slotNum = -1; + iss >> bagNum >> slotNum; + if (!iss.fail() && slotNum >= 1) { + if (bagNum == 0) { + // Backpack: bag=0, slot 1-based → 0-based + gameHandler.useItemBySlot(slotNum - 1); + chatInputBuffer_[0] = '\0'; + return; + } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { + // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) + gameHandler.useItemInBag(bagNum - 1, slotNum - 1); + chatInputBuffer_[0] = '\0'; + return; + } + } + } + + // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) + { + std::string numStr = useArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) + int slotNum = 0; + try { slotNum = std::stoi(numStr); } catch (...) {} + if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { + auto eslot = static_cast(slotNum - 1); + const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); + if (!esl.empty()) + gameHandler.useItemById(esl.item.itemId); + } + chatInputBuffer_[0] = '\0'; + return; + } + } + + std::string useArgLower = useArg; + for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + useArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /equip — auto-equip an item from backpack/bags by name + if (cmdLower == "equip" && spacePos != std::string::npos) { + std::string equipArg = command.substr(spacePos + 1); + while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); + while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); + std::string equipArgLower = equipArg; + for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + equipArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Targeting commands + if (cmdLower == "cleartarget") { + // Support macro conditionals: /cleartarget [dead] clears only if target is dead + bool ctCondPass = true; + if (spacePos != std::string::npos) { + std::string ctArg = command.substr(spacePos + 1); + while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); + if (!ctArg.empty() && ctArg.front() == '[') { + uint64_t ctOver = static_cast(-1); + std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); + ctCondPass = !(res.empty() && ctOver == static_cast(-1)); + } + } + if (ctCondPass) gameHandler.clearTarget(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "target" && spacePos != std::string::npos) { + // Search visible entities for name match (case-insensitive prefix). + // Among all matches, pick the nearest living unit to the player. + // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss + std::string targetArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t targetCmdOverride = static_cast(-1); + if (!targetArg.empty() && targetArg.front() == '[') { + targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); + if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { + // No condition matched — silently skip (macro fallthrough) + chatInputBuffer_[0] = '\0'; + return; + } + while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); + while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); + } + + // If conditionals resolved to a specific GUID, target it directly + if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { + gameHandler.setTarget(targetCmdOverride); + chatInputBuffer_[0] = '\0'; + return; + } + + // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently + if (targetArg.empty()) { + chatInputBuffer_[0] = '\0'; + return; + } + + std::string targetArgLower = targetArg; + for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + const float playerX = pmi.x; + const float playerY = pmi.y; + const float playerZ = pmi.z; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(targetArgLower) == 0) { + float dx = entity->getX() - playerX; + float dy = entity->getY() - playerY; + float dz = entity->getZ() - playerZ; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { + bestDist = dist; + bestGuid = guid; + } + } + } + if (bestGuid) { + gameHandler.setTarget(bestGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No target matching '" + targetArg + "' found."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetenemy") { + gameHandler.targetEnemy(false); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetfriend") { + gameHandler.targetFriend(false); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetlasttarget" || cmdLower == "targetlast") { + gameHandler.targetLastTarget(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetlastenemy") { + gameHandler.targetEnemy(true); // Reverse direction + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetlastfriend") { + gameHandler.targetFriend(true); // Reverse direction + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "focus") { + // /focus → set current target as focus + // /focus PlayerName → search for entity by name and set as focus + // /focus [target=X] Name → macro conditional: set focus to resolved target + if (spacePos != std::string::npos) { + std::string focusArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t focusCmdOverride = static_cast(-1); + if (!focusArg.empty() && focusArg.front() == '[') { + focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); + if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { + chatInputBuffer_[0] = '\0'; + return; + } + while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); + while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); + } + + if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { + // Conditional resolved to a specific GUID (e.g. [target=mouseover]) + gameHandler.setFocus(focusCmdOverride); + } else if (!focusArg.empty()) { + // Name search — same logic as /target + std::string focusArgLower = focusArg; + for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(focusArgLower) == 0) { + float dx = entity->getX() - pmi.x; + float dy = entity->getY() - pmi.y; + float dz = entity->getZ() - pmi.z; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { bestDist = dist; bestGuid = guid; } + } + } + if (bestGuid) { + gameHandler.setFocus(bestGuid); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + focusArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } + } else if (gameHandler.hasTarget()) { + gameHandler.setFocus(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a unit to set as focus."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clearfocus") { + gameHandler.clearFocus(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /unstuck command — resets player position to floor height + if (cmdLower == "unstuck") { + gameHandler.unstuck(); + chatInputBuffer_[0] = '\0'; + return; + } + // /unstuckgy command — move to nearest graveyard + if (cmdLower == "unstuckgy") { + gameHandler.unstuckGy(); + chatInputBuffer_[0] = '\0'; + return; + } + // /unstuckhearth command — teleport to hearthstone bind point + if (cmdLower == "unstuckhearth") { + gameHandler.unstuckHearth(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /transport board — board test transport + if (cmdLower == "transport board") { + auto* tm = gameHandler.getTransportManager(); + if (tm) { + // Test transport GUID + uint64_t testTransportGuid = 0x1000000000000001ULL; + // Place player at center of deck (rough estimate) + glm::vec3 deckCenter(0.0f, 0.0f, 5.0f); + gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Boarded test transport. Use '/transport leave' to disembark."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Transport system not available."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /transport leave — disembark from transport + if (cmdLower == "transport leave") { + if (gameHandler.isOnTransport()) { + gameHandler.clearPlayerTransport(); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Disembarked from transport."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You are not on a transport."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Chat channel slash commands + // If used without a message (e.g. just "/s"), switch the chat type dropdown + bool isChannelCommand = false; + if (cmdLower == "s" || cmdLower == "say") { + type = game::ChatType::SAY; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 0; + } else if (cmdLower == "y" || cmdLower == "yell" || cmdLower == "shout") { + type = game::ChatType::YELL; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 1; + } else if (cmdLower == "p" || cmdLower == "party") { + type = game::ChatType::PARTY; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 2; + } else if (cmdLower == "g" || cmdLower == "guild") { + type = game::ChatType::GUILD; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 3; + } else if (cmdLower == "raid" || cmdLower == "rsay" || cmdLower == "ra") { + type = game::ChatType::RAID; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 5; + } else if (cmdLower == "raidwarning" || cmdLower == "rw") { + type = game::ChatType::RAID_WARNING; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 8; + } else if (cmdLower == "officer" || cmdLower == "o" || cmdLower == "osay") { + type = game::ChatType::OFFICER; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 6; + } else if (cmdLower == "battleground" || cmdLower == "bg") { + type = game::ChatType::BATTLEGROUND; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 7; + } else if (cmdLower == "instance" || cmdLower == "i") { + // Instance chat uses PARTY chat type + type = game::ChatType::PARTY; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 9; + } else if (cmdLower == "join") { + // /join with no args: accept pending BG invite if any + if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) { + gameHandler.acceptBattlefield(); + chatInputBuffer_[0] = '\0'; + return; + } + // /join ChannelName [password] + if (spacePos != std::string::npos) { + std::string rest = command.substr(spacePos + 1); + size_t pwStart = rest.find(' '); + std::string channelName = (pwStart != std::string::npos) ? rest.substr(0, pwStart) : rest; + std::string password = (pwStart != std::string::npos) ? rest.substr(pwStart + 1) : ""; + gameHandler.joinChannel(channelName, password); + } + chatInputBuffer_[0] = '\0'; + return; + } else if (cmdLower == "leave") { + // /leave ChannelName + if (spacePos != std::string::npos) { + std::string channelName = command.substr(spacePos + 1); + gameHandler.leaveChannel(channelName); + } + chatInputBuffer_[0] = '\0'; + return; + } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { + // /wts and /wtb — send to Trade channel + // Prefix with [WTS] / [WTB] and route to the Trade channel + const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; + const std::string body = command.substr(spacePos + 1); + // Find the Trade channel among joined channels (case-insensitive prefix match) + std::string tradeChan; + for (const auto& ch : gameHandler.getJoinedChannels()) { + std::string chLow = ch; + for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); + if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } + } + if (tradeChan.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "You are not in the Trade channel."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer_[0] = '\0'; + return; + } + message = tag + body; + type = game::ChatType::CHANNEL; + target = tradeChan; + isChannelCommand = true; + } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { + // /1 msg, /2 msg — channel shortcuts + int channelIdx = cmdLower[0] - '0'; + std::string channelName = gameHandler.getChannelByIndex(channelIdx); + if (!channelName.empty() && spacePos != std::string::npos) { + message = command.substr(spacePos + 1); + type = game::ChatType::CHANNEL; + target = channelName; + isChannelCommand = true; + } else if (channelName.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.message = "You are not in channel " + std::to_string(channelIdx) + "."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer_[0] = '\0'; + return; + } else { + chatInputBuffer_[0] = '\0'; + return; + } + } else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") { + switchChatType = 4; + if (spacePos != std::string::npos) { + std::string rest = command.substr(spacePos + 1); + size_t msgStart = rest.find(' '); + if (msgStart != std::string::npos) { + // /w PlayerName message — send whisper immediately + target = rest.substr(0, msgStart); + message = rest.substr(msgStart + 1); + type = game::ChatType::WHISPER; + isChannelCommand = true; + // Set whisper target for future messages + strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + } else { + // /w PlayerName — switch to whisper mode with target set + strncpy(whisperTargetBuffer_, rest.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + message = ""; + isChannelCommand = true; + } + } else { + // Just "/w" — switch to whisper mode + message = ""; + isChannelCommand = true; + } + } else if (cmdLower == "r" || cmdLower == "reply") { + switchChatType = 4; + std::string lastSender = gameHandler.getLastWhisperSender(); + if (lastSender.empty()) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No one has whispered you yet."; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer_[0] = '\0'; + return; + } + target = lastSender; + strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + if (spacePos != std::string::npos) { + message = command.substr(spacePos + 1); + type = game::ChatType::WHISPER; + } else { + message = ""; + } + isChannelCommand = true; + } + + // Check for emote commands + if (!isChannelCommand) { + std::string targetName; + const std::string* targetNamePtr = nullptr; + if (gameHandler.hasTarget()) { + auto targetEntity = gameHandler.getTarget(); + if (targetEntity) { + targetName = getEntityName(targetEntity); + if (!targetName.empty()) targetNamePtr = &targetName; + } + } + + std::string emoteText = rendering::Renderer::getEmoteText(cmdLower, targetNamePtr); + if (!emoteText.empty()) { + // Play the emote animation + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + renderer->playEmote(cmdLower); + } + + // Send CMSG_TEXT_EMOTE to server + uint32_t dbcId = rendering::Renderer::getEmoteDbcId(cmdLower); + if (dbcId != 0) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendTextEmote(dbcId, targetGuid); + } + + // Add local chat message + game::MessageChatData msg; + msg.type = game::ChatType::TEXT_EMOTE; + msg.language = game::ChatLanguage::COMMON; + msg.message = emoteText; + gameHandler.addLocalChatMessage(msg); + + chatInputBuffer_[0] = '\0'; + return; + } + + // Not a recognized command — fall through and send as normal chat + if (!isChannelCommand) { + message = input; + } + } + + // If no valid command found and starts with /, just send as-is + if (!isChannelCommand && message == input) { + // Use the selected chat type from dropdown + switch (selectedChatType_) { + case 0: type = game::ChatType::SAY; break; + case 1: type = game::ChatType::YELL; break; + case 2: type = game::ChatType::PARTY; break; + case 3: type = game::ChatType::GUILD; break; + case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; + case 5: type = game::ChatType::RAID; break; + case 6: type = game::ChatType::OFFICER; break; + case 7: type = game::ChatType::BATTLEGROUND; break; + case 8: type = game::ChatType::RAID_WARNING; break; + case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx_]; + } else { type = game::ChatType::SAY; } + break; + } + default: type = game::ChatType::SAY; break; + } + } + } else { + // No slash command, use the selected chat type from dropdown + switch (selectedChatType_) { + case 0: type = game::ChatType::SAY; break; + case 1: type = game::ChatType::YELL; break; + case 2: type = game::ChatType::PARTY; break; + case 3: type = game::ChatType::GUILD; break; + case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; + case 5: type = game::ChatType::RAID; break; + case 6: type = game::ChatType::OFFICER; break; + case 7: type = game::ChatType::BATTLEGROUND; break; + case 8: type = game::ChatType::RAID_WARNING; break; + case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx_]; + } else { type = game::ChatType::SAY; } + break; + } + default: type = game::ChatType::SAY; break; + } + } + + // Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands. + if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { + std::string cmd = buildPortBotCommand(message); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + if (cmd.empty() || cmd == "__help__") { + msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); + msg.message = "PortBot executed: " + cmd; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Validate whisper has a target + if (type == game::ChatType::WHISPER && target.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must specify a player name for whisper."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Don't send empty messages — but switch chat type if a channel shortcut was used + if (!message.empty()) { + gameHandler.sendChatMessage(type, message, target); + } + + // Switch chat type dropdown when channel shortcut used (with or without message) + if (switchChatType >= 0) { + selectedChatType_ = switchChatType; + } + + // Clear input + chatInputBuffer_[0] = '\0'; + } +} + + +const char* ChatPanel::getChatTypeName(game::ChatType type) const { + switch (type) { + case game::ChatType::SAY: return "Say"; + case game::ChatType::YELL: return "Yell"; + case game::ChatType::EMOTE: return "Emote"; + case game::ChatType::TEXT_EMOTE: return "Emote"; + case game::ChatType::PARTY: return "Party"; + case game::ChatType::GUILD: return "Guild"; + case game::ChatType::OFFICER: return "Officer"; + case game::ChatType::RAID: return "Raid"; + case game::ChatType::RAID_LEADER: return "Raid Leader"; + case game::ChatType::RAID_WARNING: return "Raid Warning"; + case game::ChatType::BATTLEGROUND: return "Battleground"; + case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; + case game::ChatType::WHISPER: return "Whisper"; + case game::ChatType::WHISPER_INFORM: return "To"; + case game::ChatType::SYSTEM: return "System"; + case game::ChatType::MONSTER_SAY: return "Say"; + case game::ChatType::MONSTER_YELL: return "Yell"; + case game::ChatType::MONSTER_EMOTE: return "Emote"; + case game::ChatType::CHANNEL: return "Channel"; + case game::ChatType::ACHIEVEMENT: return "Achievement"; + case game::ChatType::DND: return "DND"; + case game::ChatType::AFK: return "AFK"; + case game::ChatType::BG_SYSTEM_NEUTRAL: + case game::ChatType::BG_SYSTEM_ALLIANCE: + case game::ChatType::BG_SYSTEM_HORDE: return "System"; + default: return "Unknown"; + } +} + + +ImVec4 ChatPanel::getChatTypeColor(game::ChatType type) const { + switch (type) { + case game::ChatType::SAY: + return ui::colors::kWhite; // White + case game::ChatType::YELL: + return kColorRed; // Red + case game::ChatType::EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange + case game::ChatType::TEXT_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange + case game::ChatType::PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue + case game::ChatType::GUILD: + return kColorBrightGreen; // Green + case game::ChatType::OFFICER: + return ImVec4(0.3f, 0.8f, 0.3f, 1.0f); // Dark green + case game::ChatType::RAID: + return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange + case game::ChatType::RAID_LEADER: + return ImVec4(1.0f, 0.4f, 0.0f, 1.0f); // Darker orange + case game::ChatType::RAID_WARNING: + return ImVec4(1.0f, 0.0f, 0.0f, 1.0f); // Red + case game::ChatType::BATTLEGROUND: + return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange-gold + case game::ChatType::BATTLEGROUND_LEADER: + return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange + case game::ChatType::WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink + case game::ChatType::WHISPER_INFORM: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink + case game::ChatType::SYSTEM: + return kColorYellow; // Yellow + case game::ChatType::MONSTER_SAY: + return ui::colors::kWhite; // White (same as SAY) + case game::ChatType::MONSTER_YELL: + return kColorRed; // Red (same as YELL) + case game::ChatType::MONSTER_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) + case game::ChatType::CHANNEL: + return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink + case game::ChatType::ACHIEVEMENT: + return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow + case game::ChatType::GUILD_ACHIEVEMENT: + return colors::kWarmGold; // Gold + case game::ChatType::SKILL: + return colors::kCyan; // Cyan + case game::ChatType::LOOT: + return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple + case game::ChatType::MONSTER_WHISPER: + case game::ChatType::RAID_BOSS_WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) + case game::ChatType::RAID_BOSS_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) + case game::ChatType::MONSTER_PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) + case game::ChatType::BG_SYSTEM_NEUTRAL: + return colors::kWarmGold; // Gold + case game::ChatType::BG_SYSTEM_ALLIANCE: + return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue + case game::ChatType::BG_SYSTEM_HORDE: + return kColorRed; // Red + case game::ChatType::AFK: + case game::ChatType::DND: + return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray + default: + return ui::colors::kLightGray; // Gray + } +} + + +std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { + // Get player gender, pronouns, and name + game::Gender gender = game::Gender::NONBINARY; + std::string playerName = "Adventurer"; + const auto* character = gameHandler.getActiveCharacter(); + if (character) { + gender = character->gender; + if (!character->name.empty()) { + playerName = character->name; + } + } + game::Pronouns pronouns = game::Pronouns::forGender(gender); + + std::string result = text; + + // Helper to trim whitespace + auto trim = [](std::string& s) { + const char* ws = " \t\n\r"; + size_t start = s.find_first_not_of(ws); + if (start == std::string::npos) { s.clear(); return; } + size_t end = s.find_last_not_of(ws); + s = s.substr(start, end - start + 1); + }; + + // Replace $g/$G placeholders first. + size_t pos = 0; + while ((pos = result.find('$', pos)) != std::string::npos) { + if (pos + 1 >= result.length()) break; + char marker = result[pos + 1]; + if (marker != 'g' && marker != 'G') { pos++; continue; } + + size_t endPos = result.find(';', pos); + if (endPos == std::string::npos) { pos += 2; continue; } + + std::string placeholder = result.substr(pos + 2, endPos - pos - 2); + + // Split by colons + std::vector parts; + size_t start = 0; + size_t colonPos; + while ((colonPos = placeholder.find(':', start)) != std::string::npos) { + std::string part = placeholder.substr(start, colonPos - start); + trim(part); + parts.push_back(part); + start = colonPos + 1; + } + // Add the last part + std::string lastPart = placeholder.substr(start); + trim(lastPart); + parts.push_back(lastPart); + + // Select appropriate text based on gender + std::string replacement; + if (parts.size() >= 3) { + // Three options: male, female, nonbinary + switch (gender) { + case game::Gender::MALE: + replacement = parts[0]; + break; + case game::Gender::FEMALE: + replacement = parts[1]; + break; + case game::Gender::NONBINARY: + replacement = parts[2]; + break; + } + } else if (parts.size() >= 2) { + // Two options: male, female (use first for nonbinary) + switch (gender) { + case game::Gender::MALE: + replacement = parts[0]; + break; + case game::Gender::FEMALE: + replacement = parts[1]; + break; + case game::Gender::NONBINARY: + // Default to gender-neutral: use the shorter/simpler option + replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; + break; + } + } else { + // Malformed placeholder + pos = endPos + 1; + continue; + } + + result.replace(pos, endPos - pos + 1, replacement); + pos += replacement.length(); + } + + // Resolve class and race names for $C and $R placeholders + std::string className = "Adventurer"; + std::string raceName = "Unknown"; + if (character) { + className = game::getClassName(character->characterClass); + raceName = game::getRaceName(character->race); + } + + // Replace simple placeholders. + // $n/$N = player name, $c/$C = class name, $r/$R = race name + // $p = subject pronoun (he/she/they) + // $o = object pronoun (him/her/them) + // $s = possessive adjective (his/her/their) + // $S = possessive pronoun (his/hers/theirs) + // $b/$B = line break + pos = 0; + while ((pos = result.find('$', pos)) != std::string::npos) { + if (pos + 1 >= result.length()) break; + + char code = result[pos + 1]; + std::string replacement; + switch (code) { + case 'n': case 'N': replacement = playerName; break; + case 'c': case 'C': replacement = className; break; + case 'r': case 'R': replacement = raceName; break; + case 'p': replacement = pronouns.subject; break; + case 'o': replacement = pronouns.object; break; + case 's': replacement = pronouns.possessive; break; + case 'S': replacement = pronouns.possessiveP; break; + case 'b': case 'B': replacement = "\n"; break; + case 'g': case 'G': pos++; continue; + default: pos++; continue; + } + + result.replace(pos, 2, replacement); + pos += replacement.length(); + } + + // WoW markup linebreak token. + pos = 0; + while ((pos = result.find("|n", pos)) != std::string::npos) { + result.replace(pos, 2, "\n"); + pos += 1; + } + pos = 0; + while ((pos = result.find("|N", pos)) != std::string::npos) { + result.replace(pos, 2, "\n"); + pos += 1; + } + + return result; +} + +void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { + if (chatBubbles_.empty()) return; + + auto* renderer = core::Application::getInstance().getRenderer(); + auto* camera = renderer ? renderer->getCamera() : nullptr; + if (!camera) 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; + + // Get delta time from ImGui + float dt = ImGui::GetIO().DeltaTime; + + glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); + + // Update and render bubbles + for (int i = static_cast(chatBubbles_.size()) - 1; i >= 0; --i) { + auto& bubble = chatBubbles_[i]; + bubble.timeRemaining -= dt; + if (bubble.timeRemaining <= 0.0f) { + chatBubbles_.erase(chatBubbles_.begin() + i); + continue; + } + + // Get entity position + auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid); + if (!entity) continue; + + // Convert canonical → render coordinates, offset up by 2.5 units for bubble above head + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.0f) continue; // Behind camera + + glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); + float screenX = (ndc.x * 0.5f + 0.5f) * screenW; + // Camera bakes the Vulkan Y-flip into the projection matrix: + // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. + float screenY = (ndc.y * 0.5f + 0.5f) * screenH; + + // Skip if off-screen + if (screenX < -200.0f || screenX > screenW + 200.0f || + screenY < -100.0f || screenY > screenH + 100.0f) continue; + + // Fade alpha over last 2 seconds + float alpha = 1.0f; + if (bubble.timeRemaining < 2.0f) { + alpha = bubble.timeRemaining / 2.0f; + } + + // Draw bubble window + std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid); + ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.7f * alpha); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4)); + + ImGui::Begin(winId.c_str(), nullptr, flags); + + ImVec4 textColor = bubble.isYell + ? ImVec4(1.0f, 0.2f, 0.2f, alpha) + : ImVec4(1.0f, 1.0f, 1.0f, alpha); + + ImGui::PushStyleColor(ImGuiCol_Text, textColor); + ImGui::PushTextWrapPos(200.0f); + ImGui::TextWrapped("%s", bubble.message.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + + ImGui::End(); + ImGui::PopStyleVar(2); + } +} + + +// ---- Public interface methods ---- + +void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { + if (!chatBubbleCallbackSet_) { + gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { + float duration = 8.0f + static_cast(msg.size()) * 0.06f; + if (isYell) duration += 2.0f; + if (duration > 15.0f) duration = 15.0f; + + // Replace existing bubble for same sender + for (auto& b : chatBubbles_) { + if (b.senderGuid == guid) { + b.message = msg; + b.timeRemaining = duration; + b.totalDuration = duration; + b.isYell = isYell; + return; + } + } + // Evict oldest if too many + if (chatBubbles_.size() >= 10) { + chatBubbles_.erase(chatBubbles_.begin()); + } + chatBubbles_.push_back({guid, msg, duration, duration, isYell}); + }); + chatBubbleCallbackSet_ = true; + } +} + +void ChatPanel::insertChatLink(const std::string& link) { + if (link.empty()) return; + size_t curLen = strlen(chatInputBuffer_); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer_)) { + strncat(chatInputBuffer_, link.c_str(), sizeof(chatInputBuffer_) - curLen - 1); + chatInputMoveCursorToEnd_ = true; + refocusChatInput_ = true; + } +} + +void ChatPanel::activateSlashInput() { + refocusChatInput_ = true; + chatInputBuffer_[0] = '/'; + chatInputBuffer_[1] = '\0'; + chatInputMoveCursorToEnd_ = true; +} + +void ChatPanel::activateInput() { + refocusChatInput_ = true; +} + +void ChatPanel::setWhisperTarget(const std::string& name) { + selectedChatType_ = 4; // WHISPER + strncpy(whisperTargetBuffer_, name.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + refocusChatInput_ = true; +} + +ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { + SlashCommands result = slashCmds_; + slashCmds_ = {}; + return result; +} + +void ChatPanel::renderSettingsTab(std::function saveSettingsFn) { + ImGui::Spacing(); + + ImGui::Text("Appearance"); + ImGui::Separator(); + + if (ImGui::Checkbox("Show Timestamps", &chatShowTimestamps)) { + saveSettingsFn(); + } + ImGui::SetItemTooltip("Show [HH:MM] before each chat message"); + + const char* fontSizes[] = { "Small", "Medium", "Large" }; + if (ImGui::Combo("Chat Font Size", &chatFontSize, fontSizes, 3)) { + saveSettingsFn(); + } + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Text("Auto-Join Channels"); + ImGui::Separator(); + + if (ImGui::Checkbox("General", &chatAutoJoinGeneral)) saveSettingsFn(); + if (ImGui::Checkbox("Trade", &chatAutoJoinTrade)) saveSettingsFn(); + if (ImGui::Checkbox("LocalDefense", &chatAutoJoinLocalDefense)) saveSettingsFn(); + if (ImGui::Checkbox("LookingForGroup", &chatAutoJoinLFG)) saveSettingsFn(); + if (ImGui::Checkbox("Local", &chatAutoJoinLocal)) saveSettingsFn(); + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Text("Joined Channels"); + ImGui::Separator(); + + ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels."); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) { + restoreDefaults(); + saveSettingsFn(); + } +} + +void ChatPanel::restoreDefaults() { + chatShowTimestamps = false; + chatFontSize = 1; + chatAutoJoinGeneral = true; + chatAutoJoinTrade = true; + chatAutoJoinLocalDefense = true; + chatAutoJoinLFG = true; + chatAutoJoinLocal = true; +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 42f3a6b9..0349ab34 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -196,105 +196,62 @@ namespace { } return "Unknown"; } + + // 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 {}; + } } namespace wowee { namespace ui { GameScreen::GameScreen() { loadSettings(); - initChatTabs(); } -void GameScreen::initChatTabs() { - chatTabs_.clear(); - // General tab: shows everything - chatTabs_.push_back({"General", ~0ULL}); - // Combat tab: system, loot, skills, achievements, and NPC speech/emotes - chatTabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | - (1ULL << static_cast(game::ChatType::LOOT)) | - (1ULL << static_cast(game::ChatType::SKILL)) | - (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | - (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | - (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | - (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | - (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | - (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | - (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | - (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | - (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); - // Whispers tab - chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | - (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); - // Guild tab: guild and officer chat - chatTabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | - (1ULL << static_cast(game::ChatType::OFFICER)) | - (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); - // Trade/LFG tab: channel messages - chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); - // Reset unread counts to match new tab list - chatTabUnread_.assign(chatTabs_.size(), 0); - chatTabSeenCount_ = 0; -} - -bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { - if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; - const auto& tab = chatTabs_[tabIndex]; - if (tab.typeMask == ~0ULL) return true; // General tab shows all - - uint64_t typeBit = 1ULL << static_cast(msg.type); - - // For Trade/LFG tab (now index 4), also filter by channel name - if (tabIndex == 4 && msg.type == game::ChatType::CHANNEL) { - const std::string& ch = msg.channelName; - if (ch.find("Trade") == std::string::npos && - ch.find("General") == std::string::npos && - ch.find("LookingForGroup") == std::string::npos && - ch.find("Local") == std::string::npos) { - return false; - } - return true; - } - - return (tab.typeMask & typeBit) != 0; -} - -// Forward declaration — defined near sendChatMessage below -static std::string firstMacroCommand(const std::string& macroText); -static std::vector allMacroCommands(const std::string& macroText); -static std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); -// Returns the spell/item name from #showtooltip [Name], or "__auto__" for bare -// #showtooltip (use first /cast target), or "" if no directive is present. -static std::string getMacroShowtooltipArg(const std::string& macroText); - void GameScreen::render(game::GameHandler& gameHandler) { - cachedGameHandler_ = &gameHandler; - // Set up chat bubble callback (once) - if (!chatBubbleCallbackSet_) { - gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { - float duration = 8.0f + static_cast(msg.size()) * 0.06f; - if (isYell) duration += 2.0f; - if (duration > 15.0f) duration = 15.0f; - - // Replace existing bubble for same sender - for (auto& b : chatBubbles_) { - if (b.senderGuid == guid) { - b.message = msg; - b.timeRemaining = duration; - b.totalDuration = duration; - b.isYell = isYell; - return; - } - } - // Evict oldest if too many - if (chatBubbles_.size() >= 10) { - chatBubbles_.erase(chatBubbles_.begin()); - } - chatBubbles_.push_back({guid, msg, duration, duration, isYell}); - }); - chatBubbleCallbackSet_ = true; - } + // Set up chat bubble callback (once) and cache game handler in ChatPanel + chatPanel_.setupCallbacks(gameHandler); // Set up level-up callback (once) if (!levelUpCallbackSet_) { @@ -601,11 +558,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { } // Sync chat auto-join settings to GameHandler - gameHandler.chatAutoJoin.general = chatAutoJoinGeneral_; - gameHandler.chatAutoJoin.trade = chatAutoJoinTrade_; - gameHandler.chatAutoJoin.localDefense = chatAutoJoinLocalDefense_; - gameHandler.chatAutoJoin.lfg = chatAutoJoinLFG_; - gameHandler.chatAutoJoin.local = chatAutoJoinLocal_; + gameHandler.chatAutoJoin.general = chatPanel_.chatAutoJoinGeneral; + gameHandler.chatAutoJoin.trade = chatPanel_.chatAutoJoinTrade; + gameHandler.chatAutoJoin.localDefense = chatPanel_.chatAutoJoinLocalDefense; + gameHandler.chatAutoJoin.lfg = chatPanel_.chatAutoJoinLFG; + gameHandler.chatAutoJoin.local = chatPanel_.chatAutoJoinLocal; // Process targeting input before UI windows processTargetInput(gameHandler); @@ -649,7 +606,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { } if (showChatWindow) { - renderChatWindow(gameHandler); + chatPanel_.getSpellIcon = [this](uint32_t id, pipeline::AssetManager* am) { + return getSpellIcon(id, am); + }; + chatPanel_.render(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); + // Process slash commands that affect GameScreen state + auto cmds = chatPanel_.consumeSlashCommands(); + if (cmds.showInspect) 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.takeScreenshot) takeScreenshot(gameHandler); } // ---- New UI elements ---- @@ -732,7 +701,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderResurrectDialog(gameHandler); renderTalentWipeConfirmDialog(gameHandler); renderPetUnlearnConfirmDialog(gameHandler); - renderChatBubbles(gameHandler); + chatPanel_.renderBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); renderDingEffect(); @@ -760,12 +729,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { { std::string pendingSpellLink = spellbookScreen.getAndClearPendingChatLink(); if (!pendingSpellLink.empty()) { - size_t curLen = strlen(chatInputBuffer); - if (curLen + pendingSpellLink.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, pendingSpellLink.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(pendingSpellLink); } } @@ -822,12 +786,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { { std::string pendingLink = inventoryScreen.getAndClearPendingChatLink(); if (!pendingLink.empty()) { - size_t curLen = strlen(chatInputBuffer); - if (curLen + pendingLink.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, pendingLink.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(pendingLink); } } @@ -1214,1558 +1173,21 @@ void GameScreen::renderEntityList(game::GameHandler& gameHandler) { ImGui::End(); } -void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - auto* assetMgr = core::Application::getInstance().getAssetManager(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - float chatW = std::min(500.0f, screenW * 0.4f); - float chatH = 220.0f; - float chatX = 8.0f; - float chatY = screenH - chatH - 80.0f; // Above action bar - if (chatWindowLocked) { - // Always recompute position from current window size when locked - chatWindowPos_ = ImVec2(chatX, chatY); - ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_Always); - ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_Always); - } else { - if (!chatWindowPosInit_) { - chatWindowPos_ = ImVec2(chatX, chatY); - chatWindowPosInit_ = true; - } - ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_FirstUseEver); - } - ImGuiWindowFlags flags = kDialogFlags; - if (chatWindowLocked) { - flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar; - } - ImGui::Begin("Chat", nullptr, flags); - - if (!chatWindowLocked) { - chatWindowPos_ = ImGui::GetWindowPos(); - } - - // Update unread counts: scan any new messages since last frame - { - const auto& history = gameHandler.getChatHistory(); - // Ensure unread array is sized correctly (guards against late init) - if (chatTabUnread_.size() != chatTabs_.size()) - chatTabUnread_.assign(chatTabs_.size(), 0); - // If history shrank (e.g. cleared), reset - if (chatTabSeenCount_ > history.size()) chatTabSeenCount_ = 0; - for (size_t mi = chatTabSeenCount_; mi < history.size(); ++mi) { - const auto& msg = history[mi]; - // For each non-General (non-0) tab that isn't currently active, check visibility - for (int ti = 1; ti < static_cast(chatTabs_.size()); ++ti) { - if (ti == activeChatTab_) continue; - if (shouldShowMessage(msg, ti)) { - chatTabUnread_[ti]++; - } - } - } - chatTabSeenCount_ = history.size(); - } - - // Chat tabs - if (ImGui::BeginTabBar("ChatTabs")) { - for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { - // Build label with unread count suffix for non-General tabs - std::string tabLabel = chatTabs_[i].name; - if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { - tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; - } - // Flash tab text color when unread messages exist - bool hasUnread = (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0); - if (hasUnread) { - float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f * pulse, 0.2f * pulse, 1.0f)); - } - if (ImGui::BeginTabItem(tabLabel.c_str())) { - if (activeChatTab_ != i) { - activeChatTab_ = i; - // Clear unread count when tab becomes active - if (i < static_cast(chatTabUnread_.size())) - chatTabUnread_[i] = 0; - } - ImGui::EndTabItem(); - } - if (hasUnread) ImGui::PopStyleColor(); - } - ImGui::EndTabBar(); - } - - // Chat history - const auto& chatHistory = gameHandler.getChatHistory(); - - // Apply chat font size scaling - float chatScale = chatFontSize_ == 0 ? 0.85f : (chatFontSize_ == 2 ? 1.2f : 1.0f); - ImGui::SetWindowFontScale(chatScale); - - ImGui::BeginChild("ChatHistory", ImVec2(0, -70), true, ImGuiWindowFlags_HorizontalScrollbar); - bool chatHistoryHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); - - // Helper: parse WoW color code |cAARRGGBB → ImVec4 - auto parseWowColor = [](const std::string& text, size_t pos) -> ImVec4 { - // |cAARRGGBB (10 chars total: |c + 8 hex) - if (pos + 10 > text.size()) return colors::kWhite; - auto hexByte = [&](size_t offset) -> float { - const char* s = text.c_str() + pos + offset; - char buf[3] = {s[0], s[1], '\0'}; - return static_cast(strtol(buf, nullptr, 16)) / 255.0f; - }; - float a = hexByte(2); - float r = hexByte(4); - float g = hexByte(6); - float b = hexByte(8); - return ImVec4(r, g, b, a); - }; - - // Helper: render an item tooltip from ItemQueryResponseData - auto renderItemLinkTooltip = [&](uint32_t itemEntry) { - const auto* info = gameHandler.getItemInfo(itemEntry); - if (!info || !info->valid) return; - auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { - using ES = game::EquipSlot; - const auto& inv = gameHandler.getInventory(); - auto slotPtr = [&](ES slot) -> const game::ItemSlot* { - const auto& s = inv.getEquipSlot(slot); - return s.empty() ? nullptr : &s; - }; - switch (inventoryType) { - case 1: return slotPtr(ES::HEAD); - case 2: return slotPtr(ES::NECK); - case 3: return slotPtr(ES::SHOULDERS); - case 4: return slotPtr(ES::SHIRT); - case 5: - case 20: return slotPtr(ES::CHEST); - case 6: return slotPtr(ES::WAIST); - case 7: return slotPtr(ES::LEGS); - case 8: return slotPtr(ES::FEET); - case 9: return slotPtr(ES::WRISTS); - case 10: return slotPtr(ES::HANDS); - case 11: { - if (auto* s = slotPtr(ES::RING1)) return s; - return slotPtr(ES::RING2); - } - case 12: { - if (auto* s = slotPtr(ES::TRINKET1)) return s; - return slotPtr(ES::TRINKET2); - } - case 13: - if (auto* s = slotPtr(ES::MAIN_HAND)) return s; - return slotPtr(ES::OFF_HAND); - case 14: - case 22: - case 23: return slotPtr(ES::OFF_HAND); - case 15: - case 25: - case 26: return slotPtr(ES::RANGED); - case 16: return slotPtr(ES::BACK); - case 17: - case 21: return slotPtr(ES::MAIN_HAND); - case 18: - for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { - auto slot = static_cast(static_cast(ES::BAG1) + i); - if (auto* s = slotPtr(slot)) return s; - } - return nullptr; - case 19: return slotPtr(ES::TABARD); - default: return nullptr; - } - }; - - ImGui::BeginTooltip(); - // Quality color for name - auto qColor = ui::getQualityColor(static_cast(info->quality)); - ImGui::TextColored(qColor, "%s", info->name.c_str()); - - // Heroic indicator (green, matches WoW tooltip style) - constexpr uint32_t kFlagHeroic = 0x8; - constexpr uint32_t kFlagUniqueEquipped = 0x1000000; - if (info->itemFlags & kFlagHeroic) - ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); - - // Bind type (appears right under name in WoW) - switch (info->bindType) { - case 1: ImGui::TextDisabled("Binds when picked up"); break; - case 2: ImGui::TextDisabled("Binds when equipped"); break; - case 3: ImGui::TextDisabled("Binds when used"); break; - case 4: ImGui::TextDisabled("Quest Item"); break; - } - // Unique / Unique-Equipped - if (info->maxCount == 1) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); - else if (info->itemFlags & kFlagUniqueEquipped) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); - - // Slot type - if (info->inventoryType > 0) { - const char* slotName = ui::getInventorySlotName(info->inventoryType); - if (slotName[0]) { - if (!info->subclassName.empty()) - ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); - else - ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); - } - } - auto isWeaponInventoryType = [](uint32_t invType) { - switch (invType) { - case 13: // One-Hand - case 15: // Ranged - case 17: // Two-Hand - case 21: // Main Hand - case 25: // Thrown - case 26: // Ranged Right - return true; - default: - return false; - } - }; - const bool isWeapon = isWeaponInventoryType(info->inventoryType); - - // Item level (after slot/subclass) - if (info->itemLevel > 0) - ImGui::TextDisabled("Item Level %u", info->itemLevel); - - if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - // WoW-style: "22 - 41 Damage" with speed right-aligned on same row - char dmgBuf[64], spdBuf[32]; - std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", - static_cast(info->damageMin), static_cast(info->damageMax)); - std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); - float spdW = ImGui::CalcTextSize(spdBuf).x; - ImGui::Text("%s", dmgBuf); - ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); - ImGui::Text("%s", spdBuf); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { - if (val <= 0) return; - if (!out.empty()) out += " "; - out += "+" + std::to_string(val) + " "; - out += shortName; - }; - std::string bonusLine; - appendBonus(bonusLine, info->strength, "Str"); - appendBonus(bonusLine, info->agility, "Agi"); - appendBonus(bonusLine, info->stamina, "Sta"); - appendBonus(bonusLine, info->intellect, "Int"); - appendBonus(bonusLine, info->spirit, "Spi"); - if (!bonusLine.empty()) { - ImGui::TextColored(green, "%s", bonusLine.c_str()); - } - if (info->armor > 0) { - ImGui::Text("%d Armor", info->armor); - } - // Elemental resistances (fire resist gear, nature resist gear, etc.) - { - const int32_t resVals[6] = { - info->holyRes, info->fireRes, info->natureRes, - info->frostRes, info->shadowRes, info->arcaneRes - }; - static constexpr const char* resLabels[6] = { - "Holy Resistance", "Fire Resistance", "Nature Resistance", - "Frost Resistance", "Shadow Resistance", "Arcane Resistance" - }; - for (int ri = 0; ri < 6; ++ri) - if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); - } - // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) - if (!info->extraStats.empty()) { - auto statName = [](uint32_t t) -> const char* { - switch (t) { - case 12: return "Defense Rating"; - case 13: return "Dodge Rating"; - case 14: return "Parry Rating"; - case 15: return "Block Rating"; - case 16: case 17: case 18: case 31: return "Hit Rating"; - case 19: case 20: case 21: case 32: return "Critical Strike Rating"; - case 28: case 29: case 30: case 35: return "Haste Rating"; - case 34: return "Resilience Rating"; - case 36: return "Expertise Rating"; - case 37: return "Attack Power"; - case 38: return "Ranged Attack Power"; - case 45: return "Spell Power"; - case 46: return "Healing Power"; - case 47: return "Spell Damage"; - case 49: return "Mana per 5 sec."; - case 43: return "Spell Penetration"; - case 44: return "Block Value"; - default: return nullptr; - } - }; - for (const auto& es : info->extraStats) { - const char* nm = statName(es.statType); - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - // Gem sockets (WotLK only — socketColor != 0 means socket present) - // socketColor bitmask: 1=Meta, 2=Red, 4=Yellow, 8=Blue - { - const auto& kSocketTypes = ui::kSocketTypes; - bool hasSocket = false; - for (int s = 0; s < 3; ++s) { - if (info->socketColor[s] == 0) continue; - if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } - for (const auto& st : kSocketTypes) { - if (info->socketColor[s] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); - break; - } - } - } - if (hasSocket && info->socketBonus != 0) { - // Socket bonus ID maps to SpellItemEnchantment.dbc — lazy-load names - static std::unordered_map s_enchantNames; - static bool s_enchantNamesLoaded = false; - if (!s_enchantNamesLoaded && assetMgr) { - s_enchantNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNames[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNames.find(info->socketBonus); - if (enchIt != s_enchantNames.end()) - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); - else - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); - } - } - // Item set membership - if (info->itemSetId != 0) { - struct SetEntry { - std::string name; - std::array itemIds{}; - std::array spellIds{}; - std::array thresholds{}; - }; - static std::unordered_map s_setData; - static bool s_setDataLoaded = false; - if (!s_setDataLoaded && assetMgr) { - s_setDataLoaded = true; - auto dbc = assetMgr->loadDBC("ItemSet.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; - auto lf = [&](const char* k, uint32_t def) -> uint32_t { - return layout ? (*layout)[k] : def; - }; - uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); - const auto& itemKeys = ui::kItemSetItemKeys; - const auto& spellKeys = ui::kItemSetSpellKeys; - const auto& thrKeys = ui::kItemSetThresholdKeys; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t id = dbc->getUInt32(r, idF); - if (!id) continue; - SetEntry e; - e.name = dbc->getString(r, nameF); - for (int i = 0; i < 10; ++i) { - e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); - e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); - e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); - } - s_setData[id] = std::move(e); - } - } - } - ImGui::Spacing(); - const auto& inv = gameHandler.getInventory(); - auto setIt = s_setData.find(info->itemSetId); - if (setIt != s_setData.end()) { - const SetEntry& se = setIt->second; - int equipped = 0, total = 0; - for (int i = 0; i < 10; ++i) { - if (se.itemIds[i] == 0) continue; - ++total; - for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { - const auto& eq = inv.getEquipSlot(static_cast(sl)); - if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } - } - } - if (total > 0) - ImGui::TextColored(ui::colors::kTooltipGold, - "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); - else if (!se.name.empty()) - ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); - for (int i = 0; i < 10; ++i) { - if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; - const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); - bool active = (equipped >= static_cast(se.thresholds[i])); - ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; - if (!bname.empty()) - ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); - else - ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); - } - } else { - ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); - } - } - // Item spell effects (Use / Equip / Chance on Hit / Teaches) - for (const auto& sp : info->spells) { - if (sp.spellId == 0) continue; - const char* triggerLabel = nullptr; - switch (sp.spellTrigger) { - case 0: triggerLabel = "Use"; break; - case 1: triggerLabel = "Equip"; break; - case 2: triggerLabel = "Chance on Hit"; break; - case 5: triggerLabel = "Teaches"; break; - } - if (!triggerLabel) continue; - // Use full spell description if available (matches inventory tooltip style) - const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); - const std::string& spText = !spDesc.empty() ? spDesc - : gameHandler.getSpellName(sp.spellId); - if (!spText.empty()) { - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); - ImGui::TextColored(colors::kCyan, - "%s: %s", triggerLabel, spText.c_str()); - ImGui::PopTextWrapPos(); - } - } - // Required level - if (info->requiredLevel > 1) - ImGui::TextDisabled("Requires Level %u", info->requiredLevel); - // Required skill (e.g. "Requires Blacksmithing (300)") - if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { - static std::unordered_map s_skillNames; - static bool s_skillNamesLoaded = false; - if (!s_skillNamesLoaded && assetMgr) { - s_skillNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SkillLine.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 2u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t sid = dbc->getUInt32(r, idF); - if (!sid) continue; - std::string sname = dbc->getString(r, nameF); - if (!sname.empty()) s_skillNames[sid] = std::move(sname); - } - } - } - uint32_t playerSkillVal = 0; - const auto& skills = gameHandler.getPlayerSkills(); - auto skPit = skills.find(info->requiredSkill); - if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); - bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); - ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - auto skIt = s_skillNames.find(info->requiredSkill); - if (skIt != s_skillNames.end()) - ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); - else - ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); - } - // Required reputation (e.g. "Requires Exalted with Argent Dawn") - if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { - static std::unordered_map s_factionNames; - static bool s_factionNamesLoaded = false; - if (!s_factionNamesLoaded && assetMgr) { - s_factionNamesLoaded = true; - auto dbc = assetMgr->loadDBC("Faction.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 20u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t fid = dbc->getUInt32(r, idF); - if (!fid) continue; - std::string fname = dbc->getString(r, nameF); - if (!fname.empty()) s_factionNames[fid] = std::move(fname); - } - } - } - static constexpr const char* kRepRankNames[] = { - "Hated", "Hostile", "Unfriendly", "Neutral", - "Friendly", "Honored", "Revered", "Exalted" - }; - const char* rankName = (info->requiredReputationRank < 8) - ? kRepRankNames[info->requiredReputationRank] : "Unknown"; - auto fIt = s_factionNames.find(info->requiredReputationFaction); - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", - rankName, - fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); - } - // Class restriction (e.g. "Classes: Paladin, Warrior") - if (info->allowableClass != 0) { - const auto& kClasses = ui::kClassMasks; - int matchCount = 0; - for (const auto& kc : kClasses) - if (info->allowableClass & kc.mask) ++matchCount; - if (matchCount > 0 && matchCount < 10) { - char classBuf[128] = "Classes: "; - bool first = true; - for (const auto& kc : kClasses) { - if (!(info->allowableClass & kc.mask)) continue; - if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); - strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); - first = false; - } - uint8_t pc = gameHandler.getPlayerClass(); - uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); - ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(clColor, "%s", classBuf); - } - } - // Race restriction (e.g. "Races: Night Elf, Human") - if (info->allowableRace != 0) { - const auto& kRaces = ui::kRaceMasks; - constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; - if ((info->allowableRace & kAllPlayable) != kAllPlayable) { - int matchCount = 0; - for (const auto& kr : kRaces) - if (info->allowableRace & kr.mask) ++matchCount; - if (matchCount > 0) { - char raceBuf[160] = "Races: "; - bool first = true; - for (const auto& kr : kRaces) { - if (!(info->allowableRace & kr.mask)) continue; - if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); - strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); - first = false; - } - uint8_t pr = gameHandler.getPlayerRace(); - uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); - ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(rColor, "%s", raceBuf); - } - } - } - // Flavor / lore text (shown in gold italic in WoW, use a yellow-ish dim color here) - if (!info->description.empty()) { - ImGui::Spacing(); - ImGui::PushTextWrapPos(300.0f); - ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); - ImGui::PopTextWrapPos(); - } - if (info->sellPrice > 0) { - ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(info->sellPrice); - } - - if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { - if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { - ImGui::Separator(); - ImGui::TextDisabled("Equipped:"); - VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); - if (eqIcon) { - ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); - ImGui::SameLine(); - } - ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (isWeaponInventoryType(eq->item.inventoryType) && - eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { - float speed = static_cast(eq->item.delayMs) / 1000.0f; - float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - char eqDmg[64], eqSpd[32]; - std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", - static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); - std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); - float eqSpdW = ImGui::CalcTextSize(eqSpd).x; - ImGui::Text("%s", eqDmg); - ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); - ImGui::Text("%s", eqSpd); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - if (eq->item.armor > 0) { - ImGui::Text("%d Armor", eq->item.armor); - } - std::string eqBonusLine; - appendBonus(eqBonusLine, eq->item.strength, "Str"); - appendBonus(eqBonusLine, eq->item.agility, "Agi"); - appendBonus(eqBonusLine, eq->item.stamina, "Sta"); - appendBonus(eqBonusLine, eq->item.intellect, "Int"); - appendBonus(eqBonusLine, eq->item.spirit, "Spi"); - if (!eqBonusLine.empty()) { - ImGui::TextColored(green, "%s", eqBonusLine.c_str()); - } - // Extra stats for the equipped item - for (const auto& es : eq->item.extraStats) { - const char* nm = nullptr; - switch (es.statType) { - case 12: nm = "Defense Rating"; break; - case 13: nm = "Dodge Rating"; break; - case 14: nm = "Parry Rating"; break; - case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; - case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; - case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; - case 34: nm = "Resilience Rating"; break; - case 36: nm = "Expertise Rating"; break; - case 37: nm = "Attack Power"; break; - case 38: nm = "Ranged Attack Power"; break; - case 45: nm = "Spell Power"; break; - case 46: nm = "Healing Power"; break; - case 49: nm = "Mana per 5 sec."; break; - default: break; - } - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - } - ImGui::EndTooltip(); - }; - - // Helper: render text with clickable URLs and WoW item links - auto renderTextWithLinks = [&](const std::string& text, const ImVec4& color) { - size_t pos = 0; - while (pos < text.size()) { - // Find next special element: URL or WoW link - size_t urlStart = text.find("https://", pos); - - // Find next WoW link (may be colored with |c prefix or bare |H) - size_t linkStart = text.find("|c", pos); - // Also handle bare |H links without color prefix - size_t bareItem = text.find("|Hitem:", pos); - size_t bareSpell = text.find("|Hspell:", pos); - size_t bareQuest = text.find("|Hquest:", pos); - size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); - - // Determine which comes first - size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart}); - - if (nextSpecial == std::string::npos) { - // No more special elements, render remaining text - std::string remaining = text.substr(pos); - if (!remaining.empty()) { - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::TextWrapped("%s", remaining.c_str()); - ImGui::PopStyleColor(); - } - break; - } - - // Render plain text before special element - if (nextSpecial > pos) { - std::string before = text.substr(pos, nextSpecial - pos); - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::TextWrapped("%s", before.c_str()); - ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - } - - // Handle WoW item link - if (nextSpecial == linkStart || nextSpecial == bareLinkStart) { - ImVec4 linkColor = color; - size_t hStart = std::string::npos; - - if (nextSpecial == linkStart && text.size() > linkStart + 10) { - // Parse |cAARRGGBB color - linkColor = parseWowColor(text, linkStart); - // Find the nearest |H link of any supported type - size_t hItem = text.find("|Hitem:", linkStart + 10); - size_t hSpell = text.find("|Hspell:", linkStart + 10); - size_t hQuest = text.find("|Hquest:", linkStart + 10); - size_t hAch = text.find("|Hachievement:", linkStart + 10); - hStart = std::min({hItem, hSpell, hQuest, hAch}); - } else if (nextSpecial == bareLinkStart) { - hStart = bareLinkStart; - } - - if (hStart != std::string::npos) { - // Determine link type - const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); - const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); - const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); - // Default: item link - - // Parse the first numeric ID after |Htype: - size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); - size_t entryStart = hStart + idOffset; - size_t entryEnd = text.find(':', entryStart); - uint32_t linkId = 0; - if (entryEnd != std::string::npos) { - linkId = static_cast(strtoul( - text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10)); - } - - // Find display name: |h[Name]|h - size_t nameTagStart = text.find("|h[", hStart); - size_t nameTagEnd = (nameTagStart != std::string::npos) - ? text.find("]|h", nameTagStart + 3) : std::string::npos; - - std::string linkName = isSpellLink ? "Unknown Spell" - : isQuestLink ? "Unknown Quest" - : isAchievLink ? "Unknown Achievement" - : "Unknown Item"; - if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { - linkName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); - } - - // Find end of entire link sequence (|r or after ]|h) - size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + idOffset; - size_t resetPos = text.find("|r", linkEnd); - if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { - linkEnd = resetPos + 2; - } - - if (!isSpellLink && !isQuestLink && !isAchievLink) { - // --- Item link --- - uint32_t itemEntry = linkId; - if (itemEntry > 0) { - gameHandler.ensureItemInfo(itemEntry); - } - - // Show small icon before item link if available - if (itemEntry > 0) { - const auto* chatInfo = gameHandler.getItemInfo(itemEntry); - if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) { - VkDescriptorSet chatIcon = inventoryScreen.getItemIcon(chatInfo->displayInfoId); - if (chatIcon) { - ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - renderItemLinkTooltip(itemEntry); - } - ImGui::SameLine(0, 2); - } - } - } - - // Render bracketed item name in quality color - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, linkColor); - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - if (itemEntry > 0) { - renderItemLinkTooltip(itemEntry); - } - } - } else if (isSpellLink) { - // --- Spell link: |Hspell:SPELLID:RANK|h[Name]|h --- - // Small icon (use spell icon cache if available) - VkDescriptorSet spellIcon = (linkId > 0) ? getSpellIcon(linkId, assetMgr) : VK_NULL_HANDLE; - if (spellIcon) { - ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); - } - ImGui::SameLine(0, 2); - } - - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, linkColor); - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - if (linkId > 0) { - spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); - } - } - } else if (isQuestLink) { - // --- Quest link: |Hquest:QUESTID:QUESTLEVEL|h[Name]|h --- - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, colors::kWarmGold); // gold - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::BeginTooltip(); - ImGui::TextColored(colors::kWarmGold, "%s", linkName.c_str()); - // Parse quest level (second field after questId) - if (entryEnd != std::string::npos) { - size_t lvlEnd = text.find(':', entryEnd + 1); - if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); - if (lvlEnd != std::string::npos) { - uint32_t qLvl = static_cast(strtoul( - text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); - if (qLvl > 0) ImGui::TextDisabled("Level %u Quest", qLvl); - } - } - ImGui::TextDisabled("Click quest log to view details"); - ImGui::EndTooltip(); - } - // Click: open quest log and select this quest if we have it - if (ImGui::IsItemClicked() && linkId > 0) { - questLogScreen.openAndSelectQuest(linkId); - } - } else { - // --- Achievement link --- - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); // gold - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::SetTooltip("Achievement: %s", linkName.c_str()); - } - } - - // Shift-click: insert entire link back into chat input - if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { - std::string linkText = text.substr(nextSpecial, linkEnd - nextSpecial); - size_t curLen = strlen(chatInputBuffer); - if (curLen + linkText.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, linkText.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - } - } - - pos = linkEnd; - continue; - } - - // Not an item link — treat as colored text: |cAARRGGBB...text...|r - if (nextSpecial == linkStart && text.size() > linkStart + 10) { - ImVec4 cColor = parseWowColor(text, linkStart); - size_t textStart = linkStart + 10; // after |cAARRGGBB - size_t resetPos2 = text.find("|r", textStart); - std::string coloredText; - if (resetPos2 != std::string::npos) { - coloredText = text.substr(textStart, resetPos2 - textStart); - pos = resetPos2 + 2; // skip |r - } else { - coloredText = text.substr(textStart); - pos = text.size(); - } - // Strip any remaining WoW markup from the colored segment - // (e.g. |H...|h pairs that aren't item links) - std::string clean; - for (size_t i = 0; i < coloredText.size(); i++) { - if (coloredText[i] == '|' && i + 1 < coloredText.size()) { - char next = coloredText[i + 1]; - if (next == 'H') { - // Skip |H...|h - size_t hEnd = coloredText.find("|h", i + 2); - if (hEnd != std::string::npos) { i = hEnd + 1; continue; } - } else if (next == 'h') { - i += 1; continue; // skip |h - } else if (next == 'r') { - i += 1; continue; // skip |r - } - } - clean += coloredText[i]; - } - if (!clean.empty()) { - ImGui::PushStyleColor(ImGuiCol_Text, cColor); - ImGui::TextWrapped("%s", clean.c_str()); - ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - } - } else { - // Bare |c without enough chars for color — render literally - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::TextWrapped("|c"); - ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - pos = nextSpecial + 2; - } - continue; - } - - // Handle URL - if (nextSpecial == urlStart) { - size_t urlEnd = text.find_first_of(" \t\n\r", urlStart); - if (urlEnd == std::string::npos) urlEnd = text.size(); - std::string url = text.substr(urlStart, urlEnd - urlStart); - - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.7f, 1.0f, 1.0f)); - ImGui::TextWrapped("%s", url.c_str()); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::SetTooltip("Open: %s", url.c_str()); - } - if (ImGui::IsItemClicked()) { - std::string cmd = "xdg-open '" + url + "' &"; - [[maybe_unused]] int result = system(cmd.c_str()); - } - ImGui::PopStyleColor(); - - pos = urlEnd; - continue; - } - } - }; - - // Determine local player name for mention detection (case-insensitive) - std::string selfNameLower; - { - const auto* ch = gameHandler.getActiveCharacter(); - if (ch && !ch->name.empty()) { - selfNameLower = ch->name; - for (auto& c : selfNameLower) c = static_cast(std::tolower(static_cast(c))); - } - } - - // Scan NEW messages (beyond chatMentionSeenCount_) for mentions and play notification sound - if (!selfNameLower.empty() && chatHistory.size() > chatMentionSeenCount_) { - for (size_t mi = chatMentionSeenCount_; mi < chatHistory.size(); ++mi) { - const auto& mMsg = chatHistory[mi]; - // Skip outgoing whispers, system, and monster messages - if (mMsg.type == game::ChatType::WHISPER_INFORM || - mMsg.type == game::ChatType::SYSTEM) continue; - // Case-insensitive search in message body - std::string bodyLower = mMsg.message; - for (auto& c : bodyLower) c = static_cast(std::tolower(static_cast(c))); - if (bodyLower.find(selfNameLower) != std::string::npos) { - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* ui = renderer->getUiSoundManager()) - ui->playWhisperReceived(); - } - break; // play at most once per scan pass - } - } - chatMentionSeenCount_ = chatHistory.size(); - } else if (chatHistory.size() <= chatMentionSeenCount_) { - chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared - } - - // Scan NEW messages for incoming whispers and push a toast notification - { - size_t histSize = chatHistory.size(); - if (histSize < whisperSeenCount_) whisperSeenCount_ = histSize; // cleared - for (size_t wi = whisperSeenCount_; wi < histSize; ++wi) { - const auto& wMsg = chatHistory[wi]; - if (wMsg.type == game::ChatType::WHISPER || - wMsg.type == game::ChatType::RAID_BOSS_WHISPER) { - WhisperToastEntry toast; - toast.sender = wMsg.senderName; - if (toast.sender.empty() && wMsg.senderGuid != 0) - toast.sender = gameHandler.lookupName(wMsg.senderGuid); - if (toast.sender.empty()) toast.sender = "Unknown"; - // Truncate preview to 60 chars - toast.preview = wMsg.message.size() > 60 - ? wMsg.message.substr(0, 57) + "..." - : wMsg.message; - toast.age = 0.0f; - // Keep at most 3 stacked toasts - if (whisperToasts_.size() >= 3) whisperToasts_.erase(whisperToasts_.begin()); - whisperToasts_.push_back(std::move(toast)); - } - } - whisperSeenCount_ = histSize; - } - - int chatMsgIdx = 0; - for (const auto& msg : chatHistory) { - if (!shouldShowMessage(msg, activeChatTab_)) continue; - std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); - - // Resolve sender name at render time in case it wasn't available at parse time. - // This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns. - const std::string& resolvedSenderName = [&]() -> const std::string& { - if (!msg.senderName.empty()) return msg.senderName; - if (msg.senderGuid == 0) return msg.senderName; - const std::string& cached = gameHandler.lookupName(msg.senderGuid); - if (!cached.empty()) return cached; - return msg.senderName; - }(); - - ImVec4 color = getChatTypeColor(msg.type); - - // Optional timestamp prefix - std::string tsPrefix; - if (chatShowTimestamps_) { - auto tt = std::chrono::system_clock::to_time_t(msg.timestamp); - std::tm tm{}; -#ifdef _WIN32 - localtime_s(&tm, &tt); -#else - localtime_r(&tt, &tm); -#endif - char tsBuf[16]; - snprintf(tsBuf, sizeof(tsBuf), "[%02d:%02d] ", tm.tm_hour, tm.tm_min); - tsPrefix = tsBuf; - } - - // Build chat tag prefix: , , from chatTag bitmask - std::string tagPrefix; - if (msg.chatTag & 0x04) tagPrefix = " "; - else if (msg.chatTag & 0x01) tagPrefix = " "; - else if (msg.chatTag & 0x02) tagPrefix = " "; - - // Build full message string for this entry - std::string fullMsg; - if (msg.type == game::ChatType::SYSTEM || msg.type == game::ChatType::TEXT_EMOTE) { - fullMsg = tsPrefix + processedMessage; - } else if (!resolvedSenderName.empty()) { - if (msg.type == game::ChatType::SAY || - msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; - } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; - } else if (msg.type == game::ChatType::WHISPER || - msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; - } else if (msg.type == game::ChatType::WHISPER_INFORM) { - const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; - fullMsg = tsPrefix + "To " + target + ": " + processedMessage; - } else if (msg.type == game::ChatType::EMOTE || - msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; - } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { - int chIdx = gameHandler.getChannelIndex(msg.channelName); - std::string chDisplay = chIdx > 0 - ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" - : "[" + msg.channelName + "]"; - fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; - } else { - fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; - } - } else { - bool isGroupType = - msg.type == game::ChatType::PARTY || - msg.type == game::ChatType::GUILD || - msg.type == game::ChatType::OFFICER || - msg.type == game::ChatType::RAID || - msg.type == game::ChatType::RAID_LEADER || - msg.type == game::ChatType::RAID_WARNING || - msg.type == game::ChatType::BATTLEGROUND || - msg.type == game::ChatType::BATTLEGROUND_LEADER; - if (isGroupType) { - fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; - } else { - fullMsg = tsPrefix + processedMessage; - } - } - - // Detect mention: does this message contain the local player's name? - bool isMention = false; - if (!selfNameLower.empty() && - msg.type != game::ChatType::WHISPER_INFORM && - msg.type != game::ChatType::SYSTEM) { - std::string msgLower = fullMsg; - for (auto& c : msgLower) c = static_cast(std::tolower(static_cast(c))); - isMention = (msgLower.find(selfNameLower) != std::string::npos); - } - - // Render message in a group so we can attach a right-click context menu - ImGui::PushID(chatMsgIdx++); - ImGui::BeginGroup(); - renderTextWithLinks(fullMsg, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : color); - ImGui::EndGroup(); - if (isMention) { - // Draw highlight AFTER rendering so the rect covers all wrapped lines, - // not just the first. Previously used a pre-render single-lineH rect. - ImVec2 rMin = ImGui::GetItemRectMin(); - ImVec2 rMax = ImGui::GetItemRectMax(); - float availW = ImGui::GetContentRegionAvail().x + ImGui::GetCursorScreenPos().x - rMin.x; - ImGui::GetWindowDrawList()->AddRectFilled( - rMin, ImVec2(rMin.x + availW, rMax.y), - IM_COL32(255, 200, 50, 45)); // soft golden tint - } - - // Right-click context menu (only for player messages with a sender) - bool isPlayerMsg = !resolvedSenderName.empty() && - msg.type != game::ChatType::SYSTEM && - msg.type != game::ChatType::TEXT_EMOTE && - msg.type != game::ChatType::MONSTER_SAY && - msg.type != game::ChatType::MONSTER_YELL && - msg.type != game::ChatType::MONSTER_WHISPER && - msg.type != game::ChatType::MONSTER_EMOTE && - msg.type != game::ChatType::MONSTER_PARTY && - msg.type != game::ChatType::RAID_BOSS_WHISPER && - msg.type != game::ChatType::RAID_BOSS_EMOTE; - - if (isPlayerMsg && ImGui::BeginPopupContextItem("ChatMsgCtx")) { - ImGui::TextDisabled("%s", resolvedSenderName.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; // WHISPER - strncpy(whisperTargetBuffer, resolvedSenderName.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; - } - if (ImGui::MenuItem("Invite to Group")) { - gameHandler.inviteToGroup(resolvedSenderName); - } - if (ImGui::MenuItem("Add Friend")) { - gameHandler.addFriend(resolvedSenderName); - } - if (ImGui::MenuItem("Ignore")) { - gameHandler.addIgnore(resolvedSenderName); - } - ImGui::EndPopup(); - } - - ImGui::PopID(); - } - - // Auto-scroll to bottom; track whether user has scrolled up - { - float scrollY = ImGui::GetScrollY(); - float scrollMaxY = ImGui::GetScrollMaxY(); - bool atBottom = (scrollMaxY <= 0.0f) || (scrollY >= scrollMaxY - 2.0f); - if (atBottom || chatForceScrollToBottom_) { - ImGui::SetScrollHereY(1.0f); - chatScrolledUp_ = false; - chatForceScrollToBottom_ = false; - } else { - chatScrolledUp_ = true; - } - } - - ImGui::EndChild(); - - // Reset font scale after chat history - ImGui::SetWindowFontScale(1.0f); - - // "Jump to bottom" indicator when scrolled up - if (chatScrolledUp_) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.35f, 0.7f, 0.9f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); - if (ImGui::SmallButton(" v New messages ")) { - chatForceScrollToBottom_ = true; - } - ImGui::PopStyleColor(2); - ImGui::SameLine(); - } - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - // Lock toggle - ImGui::Checkbox("Lock", &chatWindowLocked); - ImGui::SameLine(); - ImGui::TextDisabled(chatWindowLocked ? "(locked)" : "(movable)"); - - // Chat input - ImGui::Text("Type:"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(100); - const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE", "CHANNEL" }; - ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 11); - - // Auto-fill whisper target when switching to WHISPER mode - if (selectedChatType == 4 && lastChatType != 4) { - // Just switched to WHISPER mode - if (gameHandler.hasTarget()) { - auto target = gameHandler.getTarget(); - if (target && target->getType() == game::ObjectType::PLAYER) { - auto player = std::static_pointer_cast(target); - if (!player->getName().empty()) { - strncpy(whisperTargetBuffer, player->getName().c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - } - } - } - } - lastChatType = selectedChatType; - - // Show whisper target field if WHISPER is selected - if (selectedChatType == 4) { - ImGui::SameLine(); - ImGui::Text("To:"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(120); - ImGui::InputText("##WhisperTarget", whisperTargetBuffer, sizeof(whisperTargetBuffer)); - } - - // Show channel picker if CHANNEL is selected - if (selectedChatType == 10) { - const auto& channels = gameHandler.getJoinedChannels(); - if (channels.empty()) { - ImGui::SameLine(); - ImGui::TextDisabled("(no channels joined)"); - } else { - ImGui::SameLine(); - if (selectedChannelIdx >= static_cast(channels.size())) selectedChannelIdx = 0; - ImGui::SetNextItemWidth(140); - if (ImGui::BeginCombo("##ChannelPicker", channels[selectedChannelIdx].c_str())) { - for (int ci = 0; ci < static_cast(channels.size()); ++ci) { - bool selected = (ci == selectedChannelIdx); - if (ImGui::Selectable(channels[ci].c_str(), selected)) selectedChannelIdx = ci; - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - } - } - - ImGui::SameLine(); - ImGui::Text("Message:"); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(-1); - if (refocusChatInput) { - ImGui::SetKeyboardFocusHere(); - refocusChatInput = false; - } - - // Detect chat channel prefix as user types and switch the dropdown - { - std::string buf(chatInputBuffer); - if (buf.size() >= 2 && buf[0] == '/') { - // Find the command and check if there's a space after it - size_t sp = buf.find(' ', 1); - if (sp != std::string::npos) { - std::string cmd = buf.substr(1, sp - 1); - for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); - int detected = -1; - bool isReply = false; - if (cmd == "s" || cmd == "say") detected = 0; - else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; - else if (cmd == "p" || cmd == "party") detected = 2; - else if (cmd == "g" || cmd == "guild") detected = 3; - else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; - else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } - else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; - else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; - else if (cmd == "bg" || cmd == "battleground") detected = 7; - else if (cmd == "rw" || cmd == "raidwarning") detected = 8; - else if (cmd == "i" || cmd == "instance") detected = 9; - else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. - if (detected >= 0 && (selectedChatType != detected || detected == 10 || isReply)) { - // For channel shortcuts, also update selectedChannelIdx - if (detected == 10) { - int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. - const auto& chans = gameHandler.getJoinedChannels(); - if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { - selectedChannelIdx = chanIdx; - } - } - selectedChatType = detected; - // Strip the prefix, keep only the message part - std::string remaining = buf.substr(sp + 1); - // /r reply: pre-fill whisper target from last whisper sender - if (detected == 4 && isReply) { - std::string lastSender = gameHandler.getLastWhisperSender(); - if (!lastSender.empty()) { - strncpy(whisperTargetBuffer, lastSender.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - } - // remaining is the message — don't extract a target from it - } else if (detected == 4) { - // For whisper, first word after /w is the target - size_t msgStart = remaining.find(' '); - if (msgStart != std::string::npos) { - std::string wTarget = remaining.substr(0, msgStart); - strncpy(whisperTargetBuffer, wTarget.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - remaining = remaining.substr(msgStart + 1); - } else { - // Just the target name so far, no message yet - strncpy(whisperTargetBuffer, remaining.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - remaining = ""; - } - } - strncpy(chatInputBuffer, remaining.c_str(), sizeof(chatInputBuffer) - 1); - chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; - chatInputMoveCursorToEnd = true; - } - } - } - } - - // Color the input text based on current chat type - ImVec4 inputColor; - switch (selectedChatType) { - case 1: inputColor = kColorRed; break; // YELL - red - case 2: inputColor = colors::kLightBlue; break; // PARTY - blue - case 3: inputColor = kColorBrightGreen; break; // GUILD - green - case 4: inputColor = ImVec4(1.0f, 0.5f, 1.0f, 1.0f); break; // WHISPER - pink - case 5: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // RAID - orange - case 6: inputColor = kColorBrightGreen; break; // OFFICER - green - case 7: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // BG - orange - case 8: inputColor = ImVec4(1.0f, 0.3f, 0.0f, 1.0f); break; // RAID WARNING - red-orange - case 9: inputColor = colors::kLightBlue; break; // INSTANCE - blue - case 10: inputColor = ImVec4(0.3f, 0.9f, 0.9f, 1.0f); break; // CHANNEL - cyan - default: inputColor = ui::colors::kWhite; break; // SAY - white - } - ImGui::PushStyleColor(ImGuiCol_Text, inputColor); - - auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { - auto* self = static_cast(data->UserData); - if (!self) return 0; - - // Cursor-to-end after channel switch - if (self->chatInputMoveCursorToEnd) { - int len = static_cast(std::strlen(data->Buf)); - data->CursorPos = len; - data->SelectionStart = len; - data->SelectionEnd = len; - self->chatInputMoveCursorToEnd = false; - } - - // Tab: slash-command autocomplete - if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { - if (data->BufTextLen > 0 && data->Buf[0] == '/') { - // Split buffer into command word and trailing args - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; - std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; - - // Normalize to lowercase for matching - std::string lowerWord = word; - for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); - - static const std::vector kCmds = { - "/afk", "/assist", "/away", - "/cancelaura", "/cancelform", "/cancellogout", "/cancelshapeshift", - "/cast", "/castsequence", "/chathelp", "/clear", "/clearfocus", - "/clearmainassist", "/clearmaintank", "/cleartarget", "/cloak", - "/combatlog", "/dance", "/difficulty", "/dismount", "/dnd", "/do", "/duel", "/dump", - "/e", "/emote", "/equip", "/equipset", "/exit", - "/focus", "/follow", "/forfeit", "/friend", - "/g", "/gdemote", "/ginvite", "/gkick", "/gleader", "/gmotd", - "/gmticket", "/gpromote", "/gquit", "/grouploot", "/groster", - "/guild", "/guildinfo", - "/helm", "/help", - "/i", "/ignore", "/inspect", "/instance", "/invite", - "/j", "/join", "/kick", "/kneel", - "/l", "/leave", "/leaveparty", "/loc", "/local", "/logout", - "/lootmethod", "/lootthreshold", - "/macrohelp", "/mainassist", "/maintank", "/mark", "/me", - "/notready", - "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", - "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", - "/played", "/pvp", - "/quit", - "/r", "/raid", "/raidconvert", "/raidinfo", "/raidwarning", "/random", "/ready", - "/readycheck", "/reload", "/reloadui", "/removefriend", - "/reply", "/rl", "/roll", "/run", - "/s", "/say", "/score", "/screenshot", "/script", "/setloot", - "/shout", "/sit", "/stand", - "/startattack", "/stopattack", "/stopcasting", "/stopfollow", "/stopmacro", - "/t", "/target", "/targetenemy", "/targetfriend", "/targetlast", - "/threat", "/ticket", "/time", "/trade", - "/unignore", "/uninvite", "/unstuck", "/use", - "/w", "/whisper", "/who", "/wts", "/wtb", - "/y", "/yell", "/zone" - }; - - // New session if prefix changed - if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) { - self->chatTabPrefix_ = lowerWord; - self->chatTabMatches_.clear(); - for (const auto& cmd : kCmds) { - if (cmd.size() >= lowerWord.size() && - cmd.compare(0, lowerWord.size(), lowerWord) == 0) - self->chatTabMatches_.push_back(cmd); - } - self->chatTabMatchIdx_ = 0; - } else { - // Cycle forward through matches - ++self->chatTabMatchIdx_; - if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) - self->chatTabMatchIdx_ = 0; - } - - if (!self->chatTabMatches_.empty()) { - std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; - // Append trailing space when match is unambiguous - if (self->chatTabMatches_.size() == 1 && rest.empty()) - match += ' '; - std::string newBuf = match + rest; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } else if (data->BufTextLen > 0) { - // Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel - // Also works for plain text (completes nearby player names) - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - bool isNameCommand = false; - std::string namePrefix; - size_t replaceStart = 0; - - if (fullBuf[0] == '/' && spacePos != std::string::npos) { - std::string cmd = fullBuf.substr(0, spacePos); - for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); - // Commands that take a player name as the first argument after the command - if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || - cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || - cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || - cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || - cmd == "/t" || cmd == "/target" || cmd == "/kick" || - cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { - // Extract the partial name after the space - namePrefix = fullBuf.substr(spacePos + 1); - // Only complete the first word after the command - size_t nameSpace = namePrefix.find(' '); - if (nameSpace == std::string::npos) { - isNameCommand = true; - replaceStart = spacePos + 1; - } - } - } - - if (isNameCommand && !namePrefix.empty()) { - std::string lowerPrefix = namePrefix; - for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); - - if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerPrefix) { - self->chatTabPrefix_ = lowerPrefix; - self->chatTabMatches_.clear(); - // Search player name cache and nearby entities - auto* gh = self->cachedGameHandler_; - // Party/raid members - for (const auto& m : gh->getPartyData().members) { - if (m.name.empty()) continue; - std::string lname = m.name; - for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) - self->chatTabMatches_.push_back(m.name); - } - // Friends - for (const auto& c : gh->getContacts()) { - if (!c.isFriend() || c.name.empty()) continue; - std::string lname = c.name; - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - // Avoid duplicates from party - bool dup = false; - for (const auto& em : self->chatTabMatches_) - if (em == c.name) { dup = true; break; } - if (!dup) self->chatTabMatches_.push_back(c.name); - } - } - // Nearby visible players - for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; - auto player = std::static_pointer_cast(entity); - if (player->getName().empty()) continue; - std::string lname = player->getName(); - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : self->chatTabMatches_) - if (em == player->getName()) { dup = true; break; } - if (!dup) self->chatTabMatches_.push_back(player->getName()); - } - } - // Last whisper sender - if (!gh->getLastWhisperSender().empty()) { - std::string lname = gh->getLastWhisperSender(); - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : self->chatTabMatches_) - if (em == gh->getLastWhisperSender()) { dup = true; break; } - if (!dup) self->chatTabMatches_.insert(self->chatTabMatches_.begin(), gh->getLastWhisperSender()); - } - } - self->chatTabMatchIdx_ = 0; - } else { - ++self->chatTabMatchIdx_; - if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) - self->chatTabMatchIdx_ = 0; - } - - if (!self->chatTabMatches_.empty()) { - std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; - std::string prefix = fullBuf.substr(0, replaceStart); - std::string newBuf = prefix + match; - if (self->chatTabMatches_.size() == 1) newBuf += ' '; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } - } - return 0; - } - - // Up/Down arrow: cycle through sent message history - if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { - // Any history navigation resets autocomplete - self->chatTabMatchIdx_ = -1; - self->chatTabMatches_.clear(); - - const int histSize = static_cast(self->chatSentHistory_.size()); - if (histSize == 0) return 0; - - if (data->EventKey == ImGuiKey_UpArrow) { - // Go back in history - if (self->chatHistoryIdx_ == -1) - self->chatHistoryIdx_ = histSize - 1; - else if (self->chatHistoryIdx_ > 0) - --self->chatHistoryIdx_; - } else if (data->EventKey == ImGuiKey_DownArrow) { - if (self->chatHistoryIdx_ == -1) return 0; - ++self->chatHistoryIdx_; - if (self->chatHistoryIdx_ >= histSize) { - self->chatHistoryIdx_ = -1; - data->DeleteChars(0, data->BufTextLen); - return 0; - } - } - - if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { - const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, entry.c_str()); - } - } - return 0; - }; - - ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | - ImGuiInputTextFlags_CallbackAlways | - ImGuiInputTextFlags_CallbackHistory | - ImGuiInputTextFlags_CallbackCompletion; - if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { - sendChatMessage(gameHandler); - // Close chat input on send so movement keys work immediately. - refocusChatInput = false; - ImGui::ClearActiveID(); - } - ImGui::PopStyleColor(); - - if (ImGui::IsItemActive()) { - chatInputActive = true; - } else { - chatInputActive = false; - } - - // Click in chat history area (received messages) → focus input. - { - if (chatHistoryHovered && ImGui::IsMouseClicked(0)) { - refocusChatInput = true; - } - } - - ImGui::End(); -} - void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto& io = ImGui::GetIO(); auto& input = core::Input::getInstance(); // If the user is typing (or about to focus chat this frame), do not allow // A-Z or 1-0 shortcuts to fire. - if (!io.WantTextInput && !chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { - refocusChatInput = true; - chatInputBuffer[0] = '/'; - chatInputBuffer[1] = '\0'; - chatInputMoveCursorToEnd = true; + if (!io.WantTextInput && !chatPanel_.isChatInputActive() && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { + chatPanel_.activateSlashInput(); } - if (!io.WantTextInput && !chatInputActive && + if (!io.WantTextInput && !chatPanel_.isChatInputActive() && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { - refocusChatInput = true; + chatPanel_.activateInput(); } - const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput; + const bool textFocus = chatPanel_.isChatInputActive() || io.WantTextInput; // Tab targeting (when keyboard not captured by UI) if (!io.WantCaptureKeyboard) { @@ -2931,7 +1353,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { gameHandler.useItemById(bar[slotIdx].id); } else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) { - executeMacroText(gameHandler, gameHandler.getMacroText(bar[slotIdx].id)); + chatPanel_.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(bar[slotIdx].id)); } } } @@ -4249,10 +2671,7 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (isPlayer) { ImGui::Separator(); if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(name); } if (ImGui::MenuItem("Follow")) gameHandler.followTarget(); @@ -4357,10 +2776,7 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (isPlayer) { ImGui::Separator(); if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(name); } if (ImGui::MenuItem("Follow")) { gameHandler.followTarget(); @@ -5183,10 +3599,7 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { if (focus->getType() == game::ObjectType::PLAYER) { ImGui::Separator(); if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(focusName); } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(focusName); @@ -5295,10 +3708,7 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { if (focusIsPlayer) { ImGui::Separator(); if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(focusName); } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(focusName); @@ -5604,2840 +4014,6 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } -// Returns the first executable line of a macro text block, skipping blank lines -// and # directive lines (e.g. #showtooltip). Returns empty string if none found. -static std::string firstMacroCommand(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 start = line.find_first_not_of(" \t"); - if (start != std::string::npos) line = line.substr(start); - if (!line.empty() && line.front() != '#') - return line; - if (nl == std::string::npos) break; - pos = nl + 1; - } - return {}; -} - -// Collect all non-comment, non-empty lines from a macro body. -static 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: -// "#showtooltip Spell" → "Spell" -// "#showtooltip" → "__auto__" (derive from first /cast) -// (none) → "" -static 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 {}; -} - -// --------------------------------------------------------------------------- -// WoW macro conditional evaluator -// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell -// Returns the first matching alternative's argument, or "" if none matches. -// targetOverride is set to a specific GUID if [target=X] was in the conditions, -// or left as UINT64_MAX to mean "use the normal target". -// --------------------------------------------------------------------------- -static std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride) { - targetOverride = static_cast(-1); - - auto& input = core::Input::getInstance(); - - const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || - input.isKeyPressed(SDL_SCANCODE_RSHIFT); - const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || - input.isKeyPressed(SDL_SCANCODE_RCTRL); - const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || - input.isKeyPressed(SDL_SCANCODE_RALT); - const bool anyMod = shiftHeld || ctrlHeld || altHeld; - - // Split rawArg on ';' → alternatives - std::vector alts; - { - std::string cur; - for (char c : rawArg) { - if (c == ';') { alts.push_back(cur); cur.clear(); } - else cur += c; - } - alts.push_back(cur); - } - - // Evaluate a single comma-separated condition token. - // tgt is updated if a target= or @ specifier is found. - auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { - std::string c = raw; - // trim - size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; - size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); - if (c.empty()) return true; - - // @target specifiers: @player, @focus, @pet, @mouseover, @target - if (!c.empty() && c[0] == '@') { - std::string spec = c.substr(1); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); - else if (spec == "pet") { - uint64_t pg = gameHandler.getPetGuid(); - if (pg != 0) tgt = pg; - else return false; // no pet — skip this alternative - } - else if (spec == "mouseover") { - uint64_t mo = gameHandler.getMouseoverGuid(); - if (mo != 0) tgt = mo; - else return false; // no mouseover — skip this alternative - } - return true; - } - // target=X specifiers - if (c.rfind("target=", 0) == 0) { - std::string spec = c.substr(7); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); - else if (spec == "pet") { - uint64_t pg = gameHandler.getPetGuid(); - if (pg != 0) tgt = pg; - else return false; // no pet — skip this alternative - } - else if (spec == "mouseover") { - uint64_t mo = gameHandler.getMouseoverGuid(); - if (mo != 0) tgt = mo; - else return false; // no mouseover — skip this alternative - } - return true; - } - - // mod / nomod - if (c == "nomod" || c == "mod:none") return !anyMod; - if (c.rfind("mod:", 0) == 0) { - std::string mods = c.substr(4); - bool ok = true; - if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; - if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; - if (mods.find("alt") != std::string::npos && !altHeld) ok = false; - return ok; - } - - // combat / nocombat - if (c == "combat") return gameHandler.isInCombat(); - if (c == "nocombat") return !gameHandler.isInCombat(); - - // Helper to get the effective target entity - auto effTarget = [&]() -> std::shared_ptr { - if (tgt != static_cast(-1) && tgt != 0) - return gameHandler.getEntityManager().getEntity(tgt); - return gameHandler.getTarget(); - }; - - // exists / noexists - if (c == "exists") return effTarget() != nullptr; - if (c == "noexists") return effTarget() == nullptr; - - // dead / nodead - if (c == "dead") { - auto t = effTarget(); - auto u = t ? std::dynamic_pointer_cast(t) : nullptr; - return u && u->getHealth() == 0; - } - if (c == "nodead") { - auto t = effTarget(); - auto u = t ? std::dynamic_pointer_cast(t) : nullptr; - return u && u->getHealth() > 0; - } - - // help (friendly) / harm (hostile) and their no- variants - auto unitHostile = [&](const std::shared_ptr& t) -> bool { - if (!t) return false; - auto u = std::dynamic_pointer_cast(t); - return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); - }; - if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } - if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } - - // mounted / nomounted - if (c == "mounted") return gameHandler.isMounted(); - if (c == "nomounted") return !gameHandler.isMounted(); - - // swimming / noswimming - if (c == "swimming") return gameHandler.isSwimming(); - if (c == "noswimming") return !gameHandler.isSwimming(); - - // flying / noflying (CAN_FLY + FLYING flags active) - if (c == "flying") return gameHandler.isPlayerFlying(); - if (c == "noflying") return !gameHandler.isPlayerFlying(); - - // channeling / nochanneling - if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling(); - if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling()); - - // stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING) - auto isStealthedFn = [&]() -> bool { - auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); - if (!pe) return false; - auto pu = std::dynamic_pointer_cast(pe); - return pu && (pu->getUnitFlags() & 0x02000000u) != 0; - }; - if (c == "stealthed") return isStealthedFn(); - if (c == "nostealthed") return !isStealthedFn(); - - // pet / nopet — player has an active pet (hunters, warlocks, DKs) - if (c == "pet") return gameHandler.hasPet(); - if (c == "nopet") return !gameHandler.hasPet(); - - // indoors / outdoors — WMO interior detection (affects mount type selection) - if (c == "indoors" || c == "nooutdoors") { - auto* r = core::Application::getInstance().getRenderer(); - return r && r->isPlayerIndoors(); - } - if (c == "outdoors" || c == "noindoors") { - auto* r = core::Application::getInstance().getRenderer(); - return !r || !r->isPlayerIndoors(); - } - - // group / nogroup — player is in a party or raid - if (c == "group" || c == "party") return gameHandler.isInGroup(); - if (c == "nogroup") return !gameHandler.isInGroup(); - - // raid / noraid — player is in a raid group (groupType == 1) - if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1; - if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1; - - // spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary) - if (c.rfind("spec:", 0) == 0) { - uint8_t wantSpec = 0; - try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} - return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1); - } - - // noform / nostance — player is NOT in a shapeshift/stance - if (c == "noform" || c == "nostance") { - for (const auto& a : gameHandler.getPlayerAuras()) - if (!a.isEmpty() && a.maxDurationMs == -1) return false; - return true; - } - // form:0 same as noform - if (c == "form:0" || c == "stance:0") { - for (const auto& a : gameHandler.getPlayerAuras()) - if (!a.isEmpty() && a.maxDurationMs == -1) return false; - return true; - } - - // buff:SpellName / nobuff:SpellName — check if the effective target (or player - // if no target specified) has a buff with the given name. - // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). - auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, - bool negate) -> bool { - // Determine which aura list to check: effective target or player - const std::vector* auras = nullptr; - if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { - // Check target's auras - auras = &gameHandler.getTargetAuras(); - } else { - auras = &gameHandler.getPlayerAuras(); - } - std::string nameLow = spellName; - for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); - for (const auto& a : *auras) { - if (a.isEmpty() || a.spellId == 0) continue; - // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type - bool isDebuff = (a.flags & 0x80) != 0; - if (wantDebuff ? !isDebuff : isDebuff) continue; - std::string sn = gameHandler.getSpellName(a.spellId); - for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); - if (sn == nameLow) return !negate; - } - return negate; - }; - if (c.rfind("buff:", 0) == 0 && c.size() > 5) - return checkAuraByName(c.substr(5), false, false); - if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) - return checkAuraByName(c.substr(7), false, true); - if (c.rfind("debuff:", 0) == 0 && c.size() > 7) - return checkAuraByName(c.substr(7), true, false); - if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) - return checkAuraByName(c.substr(9), true, true); - - // mounted / nomounted - if (c == "mounted") return gameHandler.isMounted(); - if (c == "nomounted") return !gameHandler.isMounted(); - - // group (any group) / nogroup / raid - if (c == "group") return !gameHandler.getPartyData().isEmpty(); - if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); - if (c == "raid") { - const auto& pd = gameHandler.getPartyData(); - return pd.groupType >= 1; // groupType 1 = raid, 0 = party - } - - // channeling:SpellName — player is currently channeling that spell - if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { - if (!gameHandler.isChanneling()) return false; - std::string want = c.substr(11); - for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); - uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); - std::string sn = gameHandler.getSpellName(castSpellId); - for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); - return sn == want; - } - if (c == "channeling") return gameHandler.isChanneling(); - if (c == "nochanneling") return !gameHandler.isChanneling(); - - // casting (any active cast or channel) - if (c == "casting") return gameHandler.isCasting(); - if (c == "nocasting") return !gameHandler.isCasting(); - - // vehicle / novehicle (WotLK) - if (c == "vehicle") return gameHandler.getVehicleId() != 0; - if (c == "novehicle") return gameHandler.getVehicleId() == 0; - - // Unknown → permissive (don't block) - return true; - }; - - for (auto& alt : alts) { - // trim - size_t fs = alt.find_first_not_of(" \t"); - if (fs == std::string::npos) continue; - alt = alt.substr(fs); - size_t ls = alt.find_last_not_of(" \t"); - if (ls != std::string::npos) alt.resize(ls + 1); - - if (!alt.empty() && alt[0] == '[') { - size_t close = alt.find(']'); - if (close == std::string::npos) continue; - std::string condStr = alt.substr(1, close - 1); - std::string argPart = alt.substr(close + 1); - // Trim argPart - size_t as = argPart.find_first_not_of(" \t"); - argPart = (as != std::string::npos) ? argPart.substr(as) : ""; - - // Evaluate comma-separated conditions - uint64_t tgt = static_cast(-1); - bool pass = true; - size_t cp = 0; - while (pass) { - size_t comma = condStr.find(',', cp); - std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); - if (!evalCond(tok, tgt)) { pass = false; break; } - if (comma == std::string::npos) break; - cp = comma + 1; - } - if (pass) { - if (tgt != static_cast(-1)) targetOverride = tgt; - return argPart; - } - } else { - // No condition block — default fallback always matches - return alt; - } - } - return {}; -} - -// Execute all non-comment lines of a macro body in sequence. -// In WoW, every line executes per click; the server enforces spell-cast limits. -// /stopmacro (with optional conditionals) halts the remaining commands early. -void GameScreen::executeMacroText(game::GameHandler& gameHandler, const std::string& macroText) { - macroStopped_ = false; - for (const auto& cmd : allMacroCommands(macroText)) { - strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); - chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; - sendChatMessage(gameHandler); - if (macroStopped_) break; - } - macroStopped_ = false; -} - -// /castsequence persistent state — shared across all macros using the same spell list. -// Keyed by the normalized (lowercase, comma-joined) spell sequence string. -namespace { -struct CastSeqState { - size_t index = 0; - float lastPressSec = 0.0f; - uint64_t lastTargetGuid = 0; - bool lastInCombat = false; -}; -std::unordered_map s_castSeqStates; -} // namespace - -void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { - if (strlen(chatInputBuffer) > 0) { - std::string input(chatInputBuffer); - - // Save to sent-message history (skip pure whitespace, cap at 50 entries) - { - bool allSpace = true; - for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } - if (!allSpace) { - // Remove duplicate of last entry if identical - if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { - chatSentHistory_.push_back(input); - if (chatSentHistory_.size() > 50) - chatSentHistory_.erase(chatSentHistory_.begin()); - } - } - } - chatHistoryIdx_ = -1; // reset browsing position after send - - game::ChatType type = game::ChatType::SAY; - std::string message = input; - std::string target; - - // Track if a channel shortcut should change the chat type dropdown - int switchChatType = -1; - - // Check for slash commands - if (input.size() > 1 && input[0] == '/') { - std::string command = input.substr(1); - size_t spacePos = command.find(' '); - std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; - - // Convert command to lowercase for comparison - std::string cmdLower = cmd; - for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); - - // /run — execute Lua script via addon system - if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { - std::string luaCode = command.substr(spacePos + 1); - auto* am = core::Application::getInstance().getAddonManager(); - if (am) { - am->runScript(luaCode); - } else { - gameHandler.addUIError("Addon system not initialized."); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /dump — evaluate Lua expression and print result - if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { - std::string expr = command.substr(spacePos + 1); - auto* am = core::Application::getInstance().getAddonManager(); - if (am && am->isInitialized()) { - // Wrap expression in print(tostring(...)) to display the value - std::string wrapped = "local __v = " + expr + - "; if type(__v) == 'table' then " - " local parts = {} " - " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " - " print('{' .. table.concat(parts, ', ') .. '}') " - "else print(tostring(__v)) end"; - am->runScript(wrapped); - } else { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "Addon system not initialized."; - gameHandler.addLocalChatMessage(errMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // Check addon slash commands (SlashCmdList) before built-in commands - { - auto* am = core::Application::getInstance().getAddonManager(); - if (am && am->isInitialized()) { - std::string slashCmd = "/" + cmdLower; - std::string slashArgs; - if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); - if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { - chatInputBuffer[0] = '\0'; - return; - } - } - } - - // Special commands - if (cmdLower == "logout") { - core::Application::getInstance().logoutToLogin(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "clear") { - gameHandler.clearChatHistory(); - chatInputBuffer[0] = '\0'; - return; - } - - // /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files) - if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") { - auto* am = core::Application::getInstance().getAddonManager(); - if (am) { - am->reload(); - am->fireEvent("VARIABLES_LOADED"); - am->fireEvent("PLAYER_LOGIN"); - am->fireEvent("PLAYER_ENTERING_WORLD"); - game::MessageChatData rlMsg; - rlMsg.type = game::ChatType::SYSTEM; - rlMsg.language = game::ChatLanguage::UNIVERSAL; - rlMsg.message = "Interface reloaded."; - gameHandler.addLocalChatMessage(rlMsg); - } else { - game::MessageChatData rlMsg; - rlMsg.type = game::ChatType::SYSTEM; - rlMsg.language = game::ChatLanguage::UNIVERSAL; - rlMsg.message = "Addon system not available."; - gameHandler.addLocalChatMessage(rlMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /stopmacro [conditions] - // Halts execution of the current macro (remaining lines are skipped). - // With a condition block, only stops if the conditions evaluate to true. - // /stopmacro → always stops - // /stopmacro [combat] → stops only while in combat - // /stopmacro [nocombat] → stops only when not in combat - if (cmdLower == "stopmacro") { - bool shouldStop = true; - if (spacePos != std::string::npos) { - std::string condArg = command.substr(spacePos + 1); - while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); - if (!condArg.empty() && condArg.front() == '[') { - // Append a sentinel action so evaluateMacroConditionals can signal a match. - uint64_t tgtOver = static_cast(-1); - std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); - shouldStop = !hit.empty(); - } - } - if (shouldStop) macroStopped_ = true; - chatInputBuffer[0] = '\0'; - return; - } - - // /invite command - if (cmdLower == "invite" && spacePos != std::string::npos) { - std::string targetName = command.substr(spacePos + 1); - gameHandler.inviteToGroup(targetName); - chatInputBuffer[0] = '\0'; - return; - } - - // /inspect command - if (cmdLower == "inspect") { - gameHandler.inspectTarget(); - showInspectWindow_ = true; - chatInputBuffer[0] = '\0'; - return; - } - - // /threat command - if (cmdLower == "threat") { - showThreatWindow_ = !showThreatWindow_; - chatInputBuffer[0] = '\0'; - return; - } - - // /score command — BG scoreboard - if (cmdLower == "score") { - gameHandler.requestPvpLog(); - showBgScoreboard_ = true; - chatInputBuffer[0] = '\0'; - return; - } - - // /time command - if (cmdLower == "time") { - gameHandler.queryServerTime(); - chatInputBuffer[0] = '\0'; - return; - } - - // /loc command — print player coordinates and zone name - if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { - const auto& pmi = gameHandler.getMovementInfo(); - std::string zoneName; - if (auto* rend = core::Application::getInstance().getRenderer()) - zoneName = rend->getCurrentZoneName(); - char buf[256]; - snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", - pmi.x, pmi.y, pmi.z, - zoneName.empty() ? "" : " — ", - zoneName.c_str()); - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = buf; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer[0] = '\0'; - return; - } - - // /screenshot command — capture current frame to PNG - if (cmdLower == "screenshot" || cmdLower == "ss") { - takeScreenshot(gameHandler); - chatInputBuffer[0] = '\0'; - return; - } - - // /zone command — print current zone name - if (cmdLower == "zone") { - std::string zoneName; - if (auto* rend = core::Application::getInstance().getRenderer()) - zoneName = rend->getCurrentZoneName(); - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer[0] = '\0'; - return; - } - - // /played command - if (cmdLower == "played") { - gameHandler.requestPlayedTime(); - chatInputBuffer[0] = '\0'; - return; - } - - // /ticket command — open GM ticket window - if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { - showGmTicketWindow_ = true; - chatInputBuffer[0] = '\0'; - return; - } - - // /chathelp command — list chat-channel slash commands - if (cmdLower == "chathelp") { - static constexpr const char* kChatHelp[] = { - "--- Chat Channel Commands ---", - "/s [msg] Say to nearby players", - "/y [msg] Yell to a wider area", - "/w [msg] Whisper to player", - "/r [msg] Reply to last whisper", - "/p [msg] Party chat", - "/g [msg] Guild chat", - "/o [msg] Guild officer chat", - "/raid [msg] Raid chat", - "/rw [msg] Raid warning", - "/bg [msg] Battleground chat", - "/1 [msg] General channel", - "/2 [msg] Trade channel (also /wts /wtb)", - "/ [msg] Channel by number", - "/join Join a channel", - "/leave Leave a channel", - "/afk [msg] Set AFK status", - "/dnd [msg] Set Do Not Disturb", - }; - for (const char* line : kChatHelp) { - game::MessageChatData helpMsg; - helpMsg.type = game::ChatType::SYSTEM; - helpMsg.language = game::ChatLanguage::UNIVERSAL; - helpMsg.message = line; - gameHandler.addLocalChatMessage(helpMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /macrohelp command — list available macro conditionals - if (cmdLower == "macrohelp") { - static constexpr const char* kMacroHelp[] = { - "--- Macro Conditionals ---", - "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", - "State: [combat] [mounted] [swimming] [flying] [stealthed]", - " [channeling] [pet] [group] [raid] [indoors] [outdoors]", - "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", - " (prefix no- to negate any condition)", - "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", - " [target=focus] [target=pet] [target=mouseover] [target=player]", - " (also: @focus, @pet, @mouseover, @player, @target)", - "Form: [noform] [nostance] [form:0]", - "Keys: [mod:shift] [mod:ctrl] [mod:alt]", - "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", - "Other: #showtooltip, /stopmacro [cond], /castsequence", - }; - for (const char* line : kMacroHelp) { - game::MessageChatData m; - m.type = game::ChatType::SYSTEM; - m.language = game::ChatLanguage::UNIVERSAL; - m.message = line; - gameHandler.addLocalChatMessage(m); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /help command — list available slash commands - if (cmdLower == "help" || cmdLower == "?") { - static constexpr const char* kHelpLines[] = { - "--- Wowee Slash Commands ---", - "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", - "Social: /who /friend add/remove /ignore /unignore", - "Party: /invite /uninvite /leave /readycheck /mark /roll", - " /maintank /mainassist /raidconvert /raidinfo", - " /lootmethod /lootthreshold", - "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", - " /gleader /groster /ginfo /gcreate /gdisband", - "Combat: /cast /castsequence /use /startattack /stopattack", - " /stopcasting /duel /forfeit /pvp /assist", - " /follow /stopfollow /threat /combatlog", - "Items: /use /equip /equipset [name]", - "Target: /target /cleartarget /focus /clearfocus /inspect", - "Movement: /sit /stand /kneel /dismount", - "Misc: /played /time /zone /loc /afk /dnd /helm /cloak", - " /trade /score /unstuck /logout /quit /exit /ticket", - " /screenshot /difficulty", - " /macrohelp /chathelp /help", - }; - for (const char* line : kHelpLines) { - game::MessageChatData helpMsg; - helpMsg.type = game::ChatType::SYSTEM; - helpMsg.language = game::ChatLanguage::UNIVERSAL; - helpMsg.message = line; - gameHandler.addLocalChatMessage(helpMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /who commands - if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { - std::string query; - if (spacePos != std::string::npos) { - query = command.substr(spacePos + 1); - // Trim leading/trailing whitespace - size_t first = query.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) { - query.clear(); - } else { - size_t last = query.find_last_not_of(" \t\r\n"); - query = query.substr(first, last - first + 1); - } - } - - if ((cmdLower == "whois") && query.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /whois "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "who" && (query == "help" || query == "?")) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Who commands: /who [name/filter], /whois , /online"; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - gameHandler.queryWho(query); - showWhoWindow_ = true; - chatInputBuffer[0] = '\0'; - return; - } - - // /combatlog command - if (cmdLower == "combatlog" || cmdLower == "cl") { - showCombatLog_ = !showCombatLog_; - chatInputBuffer[0] = '\0'; - return; - } - - // /roll command - if (cmdLower == "roll" || cmdLower == "random" || cmdLower == "rnd") { - uint32_t minRoll = 1; - uint32_t maxRoll = 100; - - if (spacePos != std::string::npos) { - std::string args = command.substr(spacePos + 1); - size_t dashPos = args.find('-'); - size_t spacePos2 = args.find(' '); - - if (dashPos != std::string::npos) { - // Format: /roll 1-100 - try { - minRoll = std::stoul(args.substr(0, dashPos)); - maxRoll = std::stoul(args.substr(dashPos + 1)); - } catch (...) {} - } else if (spacePos2 != std::string::npos) { - // Format: /roll 1 100 - try { - minRoll = std::stoul(args.substr(0, spacePos2)); - maxRoll = std::stoul(args.substr(spacePos2 + 1)); - } catch (...) {} - } else { - // Format: /roll 100 (means 1-100) - try { - maxRoll = std::stoul(args); - } catch (...) {} - } - } - - gameHandler.randomRoll(minRoll, maxRoll); - chatInputBuffer[0] = '\0'; - return; - } - - // /friend or /addfriend command - if (cmdLower == "friend" || cmdLower == "addfriend") { - if (spacePos != std::string::npos) { - std::string args = command.substr(spacePos + 1); - size_t subCmdSpace = args.find(' '); - - if (cmdLower == "friend" && subCmdSpace != std::string::npos) { - std::string subCmd = args.substr(0, subCmdSpace); - std::transform(subCmd.begin(), subCmd.end(), subCmd.begin(), ::tolower); - - if (subCmd == "add") { - std::string playerName = args.substr(subCmdSpace + 1); - gameHandler.addFriend(playerName); - chatInputBuffer[0] = '\0'; - return; - } else if (subCmd == "remove" || subCmd == "delete" || subCmd == "rem") { - std::string playerName = args.substr(subCmdSpace + 1); - gameHandler.removeFriend(playerName); - chatInputBuffer[0] = '\0'; - return; - } - } else { - // /addfriend name or /friend name (assume add) - gameHandler.addFriend(args); - chatInputBuffer[0] = '\0'; - return; - } - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /friend add or /friend remove "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /removefriend or /delfriend command - if (cmdLower == "removefriend" || cmdLower == "delfriend" || cmdLower == "remfriend") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.removeFriend(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /removefriend "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /ignore command - if (cmdLower == "ignore") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.addIgnore(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /ignore "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /unignore command - if (cmdLower == "unignore") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.removeIgnore(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /unignore "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /dismount command - if (cmdLower == "dismount") { - gameHandler.dismount(); - chatInputBuffer[0] = '\0'; - return; - } - - // Pet control commands (common macro use) - // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive - if (cmdLower == "petattack") { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.sendPetAction(5, target); - chatInputBuffer[0] = '\0'; - return; - } - if (cmdLower == "petfollow") { - gameHandler.sendPetAction(2, 0); - chatInputBuffer[0] = '\0'; - return; - } - if (cmdLower == "petstay" || cmdLower == "pethalt") { - gameHandler.sendPetAction(3, 0); - chatInputBuffer[0] = '\0'; - return; - } - if (cmdLower == "petpassive") { - gameHandler.sendPetAction(1, 0); - chatInputBuffer[0] = '\0'; - return; - } - if (cmdLower == "petdefensive") { - gameHandler.sendPetAction(4, 0); - chatInputBuffer[0] = '\0'; - return; - } - if (cmdLower == "petaggressive") { - gameHandler.sendPetAction(6, 0); - chatInputBuffer[0] = '\0'; - return; - } - if (cmdLower == "petdismiss") { - gameHandler.dismissPet(); - chatInputBuffer[0] = '\0'; - return; - } - - // /cancelform / /cancelshapeshift — leave current shapeshift/stance - if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { - // Cancel the first permanent shapeshift aura the player has - for (const auto& aura : gameHandler.getPlayerAuras()) { - if (aura.spellId == 0) continue; - // Permanent shapeshift auras have the permanent flag (0x20) set - if (aura.flags & 0x20) { - gameHandler.cancelAura(aura.spellId); - break; - } - } - chatInputBuffer[0] = '\0'; - return; - } - - // /cancelaura — cancel a specific buff by name or ID - if (cmdLower == "cancelaura" && spacePos != std::string::npos) { - std::string auraArg = command.substr(spacePos + 1); - while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); - while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); - // Try numeric ID first - { - std::string numStr = auraArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNum = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNum) { - uint32_t spellId = 0; - try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} - if (spellId) gameHandler.cancelAura(spellId); - chatInputBuffer[0] = '\0'; - return; - } - } - // Name match against player auras - std::string argLow = auraArg; - for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); - for (const auto& aura : gameHandler.getPlayerAuras()) { - if (aura.spellId == 0) continue; - std::string sn = gameHandler.getSpellName(aura.spellId); - for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); - if (sn == argLow) { - gameHandler.cancelAura(aura.spellId); - break; - } - } - chatInputBuffer[0] = '\0'; - return; - } - - // /sit command - if (cmdLower == "sit") { - gameHandler.setStandState(1); // 1 = sit - chatInputBuffer[0] = '\0'; - return; - } - - // /stand command - if (cmdLower == "stand") { - gameHandler.setStandState(0); // 0 = stand - chatInputBuffer[0] = '\0'; - return; - } - - // /kneel command - if (cmdLower == "kneel") { - gameHandler.setStandState(8); // 8 = kneel - chatInputBuffer[0] = '\0'; - return; - } - - // /logout command (also /camp, /quit, /exit) - if (cmdLower == "logout" || cmdLower == "camp" || cmdLower == "quit" || cmdLower == "exit") { - gameHandler.requestLogout(); - chatInputBuffer[0] = '\0'; - return; - } - - // /cancellogout command - if (cmdLower == "cancellogout") { - gameHandler.cancelLogout(); - chatInputBuffer[0] = '\0'; - return; - } - - // /difficulty command — set dungeon/raid difficulty (WotLK) - if (cmdLower == "difficulty") { - std::string arg; - if (spacePos != std::string::npos) { - arg = command.substr(spacePos + 1); - // Trim whitespace - size_t first = arg.find_first_not_of(" \t"); - size_t last = arg.find_last_not_of(" \t"); - if (first != std::string::npos) - arg = arg.substr(first, last - first + 1); - else - arg.clear(); - for (auto& ch : arg) ch = static_cast(std::tolower(static_cast(ch))); - } - - uint32_t diff = 0; - bool valid = true; - if (arg == "normal" || arg == "0") diff = 0; - else if (arg == "heroic" || arg == "1") diff = 1; - else if (arg == "25" || arg == "25normal" || arg == "25man" || arg == "2") - diff = 2; - else if (arg == "25heroic" || arg == "25manheroic" || arg == "3") - diff = 3; - else valid = false; - - if (!valid || arg.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /difficulty normal|heroic|25|25heroic (0-3)"; - gameHandler.addLocalChatMessage(msg); - } else { - static constexpr const char* kDiffNames[] = { - "Normal (5-man)", "Heroic (5-man)", "Normal (25-man)", "Heroic (25-man)" - }; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Setting difficulty to: ") + kDiffNames[diff]; - gameHandler.addLocalChatMessage(msg); - gameHandler.sendSetDifficulty(diff); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /helm command - if (cmdLower == "helm" || cmdLower == "helmet" || cmdLower == "showhelm") { - gameHandler.toggleHelm(); - chatInputBuffer[0] = '\0'; - return; - } - - // /cloak command - if (cmdLower == "cloak" || cmdLower == "showcloak") { - gameHandler.toggleCloak(); - chatInputBuffer[0] = '\0'; - return; - } - - // /follow command - if (cmdLower == "follow" || cmdLower == "f") { - gameHandler.followTarget(); - chatInputBuffer[0] = '\0'; - return; - } - - // /stopfollow command - if (cmdLower == "stopfollow") { - gameHandler.cancelFollow(); - chatInputBuffer[0] = '\0'; - return; - } - - // /assist command - if (cmdLower == "assist") { - // /assist → assist current target (use their target) - // /assist PlayerName → find PlayerName, target their target - // /assist [target=X] → evaluate conditional, target that entity's target - auto assistEntityTarget = [&](uint64_t srcGuid) { - auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); - if (!srcEnt) { gameHandler.assistTarget(); return; } - uint64_t atkGuid = 0; - const auto& flds = srcEnt->getFields(); - auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); - if (iLo != flds.end()) { - atkGuid = iLo->second; - auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); - if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); - } - if (atkGuid != 0) { - gameHandler.setTarget(atkGuid); - } else { - std::string sn = getEntityName(srcEnt); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = (sn.empty() ? "Target" : sn) + " has no target."; - gameHandler.addLocalChatMessage(msg); - } - }; - - if (spacePos != std::string::npos) { - std::string assistArg = command.substr(spacePos + 1); - while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); - - // Evaluate conditionals if present - uint64_t assistOver = static_cast(-1); - if (!assistArg.empty() && assistArg.front() == '[') { - assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); - if (assistArg.empty() && assistOver == static_cast(-1)) { - chatInputBuffer[0] = '\0'; return; // no condition matched - } - while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); - while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); - } - - if (assistOver != static_cast(-1) && assistOver != 0) { - assistEntityTarget(assistOver); - } else if (!assistArg.empty()) { - // Name search - std::string argLow = assistArg; - for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { - if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; - std::string nm = getEntityName(ent); - std::string nml = nm; - for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); - if (nml.find(argLow) != 0) continue; - float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) - + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); - if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } - } - if (bestGuid) assistEntityTarget(bestGuid); - else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "No unit matching '" + assistArg + "' found."; - gameHandler.addLocalChatMessage(msg); - } - } else { - gameHandler.assistTarget(); - } - } else { - gameHandler.assistTarget(); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /pvp command - if (cmdLower == "pvp") { - gameHandler.togglePvp(); - chatInputBuffer[0] = '\0'; - return; - } - - // /ginfo command - if (cmdLower == "ginfo" || cmdLower == "guildinfo") { - gameHandler.requestGuildInfo(); - chatInputBuffer[0] = '\0'; - return; - } - - // /groster command - if (cmdLower == "groster" || cmdLower == "guildroster") { - gameHandler.requestGuildRoster(); - chatInputBuffer[0] = '\0'; - return; - } - - // /gmotd command - if (cmdLower == "gmotd" || cmdLower == "guildmotd") { - if (spacePos != std::string::npos) { - std::string motd = command.substr(spacePos + 1); - gameHandler.setGuildMotd(motd); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gmotd "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /gpromote command - if (cmdLower == "gpromote" || cmdLower == "guildpromote") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.promoteGuildMember(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gpromote "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /gdemote command - if (cmdLower == "gdemote" || cmdLower == "guilddemote") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.demoteGuildMember(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gdemote "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /gquit command - if (cmdLower == "gquit" || cmdLower == "guildquit" || cmdLower == "leaveguild") { - gameHandler.leaveGuild(); - chatInputBuffer[0] = '\0'; - return; - } - - // /ginvite command - if (cmdLower == "ginvite" || cmdLower == "guildinvite") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.inviteToGuild(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /ginvite "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /gkick command - if (cmdLower == "gkick" || cmdLower == "guildkick") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.kickGuildMember(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gkick "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /gcreate command - if (cmdLower == "gcreate" || cmdLower == "guildcreate") { - if (spacePos != std::string::npos) { - std::string guildName = command.substr(spacePos + 1); - gameHandler.createGuild(guildName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gcreate "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /gdisband command - if (cmdLower == "gdisband" || cmdLower == "guilddisband") { - gameHandler.disbandGuild(); - chatInputBuffer[0] = '\0'; - return; - } - - // /gleader command - if (cmdLower == "gleader" || cmdLower == "guildleader") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.setGuildLeader(playerName); - chatInputBuffer[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gleader "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // /readycheck command - if (cmdLower == "readycheck" || cmdLower == "rc") { - gameHandler.initiateReadyCheck(); - chatInputBuffer[0] = '\0'; - return; - } - - // /ready command (respond yes to ready check) - if (cmdLower == "ready") { - gameHandler.respondToReadyCheck(true); - chatInputBuffer[0] = '\0'; - return; - } - - // /notready command (respond no to ready check) - if (cmdLower == "notready" || cmdLower == "nr") { - gameHandler.respondToReadyCheck(false); - chatInputBuffer[0] = '\0'; - return; - } - - // /yield or /forfeit command - if (cmdLower == "yield" || cmdLower == "forfeit" || cmdLower == "surrender") { - gameHandler.forfeitDuel(); - chatInputBuffer[0] = '\0'; - return; - } - - // AFK command - if (cmdLower == "afk" || cmdLower == "away") { - std::string afkMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - gameHandler.toggleAfk(afkMsg); - chatInputBuffer[0] = '\0'; - return; - } - - // DND command - if (cmdLower == "dnd" || cmdLower == "busy") { - std::string dndMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - gameHandler.toggleDnd(dndMsg); - chatInputBuffer[0] = '\0'; - return; - } - - // Reply command - if (cmdLower == "r" || cmdLower == "reply") { - std::string lastSender = gameHandler.getLastWhisperSender(); - if (lastSender.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "No one has whispered you yet."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer[0] = '\0'; - return; - } - // Set whisper target to last whisper sender - strncpy(whisperTargetBuffer, lastSender.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - if (spacePos != std::string::npos) { - // /r message — send reply immediately - std::string replyMsg = command.substr(spacePos + 1); - gameHandler.sendChatMessage(game::ChatType::WHISPER, replyMsg, lastSender); - } - // Switch to whisper tab - selectedChatType = 4; - chatInputBuffer[0] = '\0'; - return; - } - - // Party/Raid management commands - if (cmdLower == "uninvite" || cmdLower == "kick") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.uninvitePlayer(playerName); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /uninvite "; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "leave" || cmdLower == "leaveparty") { - gameHandler.leaveParty(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "maintank" || cmdLower == "mt") { - if (gameHandler.hasTarget()) { - gameHandler.setMainTank(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to set as main tank."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "mainassist" || cmdLower == "ma") { - if (gameHandler.hasTarget()) { - gameHandler.setMainAssist(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to set as main assist."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "clearmaintank") { - gameHandler.clearMainTank(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "clearmainassist") { - gameHandler.clearMainAssist(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "raidinfo") { - gameHandler.requestRaidInfo(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "raidconvert") { - gameHandler.convertToRaid(); - chatInputBuffer[0] = '\0'; - return; - } - - // /lootmethod (or /grouploot, /setloot) — set party/raid loot method - if (cmdLower == "lootmethod" || cmdLower == "grouploot" || cmdLower == "setloot") { - if (!gameHandler.isInGroup()) { - gameHandler.addUIError("You are not in a group."); - } else if (spacePos == std::string::npos) { - // No argument — show current method and usage - static constexpr const char* kMethodNames[] = { - "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" - }; - const auto& pd = gameHandler.getPartyData(); - const char* cur = (pd.lootMethod < 5) ? kMethodNames[pd.lootMethod] : "Unknown"; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Current loot method: ") + cur; - gameHandler.addLocalChatMessage(msg); - msg.message = "Usage: /lootmethod ffa|roundrobin|master|group|needbeforegreed"; - gameHandler.addLocalChatMessage(msg); - } else { - std::string arg = command.substr(spacePos + 1); - // Lowercase the argument - for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); - uint32_t method = 0xFFFFFFFF; - if (arg == "ffa" || arg == "freeforall") method = 0; - else if (arg == "roundrobin" || arg == "rr") method = 1; - else if (arg == "master" || arg == "masterloot") method = 2; - else if (arg == "group" || arg == "grouploot") method = 3; - else if (arg == "needbeforegreed" || arg == "nbg" || arg == "need") method = 4; - - if (method == 0xFFFFFFFF) { - gameHandler.addUIError("Unknown loot method. Use: ffa, roundrobin, master, group, needbeforegreed"); - } else { - const auto& pd = gameHandler.getPartyData(); - // Master loot uses player guid as master looter; otherwise 0 - uint64_t masterGuid = (method == 2) ? gameHandler.getPlayerGuid() : 0; - gameHandler.sendSetLootMethod(method, pd.lootThreshold, masterGuid); - } - } - chatInputBuffer[0] = '\0'; - return; - } - - // /lootthreshold — set minimum item quality for group loot rolls - if (cmdLower == "lootthreshold") { - if (!gameHandler.isInGroup()) { - gameHandler.addUIError("You are not in a group."); - } else if (spacePos == std::string::npos) { - const auto& pd = gameHandler.getPartyData(); - static constexpr const char* kQualityNames[] = { - "Poor (grey)", "Common (white)", "Uncommon (green)", - "Rare (blue)", "Epic (purple)", "Legendary (orange)" - }; - const char* cur = (pd.lootThreshold < 6) ? kQualityNames[pd.lootThreshold] : "Unknown"; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Current loot threshold: ") + cur; - gameHandler.addLocalChatMessage(msg); - msg.message = "Usage: /lootthreshold <0-5> (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary)"; - gameHandler.addLocalChatMessage(msg); - } else { - std::string arg = command.substr(spacePos + 1); - // Trim whitespace - while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); - uint32_t threshold = 0xFFFFFFFF; - if (arg.size() == 1 && arg[0] >= '0' && arg[0] <= '5') { - threshold = static_cast(arg[0] - '0'); - } else { - // Accept quality names - for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); - if (arg == "poor" || arg == "grey" || arg == "gray") threshold = 0; - else if (arg == "common" || arg == "white") threshold = 1; - else if (arg == "uncommon" || arg == "green") threshold = 2; - else if (arg == "rare" || arg == "blue") threshold = 3; - else if (arg == "epic" || arg == "purple") threshold = 4; - else if (arg == "legendary" || arg == "orange") threshold = 5; - } - - if (threshold == 0xFFFFFFFF) { - gameHandler.addUIError("Invalid threshold. Use 0-5 or: poor, common, uncommon, rare, epic, legendary"); - } else { - const auto& pd = gameHandler.getPartyData(); - uint64_t masterGuid = (pd.lootMethod == 2) ? gameHandler.getPlayerGuid() : 0; - gameHandler.sendSetLootMethod(pd.lootMethod, threshold, masterGuid); - } - } - chatInputBuffer[0] = '\0'; - return; - } - - // /mark [icon] — set or clear a raid target mark on the current target. - // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull - // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) - // /mark — no arg marks with skull (icon 7) - if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { - if (!gameHandler.hasTarget()) { - game::MessageChatData noTgt; - noTgt.type = game::ChatType::SYSTEM; - noTgt.language = game::ChatLanguage::UNIVERSAL; - noTgt.message = "No target selected."; - gameHandler.addLocalChatMessage(noTgt); - chatInputBuffer[0] = '\0'; - return; - } - static constexpr const char* kMarkWords[] = { - "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" - }; - uint8_t icon = 7; // default: skull - if (spacePos != std::string::npos) { - std::string arg = command.substr(spacePos + 1); - while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); - std::string argLow = arg; - for (auto& c : argLow) c = static_cast(std::tolower(c)); - if (argLow == "clear" || argLow == "0" || argLow == "none") { - gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); - chatInputBuffer[0] = '\0'; - return; - } - bool found = false; - for (int mi = 0; mi < 8; ++mi) { - if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } - } - if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { - icon = static_cast(argLow[0] - '1'); - found = true; - } - if (!found) { - game::MessageChatData badArg; - badArg.type = game::ChatType::SYSTEM; - badArg.language = game::ChatLanguage::UNIVERSAL; - badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; - gameHandler.addLocalChatMessage(badArg); - chatInputBuffer[0] = '\0'; - return; - } - } - gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); - chatInputBuffer[0] = '\0'; - return; - } - - // Combat and Trade commands - if (cmdLower == "duel") { - if (gameHandler.hasTarget()) { - gameHandler.proposeDuel(gameHandler.getTargetGuid()); - } else if (spacePos != std::string::npos) { - // Target player by name (would need name-to-GUID lookup) - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to challenge to a duel."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to challenge to a duel."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "trade") { - if (gameHandler.hasTarget()) { - gameHandler.initiateTrade(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to trade with."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "startattack") { - // Support macro conditionals: /startattack [harm,nodead] - bool condPass = true; - uint64_t saOverride = static_cast(-1); - if (spacePos != std::string::npos) { - std::string saArg = command.substr(spacePos + 1); - while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); - if (!saArg.empty() && saArg.front() == '[') { - std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); - condPass = !(result.empty() && saOverride == static_cast(-1)); - } - } - if (condPass) { - uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) - ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - if (atkTarget != 0) { - gameHandler.startAutoAttack(atkTarget); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You have no target."; - gameHandler.addLocalChatMessage(msg); - } - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "stopattack") { - gameHandler.stopAutoAttack(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "stopcasting") { - gameHandler.stopCasting(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "cancelqueuedspell" || cmdLower == "stopspellqueue") { - gameHandler.cancelQueuedSpell(); - chatInputBuffer[0] = '\0'; - return; - } - - // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) - // /equipset — list available sets in chat - if (cmdLower == "equipset") { - const auto& sets = gameHandler.getEquipmentSets(); - auto sysSay = [&](const std::string& msg) { - game::MessageChatData m; - m.type = game::ChatType::SYSTEM; - m.language = game::ChatLanguage::UNIVERSAL; - m.message = msg; - gameHandler.addLocalChatMessage(m); - }; - if (spacePos == std::string::npos) { - // No argument: list available sets - if (sets.empty()) { - sysSay("[System] No equipment sets saved."); - } else { - sysSay("[System] Equipment sets:"); - for (const auto& es : sets) - sysSay(" " + es.name); - } - } else { - std::string setName = command.substr(spacePos + 1); - while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); - while (!setName.empty() && setName.back() == ' ') setName.pop_back(); - // Case-insensitive prefix match - std::string setLower = setName; - std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); - const game::GameHandler::EquipmentSetInfo* found = nullptr; - for (const auto& es : sets) { - std::string nameLow = es.name; - std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); - if (nameLow == setLower || nameLow.find(setLower) == 0) { - found = &es; - break; - } - } - if (found) { - gameHandler.useEquipmentSet(found->setId); - } else { - sysSay("[System] No equipment set matching '" + setName + "'."); - } - } - chatInputBuffer[0] = '\0'; - return; - } - - // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... - // Cycles through the spell list on successive presses; resets per the reset= spec. - if (cmdLower == "castsequence" && spacePos != std::string::npos) { - std::string seqArg = command.substr(spacePos + 1); - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - - // Macro conditionals - uint64_t seqTgtOver = static_cast(-1); - if (!seqArg.empty() && seqArg.front() == '[') { - seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); - if (seqArg.empty() && seqTgtOver == static_cast(-1)) { - chatInputBuffer[0] = '\0'; return; - } - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); - } - - // Optional reset= spec (may contain slash-separated conditions: reset=5/target) - std::string resetSpec; - if (seqArg.rfind("reset=", 0) == 0) { - size_t spAfter = seqArg.find(' '); - if (spAfter != std::string::npos) { - resetSpec = seqArg.substr(6, spAfter - 6); - seqArg = seqArg.substr(spAfter + 1); - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - } - } - - // Parse comma-separated spell list - std::vector seqSpells; - { - std::string cur; - for (char c : seqArg) { - if (c == ',') { - while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); - while (!cur.empty() && cur.back() == ' ') cur.pop_back(); - if (!cur.empty()) seqSpells.push_back(cur); - cur.clear(); - } else { cur += c; } - } - while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); - while (!cur.empty() && cur.back() == ' ') cur.pop_back(); - if (!cur.empty()) seqSpells.push_back(cur); - } - if (seqSpells.empty()) { chatInputBuffer[0] = '\0'; return; } - - // Build stable key from lowercase spell list - std::string seqKey; - for (size_t k = 0; k < seqSpells.size(); ++k) { - if (k) seqKey += ','; - std::string sl = seqSpells[k]; - for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); - seqKey += sl; - } - - auto& seqState = s_castSeqStates[seqKey]; - - // Check reset conditions (slash-separated: e.g. "5/target") - float nowSec = static_cast(ImGui::GetTime()); - bool shouldReset = false; - if (!resetSpec.empty()) { - size_t rpos = 0; - while (rpos <= resetSpec.size()) { - size_t slash = resetSpec.find('/', rpos); - std::string part = (slash != std::string::npos) - ? resetSpec.substr(rpos, slash - rpos) - : resetSpec.substr(rpos); - std::string plow = part; - for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); - bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), - [](unsigned char c){ return std::isdigit(c) || c == '.'; }); - if (isNum) { - float rSec = 0.0f; - try { rSec = std::stof(plow); } catch (...) {} - if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; - } else if (plow == "target") { - if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; - } else if (plow == "combat") { - if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; - } - if (slash == std::string::npos) break; - rpos = slash + 1; - } - } - if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; - - const std::string& seqSpell = seqSpells[seqState.index]; - seqState.index = (seqState.index + 1) % seqSpells.size(); - seqState.lastPressSec = nowSec; - seqState.lastTargetGuid = gameHandler.getTargetGuid(); - seqState.lastInCombat = gameHandler.isInCombat(); - - // Cast the selected spell — mirrors /cast spell lookup - std::string ssLow = seqSpell; - for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); - if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); - - uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) - ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - - // Numeric ID - if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); - bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (ssNumeric) { - uint32_t ssId = 0; - try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} - if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); - } else { - uint32_t ssBest = 0; int ssBestRank = -1; - 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 != ssLow) continue; - int sRnk = 0; - const std::string& rk = gameHandler.getSpellRank(sid); - if (!rk.empty()) { - std::string rkl = rk; - for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); - if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } - } - if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } - } - if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "cast" && spacePos != std::string::npos) { - std::string spellArg = command.substr(spacePos + 1); - // Trim leading/trailing whitespace - while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); - while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); - - // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal - uint64_t castTargetOverride = static_cast(-1); - if (!spellArg.empty() && spellArg.front() == '[') { - spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); - if (spellArg.empty()) { - chatInputBuffer[0] = '\0'; - return; // No conditional matched — skip cast - } - while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); - while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); - } - - // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) - if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); - - // Support numeric spell ID: /cast 133 or /cast #133 - { - std::string numStr = spellArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNumeric = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNumeric) { - uint32_t spellId = 0; - try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} - if (spellId != 0) { - uint64_t targetGuid = (castTargetOverride != static_cast(-1)) - ? castTargetOverride - : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - gameHandler.castSpell(spellId, targetGuid); - } - chatInputBuffer[0] = '\0'; - return; - } - } - - // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" - int requestedRank = -1; // -1 = highest rank - std::string spellName = spellArg; - { - auto rankPos = spellArg.find('('); - if (rankPos != std::string::npos) { - std::string rankStr = spellArg.substr(rankPos + 1); - // Strip closing paren and whitespace - auto closePos = rankStr.find(')'); - if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); - for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); - // Expect "rank N" - if (rankStr.rfind("rank ", 0) == 0) { - try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} - } - spellName = spellArg.substr(0, rankPos); - while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); - } - } - - std::string spellNameLower = spellName; - for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); - - // Search known spells for a name match; pick highest rank (or specific rank) - uint32_t bestSpellId = 0; - int bestRank = -1; - for (uint32_t sid : gameHandler.getKnownSpells()) { - const std::string& sName = gameHandler.getSpellName(sid); - if (sName.empty()) continue; - std::string sNameLower = sName; - for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); - if (sNameLower != spellNameLower) continue; - - // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) - int sRank = 0; - const std::string& rankStr = gameHandler.getSpellRank(sid); - if (!rankStr.empty()) { - std::string rLow = rankStr; - for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); - if (rLow.rfind("rank ", 0) == 0) { - try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} - } - } - - if (requestedRank >= 0) { - if (sRank == requestedRank) { bestSpellId = sid; break; } - } else { - if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } - } - } - - if (bestSpellId) { - uint64_t targetGuid = (castTargetOverride != static_cast(-1)) - ? castTargetOverride - : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - gameHandler.castSpell(bestSpellId, targetGuid); - } else { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = requestedRank >= 0 - ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." - : "Unknown spell: '" + spellName + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /use - // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, - // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) - if (cmdLower == "use" && spacePos != std::string::npos) { - std::string useArg = command.substr(spacePos + 1); - while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); - while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); - - // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem - if (!useArg.empty() && useArg.front() == '[') { - uint64_t dummy = static_cast(-1); - useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); - if (useArg.empty()) { chatInputBuffer[0] = '\0'; return; } - while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); - while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); - } - - // Check for bag/slot notation: two numbers separated by whitespace - { - std::istringstream iss(useArg); - int bagNum = -1, slotNum = -1; - iss >> bagNum >> slotNum; - if (!iss.fail() && slotNum >= 1) { - if (bagNum == 0) { - // Backpack: bag=0, slot 1-based → 0-based - gameHandler.useItemBySlot(slotNum - 1); - chatInputBuffer[0] = '\0'; - return; - } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { - // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) - gameHandler.useItemInBag(bagNum - 1, slotNum - 1); - chatInputBuffer[0] = '\0'; - return; - } - } - } - - // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) - { - std::string numStr = useArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNumeric = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNumeric) { - // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) - int slotNum = 0; - try { slotNum = std::stoi(numStr); } catch (...) {} - if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { - auto eslot = static_cast(slotNum - 1); - const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); - if (!esl.empty()) - gameHandler.useItemById(esl.item.itemId); - } - chatInputBuffer[0] = '\0'; - return; - } - } - - std::string useArgLower = useArg; - for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); - - bool found = false; - const auto& inv = gameHandler.getInventory(); - // Search backpack - for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { - const auto& slot = inv.getBackpackSlot(s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == useArgLower) { - gameHandler.useItemBySlot(s); - found = true; - } - } - // Search bags - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { - for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { - const auto& slot = inv.getBagSlot(b, s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == useArgLower) { - gameHandler.useItemInBag(b, s); - found = true; - } - } - } - if (!found) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "Item not found: '" + useArg + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /equip — auto-equip an item from backpack/bags by name - if (cmdLower == "equip" && spacePos != std::string::npos) { - std::string equipArg = command.substr(spacePos + 1); - while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); - while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); - std::string equipArgLower = equipArg; - for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); - - bool found = false; - const auto& inv = gameHandler.getInventory(); - // Search backpack - for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { - const auto& slot = inv.getBackpackSlot(s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == equipArgLower) { - gameHandler.autoEquipItemBySlot(s); - found = true; - } - } - // Search bags - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { - for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { - const auto& slot = inv.getBagSlot(b, s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == equipArgLower) { - gameHandler.autoEquipItemInBag(b, s); - found = true; - } - } - } - if (!found) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "Item not found: '" + equipArg + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // Targeting commands - if (cmdLower == "cleartarget") { - // Support macro conditionals: /cleartarget [dead] clears only if target is dead - bool ctCondPass = true; - if (spacePos != std::string::npos) { - std::string ctArg = command.substr(spacePos + 1); - while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); - if (!ctArg.empty() && ctArg.front() == '[') { - uint64_t ctOver = static_cast(-1); - std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); - ctCondPass = !(res.empty() && ctOver == static_cast(-1)); - } - } - if (ctCondPass) gameHandler.clearTarget(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "target" && spacePos != std::string::npos) { - // Search visible entities for name match (case-insensitive prefix). - // Among all matches, pick the nearest living unit to the player. - // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss - std::string targetArg = command.substr(spacePos + 1); - - // Evaluate conditionals if present - uint64_t targetCmdOverride = static_cast(-1); - if (!targetArg.empty() && targetArg.front() == '[') { - targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); - if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { - // No condition matched — silently skip (macro fallthrough) - chatInputBuffer[0] = '\0'; - return; - } - while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); - while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); - } - - // If conditionals resolved to a specific GUID, target it directly - if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { - gameHandler.setTarget(targetCmdOverride); - chatInputBuffer[0] = '\0'; - return; - } - - // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently - if (targetArg.empty()) { - chatInputBuffer[0] = '\0'; - return; - } - - std::string targetArgLower = targetArg; - for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; - float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - const float playerX = pmi.x; - const float playerY = pmi.y; - const float playerZ = pmi.z; - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; - std::string name; - if (entity->getType() == game::ObjectType::PLAYER || - entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - name = unit->getName(); - } - if (name.empty()) continue; - std::string nameLower = name; - for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); - if (nameLower.find(targetArgLower) == 0) { - float dx = entity->getX() - playerX; - float dy = entity->getY() - playerY; - float dz = entity->getZ() - playerZ; - float dist = dx*dx + dy*dy + dz*dz; - if (dist < bestDist) { - bestDist = dist; - bestGuid = guid; - } - } - } - if (bestGuid) { - gameHandler.setTarget(bestGuid); - } else { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "No target matching '" + targetArg + "' found."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "targetenemy") { - gameHandler.targetEnemy(false); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "targetfriend") { - gameHandler.targetFriend(false); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "targetlasttarget" || cmdLower == "targetlast") { - gameHandler.targetLastTarget(); - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "targetlastenemy") { - gameHandler.targetEnemy(true); // Reverse direction - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "targetlastfriend") { - gameHandler.targetFriend(true); // Reverse direction - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "focus") { - // /focus → set current target as focus - // /focus PlayerName → search for entity by name and set as focus - // /focus [target=X] Name → macro conditional: set focus to resolved target - if (spacePos != std::string::npos) { - std::string focusArg = command.substr(spacePos + 1); - - // Evaluate conditionals if present - uint64_t focusCmdOverride = static_cast(-1); - if (!focusArg.empty() && focusArg.front() == '[') { - focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); - if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { - chatInputBuffer[0] = '\0'; - return; - } - while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); - while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); - } - - if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { - // Conditional resolved to a specific GUID (e.g. [target=mouseover]) - gameHandler.setFocus(focusCmdOverride); - } else if (!focusArg.empty()) { - // Name search — same logic as /target - std::string focusArgLower = focusArg; - for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; - float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; - std::string name; - if (entity->getType() == game::ObjectType::PLAYER || - entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - name = unit->getName(); - } - if (name.empty()) continue; - std::string nameLower = name; - for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); - if (nameLower.find(focusArgLower) == 0) { - float dx = entity->getX() - pmi.x; - float dy = entity->getY() - pmi.y; - float dz = entity->getZ() - pmi.z; - float dist = dx*dx + dy*dy + dz*dz; - if (dist < bestDist) { bestDist = dist; bestGuid = guid; } - } - } - if (bestGuid) { - gameHandler.setFocus(bestGuid); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "No unit matching '" + focusArg + "' found."; - gameHandler.addLocalChatMessage(msg); - } - } - } else if (gameHandler.hasTarget()) { - gameHandler.setFocus(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a unit to set as focus."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - if (cmdLower == "clearfocus") { - gameHandler.clearFocus(); - chatInputBuffer[0] = '\0'; - return; - } - - // /unstuck command — resets player position to floor height - if (cmdLower == "unstuck") { - gameHandler.unstuck(); - chatInputBuffer[0] = '\0'; - return; - } - // /unstuckgy command — move to nearest graveyard - if (cmdLower == "unstuckgy") { - gameHandler.unstuckGy(); - chatInputBuffer[0] = '\0'; - return; - } - // /unstuckhearth command — teleport to hearthstone bind point - if (cmdLower == "unstuckhearth") { - gameHandler.unstuckHearth(); - chatInputBuffer[0] = '\0'; - return; - } - - // /transport board — board test transport - if (cmdLower == "transport board") { - auto* tm = gameHandler.getTransportManager(); - if (tm) { - // Test transport GUID - uint64_t testTransportGuid = 0x1000000000000001ULL; - // Place player at center of deck (rough estimate) - glm::vec3 deckCenter(0.0f, 0.0f, 5.0f); - gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Boarded test transport. Use '/transport leave' to disembark."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Transport system not available."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // /transport leave — disembark from transport - if (cmdLower == "transport leave") { - if (gameHandler.isOnTransport()) { - gameHandler.clearPlayerTransport(); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Disembarked from transport."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You are not on a transport."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer[0] = '\0'; - return; - } - - // Chat channel slash commands - // If used without a message (e.g. just "/s"), switch the chat type dropdown - bool isChannelCommand = false; - if (cmdLower == "s" || cmdLower == "say") { - type = game::ChatType::SAY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 0; - } else if (cmdLower == "y" || cmdLower == "yell" || cmdLower == "shout") { - type = game::ChatType::YELL; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 1; - } else if (cmdLower == "p" || cmdLower == "party") { - type = game::ChatType::PARTY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 2; - } else if (cmdLower == "g" || cmdLower == "guild") { - type = game::ChatType::GUILD; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 3; - } else if (cmdLower == "raid" || cmdLower == "rsay" || cmdLower == "ra") { - type = game::ChatType::RAID; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 5; - } else if (cmdLower == "raidwarning" || cmdLower == "rw") { - type = game::ChatType::RAID_WARNING; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 8; - } else if (cmdLower == "officer" || cmdLower == "o" || cmdLower == "osay") { - type = game::ChatType::OFFICER; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 6; - } else if (cmdLower == "battleground" || cmdLower == "bg") { - type = game::ChatType::BATTLEGROUND; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 7; - } else if (cmdLower == "instance" || cmdLower == "i") { - // Instance chat uses PARTY chat type - type = game::ChatType::PARTY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 9; - } else if (cmdLower == "join") { - // /join with no args: accept pending BG invite if any - if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) { - gameHandler.acceptBattlefield(); - chatInputBuffer[0] = '\0'; - return; - } - // /join ChannelName [password] - if (spacePos != std::string::npos) { - std::string rest = command.substr(spacePos + 1); - size_t pwStart = rest.find(' '); - std::string channelName = (pwStart != std::string::npos) ? rest.substr(0, pwStart) : rest; - std::string password = (pwStart != std::string::npos) ? rest.substr(pwStart + 1) : ""; - gameHandler.joinChannel(channelName, password); - } - chatInputBuffer[0] = '\0'; - return; - } else if (cmdLower == "leave") { - // /leave ChannelName - if (spacePos != std::string::npos) { - std::string channelName = command.substr(spacePos + 1); - gameHandler.leaveChannel(channelName); - } - chatInputBuffer[0] = '\0'; - return; - } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { - // /wts and /wtb — send to Trade channel - // Prefix with [WTS] / [WTB] and route to the Trade channel - const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; - const std::string body = command.substr(spacePos + 1); - // Find the Trade channel among joined channels (case-insensitive prefix match) - std::string tradeChan; - for (const auto& ch : gameHandler.getJoinedChannels()) { - std::string chLow = ch; - for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); - if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } - } - if (tradeChan.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "You are not in the Trade channel."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer[0] = '\0'; - return; - } - message = tag + body; - type = game::ChatType::CHANNEL; - target = tradeChan; - isChannelCommand = true; - } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { - // /1 msg, /2 msg — channel shortcuts - int channelIdx = cmdLower[0] - '0'; - std::string channelName = gameHandler.getChannelByIndex(channelIdx); - if (!channelName.empty() && spacePos != std::string::npos) { - message = command.substr(spacePos + 1); - type = game::ChatType::CHANNEL; - target = channelName; - isChannelCommand = true; - } else if (channelName.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.message = "You are not in channel " + std::to_string(channelIdx) + "."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer[0] = '\0'; - return; - } else { - chatInputBuffer[0] = '\0'; - return; - } - } else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") { - switchChatType = 4; - if (spacePos != std::string::npos) { - std::string rest = command.substr(spacePos + 1); - size_t msgStart = rest.find(' '); - if (msgStart != std::string::npos) { - // /w PlayerName message — send whisper immediately - target = rest.substr(0, msgStart); - message = rest.substr(msgStart + 1); - type = game::ChatType::WHISPER; - isChannelCommand = true; - // Set whisper target for future messages - strncpy(whisperTargetBuffer, target.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - } else { - // /w PlayerName — switch to whisper mode with target set - strncpy(whisperTargetBuffer, rest.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - message = ""; - isChannelCommand = true; - } - } else { - // Just "/w" — switch to whisper mode - message = ""; - isChannelCommand = true; - } - } else if (cmdLower == "r" || cmdLower == "reply") { - switchChatType = 4; - std::string lastSender = gameHandler.getLastWhisperSender(); - if (lastSender.empty()) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "No one has whispered you yet."; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer[0] = '\0'; - return; - } - target = lastSender; - strncpy(whisperTargetBuffer, target.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - if (spacePos != std::string::npos) { - message = command.substr(spacePos + 1); - type = game::ChatType::WHISPER; - } else { - message = ""; - } - isChannelCommand = true; - } - - // Check for emote commands - if (!isChannelCommand) { - std::string targetName; - const std::string* targetNamePtr = nullptr; - if (gameHandler.hasTarget()) { - auto targetEntity = gameHandler.getTarget(); - if (targetEntity) { - targetName = getEntityName(targetEntity); - if (!targetName.empty()) targetNamePtr = &targetName; - } - } - - std::string emoteText = rendering::Renderer::getEmoteText(cmdLower, targetNamePtr); - if (!emoteText.empty()) { - // Play the emote animation - auto* renderer = core::Application::getInstance().getRenderer(); - if (renderer) { - renderer->playEmote(cmdLower); - } - - // Send CMSG_TEXT_EMOTE to server - uint32_t dbcId = rendering::Renderer::getEmoteDbcId(cmdLower); - if (dbcId != 0) { - uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.sendTextEmote(dbcId, targetGuid); - } - - // Add local chat message - game::MessageChatData msg; - msg.type = game::ChatType::TEXT_EMOTE; - msg.language = game::ChatLanguage::COMMON; - msg.message = emoteText; - gameHandler.addLocalChatMessage(msg); - - chatInputBuffer[0] = '\0'; - return; - } - - // Not a recognized command — fall through and send as normal chat - if (!isChannelCommand) { - message = input; - } - } - - // If no valid command found and starts with /, just send as-is - if (!isChannelCommand && message == input) { - // Use the selected chat type from dropdown - switch (selectedChatType) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } - } - } else { - // No slash command, use the selected chat type from dropdown - switch (selectedChatType) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } - } - - // Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands. - if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { - std::string cmd = buildPortBotCommand(message); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - if (cmd.empty() || cmd == "__help__") { - msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); - msg.message = "PortBot executed: " + cmd; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // Validate whisper has a target - if (type == game::ChatType::WHISPER && target.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must specify a player name for whisper."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer[0] = '\0'; - return; - } - - // Don't send empty messages — but switch chat type if a channel shortcut was used - if (!message.empty()) { - gameHandler.sendChatMessage(type, message, target); - } - - // Switch chat type dropdown when channel shortcut used (with or without message) - if (switchChatType >= 0) { - selectedChatType = switchChatType; - } - - // Clear input - chatInputBuffer[0] = '\0'; - } -} - -const char* GameScreen::getChatTypeName(game::ChatType type) const { - switch (type) { - case game::ChatType::SAY: return "Say"; - case game::ChatType::YELL: return "Yell"; - case game::ChatType::EMOTE: return "Emote"; - case game::ChatType::TEXT_EMOTE: return "Emote"; - case game::ChatType::PARTY: return "Party"; - case game::ChatType::GUILD: return "Guild"; - case game::ChatType::OFFICER: return "Officer"; - case game::ChatType::RAID: return "Raid"; - case game::ChatType::RAID_LEADER: return "Raid Leader"; - case game::ChatType::RAID_WARNING: return "Raid Warning"; - case game::ChatType::BATTLEGROUND: return "Battleground"; - case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; - case game::ChatType::WHISPER: return "Whisper"; - case game::ChatType::WHISPER_INFORM: return "To"; - case game::ChatType::SYSTEM: return "System"; - case game::ChatType::MONSTER_SAY: return "Say"; - case game::ChatType::MONSTER_YELL: return "Yell"; - case game::ChatType::MONSTER_EMOTE: return "Emote"; - case game::ChatType::CHANNEL: return "Channel"; - case game::ChatType::ACHIEVEMENT: return "Achievement"; - case game::ChatType::DND: return "DND"; - case game::ChatType::AFK: return "AFK"; - case game::ChatType::BG_SYSTEM_NEUTRAL: - case game::ChatType::BG_SYSTEM_ALLIANCE: - case game::ChatType::BG_SYSTEM_HORDE: return "System"; - default: return "Unknown"; - } -} - -ImVec4 GameScreen::getChatTypeColor(game::ChatType type) const { - switch (type) { - case game::ChatType::SAY: - return ui::colors::kWhite; // White - case game::ChatType::YELL: - return kColorRed; // Red - case game::ChatType::EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange - case game::ChatType::TEXT_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange - case game::ChatType::PARTY: - return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue - case game::ChatType::GUILD: - return kColorBrightGreen; // Green - case game::ChatType::OFFICER: - return ImVec4(0.3f, 0.8f, 0.3f, 1.0f); // Dark green - case game::ChatType::RAID: - return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange - case game::ChatType::RAID_LEADER: - return ImVec4(1.0f, 0.4f, 0.0f, 1.0f); // Darker orange - case game::ChatType::RAID_WARNING: - return ImVec4(1.0f, 0.0f, 0.0f, 1.0f); // Red - case game::ChatType::BATTLEGROUND: - return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange-gold - case game::ChatType::BATTLEGROUND_LEADER: - return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange - case game::ChatType::WHISPER: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink - case game::ChatType::WHISPER_INFORM: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink - case game::ChatType::SYSTEM: - return kColorYellow; // Yellow - case game::ChatType::MONSTER_SAY: - return ui::colors::kWhite; // White (same as SAY) - case game::ChatType::MONSTER_YELL: - return kColorRed; // Red (same as YELL) - case game::ChatType::MONSTER_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) - case game::ChatType::CHANNEL: - return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink - case game::ChatType::ACHIEVEMENT: - return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow - case game::ChatType::GUILD_ACHIEVEMENT: - return colors::kWarmGold; // Gold - case game::ChatType::SKILL: - return colors::kCyan; // Cyan - case game::ChatType::LOOT: - return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple - case game::ChatType::MONSTER_WHISPER: - case game::ChatType::RAID_BOSS_WHISPER: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) - case game::ChatType::RAID_BOSS_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) - case game::ChatType::MONSTER_PARTY: - return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) - case game::ChatType::BG_SYSTEM_NEUTRAL: - return colors::kWarmGold; // Gold - case game::ChatType::BG_SYSTEM_ALLIANCE: - return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue - case game::ChatType::BG_SYSTEM_HORDE: - return kColorRed; // Red - case game::ChatType::AFK: - case game::ChatType::DND: - return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray - default: - return ui::colors::kLightGray; // Gray - } -} - void GameScreen::updateCharacterGeosets(game::Inventory& inventory) { auto& app = core::Application::getInstance(); auto* renderer = app.getRenderer(); @@ -9378,7 +4954,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); } else if (slot.type == game::ActionBarSlot::MACRO) { - executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); + chatPanel_.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); } } @@ -9411,7 +4987,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::TextDisabled("Macro #%u", slot.id); ImGui::Separator(); if (ImGui::MenuItem("Execute")) { - executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); + chatPanel_.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); } if (ImGui::MenuItem("Edit")) { const std::string& txt = gameHandler.getMacroText(slot.id); @@ -12244,10 +7820,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { if (isPlayer && !ctxName.empty()) { ImGui::Separator(); if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, ctxName.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(ctxName); } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(ctxName); @@ -12552,10 +8125,7 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Set Focus")) gameHandler.setFocus(m.guid); if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, m.name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(m.name); } if (ImGui::MenuItem("Trade")) gameHandler.initiateTrade(m.guid); @@ -12895,10 +8465,7 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { gameHandler.setFocus(member.guid); } if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; // WHISPER - strncpy(whisperTargetBuffer, member.name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(member.name); } if (ImGui::MenuItem("Follow")) { gameHandler.setTarget(member.guid); @@ -13953,12 +9520,7 @@ void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } } else { ImGui::TextDisabled(" %d. (empty)", i + 1); @@ -14117,12 +9679,7 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && rollInfo && rollInfo->valid && !rollInfo->name.empty()) { std::string link = buildItemChatLink(rollInfo->entry, rollInfo->quality, rollInfo->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } ImGui::Spacing(); @@ -14530,7 +10087,7 @@ void GameScreen::renderLfgRoleCheckPopup(game::GameHandler& gameHandler) { void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Guild Roster toggle (customizable keybind) - if (!chatInputActive && !ImGui::GetIO().WantTextInput && + if (!chatPanel_.isChatInputActive() && !ImGui::GetIO().WantTextInput && !ImGui::GetIO().WantCaptureKeyboard && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { showGuildRoster_ = !showGuildRoster_; @@ -14765,11 +10322,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { } if (memberOnline) { if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, selectedGuildMember_.c_str(), - sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(selectedGuildMember_); } if (ImGui::MenuItem("Invite to Group")) { gameHandler.inviteToGroup(selectedGuildMember_); @@ -15030,10 +10583,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Double-click to whisper if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && !c.name.empty()) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(c.name); } // Right-click context menu @@ -15041,10 +10591,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::TextDisabled("%s", displayName); ImGui::Separator(); if (ImGui::MenuItem("Whisper") && !c.name.empty()) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(c.name); } if (c.isOnline() && ImGui::MenuItem("Invite to Group") && !c.name.empty()) { gameHandler.inviteToGroup(c.name); @@ -15290,10 +10837,7 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { if (c.isOnline()) { if (ImGui::MenuItem("Whisper")) { showSocialFrame_ = false; - strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - selectedChatType = 4; - refocusChatInput = true; + chatPanel_.setWhisperTarget(c.name); } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(c.name); @@ -15906,12 +11450,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { if (ImGui::GetIO().KeyShift && info && !info->name.empty()) { // Shift-click: insert item link into chat std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } else { lootSlotClicked = item.slotIndex; } @@ -16129,7 +11668,7 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { displayText = placeholderIt->second; } - std::string processedText = replaceGenderPlaceholders(displayText, gameHandler); + 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") { @@ -16237,11 +11776,11 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { bool open = true; const auto& quest = gameHandler.getQuestDetails(); - std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chatPanel_.replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open)) { // Quest description if (!quest.details.empty()) { - std::string processedDetails = replaceGenderPlaceholders(quest.details, gameHandler); + std::string processedDetails = chatPanel_.replaceGenderPlaceholders(quest.details, gameHandler); ImGui::TextWrapped("%s", processedDetails.c_str()); } @@ -16250,7 +11789,7 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::Separator(); ImGui::TextColored(ui::colors::kTooltipGold, "Objectives:"); - std::string processedObjectives = replaceGenderPlaceholders(quest.objectives, gameHandler); + std::string processedObjectives = chatPanel_.replaceGenderPlaceholders(quest.objectives, gameHandler); ImGui::TextWrapped("%s", processedObjectives.c_str()); } @@ -16285,12 +11824,7 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } }; @@ -16385,10 +11919,10 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { return total; }; - std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chatPanel_.replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { if (!quest.completionText.empty()) { - std::string processedCompletionText = replaceGenderPlaceholders(quest.completionText, gameHandler); + std::string processedCompletionText = chatPanel_.replaceGenderPlaceholders(quest.completionText, gameHandler); ImGui::TextWrapped("%s", processedCompletionText.c_str()); } @@ -16422,12 +11956,7 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } } } @@ -16485,10 +12014,10 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { selectedChoice = 0; } - std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chatPanel_.replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { if (!quest.rewardText.empty()) { - std::string processedRewardText = replaceGenderPlaceholders(quest.rewardText, gameHandler); + std::string processedRewardText = chatPanel_.replaceGenderPlaceholders(quest.rewardText, gameHandler); ImGui::TextWrapped("%s", processedRewardText.c_str()); } @@ -16551,12 +12080,7 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { 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); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } else { selectedChoice = static_cast(i); } @@ -16592,12 +12116,7 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } } } @@ -16940,12 +12459,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { // Shift-click: insert item link into chat if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } } else { ImGui::Text("Item %u", item.itemId); @@ -18826,57 +14340,6 @@ if (ImGui::Button("Restore Audio Defaults", ImVec2(-1, 0))) { } -void GameScreen::renderSettingsChatTab() { -ImGui::Spacing(); - -ImGui::Text("Appearance"); -ImGui::Separator(); - -if (ImGui::Checkbox("Show Timestamps", &chatShowTimestamps_)) { - saveSettings(); -} -ImGui::SetItemTooltip("Show [HH:MM] before each chat message"); - -const char* fontSizes[] = { "Small", "Medium", "Large" }; -if (ImGui::Combo("Chat Font Size", &chatFontSize_, fontSizes, 3)) { - saveSettings(); -} - -ImGui::Spacing(); -ImGui::Spacing(); -ImGui::Text("Auto-Join Channels"); -ImGui::Separator(); - -if (ImGui::Checkbox("General", &chatAutoJoinGeneral_)) saveSettings(); -if (ImGui::Checkbox("Trade", &chatAutoJoinTrade_)) saveSettings(); -if (ImGui::Checkbox("LocalDefense", &chatAutoJoinLocalDefense_)) saveSettings(); -if (ImGui::Checkbox("LookingForGroup", &chatAutoJoinLFG_)) saveSettings(); -if (ImGui::Checkbox("Local", &chatAutoJoinLocal_)) saveSettings(); - -ImGui::Spacing(); -ImGui::Spacing(); -ImGui::Text("Joined Channels"); -ImGui::Separator(); - -ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels."); - -ImGui::Spacing(); -ImGui::Separator(); -ImGui::Spacing(); - -if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) { - chatShowTimestamps_ = false; - chatFontSize_ = 1; - chatAutoJoinGeneral_ = true; - chatAutoJoinTrade_ = true; - chatAutoJoinLocalDefense_ = true; - chatAutoJoinLFG_ = true; - chatAutoJoinLocal_ = true; - saveSettings(); -} - -} - void GameScreen::renderSettingsAboutTab() { ImGui::Spacing(); ImGui::Spacing(); @@ -19350,7 +14813,7 @@ void GameScreen::renderSettingsWindow() { // CHAT TAB // ============================================================ if (ImGui::BeginTabItem("Chat")) { - renderSettingsChatTab(); + chatPanel_.renderSettingsTab([this]{ saveSettings(); }); ImGui::EndTabItem(); } @@ -20997,231 +16460,6 @@ std::string GameScreen::getSettingsPath() { return dir + "/settings.cfg"; } -std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { - // Get player gender, pronouns, and name - game::Gender gender = game::Gender::NONBINARY; - std::string playerName = "Adventurer"; - const auto* character = gameHandler.getActiveCharacter(); - if (character) { - gender = character->gender; - if (!character->name.empty()) { - playerName = character->name; - } - } - game::Pronouns pronouns = game::Pronouns::forGender(gender); - - std::string result = text; - - // Helper to trim whitespace - auto trim = [](std::string& s) { - const char* ws = " \t\n\r"; - size_t start = s.find_first_not_of(ws); - if (start == std::string::npos) { s.clear(); return; } - size_t end = s.find_last_not_of(ws); - s = s.substr(start, end - start + 1); - }; - - // Replace $g/$G placeholders first. - size_t pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - char marker = result[pos + 1]; - if (marker != 'g' && marker != 'G') { pos++; continue; } - - size_t endPos = result.find(';', pos); - if (endPos == std::string::npos) { pos += 2; continue; } - - std::string placeholder = result.substr(pos + 2, endPos - pos - 2); - - // Split by colons - std::vector parts; - size_t start = 0; - size_t colonPos; - while ((colonPos = placeholder.find(':', start)) != std::string::npos) { - std::string part = placeholder.substr(start, colonPos - start); - trim(part); - parts.push_back(part); - start = colonPos + 1; - } - // Add the last part - std::string lastPart = placeholder.substr(start); - trim(lastPart); - parts.push_back(lastPart); - - // Select appropriate text based on gender - std::string replacement; - if (parts.size() >= 3) { - // Three options: male, female, nonbinary - switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; - case game::Gender::NONBINARY: - replacement = parts[2]; - break; - } - } else if (parts.size() >= 2) { - // Two options: male, female (use first for nonbinary) - switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; - case game::Gender::NONBINARY: - // Default to gender-neutral: use the shorter/simpler option - replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; - break; - } - } else { - // Malformed placeholder - pos = endPos + 1; - continue; - } - - result.replace(pos, endPos - pos + 1, replacement); - pos += replacement.length(); - } - - // Resolve class and race names for $C and $R placeholders - std::string className = "Adventurer"; - std::string raceName = "Unknown"; - if (character) { - className = game::getClassName(character->characterClass); - raceName = game::getRaceName(character->race); - } - - // Replace simple placeholders. - // $n/$N = player name, $c/$C = class name, $r/$R = race name - // $p = subject pronoun (he/she/they) - // $o = object pronoun (him/her/them) - // $s = possessive adjective (his/her/their) - // $S = possessive pronoun (his/hers/theirs) - // $b/$B = line break - pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - - char code = result[pos + 1]; - std::string replacement; - switch (code) { - case 'n': case 'N': replacement = playerName; break; - case 'c': case 'C': replacement = className; break; - case 'r': case 'R': replacement = raceName; break; - case 'p': replacement = pronouns.subject; break; - case 'o': replacement = pronouns.object; break; - case 's': replacement = pronouns.possessive; break; - case 'S': replacement = pronouns.possessiveP; break; - case 'b': case 'B': replacement = "\n"; break; - case 'g': case 'G': pos++; continue; - default: pos++; continue; - } - - result.replace(pos, 2, replacement); - pos += replacement.length(); - } - - // WoW markup linebreak token. - pos = 0; - while ((pos = result.find("|n", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - pos = 0; - while ((pos = result.find("|N", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - - return result; -} - -void GameScreen::renderChatBubbles(game::GameHandler& gameHandler) { - if (chatBubbles_.empty()) return; - - auto* renderer = core::Application::getInstance().getRenderer(); - auto* camera = renderer ? renderer->getCamera() : nullptr; - if (!camera) 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; - - // Get delta time from ImGui - float dt = ImGui::GetIO().DeltaTime; - - glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - - // Update and render bubbles - for (int i = static_cast(chatBubbles_.size()) - 1; i >= 0; --i) { - auto& bubble = chatBubbles_[i]; - bubble.timeRemaining -= dt; - if (bubble.timeRemaining <= 0.0f) { - chatBubbles_.erase(chatBubbles_.begin() + i); - continue; - } - - // Get entity position - auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid); - if (!entity) continue; - - // Convert canonical → render coordinates, offset up by 2.5 units for bubble above head - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f); - glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - - // Project to screen - glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); - if (clipPos.w <= 0.0f) continue; // Behind camera - - glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); - float screenX = (ndc.x * 0.5f + 0.5f) * screenW; - // Camera bakes the Vulkan Y-flip into the projection matrix: - // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. - float screenY = (ndc.y * 0.5f + 0.5f) * screenH; - - // Skip if off-screen - if (screenX < -200.0f || screenX > screenW + 200.0f || - screenY < -100.0f || screenY > screenH + 100.0f) continue; - - // Fade alpha over last 2 seconds - float alpha = 1.0f; - if (bubble.timeRemaining < 2.0f) { - alpha = bubble.timeRemaining / 2.0f; - } - - // Draw bubble window - std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid); - ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); - ImGui::SetNextWindowBgAlpha(0.7f * alpha); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4)); - - ImGui::Begin(winId.c_str(), nullptr, flags); - - ImVec4 textColor = bubble.isYell - ? ImVec4(1.0f, 0.2f, 0.2f, alpha) - : ImVec4(1.0f, 1.0f, 1.0f, alpha); - - ImGui::PushStyleColor(ImGuiCol_Text, textColor); - ImGui::PushTextWrapPos(200.0f); - ImGui::TextWrapped("%s", bubble.message.c_str()); - ImGui::PopTextWrapPos(); - ImGui::PopStyleColor(); - - ImGui::End(); - ImGui::PopStyleVar(2); - } -} - void GameScreen::applyAudioVolumes(rendering::Renderer* renderer) { if (!renderer) return; float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; @@ -21338,14 +16576,14 @@ void GameScreen::saveSettings() { out << "quest_tracker_h=" << questTrackerSize_.y << "\n"; // Chat - out << "chat_active_tab=" << activeChatTab_ << "\n"; - out << "chat_timestamps=" << (chatShowTimestamps_ ? 1 : 0) << "\n"; - out << "chat_font_size=" << chatFontSize_ << "\n"; - out << "chat_autojoin_general=" << (chatAutoJoinGeneral_ ? 1 : 0) << "\n"; - out << "chat_autojoin_trade=" << (chatAutoJoinTrade_ ? 1 : 0) << "\n"; - out << "chat_autojoin_localdefense=" << (chatAutoJoinLocalDefense_ ? 1 : 0) << "\n"; - out << "chat_autojoin_lfg=" << (chatAutoJoinLFG_ ? 1 : 0) << "\n"; - out << "chat_autojoin_local=" << (chatAutoJoinLocal_ ? 1 : 0) << "\n"; + out << "chat_active_tab=" << chatPanel_.activeChatTab << "\n"; + out << "chat_timestamps=" << (chatPanel_.chatShowTimestamps ? 1 : 0) << "\n"; + out << "chat_font_size=" << chatPanel_.chatFontSize << "\n"; + out << "chat_autojoin_general=" << (chatPanel_.chatAutoJoinGeneral ? 1 : 0) << "\n"; + out << "chat_autojoin_trade=" << (chatPanel_.chatAutoJoinTrade ? 1 : 0) << "\n"; + out << "chat_autojoin_localdefense=" << (chatPanel_.chatAutoJoinLocalDefense ? 1 : 0) << "\n"; + out << "chat_autojoin_lfg=" << (chatPanel_.chatAutoJoinLFG ? 1 : 0) << "\n"; + out << "chat_autojoin_local=" << (chatPanel_.chatAutoJoinLocal ? 1 : 0) << "\n"; out.close(); @@ -21515,14 +16753,14 @@ void GameScreen::loadSettings() { questTrackerSize_.y = std::max(60.0f, std::stof(val)); } // Chat - else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); - else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); - else if (key == "chat_font_size") chatFontSize_ = std::clamp(std::stoi(val), 0, 2); - else if (key == "chat_autojoin_general") chatAutoJoinGeneral_ = (std::stoi(val) != 0); - else if (key == "chat_autojoin_trade") chatAutoJoinTrade_ = (std::stoi(val) != 0); - else if (key == "chat_autojoin_localdefense") chatAutoJoinLocalDefense_ = (std::stoi(val) != 0); - else if (key == "chat_autojoin_lfg") chatAutoJoinLFG_ = (std::stoi(val) != 0); - else if (key == "chat_autojoin_local") chatAutoJoinLocal_ = (std::stoi(val) != 0); + else if (key == "chat_active_tab") chatPanel_.activeChatTab = std::clamp(std::stoi(val), 0, 3); + else if (key == "chat_timestamps") chatPanel_.chatShowTimestamps = (std::stoi(val) != 0); + else if (key == "chat_font_size") chatPanel_.chatFontSize = std::clamp(std::stoi(val), 0, 2); + else if (key == "chat_autojoin_general") chatPanel_.chatAutoJoinGeneral = (std::stoi(val) != 0); + else if (key == "chat_autojoin_trade") chatPanel_.chatAutoJoinTrade = (std::stoi(val) != 0); + else if (key == "chat_autojoin_localdefense") chatPanel_.chatAutoJoinLocalDefense = (std::stoi(val) != 0); + else if (key == "chat_autojoin_lfg") chatPanel_.chatAutoJoinLFG = (std::stoi(val) != 0); + else if (key == "chat_autojoin_local") chatPanel_.chatAutoJoinLocal = (std::stoi(val) != 0); } catch (...) {} } @@ -21752,12 +16990,7 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } ImGui::SameLine(); ImGui::TextColored(qc, "%s", name.c_str()); @@ -21766,12 +16999,7 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } ImGui::SameLine(); if (ImGui::SmallButton("Take")) { @@ -22088,12 +17316,7 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { const std::string& lname = (info2 && info2->valid && !info2->name.empty()) ? info2->name : item.name; std::string link = buildItemChatLink(item.itemId, q, lname); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } } } @@ -22272,12 +17495,7 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { && !name.empty() && item.itemEntry != 0) { uint8_t q = static_cast(quality); std::string link = buildItemChatLink(item.itemEntry, q, name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } } } @@ -22548,12 +17766,7 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } ImGui::TableSetColumnIndex(1); @@ -22752,12 +17965,7 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); @@ -22836,12 +18044,7 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { std::string link = buildItemChatLink(info->entry, info->quality, info->name); - size_t curLen = strlen(chatInputBuffer); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { - strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); - chatInputMoveCursorToEnd = true; - refocusChatInput = true; - } + chatPanel_.insertChatLink(link); } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); @@ -23809,7 +19012,7 @@ void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) { // --------------------------------------------------------------------------- void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // Toggle Dungeon Finder (customizable keybind) - if (!chatInputActive && !ImGui::GetIO().WantTextInput && + if (!chatPanel_.isChatInputActive() && !ImGui::GetIO().WantTextInput && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { showDungeonFinder_ = !showDungeonFinder_; } @@ -24355,10 +19558,7 @@ void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { ImGui::TextDisabled("%s", e.name.c_str()); ImGui::Separator(); if (ImGui::MenuItem("Whisper")) { - selectedChatType = 4; - strncpy(whisperTargetBuffer, e.name.c_str(), sizeof(whisperTargetBuffer) - 1); - whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; - refocusChatInput = true; + chatPanel_.setWhisperTarget(e.name); } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(e.name); From 9764286cae69499ca0ecfb984cbd8ecdf705713b Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 31 Mar 2026 09:18:17 +0300 Subject: [PATCH 2/5] `chore(game-screen): extract toast manager from game screen` - refactor: move toast UI logic into new `ToastManager` component - add toast_manager.hpp + toast_manager.cpp - update game_screen.hpp + game_screen.cpp to use `ToastManager` - adjust app initialization in application.cpp - keep root CMake target in CMakeLists.txt updated --- CMakeLists.txt | 1 + include/ui/game_screen.hpp | 136 +--- include/ui/toast_manager.hpp | 190 ++++++ src/core/application.cpp | 4 +- src/ui/game_screen.cpp | 1248 +-------------------------------- src/ui/toast_manager.cpp | 1250 ++++++++++++++++++++++++++++++++++ 6 files changed, 1462 insertions(+), 1367 deletions(-) create mode 100644 include/ui/toast_manager.hpp create mode 100644 src/ui/toast_manager.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 06a4727e..da19c235 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -557,6 +557,7 @@ set(WOWEE_SOURCES src/ui/character_screen.cpp src/ui/game_screen.cpp src/ui/chat_panel.cpp + src/ui/toast_manager.cpp src/ui/inventory_screen.cpp src/ui/quest_log_screen.cpp src/ui/spellbook_screen.cpp diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index b6dd4d27..2260fb97 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -10,6 +10,7 @@ #include "ui/talent_screen.hpp" #include "ui/keybinding_manager.hpp" #include "ui/chat_panel.hpp" +#include "ui/toast_manager.hpp" #include #include #include @@ -48,6 +49,9 @@ private: // Chat panel (extracted from GameScreen — owns all chat state and rendering) ChatPanel chatPanel_; + // Toast manager (extracted from GameScreen — owns all toast/notification state and rendering) + ToastManager toastManager_; + // 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_; @@ -65,8 +69,7 @@ private: float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) bool damageFlashEnabled_ = true; bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP - float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) - uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text + // Raid Warning / Boss Emote big-text overlay (center-screen, fades after 5s) struct RaidWarnEntry { @@ -87,34 +90,14 @@ private: bool castFailedCallbackSet_ = false; static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade - // Reputation change toast: brief colored slide-in below minimap - struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; }; - std::vector repToasts_; - bool repChangeCallbackSet_ = false; - static constexpr float kRepToastLifetime = 3.5f; - // Quest completion toast: slide-in when a quest is turned in - struct QuestCompleteToastEntry { uint32_t questId = 0; std::string title; float age = 0.0f; }; - std::vector questCompleteToasts_; - bool questCompleteCallbackSet_ = false; - static constexpr float kQuestCompleteToastLifetime = 4.0f; - - // Zone entry toast: brief banner when entering a new zone - struct ZoneToastEntry { std::string zoneName; float age = 0.0f; }; - std::vector zoneToasts_; - - struct AreaTriggerToast { std::string text; float age = 0.0f; }; - std::vector areaTriggerToasts_; - void renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler); - std::string lastKnownZone_; - static constexpr float kZoneToastLifetime = 3.0f; // Death screen: elapsed time since the death dialog first appeared float deathElapsed_ = 0.0f; bool deathTimerRunning_ = false; // WoW forces release after ~6 minutes; show countdown until then static constexpr float kForcedReleaseSec = 360.0f; - void renderZoneToasts(float deltaTime); + bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; @@ -291,8 +274,7 @@ private: void renderPartyFrames(game::GameHandler& gameHandler); void renderBossFrames(game::GameHandler& gameHandler); void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); - void renderRepToasts(float deltaTime); - void renderQuestCompleteToasts(float deltaTime); + void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderDuelCountdown(game::GameHandler& gameHandler); @@ -465,8 +447,7 @@ private: static std::string getSettingsPath(); - bool levelUpCallbackSet_ = false; - bool achievementCallbackSet_ = false; + // Mail compose state char mailRecipientBuffer_[256] = ""; @@ -520,107 +501,13 @@ private: glm::vec2 leftClickPressPos_ = glm::vec2(0.0f); bool leftClickWasPress_ = false; - // Level-up ding animation - static constexpr float DING_DURATION = 4.0f; - float dingTimer_ = 0.0f; - uint32_t dingLevel_ = 0; - uint32_t dingHpDelta_ = 0; - uint32_t dingManaDelta_ = 0; - uint32_t dingStats_[5] = {}; // str/agi/sta/int/spi deltas - void renderDingEffect(); - // Achievement toast banner - static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f; - float achievementToastTimer_ = 0.0f; - uint32_t achievementToastId_ = 0; - std::string achievementToastName_; - void renderAchievementToast(); - - // Area discovery toast ("Discovered! +XP XP") - static constexpr float DISCOVERY_TOAST_DURATION = 4.0f; - float discoveryToastTimer_ = 0.0f; - std::string discoveryToastName_; - uint32_t discoveryToastXP_ = 0; - bool areaDiscoveryCallbackSet_ = false; - void renderDiscoveryToast(); - - // Whisper toast — brief overlay at screen top when a whisper arrives while chat is not focused - struct WhisperToastEntry { - std::string sender; - std::string preview; // first ~60 chars of message - float age = 0.0f; - }; - static constexpr float WHISPER_TOAST_DURATION = 5.0f; - std::vector whisperToasts_; - size_t whisperSeenCount_ = 0; // how many chat entries have been scanned for whispers - void renderWhisperToasts(); - - // Quest objective progress toast ("Quest: X/Y") - struct QuestProgressToastEntry { - std::string questTitle; - std::string objectiveName; - uint32_t current = 0; - uint32_t required = 0; - float age = 0.0f; - }; - static constexpr float QUEST_TOAST_DURATION = 4.0f; - std::vector questToasts_; - bool questProgressCallbackSet_ = false; - void renderQuestProgressToasts(); - - // Nearby player level-up toast (" is now level X!") - struct PlayerLevelUpToastEntry { - uint64_t guid = 0; - std::string playerName; // resolved lazily at render time - uint32_t newLevel = 0; - float age = 0.0f; - }; - static constexpr float PLAYER_LEVELUP_TOAST_DURATION = 4.0f; - std::vector playerLevelUpToasts_; - bool otherPlayerLevelUpCallbackSet_ = false; - void renderPlayerLevelUpToasts(game::GameHandler& gameHandler); - - // PvP honor credit toast ("+N Honor" shown when an honorable kill is credited) - struct PvpHonorToastEntry { - uint32_t honor = 0; - uint32_t victimRank = 0; // 0 = unranked / not available - float age = 0.0f; - }; - static constexpr float PVP_HONOR_TOAST_DURATION = 3.5f; - std::vector pvpHonorToasts_; - bool pvpHonorCallbackSet_ = false; - void renderPvpHonorToasts(); - - // Item loot toast — quality-coloured popup when an item is received - struct ItemLootToastEntry { - uint32_t itemId = 0; - uint32_t count = 0; - uint32_t quality = 1; // 0=grey,1=white,2=green,3=blue,4=purple,5=orange - std::string name; - float age = 0.0f; - }; - static constexpr float ITEM_LOOT_TOAST_DURATION = 3.0f; - std::vector itemLootToasts_; - bool itemLootCallbackSet_ = false; - void renderItemLootToasts(); - - // Resurrection flash: brief "You have been resurrected!" overlay on ghost→alive transition - float resurrectFlashTimer_ = 0.0f; - static constexpr float kResurrectFlashDuration = 3.0f; - bool ghostStateCallbackSet_ = false; bool appearanceCallbackSet_ = false; bool ghostOpacityStateKnown_ = false; bool ghostOpacityLastState_ = false; uint32_t ghostOpacityLastInstanceId_ = 0; - void renderResurrectFlash(); - // Zone discovery text ("Entering: ") - static constexpr float ZONE_TEXT_DURATION = 5.0f; - float zoneTextTimer_ = 0.0f; - std::string zoneTextName_; - std::string lastKnownZoneName_; - uint32_t lastKnownWorldStateZoneId_ = 0; - void renderZoneText(game::GameHandler& gameHandler); + void renderWeatherOverlay(game::GameHandler& gameHandler); // Cooldown tracker @@ -635,11 +522,8 @@ private: size_t dpsLogSeenCount_ = 0; // log entries already scanned public: - void triggerDing(uint32_t newLevel, uint32_t hpDelta = 0, uint32_t manaDelta = 0, - uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0, - uint32_t intel = 0, uint32_t spi = 0); - void triggerAchievementToast(uint32_t achievementId, std::string name = {}); void openDungeonFinder() { showDungeonFinder_ = true; } + ToastManager& toastManager() { return toastManager_; } }; } // namespace ui diff --git a/include/ui/toast_manager.hpp b/include/ui/toast_manager.hpp new file mode 100644 index 00000000..29c27983 --- /dev/null +++ b/include/ui/toast_manager.hpp @@ -0,0 +1,190 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace ui { + +/** + * Toast / notification overlay manager + * + * Owns all toast state, callbacks, and rendering: + * level-up ding, achievement, area discovery, whisper, quest progress, + * player level-up, PvP honor, item loot, reputation, quest complete, + * zone entry, area trigger, resurrect flash, and zone text. + */ +class ToastManager { +public: + ToastManager() = default; + + /// Register toast-related callbacks on GameHandler (idempotent — safe every frame) + void setupCallbacks(game::GameHandler& gameHandler); + + /// Render "early" toasts (rep, quest-complete, zone, area-trigger) — called before action bars + void renderEarlyToasts(float deltaTime, game::GameHandler& gameHandler); + + /// Render "late" toasts (ding, achievement, discovery, whisper, quest progress, + /// player level-up, PvP honor, item loot, resurrect flash, zone text) — called after escape menu + void renderLateToasts(game::GameHandler& gameHandler); + + /// Fire level-up ding animation + sound + void triggerDing(uint32_t newLevel, uint32_t hpDelta = 0, uint32_t manaDelta = 0, + uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0, + uint32_t intel = 0, uint32_t spi = 0); + + /// Fire achievement earned toast + sound + void triggerAchievementToast(uint32_t achievementId, std::string name = {}); + + // --- public state consumed by GameScreen for the golden burst overlay --- + float levelUpFlashAlpha = 0.0f; + uint32_t levelUpDisplayLevel = 0; + +private: + // ---- Ding effect (own level-up) ---- + static constexpr float DING_DURATION = 4.0f; + float dingTimer_ = 0.0f; + uint32_t dingLevel_ = 0; + uint32_t dingHpDelta_ = 0; + uint32_t dingManaDelta_ = 0; + uint32_t dingStats_[5] = {}; + void renderDingEffect(); + + // ---- Achievement toast ---- + static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f; + float achievementToastTimer_ = 0.0f; + uint32_t achievementToastId_ = 0; + std::string achievementToastName_; + bool achievementCallbackSet_ = false; + void renderAchievementToast(); + + // ---- Area discovery toast ---- + static constexpr float DISCOVERY_TOAST_DURATION = 4.0f; + float discoveryToastTimer_ = 0.0f; + std::string discoveryToastName_; + uint32_t discoveryToastXP_ = 0; + bool areaDiscoveryCallbackSet_ = false; + void renderDiscoveryToast(); + + // ---- Whisper toast ---- + struct WhisperToastEntry { + std::string sender; + std::string preview; + float age = 0.0f; + }; + static constexpr float WHISPER_TOAST_DURATION = 5.0f; + std::vector whisperToasts_; + size_t whisperSeenCount_ = 0; + void renderWhisperToasts(); + + // ---- Quest objective progress toast ---- + struct QuestProgressToastEntry { + std::string questTitle; + std::string objectiveName; + uint32_t current = 0; + uint32_t required = 0; + float age = 0.0f; + }; + static constexpr float QUEST_TOAST_DURATION = 4.0f; + std::vector questToasts_; + bool questProgressCallbackSet_ = false; + void renderQuestProgressToasts(); + + // ---- Nearby player level-up toast ---- + struct PlayerLevelUpToastEntry { + uint64_t guid = 0; + std::string playerName; + uint32_t newLevel = 0; + float age = 0.0f; + }; + static constexpr float PLAYER_LEVELUP_TOAST_DURATION = 4.0f; + std::vector playerLevelUpToasts_; + bool otherPlayerLevelUpCallbackSet_ = false; + void renderPlayerLevelUpToasts(game::GameHandler& gameHandler); + + // ---- PvP honor toast ---- + struct PvpHonorToastEntry { + uint32_t honor = 0; + uint32_t victimRank = 0; + float age = 0.0f; + }; + static constexpr float PVP_HONOR_TOAST_DURATION = 3.5f; + std::vector pvpHonorToasts_; + bool pvpHonorCallbackSet_ = false; + void renderPvpHonorToasts(); + + // ---- Item loot toast ---- + struct ItemLootToastEntry { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t quality = 1; + std::string name; + float age = 0.0f; + }; + static constexpr float ITEM_LOOT_TOAST_DURATION = 3.0f; + std::vector itemLootToasts_; + bool itemLootCallbackSet_ = false; + void renderItemLootToasts(); + + // ---- Reputation change toast ---- + struct RepToastEntry { + std::string factionName; + int32_t delta = 0; + int32_t standing = 0; + float age = 0.0f; + }; + std::vector repToasts_; + bool repChangeCallbackSet_ = false; + static constexpr float kRepToastLifetime = 3.5f; + void renderRepToasts(float deltaTime); + + // ---- Quest completion toast ---- + struct QuestCompleteToastEntry { + uint32_t questId = 0; + std::string title; + float age = 0.0f; + }; + std::vector questCompleteToasts_; + bool questCompleteCallbackSet_ = false; + static constexpr float kQuestCompleteToastLifetime = 4.0f; + void renderQuestCompleteToasts(float deltaTime); + + // ---- Zone entry toast ---- + struct ZoneToastEntry { + std::string zoneName; + float age = 0.0f; + }; + std::vector zoneToasts_; + std::string lastKnownZone_; + static constexpr float kZoneToastLifetime = 3.0f; + void renderZoneToasts(float deltaTime); + + // ---- Area trigger message toast ---- + struct AreaTriggerToast { + std::string text; + float age = 0.0f; + }; + std::vector areaTriggerToasts_; + void renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler); + + // ---- Resurrection flash ---- + float resurrectFlashTimer_ = 0.0f; + static constexpr float kResurrectFlashDuration = 3.0f; + bool ghostStateCallbackSet_ = false; + void renderResurrectFlash(); + + // ---- Zone discovery text ("Entering: ") ---- + static constexpr float ZONE_TEXT_DURATION = 5.0f; + float zoneTextTimer_ = 0.0f; + std::string zoneTextName_; + std::string lastKnownZoneName_; + uint32_t lastKnownWorldStateZoneId_ = 0; + void renderZoneText(game::GameHandler& gameHandler); +}; + +} // namespace ui +} // namespace wowee diff --git a/src/core/application.cpp b/src/core/application.cpp index e4ee3342..443276f5 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2928,7 +2928,7 @@ void Application::setupUICallbacks() { // Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect gameHandler->setLevelUpCallback([this](uint32_t newLevel) { if (uiManager) { - uiManager->getGameScreen().triggerDing(newLevel); + uiManager->getGameScreen().toastManager().triggerDing(newLevel); } if (renderer) { renderer->triggerLevelUpEffect(renderer->getCharacterPosition()); @@ -2938,7 +2938,7 @@ void Application::setupUICallbacks() { // Achievement earned callback — show toast banner gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { if (uiManager) { - uiManager->getGameScreen().triggerAchievementToast(achievementId, name); + uiManager->getGameScreen().toastManager().triggerAchievementToast(achievementId, name); } }); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0349ab34..abead7ec 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -252,114 +252,7 @@ GameScreen::GameScreen() { void GameScreen::render(game::GameHandler& gameHandler) { // Set up chat bubble callback (once) and cache game handler in ChatPanel chatPanel_.setupCallbacks(gameHandler); - - // Set up level-up callback (once) - if (!levelUpCallbackSet_) { - gameHandler.setLevelUpCallback([this, &gameHandler](uint32_t newLevel) { - levelUpFlashAlpha_ = 1.0f; - levelUpDisplayLevel_ = newLevel; - const auto& d = gameHandler.getLastLevelUpDeltas(); - triggerDing(newLevel, d.hp, d.mana, d.str, d.agi, d.sta, d.intel, d.spi); - }); - levelUpCallbackSet_ = true; - } - - // Set up achievement toast callback (once) - if (!achievementCallbackSet_) { - gameHandler.setAchievementEarnedCallback([this](uint32_t id, const std::string& name) { - triggerAchievementToast(id, name); - }); - achievementCallbackSet_ = true; - } - - // Set up area discovery toast callback (once) - if (!areaDiscoveryCallbackSet_) { - gameHandler.setAreaDiscoveryCallback([this](const std::string& areaName, uint32_t xpGained) { - discoveryToastName_ = areaName.empty() ? "New Area" : areaName; - discoveryToastXP_ = xpGained; - discoveryToastTimer_ = DISCOVERY_TOAST_DURATION; - }); - areaDiscoveryCallbackSet_ = true; - } - - // Set up quest objective progress toast callback (once) - if (!questProgressCallbackSet_) { - gameHandler.setQuestProgressCallback([this](const std::string& questTitle, - const std::string& objectiveName, - uint32_t current, uint32_t required) { - // Coalesce: if the same objective already has a toast, just update counts - for (auto& t : questToasts_) { - if (t.questTitle == questTitle && t.objectiveName == objectiveName) { - t.current = current; - t.required = required; - t.age = 0.0f; // restart lifetime - return; - } - } - if (questToasts_.size() >= 4) questToasts_.erase(questToasts_.begin()); - questToasts_.push_back({questTitle, objectiveName, current, required, 0.0f}); - }); - questProgressCallbackSet_ = true; - } - - // Set up other-player level-up toast callback (once) - if (!otherPlayerLevelUpCallbackSet_) { - gameHandler.setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { - // Coalesce: update existing toast for same player - for (auto& t : playerLevelUpToasts_) { - if (t.guid == guid) { - t.newLevel = newLevel; - t.age = 0.0f; - return; - } - } - if (playerLevelUpToasts_.size() >= 3) - playerLevelUpToasts_.erase(playerLevelUpToasts_.begin()); - playerLevelUpToasts_.push_back({guid, "", newLevel, 0.0f}); - }); - otherPlayerLevelUpCallbackSet_ = true; - } - - // Set up PvP honor credit toast callback (once) - if (!pvpHonorCallbackSet_) { - gameHandler.setPvpHonorCallback([this](uint32_t honor, uint64_t /*victimGuid*/, uint32_t rank) { - if (honor == 0) return; - pvpHonorToasts_.push_back({honor, rank, 0.0f}); - if (pvpHonorToasts_.size() > 4) - pvpHonorToasts_.erase(pvpHonorToasts_.begin()); - }); - pvpHonorCallbackSet_ = true; - } - - // Set up item loot toast callback (once) - if (!itemLootCallbackSet_) { - gameHandler.setItemLootCallback([this](uint32_t itemId, uint32_t count, - uint32_t quality, const std::string& name) { - // Coalesce: if same item already in queue, bump count and reset age - for (auto& t : itemLootToasts_) { - if (t.itemId == itemId) { - t.count += count; - t.age = 0.0f; - return; - } - } - if (itemLootToasts_.size() >= 5) - itemLootToasts_.erase(itemLootToasts_.begin()); - itemLootToasts_.push_back({itemId, count, quality, name, 0.0f}); - }); - itemLootCallbackSet_ = true; - } - - // Set up ghost-state callback to flash "You have been resurrected!" on revival (once) - if (!ghostStateCallbackSet_) { - gameHandler.setGhostStateCallback([this](bool isGhost) { - if (!isGhost) { - // Transitioning ghost→alive: trigger the resurrection flash - resurrectFlashTimer_ = kResurrectFlashDuration; - } - }); - ghostStateCallbackSet_ = true; - } + toastManager_.setupCallbacks(gameHandler); // Set up appearance-changed callback to refresh inventory preview (barber shop, etc.) if (!appearanceCallbackSet_) { @@ -392,24 +285,6 @@ void GameScreen::render(game::GameHandler& gameHandler) { castFailedCallbackSet_ = true; } - // Set up reputation change toast callback (once) - if (!repChangeCallbackSet_) { - gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) { - repToasts_.push_back({name, delta, standing, 0.0f}); - if (repToasts_.size() > 4) repToasts_.erase(repToasts_.begin()); - }); - repChangeCallbackSet_ = true; - } - - // Set up quest completion toast callback (once) - if (!questCompleteCallbackSet_) { - gameHandler.setQuestCompleteCallback([this](uint32_t id, const std::string& title) { - questCompleteToasts_.push_back({id, title, 0.0f}); - if (questCompleteToasts_.size() > 3) questCompleteToasts_.erase(questCompleteToasts_.begin()); - }); - questCompleteCallbackSet_ = true; - } - // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -543,20 +418,6 @@ void GameScreen::render(game::GameHandler& gameHandler) { gameHandler.setAutoSellGrey(pendingAutoSellGrey); gameHandler.setAutoRepair(pendingAutoRepair); - // Zone entry detection — fire a toast when the renderer's zone name changes - if (auto* rend = core::Application::getInstance().getRenderer()) { - const std::string& curZone = rend->getCurrentZoneName(); - if (!curZone.empty() && curZone != lastKnownZone_) { - if (!lastKnownZone_.empty()) { - // Genuine zone change (not first entry) - zoneToasts_.push_back({curZone, 0.0f}); - if (zoneToasts_.size() > 3) - zoneToasts_.erase(zoneToasts_.begin()); - } - lastKnownZone_ = curZone; - } - } - // Sync chat auto-join settings to GameHandler gameHandler.chatAutoJoin.general = chatPanel_.chatAutoJoinGeneral; gameHandler.chatAutoJoin.trade = chatPanel_.chatAutoJoinTrade; @@ -638,10 +499,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDPSMeter(gameHandler); renderDurabilityWarning(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); - renderRepToasts(ImGui::GetIO().DeltaTime); - renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); - renderZoneToasts(ImGui::GetIO().DeltaTime); - renderAreaTriggerToasts(ImGui::GetIO().DeltaTime, gameHandler); + toastManager_.renderEarlyToasts(ImGui::GetIO().DeltaTime, gameHandler); if (showRaidFrames_) { renderPartyFrames(gameHandler); } @@ -704,16 +562,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { chatPanel_.renderBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); - renderDingEffect(); - renderAchievementToast(); - renderDiscoveryToast(); - renderWhisperToasts(); - renderQuestProgressToasts(); - renderPlayerLevelUpToasts(gameHandler); - renderPvpHonorToasts(); - renderItemLootToasts(); - renderResurrectFlash(); - renderZoneText(gameHandler); + toastManager_.renderLateToasts(gameHandler); renderWeatherOverlay(gameHandler); // World map (M key toggle handled inside) @@ -989,15 +838,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { } // Level-up golden burst overlay - if (levelUpFlashAlpha_ > 0.0f) { - levelUpFlashAlpha_ -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second - if (levelUpFlashAlpha_ < 0.0f) levelUpFlashAlpha_ = 0.0f; + if (toastManager_.levelUpFlashAlpha > 0.0f) { + toastManager_.levelUpFlashAlpha -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second + if (toastManager_.levelUpFlashAlpha < 0.0f) toastManager_.levelUpFlashAlpha = 0.0f; ImDrawList* fg = ImGui::GetForegroundDrawList(); ImGuiIO& io = ImGui::GetIO(); const float W = io.DisplaySize.x; const float H = io.DisplaySize.y; - const int alpha = static_cast(levelUpFlashAlpha_ * 160.0f); + const int alpha = static_cast(toastManager_.levelUpFlashAlpha * 160.0f); const ImU32 goldEdge = IM_COL32(255, 210, 50, alpha); const ImU32 goldFade = IM_COL32(255, 210, 50, 0); const float thickness = std::min(W, H) * 0.18f; @@ -1013,9 +862,9 @@ void GameScreen::render(game::GameHandler& gameHandler) { goldFade, goldEdge, goldEdge, goldFade); // "Level X!" text in the center during the first half of the animation - if (levelUpFlashAlpha_ > 0.5f && levelUpDisplayLevel_ > 0) { + if (toastManager_.levelUpFlashAlpha > 0.5f && toastManager_.levelUpDisplayLevel > 0) { char lvlText[32]; - snprintf(lvlText, sizeof(lvlText), "Level %u!", levelUpDisplayLevel_); + snprintf(lvlText, sizeof(lvlText), "Level %u!", toastManager_.levelUpDisplayLevel); ImVec2 ts = ImGui::CalcTextSize(lvlText); float tx = (W - ts.x) * 0.5f; float ty = H * 0.35f; @@ -8685,283 +8534,6 @@ void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaT ImGui::PopStyleVar(); } -// ============================================================ -// Reputation change toasts -// ============================================================ - -void GameScreen::renderRepToasts(float deltaTime) { - for (auto& e : repToasts_) e.age += deltaTime; - repToasts_.erase( - std::remove_if(repToasts_.begin(), repToasts_.end(), - [](const RepToastEntry& e) { return e.age >= kRepToastLifetime; }), - repToasts_.end()); - - if (repToasts_.empty()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - // Stack toasts in the lower-right corner (above the action bar), newest on top - const float toastW = 220.0f; - const float toastH = 26.0f; - const float padY = 4.0f; - const float rightEdge = screenW - 14.0f; - const float baseY = screenH - 180.0f; - - const int count = static_cast(repToasts_.size()); - - ImDrawList* draw = ImGui::GetForegroundDrawList(); - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - - // Compute standing tier label (Exalted, Revered, Honored, Friendly, Neutral, Unfriendly, Hostile, Hated) - auto standingLabel = [](int32_t s) -> const char* { - if (s >= 42000) return "Exalted"; - if (s >= 21000) return "Revered"; - if (s >= 9000) return "Honored"; - if (s >= 3000) return "Friendly"; - if (s >= 0) return "Neutral"; - if (s >= -3000) return "Unfriendly"; - if (s >= -6000) return "Hostile"; - return "Hated"; - }; - - for (int i = 0; i < count; ++i) { - const auto& e = repToasts_[i]; - // Slide in from right on appear, slide out at end - constexpr float kSlideDur = 0.3f; - float slideIn = std::min(e.age, kSlideDur) / kSlideDur; - float slideOut = std::min(std::max(0.0f, kRepToastLifetime - e.age), kSlideDur) / kSlideDur; - float slide = std::min(slideIn, slideOut); - - float alpha = std::clamp(slide, 0.0f, 1.0f); - float xFull = rightEdge - toastW; - float xStart = screenW + 10.0f; - float toastX = xStart + (xFull - xStart) * slide; - float toastY = baseY - i * (toastH + padY); - - ImVec2 tl(toastX, toastY); - ImVec2 br(toastX + toastW, toastY + toastH); - - // Background - draw->AddRectFilled(tl, br, IM_COL32(15, 15, 20, static_cast(alpha * 200)), 4.0f); - // Border: green for gain, red for loss - ImU32 borderCol = (e.delta > 0) - ? IM_COL32(80, 200, 80, static_cast(alpha * 220)) - : IM_COL32(200, 60, 60, static_cast(alpha * 220)); - draw->AddRect(tl, br, borderCol, 4.0f, 0, 1.5f); - - // Delta text: "+250" or "-250" - char deltaBuf[16]; - snprintf(deltaBuf, sizeof(deltaBuf), "%+d", e.delta); - ImU32 deltaCol = (e.delta > 0) ? IM_COL32(80, 220, 80, static_cast(alpha * 255)) - : IM_COL32(220, 70, 70, static_cast(alpha * 255)); - draw->AddText(font, fontSize, ImVec2(tl.x + 6.0f, tl.y + (toastH - fontSize) * 0.5f), - deltaCol, deltaBuf); - - // Faction name + standing - char nameBuf[64]; - snprintf(nameBuf, sizeof(nameBuf), "%s (%s)", e.factionName.c_str(), standingLabel(e.standing)); - draw->AddText(font, fontSize * 0.85f, ImVec2(tl.x + 44.0f, tl.y + (toastH - fontSize * 0.85f) * 0.5f), - IM_COL32(210, 210, 210, static_cast(alpha * 220)), nameBuf); - } -} - -void GameScreen::renderQuestCompleteToasts(float deltaTime) { - for (auto& e : questCompleteToasts_) e.age += deltaTime; - questCompleteToasts_.erase( - std::remove_if(questCompleteToasts_.begin(), questCompleteToasts_.end(), - [](const QuestCompleteToastEntry& e) { return e.age >= kQuestCompleteToastLifetime; }), - questCompleteToasts_.end()); - - if (questCompleteToasts_.empty()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - const float toastW = 260.0f; - const float toastH = 40.0f; - const float padY = 4.0f; - const float baseY = screenH - 220.0f; // above rep toasts - - ImDrawList* draw = ImGui::GetForegroundDrawList(); - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - - for (int i = 0; i < static_cast(questCompleteToasts_.size()); ++i) { - const auto& e = questCompleteToasts_[i]; - constexpr float kSlideDur = 0.3f; - float slideIn = std::min(e.age, kSlideDur) / kSlideDur; - float slideOut = std::min(std::max(0.0f, kQuestCompleteToastLifetime - e.age), kSlideDur) / kSlideDur; - float slide = std::min(slideIn, slideOut); - float alpha = std::clamp(slide, 0.0f, 1.0f); - - float xFull = screenW - 14.0f - toastW; - float xStart = screenW + 10.0f; - float toastX = xStart + (xFull - xStart) * slide; - float toastY = baseY - i * (toastH + padY); - - ImVec2 tl(toastX, toastY); - ImVec2 br(toastX + toastW, toastY + toastH); - - // Background + gold border (quest completion) - draw->AddRectFilled(tl, br, IM_COL32(20, 18, 8, static_cast(alpha * 210)), 5.0f); - draw->AddRect(tl, br, IM_COL32(220, 180, 30, static_cast(alpha * 230)), 5.0f, 0, 1.5f); - - // Scroll icon placeholder (gold diamond) - float iconCx = tl.x + 18.0f; - float iconCy = tl.y + toastH * 0.5f; - draw->AddCircleFilled(ImVec2(iconCx, iconCy), 7.0f, IM_COL32(210, 170, 20, static_cast(alpha * 230))); - draw->AddCircle (ImVec2(iconCx, iconCy), 7.0f, IM_COL32(255, 220, 50, static_cast(alpha * 200))); - - // "Quest Complete" header in gold - const char* header = "Quest Complete"; - draw->AddText(font, fontSize * 0.78f, - ImVec2(tl.x + 34.0f, tl.y + 4.0f), - IM_COL32(240, 200, 40, static_cast(alpha * 240)), header); - - // Quest title in off-white - const char* titleStr = e.title.empty() ? "Unknown Quest" : e.title.c_str(); - draw->AddText(font, fontSize * 0.82f, - ImVec2(tl.x + 34.0f, tl.y + toastH * 0.5f + 1.0f), - IM_COL32(220, 215, 195, static_cast(alpha * 220)), titleStr); - } -} - -// ============================================================ -// Zone Entry Toast -// ============================================================ - -void GameScreen::renderZoneToasts(float deltaTime) { - for (auto& e : zoneToasts_) e.age += deltaTime; - zoneToasts_.erase( - std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), - [](const ZoneToastEntry& e) { return e.age >= kZoneToastLifetime; }), - zoneToasts_.end()); - - // Suppress toasts while the zone text overlay is showing the same zone — - // avoids duplicate "Entering: Stormwind City" messages. - if (zoneTextTimer_ > 0.0f) { - zoneToasts_.erase( - std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), - [this](const ZoneToastEntry& e) { return e.zoneName == zoneTextName_; }), - zoneToasts_.end()); - } - - if (zoneToasts_.empty()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImDrawList* draw = ImGui::GetForegroundDrawList(); - ImFont* font = ImGui::GetFont(); - - for (int i = 0; i < static_cast(zoneToasts_.size()); ++i) { - const auto& e = zoneToasts_[i]; - constexpr float kSlideDur = 0.35f; - float slideIn = std::min(e.age, kSlideDur) / kSlideDur; - float slideOut = std::min(std::max(0.0f, kZoneToastLifetime - e.age), kSlideDur) / kSlideDur; - float slide = std::min(slideIn, slideOut); - float alpha = std::clamp(slide, 0.0f, 1.0f); - - // Measure text to size the toast - ImVec2 nameSz = font->CalcTextSizeA(14.0f, FLT_MAX, 0.0f, e.zoneName.c_str()); - const char* header = "Entering:"; - ImVec2 hdrSz = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, header); - - float toastW = std::max(nameSz.x, hdrSz.x) + 28.0f; - float toastH = 42.0f; - - // Center the toast horizontally, appear just below the zone name area (top-center) - float toastX = (screenW - toastW) * 0.5f; - float toastY = 56.0f + i * (toastH + 4.0f); - // Slide down from above - float offY = (1.0f - slide) * (-toastH - 10.0f); - toastY += offY; - - ImVec2 tl(toastX, toastY); - ImVec2 br(toastX + toastW, toastY + toastH); - - draw->AddRectFilled(tl, br, IM_COL32(10, 10, 16, static_cast(alpha * 200)), 6.0f); - draw->AddRect(tl, br, IM_COL32(160, 140, 80, static_cast(alpha * 220)), 6.0f, 0, 1.2f); - - float cx = tl.x + toastW * 0.5f; - draw->AddText(font, 11.0f, - ImVec2(cx - hdrSz.x * 0.5f, tl.y + 5.0f), - IM_COL32(180, 170, 120, static_cast(alpha * 200)), header); - draw->AddText(font, 14.0f, - ImVec2(cx - nameSz.x * 0.5f, tl.y + toastH * 0.5f + 1.0f), - IM_COL32(255, 230, 140, static_cast(alpha * 240)), e.zoneName.c_str()); - } -} - -// ─── Area Trigger Message Toasts ───────────────────────────────────────────── -void GameScreen::renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler) { - // Drain any pending messages from GameHandler - while (gameHandler.hasAreaTriggerMsg()) { - AreaTriggerToast t; - t.text = gameHandler.popAreaTriggerMsg(); - t.age = 0.0f; - areaTriggerToasts_.push_back(std::move(t)); - if (areaTriggerToasts_.size() > 4) - areaTriggerToasts_.erase(areaTriggerToasts_.begin()); - } - - // Age and prune - constexpr float kLifetime = 4.5f; - for (auto& t : areaTriggerToasts_) t.age += deltaTime; - areaTriggerToasts_.erase( - std::remove_if(areaTriggerToasts_.begin(), areaTriggerToasts_.end(), - [](const AreaTriggerToast& t) { return t.age >= kLifetime; }), - areaTriggerToasts_.end()); - if (areaTriggerToasts_.empty()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - ImDrawList* draw = ImGui::GetForegroundDrawList(); - ImFont* font = ImGui::GetFont(); - constexpr float kSlideDur = 0.35f; - - for (int i = 0; i < static_cast(areaTriggerToasts_.size()); ++i) { - const auto& t = areaTriggerToasts_[i]; - - float slideIn = std::min(t.age, kSlideDur) / kSlideDur; - float slideOut = std::min(std::max(0.0f, kLifetime - t.age), kSlideDur) / kSlideDur; - float alpha = std::clamp(std::min(slideIn, slideOut), 0.0f, 1.0f); - - // Measure text - ImVec2 txtSz = font->CalcTextSizeA(13.0f, FLT_MAX, 0.0f, t.text.c_str()); - float toastW = txtSz.x + 30.0f; - float toastH = 30.0f; - - // Center horizontally, place below zone text (center of lower-third) - float toastX = (screenW - toastW) * 0.5f; - float toastY = screenH * 0.62f + i * (toastH + 3.0f); - // Slide up from below - float offY = (1.0f - std::min(slideIn, slideOut)) * (toastH + 12.0f); - toastY += offY; - - ImVec2 tl(toastX, toastY); - ImVec2 br(toastX + toastW, toastY + toastH); - - draw->AddRectFilled(tl, br, IM_COL32(8, 12, 22, static_cast(alpha * 190)), 5.0f); - draw->AddRect(tl, br, IM_COL32(100, 160, 220, static_cast(alpha * 200)), 5.0f, 0, 1.0f); - - float cx = tl.x + toastW * 0.5f; - // Shadow - draw->AddText(font, 13.0f, - ImVec2(cx - txtSz.x * 0.5f + 1, tl.y + (toastH - txtSz.y) * 0.5f + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 180)), t.text.c_str()); - // Text in light blue - draw->AddText(font, 13.0f, - ImVec2(cx - txtSz.x * 0.5f, tl.y + (toastH - txtSz.y) * 0.5f), - IM_COL32(180, 220, 255, static_cast(alpha * 240)), t.text.c_str()); - } -} // ============================================================ // Boss Encounter Frames @@ -18074,808 +17646,6 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (!open) gameHandler.closeAuctionHouse(); } -// ============================================================ -// Level-Up Ding Animation -// ============================================================ - -void GameScreen::triggerDing(uint32_t newLevel, uint32_t hpDelta, uint32_t manaDelta, - uint32_t str, uint32_t agi, uint32_t sta, - uint32_t intel, uint32_t spi) { - dingTimer_ = DING_DURATION; - dingLevel_ = newLevel; - dingHpDelta_ = hpDelta; - dingManaDelta_ = manaDelta; - dingStats_[0] = str; - dingStats_[1] = agi; - dingStats_[2] = sta; - dingStats_[3] = intel; - dingStats_[4] = spi; - - auto* renderer = core::Application::getInstance().getRenderer(); - if (renderer) { - if (auto* sfx = renderer->getUiSoundManager()) { - sfx->playLevelUp(); - } - renderer->playEmote("cheer"); - } -} - -void GameScreen::renderDingEffect() { - if (dingTimer_ <= 0.0f) return; - - float dt = ImGui::GetIO().DeltaTime; - dingTimer_ -= dt; - if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; - - // Show "You have reached level X!" for the first 2.5s, fade out over last 0.5s. - // The 3D visual effect is handled by Renderer::triggerLevelUpEffect (LevelUp.m2). - constexpr float kFadeTime = 0.5f; - float alpha = dingTimer_ < kFadeTime ? (dingTimer_ / kFadeTime) : 1.0f; - if (alpha <= 0.0f) return; - - ImGuiIO& io = ImGui::GetIO(); - float cx = io.DisplaySize.x * 0.5f; - float cy = io.DisplaySize.y * 0.38f; // Upper-center, like WoW - - ImDrawList* draw = ImGui::GetForegroundDrawList(); - ImFont* font = ImGui::GetFont(); - float baseSize = ImGui::GetFontSize(); - float fontSize = baseSize * 1.8f; - - char buf[64]; - snprintf(buf, sizeof(buf), "You have reached level %u!", dingLevel_); - - ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); - float tx = cx - sz.x * 0.5f; - float ty = cy - sz.y * 0.5f; - - // Slight black outline for readability - draw->AddText(font, fontSize, ImVec2(tx + 2, ty + 2), - IM_COL32(0, 0, 0, static_cast(alpha * 180)), buf); - // Gold text - draw->AddText(font, fontSize, ImVec2(tx, ty), - IM_COL32(255, 210, 0, static_cast(alpha * 255)), buf); - - // Stat gains below the main text (shown only if server sent deltas) - bool hasStatGains = (dingHpDelta_ > 0 || dingManaDelta_ > 0 || - dingStats_[0] || dingStats_[1] || dingStats_[2] || - dingStats_[3] || dingStats_[4]); - if (hasStatGains) { - float smallSize = baseSize * 0.95f; - float yOff = ty + sz.y + 6.0f; - - // Build stat delta string: "+150 HP +80 Mana +2 Str +2 Agi ..." - static constexpr const char* kStatLabels[] = { "Str", "Agi", "Sta", "Int", "Spi" }; - char statBuf[128]; - int written = 0; - if (dingHpDelta_ > 0) - written += snprintf(statBuf + written, sizeof(statBuf) - written, - "+%u HP ", dingHpDelta_); - if (dingManaDelta_ > 0) - written += snprintf(statBuf + written, sizeof(statBuf) - written, - "+%u Mana ", dingManaDelta_); - for (int i = 0; i < 5 && written < static_cast(sizeof(statBuf)) - 1; ++i) { - if (dingStats_[i] > 0) - written += snprintf(statBuf + written, sizeof(statBuf) - written, - "+%u %s ", dingStats_[i], kStatLabels[i]); - } - // Trim trailing spaces - while (written > 0 && statBuf[written - 1] == ' ') --written; - statBuf[written] = '\0'; - - if (written > 0) { - ImVec2 ssz = font->CalcTextSizeA(smallSize, FLT_MAX, 0.0f, statBuf); - float stx = cx - ssz.x * 0.5f; - draw->AddText(font, smallSize, ImVec2(stx + 1, yOff + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 160)), statBuf); - draw->AddText(font, smallSize, ImVec2(stx, yOff), - IM_COL32(100, 220, 100, static_cast(alpha * 230)), statBuf); - } - } -} - -void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) { - achievementToastId_ = achievementId; - achievementToastName_ = std::move(name); - achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION; - - // Play a UI sound if available - auto* renderer = core::Application::getInstance().getRenderer(); - if (renderer) { - if (auto* sfx = renderer->getUiSoundManager()) { - sfx->playAchievementAlert(); - } - } -} - -void GameScreen::renderAchievementToast() { - if (achievementToastTimer_ <= 0.0f) return; - - float dt = ImGui::GetIO().DeltaTime; - achievementToastTimer_ -= dt; - if (achievementToastTimer_ < 0.0f) achievementToastTimer_ = 0.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; - - // Slide in from the right — fully visible for most of the duration, slides out at end - constexpr float SLIDE_TIME = 0.4f; - float slideIn = std::min(achievementToastTimer_, ACHIEVEMENT_TOAST_DURATION - achievementToastTimer_); - float slideFrac = (ACHIEVEMENT_TOAST_DURATION > 0.0f && SLIDE_TIME > 0.0f) - ? std::min(slideIn / SLIDE_TIME, 1.0f) - : 1.0f; - - constexpr float TOAST_W = 280.0f; - constexpr float TOAST_H = 60.0f; - float xFull = screenW - TOAST_W - 20.0f; - float xHidden = screenW + 10.0f; - float toastX = xHidden + (xFull - xHidden) * slideFrac; - float toastY = screenH - TOAST_H - 80.0f; // above action bar area - - float alpha = std::min(1.0f, achievementToastTimer_ / 0.5f); // fade at very end - - ImDrawList* draw = ImGui::GetForegroundDrawList(); - - // Background panel (gold border, dark fill) - ImVec2 tl(toastX, toastY); - ImVec2 br(toastX + TOAST_W, toastY + TOAST_H); - draw->AddRectFilled(tl, br, IM_COL32(30, 20, 10, static_cast(alpha * 230)), 6.0f); - draw->AddRect(tl, br, IM_COL32(200, 170, 50, static_cast(alpha * 255)), 6.0f, 0, 2.0f); - - // Title - ImFont* font = ImGui::GetFont(); - float titleSize = 14.0f; - float bodySize = 12.0f; - const char* title = "Achievement Earned!"; - float titleW = font->CalcTextSizeA(titleSize, FLT_MAX, 0.0f, title).x; - float titleX = toastX + (TOAST_W - titleW) * 0.5f; - draw->AddText(font, titleSize, ImVec2(titleX + 1, toastY + 8 + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 180)), title); - draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8), - IM_COL32(255, 215, 0, static_cast(alpha * 255)), title); - - // Achievement name (falls back to ID if name not available) - char idBuf[256]; - const char* achText = achievementToastName_.empty() - ? nullptr : achievementToastName_.c_str(); - if (achText) { - std::snprintf(idBuf, sizeof(idBuf), "%s", achText); - } else { - std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); - } - float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x; - float idX = toastX + (TOAST_W - idW) * 0.5f; - draw->AddText(font, bodySize, ImVec2(idX, toastY + 28), - IM_COL32(220, 200, 150, static_cast(alpha * 255)), idBuf); -} - -// --------------------------------------------------------------------------- -// Area discovery toast — "Discovered: ! (+XP XP)" centered on screen -// --------------------------------------------------------------------------- - -void GameScreen::renderDiscoveryToast() { - if (discoveryToastTimer_ <= 0.0f) return; - - float dt = ImGui::GetIO().DeltaTime; - discoveryToastTimer_ -= dt; - if (discoveryToastTimer_ < 0.0f) discoveryToastTimer_ = 0.0f; - - // Fade: ramp up in first 0.4s, hold, fade out in last 1.0s - float alpha; - if (discoveryToastTimer_ > DISCOVERY_TOAST_DURATION - 0.4f) - alpha = 1.0f - (discoveryToastTimer_ - (DISCOVERY_TOAST_DURATION - 0.4f)) / 0.4f; - else if (discoveryToastTimer_ < 1.0f) - alpha = discoveryToastTimer_; - else - alpha = 1.0f; - alpha = std::clamp(alpha, 0.0f, 1.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; - - ImFont* font = ImGui::GetFont(); - ImDrawList* draw = ImGui::GetForegroundDrawList(); - - const char* header = "Discovered!"; - float headerSize = 16.0f; - float nameSize = 28.0f; - float xpSize = 14.0f; - - ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); - ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, discoveryToastName_.c_str()); - - char xpBuf[48]; - if (discoveryToastXP_ > 0) - snprintf(xpBuf, sizeof(xpBuf), "+%u XP", discoveryToastXP_); - else - xpBuf[0] = '\0'; - ImVec2 xpDim = font->CalcTextSizeA(xpSize, FLT_MAX, 0.0f, xpBuf); - - // Position slightly below zone text (at 37% down screen) - float centreY = screenH * 0.37f; - float headerX = (screenW - headerDim.x) * 0.5f; - float nameX = (screenW - nameDim.x) * 0.5f; - float xpX = (screenW - xpDim.x) * 0.5f; - float headerY = centreY; - float nameY = centreY + headerDim.y + 4.0f; - float xpY = nameY + nameDim.y + 4.0f; - - // "Discovered!" in gold - draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 160)), header); - draw->AddText(font, headerSize, ImVec2(headerX, headerY), - IM_COL32(255, 215, 0, static_cast(alpha * 255)), header); - - // Area name in white - draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 160)), discoveryToastName_.c_str()); - draw->AddText(font, nameSize, ImVec2(nameX, nameY), - IM_COL32(255, 255, 255, static_cast(alpha * 255)), discoveryToastName_.c_str()); - - // XP gain in light green (if any) - if (xpBuf[0] != '\0') { - draw->AddText(font, xpSize, ImVec2(xpX + 1, xpY + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 140)), xpBuf); - draw->AddText(font, xpSize, ImVec2(xpX, xpY), - IM_COL32(100, 220, 100, static_cast(alpha * 230)), xpBuf); - } -} - -// --------------------------------------------------------------------------- -// Quest objective progress toasts — shown at screen bottom-right on kill/item updates -// --------------------------------------------------------------------------- - -void GameScreen::renderQuestProgressToasts() { - if (questToasts_.empty()) return; - - float dt = ImGui::GetIO().DeltaTime; - for (auto& t : questToasts_) t.age += dt; - questToasts_.erase( - std::remove_if(questToasts_.begin(), questToasts_.end(), - [](const QuestProgressToastEntry& t) { return t.age >= QUEST_TOAST_DURATION; }), - questToasts_.end()); - if (questToasts_.empty()) return; - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - // Stack at bottom-right, just above action bar area - constexpr float TOAST_W = 240.0f; - constexpr float TOAST_H = 48.0f; - constexpr float TOAST_GAP = 4.0f; - float baseY = screenH * 0.72f; - float toastX = screenW - TOAST_W - 14.0f; - - ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); - const int count = static_cast(questToasts_.size()); - - for (int i = 0; i < count; ++i) { - const auto& toast = questToasts_[i]; - - float remaining = QUEST_TOAST_DURATION - toast.age; - float alpha; - if (toast.age < 0.2f) - alpha = toast.age / 0.2f; - else if (remaining < 1.0f) - alpha = remaining; - else - alpha = 1.0f; - alpha = std::clamp(alpha, 0.0f, 1.0f); - - float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); - - uint8_t bgA = static_cast(200 * alpha); - uint8_t fgA = static_cast(255 * alpha); - - // Background: dark amber tint (quest color convention) - bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), - IM_COL32(35, 25, 5, bgA), 5.0f); - bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), - IM_COL32(200, 160, 30, static_cast(160 * alpha)), 5.0f, 0, 1.5f); - - // Quest title (gold, small) - bgDL->AddText(ImVec2(toastX + 8.0f, ty + 5.0f), - IM_COL32(220, 180, 50, fgA), toast.questTitle.c_str()); - - // Progress bar + text: "ObjectiveName X / Y" - float barY = ty + 21.0f; - float barX0 = toastX + 8.0f; - float barX1 = toastX + TOAST_W - 8.0f; - float barH = 8.0f; - float pct = (toast.required > 0) - ? std::min(1.0f, static_cast(toast.current) / static_cast(toast.required)) - : 1.0f; - // Bar background - bgDL->AddRectFilled(ImVec2(barX0, barY), ImVec2(barX1, barY + barH), - IM_COL32(50, 40, 10, static_cast(180 * alpha)), 3.0f); - // Bar fill — green when complete, amber otherwise - ImU32 barCol = (pct >= 1.0f) ? IM_COL32(60, 220, 80, fgA) : IM_COL32(200, 160, 30, fgA); - bgDL->AddRectFilled(ImVec2(barX0, barY), - ImVec2(barX0 + (barX1 - barX0) * pct, barY + barH), - barCol, 3.0f); - - // Objective name + count - char progBuf[48]; - if (!toast.objectiveName.empty()) - snprintf(progBuf, sizeof(progBuf), "%.22s: %u/%u", - toast.objectiveName.c_str(), toast.current, toast.required); - else - snprintf(progBuf, sizeof(progBuf), "%u/%u", toast.current, toast.required); - bgDL->AddText(ImVec2(toastX + 8.0f, ty + 32.0f), - IM_COL32(220, 220, 200, static_cast(210 * alpha)), progBuf); - } -} - -// --------------------------------------------------------------------------- -// Item loot toasts — quality-coloured strip at bottom-left when item received -// --------------------------------------------------------------------------- - -void GameScreen::renderItemLootToasts() { - if (itemLootToasts_.empty()) return; - - float dt = ImGui::GetIO().DeltaTime; - for (auto& t : itemLootToasts_) t.age += dt; - itemLootToasts_.erase( - std::remove_if(itemLootToasts_.begin(), itemLootToasts_.end(), - [](const ItemLootToastEntry& t) { return t.age >= ITEM_LOOT_TOAST_DURATION; }), - itemLootToasts_.end()); - if (itemLootToasts_.empty()) return; - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - // Quality colours (matching WoW convention) - static const ImU32 kQualityColors[] = { - IM_COL32(157, 157, 157, 255), // 0 grey (poor) - IM_COL32(255, 255, 255, 255), // 1 white (common) - IM_COL32( 30, 255, 30, 255), // 2 green (uncommon) - IM_COL32( 0, 112, 221, 255), // 3 blue (rare) - IM_COL32(163, 53, 238, 255), // 4 purple (epic) - IM_COL32(255, 128, 0, 255), // 5 orange (legendary) - IM_COL32(230, 204, 128, 255), // 6 light gold (artifact) - IM_COL32(230, 204, 128, 255), // 7 light gold (heirloom) - }; - - // Stack at bottom-left above action bars; each item is 24 px tall - constexpr float TOAST_W = 260.0f; - constexpr float TOAST_H = 24.0f; - constexpr float TOAST_GAP = 2.0f; - constexpr float TOAST_X = 14.0f; - float baseY = screenH * 0.68f; // slightly above the whisper toasts - - ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); - const int count = static_cast(itemLootToasts_.size()); - - for (int i = 0; i < count; ++i) { - const auto& toast = itemLootToasts_[i]; - - float remaining = ITEM_LOOT_TOAST_DURATION - toast.age; - float alpha; - if (toast.age < 0.15f) - alpha = toast.age / 0.15f; - else if (remaining < 0.7f) - alpha = remaining / 0.7f; - else - alpha = 1.0f; - alpha = std::clamp(alpha, 0.0f, 1.0f); - - // Slide-in from left - float slideX = (toast.age < 0.15f) ? (TOAST_W * (1.0f - toast.age / 0.15f)) : 0.0f; - float tx = TOAST_X - slideX; - float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); - - uint8_t bgA = static_cast(180 * alpha); - uint8_t fgA = static_cast(255 * alpha); - - // Background: very dark with quality-tinted left border accent - bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), - IM_COL32(12, 12, 12, bgA), 3.0f); - - // Quality colour accent bar on left edge (3px wide) - ImU32 qualCol = kQualityColors[std::min(static_cast(7u), toast.quality)]; - ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast(fgA) << 24u); - bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f); - - // "Loot:" label in dim white - bgDL->AddText(ImVec2(tx + 7.0f, ty + 5.0f), - IM_COL32(160, 160, 160, static_cast(200 * alpha)), "Loot:"); - - // Item name in quality colour - std::string displayName = toast.name.empty() ? ("Item #" + std::to_string(toast.itemId)) : toast.name; - if (displayName.size() > 26) { displayName.resize(23); displayName += "..."; } - bgDL->AddText(ImVec2(tx + 42.0f, ty + 5.0f), qualColA, displayName.c_str()); - - // Count (if > 1) - if (toast.count > 1) { - char countBuf[12]; - snprintf(countBuf, sizeof(countBuf), "x%u", toast.count); - bgDL->AddText(ImVec2(tx + TOAST_W - 34.0f, ty + 5.0f), - IM_COL32(200, 200, 200, static_cast(200 * alpha)), countBuf); - } - } -} - -// --------------------------------------------------------------------------- -// PvP honor credit toasts — shown at screen top-right on honorable kill -// --------------------------------------------------------------------------- - -void GameScreen::renderPvpHonorToasts() { - if (pvpHonorToasts_.empty()) return; - - float dt = ImGui::GetIO().DeltaTime; - for (auto& t : pvpHonorToasts_) t.age += dt; - pvpHonorToasts_.erase( - std::remove_if(pvpHonorToasts_.begin(), pvpHonorToasts_.end(), - [](const PvpHonorToastEntry& t) { return t.age >= PVP_HONOR_TOAST_DURATION; }), - pvpHonorToasts_.end()); - if (pvpHonorToasts_.empty()) return; - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - - // Stack toasts at top-right, below any minimap area - constexpr float TOAST_W = 180.0f; - constexpr float TOAST_H = 30.0f; - constexpr float TOAST_GAP = 3.0f; - constexpr float TOAST_TOP = 10.0f; - float toastX = screenW - TOAST_W - 10.0f; - - ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); - const int count = static_cast(pvpHonorToasts_.size()); - - for (int i = 0; i < count; ++i) { - const auto& toast = pvpHonorToasts_[i]; - - float remaining = PVP_HONOR_TOAST_DURATION - toast.age; - float alpha; - if (toast.age < 0.15f) - alpha = toast.age / 0.15f; - else if (remaining < 0.8f) - alpha = remaining / 0.8f; - else - alpha = 1.0f; - alpha = std::clamp(alpha, 0.0f, 1.0f); - - float ty = TOAST_TOP + i * (TOAST_H + TOAST_GAP); - - uint8_t bgA = static_cast(190 * alpha); - uint8_t fgA = static_cast(255 * alpha); - - // Background: dark red (PvP theme) - bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), - IM_COL32(28, 5, 5, bgA), 4.0f); - bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), - IM_COL32(200, 50, 50, static_cast(160 * alpha)), 4.0f, 0, 1.2f); - - // Sword ⚔ icon (U+2694, UTF-8: e2 9a 94) - bgDL->AddText(ImVec2(toastX + 7.0f, ty + 7.0f), - IM_COL32(220, 80, 80, fgA), "\xe2\x9a\x94"); - - // "+N Honor" text in gold - char buf[40]; - snprintf(buf, sizeof(buf), "+%u Honor", toast.honor); - bgDL->AddText(ImVec2(toastX + 24.0f, ty + 8.0f), - IM_COL32(255, 210, 50, fgA), buf); - } -} - -// --------------------------------------------------------------------------- -// Nearby player level-up toasts — shown at screen bottom-centre -// --------------------------------------------------------------------------- - -void GameScreen::renderPlayerLevelUpToasts(game::GameHandler& gameHandler) { - if (playerLevelUpToasts_.empty()) return; - - float dt = ImGui::GetIO().DeltaTime; - for (auto& t : playerLevelUpToasts_) { - t.age += dt; - // Lazy name resolution — fill in once the name cache has it - if (t.playerName.empty() && t.guid != 0) { - t.playerName = gameHandler.lookupName(t.guid); - } - } - playerLevelUpToasts_.erase( - std::remove_if(playerLevelUpToasts_.begin(), playerLevelUpToasts_.end(), - [](const PlayerLevelUpToastEntry& t) { - return t.age >= PLAYER_LEVELUP_TOAST_DURATION; - }), - playerLevelUpToasts_.end()); - if (playerLevelUpToasts_.empty()) return; - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - // Stack toasts at screen bottom-centre, above action bars - constexpr float TOAST_W = 230.0f; - constexpr float TOAST_H = 38.0f; - constexpr float TOAST_GAP = 4.0f; - float baseY = screenH * 0.72f; - float toastX = (screenW - TOAST_W) * 0.5f; - - ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); - const int count = static_cast(playerLevelUpToasts_.size()); - - for (int i = 0; i < count; ++i) { - const auto& toast = playerLevelUpToasts_[i]; - - float remaining = PLAYER_LEVELUP_TOAST_DURATION - toast.age; - float alpha; - if (toast.age < 0.2f) - alpha = toast.age / 0.2f; - else if (remaining < 1.0f) - alpha = remaining; - else - alpha = 1.0f; - alpha = std::clamp(alpha, 0.0f, 1.0f); - - // Subtle pop-up from below during first 0.2s - float slideY = (toast.age < 0.2f) ? (TOAST_H * (1.0f - toast.age / 0.2f)) : 0.0f; - float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP) + slideY; - - uint8_t bgA = static_cast(200 * alpha); - uint8_t fgA = static_cast(255 * alpha); - - // Background: dark gold tint - bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), - IM_COL32(30, 22, 5, bgA), 5.0f); - // Gold border with glow at peak - float glowStr = (toast.age < 0.5f) ? (1.0f - toast.age / 0.5f) : 0.0f; - uint8_t borderA = static_cast((160 + 80 * glowStr) * alpha); - bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), - IM_COL32(255, 210, 50, borderA), 5.0f, 0, 1.5f + glowStr * 1.5f); - - // Star ★ icon on left - bgDL->AddText(ImVec2(toastX + 8.0f, ty + 10.0f), - IM_COL32(255, 220, 60, fgA), "\xe2\x98\x85"); // UTF-8 ★ - - // " is now level X!" text - const char* displayName = toast.playerName.empty() ? "A player" : toast.playerName.c_str(); - char buf[64]; - snprintf(buf, sizeof(buf), "%.18s is now level %u!", displayName, toast.newLevel); - bgDL->AddText(ImVec2(toastX + 26.0f, ty + 11.0f), - IM_COL32(255, 230, 100, fgA), buf); - } -} - -// --------------------------------------------------------------------------- -// Resurrection flash — brief screen brightening + "You have been resurrected!" -// banner when the player transitions from ghost back to alive. -// --------------------------------------------------------------------------- - -void GameScreen::renderResurrectFlash() { - if (resurrectFlashTimer_ <= 0.0f) return; - - float dt = ImGui::GetIO().DeltaTime; - resurrectFlashTimer_ -= dt; - if (resurrectFlashTimer_ <= 0.0f) { - resurrectFlashTimer_ = 0.0f; - return; - } - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - // Normalised age in [0, 1] (0 = just fired, 1 = fully elapsed) - float t = 1.0f - resurrectFlashTimer_ / kResurrectFlashDuration; - - // Alpha envelope: fast fade-in (first 0.15s), hold, then fade-out (last 0.8s) - float alpha; - const float fadeIn = 0.15f / kResurrectFlashDuration; // ~5% of lifetime - const float fadeOut = 0.8f / kResurrectFlashDuration; // ~27% of lifetime - if (t < fadeIn) - alpha = t / fadeIn; - else if (t < 1.0f - fadeOut) - alpha = 1.0f; - else - alpha = (1.0f - t) / fadeOut; - alpha = std::clamp(alpha, 0.0f, 1.0f); - - ImDrawList* bg = ImGui::GetBackgroundDrawList(); - - // Soft golden/white vignette — brightening instead of darkening - uint8_t vigA = static_cast(50 * alpha); - bg->AddRectFilled(ImVec2(0, 0), ImVec2(screenW, screenH), - IM_COL32(200, 230, 255, vigA)); - - // Centered banner panel - constexpr float PANEL_W = 360.0f; - constexpr float PANEL_H = 52.0f; - float px = (screenW - PANEL_W) * 0.5f; - float py = screenH * 0.34f; - - uint8_t bgA = static_cast(210 * alpha); - uint8_t borderA = static_cast(255 * alpha); - uint8_t textA = static_cast(255 * alpha); - - // Background: deep blue-black - bg->AddRectFilled(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), - IM_COL32(10, 18, 40, bgA), 8.0f); - - // Border glow: bright holy gold - bg->AddRect(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), - IM_COL32(200, 230, 100, borderA), 8.0f, 0, 2.0f); - // Inner halo line - bg->AddRect(ImVec2(px + 3.0f, py + 3.0f), ImVec2(px + PANEL_W - 3.0f, py + PANEL_H - 3.0f), - IM_COL32(255, 255, 180, static_cast(80 * alpha)), 6.0f, 0, 1.0f); - - // "✦ You have been resurrected! ✦" centered - // UTF-8 heavy four-pointed star U+2726: \xe2\x9c\xa6 - const char* banner = "\xe2\x9c\xa6 You have been resurrected! \xe2\x9c\xa6"; - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, banner); - float tx = px + (PANEL_W - textSz.x) * 0.5f; - float ty = py + (PANEL_H - textSz.y) * 0.5f; - - // Drop shadow - bg->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), - IM_COL32(0, 0, 0, static_cast(180 * alpha)), banner); - // Main text in warm gold - bg->AddText(font, fontSize, ImVec2(tx, ty), - IM_COL32(255, 240, 120, textA), banner); -} - -// --------------------------------------------------------------------------- -// Whisper toast notifications — brief overlay when a player whispers you -// --------------------------------------------------------------------------- - -void GameScreen::renderWhisperToasts() { - if (whisperToasts_.empty()) return; - - float dt = ImGui::GetIO().DeltaTime; - - // Age and prune expired toasts - for (auto& t : whisperToasts_) t.age += dt; - whisperToasts_.erase( - std::remove_if(whisperToasts_.begin(), whisperToasts_.end(), - [](const WhisperToastEntry& t) { return t.age >= WHISPER_TOAST_DURATION; }), - whisperToasts_.end()); - if (whisperToasts_.empty()) return; - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - // Stack toasts at bottom-left, above the action bars (y ≈ screenH * 0.72) - // Each toast is ~56px tall with a 4px gap between them. - constexpr float TOAST_W = 280.0f; - constexpr float TOAST_H = 56.0f; - constexpr float TOAST_GAP = 4.0f; - constexpr float TOAST_X = 14.0f; // left edge (won't cover action bars) - float baseY = screenH * 0.72f; - - ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); - - const int count = static_cast(whisperToasts_.size()); - for (int i = 0; i < count; ++i) { - auto& toast = whisperToasts_[i]; - - // Fade in over 0.25s; fade out in last 1.0s - float alpha; - float remaining = WHISPER_TOAST_DURATION - toast.age; - if (toast.age < 0.25f) - alpha = toast.age / 0.25f; - else if (remaining < 1.0f) - alpha = remaining; - else - alpha = 1.0f; - alpha = std::clamp(alpha, 0.0f, 1.0f); - - // Slide-in from left: offset 0→0 after 0.25s - float slideX = (toast.age < 0.25f) ? (TOAST_W * (1.0f - toast.age / 0.25f)) : 0.0f; - float tx = TOAST_X - slideX; - float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); - - uint8_t bgA = static_cast(210 * alpha); - uint8_t fgA = static_cast(255 * alpha); - - // Background panel — dark purple tint (whisper color convention) - bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), - IM_COL32(25, 10, 40, bgA), 6.0f); - // Purple border - bgDL->AddRect(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), - IM_COL32(160, 80, 220, static_cast(180 * alpha)), 6.0f, 0, 1.5f); - - // "Whisper" label (small, purple-ish) - bgDL->AddText(ImVec2(tx + 10.0f, ty + 6.0f), - IM_COL32(190, 110, 255, fgA), "Whisper from:"); - - // Sender name (gold) - bgDL->AddText(ImVec2(tx + 10.0f, ty + 20.0f), - IM_COL32(255, 210, 50, fgA), toast.sender.c_str()); - - // Message preview (white, dimmer) - bgDL->AddText(ImVec2(tx + 10.0f, ty + 36.0f), - IM_COL32(220, 220, 220, static_cast(200 * alpha)), - toast.preview.c_str()); - } -} - -// Zone discovery text — "Entering: " fades in/out at screen centre -// --------------------------------------------------------------------------- - -void GameScreen::renderZoneText(game::GameHandler& gameHandler) { - // Poll worldStateZoneId for server-driven zone changes (fires on every zone crossing, - // including sub-zones like Ironforge within Dun Morogh). - uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); - if (wsZoneId != 0 && wsZoneId != lastKnownWorldStateZoneId_) { - lastKnownWorldStateZoneId_ = wsZoneId; - std::string wsName = gameHandler.getWhoAreaName(wsZoneId); - if (!wsName.empty()) { - zoneTextName_ = wsName; - zoneTextTimer_ = ZONE_TEXT_DURATION; - } - } - - // Also poll the renderer for zone name changes (covers map-level transitions - // where worldStateZoneId may not change immediately). - auto* appRenderer = core::Application::getInstance().getRenderer(); - if (appRenderer) { - const std::string& zoneName = appRenderer->getCurrentZoneName(); - if (!zoneName.empty() && zoneName != lastKnownZoneName_) { - lastKnownZoneName_ = zoneName; - // Only override if the worldState hasn't already queued this zone - if (zoneTextName_ != zoneName) { - zoneTextName_ = zoneName; - zoneTextTimer_ = ZONE_TEXT_DURATION; - } - } - } - - if (zoneTextTimer_ <= 0.0f || zoneTextName_.empty()) return; - - float dt = ImGui::GetIO().DeltaTime; - zoneTextTimer_ -= dt; - if (zoneTextTimer_ < 0.0f) zoneTextTimer_ = 0.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; - - // Fade: ramp up in first 0.5 s, hold, fade out in last 1.0 s - float alpha; - if (zoneTextTimer_ > ZONE_TEXT_DURATION - 0.5f) - alpha = 1.0f - (zoneTextTimer_ - (ZONE_TEXT_DURATION - 0.5f)) / 0.5f; - else if (zoneTextTimer_ < 1.0f) - alpha = zoneTextTimer_; - else - alpha = 1.0f; - alpha = std::clamp(alpha, 0.0f, 1.0f); - - ImFont* font = ImGui::GetFont(); - - // "Entering:" header - const char* header = "Entering:"; - float headerSize = 16.0f; - float nameSize = 26.0f; - - ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); - ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, zoneTextName_.c_str()); - - float centreY = screenH * 0.30f; // upper third, like WoW - float headerX = (screenW - headerDim.x) * 0.5f; - float nameX = (screenW - nameDim.x) * 0.5f; - float headerY = centreY; - float nameY = centreY + headerDim.y + 4.0f; - - ImDrawList* draw = ImGui::GetForegroundDrawList(); - - // "Entering:" in gold - draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 160)), header); - draw->AddText(font, headerSize, ImVec2(headerX, headerY), - IM_COL32(255, 215, 0, static_cast(alpha * 255)), header); - - // Zone name in white - draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), - IM_COL32(0, 0, 0, static_cast(alpha * 160)), zoneTextName_.c_str()); - draw->AddText(font, nameSize, ImVec2(nameX, nameY), - IM_COL32(255, 255, 255, static_cast(alpha * 255)), zoneTextName_.c_str()); -} // --------------------------------------------------------------------------- // Screen-space weather overlay (rain / snow / storm) diff --git a/src/ui/toast_manager.cpp b/src/ui/toast_manager.cpp new file mode 100644 index 00000000..1339b7f1 --- /dev/null +++ b/src/ui/toast_manager.cpp @@ -0,0 +1,1250 @@ +#include "ui/toast_manager.hpp" +#include "game/game_handler.hpp" +#include "core/application.hpp" +#include "rendering/renderer.hpp" +#include "audio/ui_sound_manager.hpp" + +#include +#include +#include +#include + +namespace wowee { namespace ui { + +// --------------------------------------------------------------------------- +// Setup toast callbacks on GameHandler (idempotent — safe to call every frame) +// --------------------------------------------------------------------------- +void ToastManager::setupCallbacks(game::GameHandler& gameHandler) { + // NOTE: Level-up and achievement callbacks are registered by Application + // (which also triggers the 3D level-up effect). Application routes to + // triggerDing() / triggerAchievementToast() via the public API. + + // Area discovery toast callback + if (!areaDiscoveryCallbackSet_) { + gameHandler.setAreaDiscoveryCallback([this](const std::string& areaName, uint32_t xpGained) { + discoveryToastName_ = areaName.empty() ? "New Area" : areaName; + discoveryToastXP_ = xpGained; + discoveryToastTimer_ = DISCOVERY_TOAST_DURATION; + }); + areaDiscoveryCallbackSet_ = true; + } + + // Quest objective progress toast callback + if (!questProgressCallbackSet_) { + gameHandler.setQuestProgressCallback([this](const std::string& questTitle, + const std::string& objectiveName, + uint32_t current, uint32_t required) { + for (auto& t : questToasts_) { + if (t.questTitle == questTitle && t.objectiveName == objectiveName) { + t.current = current; + t.required = required; + t.age = 0.0f; + return; + } + } + if (questToasts_.size() >= 4) questToasts_.erase(questToasts_.begin()); + questToasts_.push_back({questTitle, objectiveName, current, required, 0.0f}); + }); + questProgressCallbackSet_ = true; + } + + // Other-player level-up toast callback + if (!otherPlayerLevelUpCallbackSet_) { + gameHandler.setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { + for (auto& t : playerLevelUpToasts_) { + if (t.guid == guid) { + t.newLevel = newLevel; + t.age = 0.0f; + return; + } + } + if (playerLevelUpToasts_.size() >= 3) + playerLevelUpToasts_.erase(playerLevelUpToasts_.begin()); + playerLevelUpToasts_.push_back({guid, "", newLevel, 0.0f}); + }); + otherPlayerLevelUpCallbackSet_ = true; + } + + // PvP honor credit toast callback + if (!pvpHonorCallbackSet_) { + gameHandler.setPvpHonorCallback([this](uint32_t honor, uint64_t /*victimGuid*/, uint32_t rank) { + if (honor == 0) return; + pvpHonorToasts_.push_back({honor, rank, 0.0f}); + if (pvpHonorToasts_.size() > 4) + pvpHonorToasts_.erase(pvpHonorToasts_.begin()); + }); + pvpHonorCallbackSet_ = true; + } + + // Item loot toast callback + if (!itemLootCallbackSet_) { + gameHandler.setItemLootCallback([this](uint32_t itemId, uint32_t count, + uint32_t quality, const std::string& name) { + for (auto& t : itemLootToasts_) { + if (t.itemId == itemId) { + t.count += count; + t.age = 0.0f; + return; + } + } + if (itemLootToasts_.size() >= 5) + itemLootToasts_.erase(itemLootToasts_.begin()); + itemLootToasts_.push_back({itemId, count, quality, name, 0.0f}); + }); + itemLootCallbackSet_ = true; + } + + // Ghost-state callback to flash "You have been resurrected!" on revival + if (!ghostStateCallbackSet_) { + gameHandler.setGhostStateCallback([this](bool isGhost) { + if (!isGhost) { + resurrectFlashTimer_ = kResurrectFlashDuration; + } + }); + ghostStateCallbackSet_ = true; + } + + // Reputation change toast callback + if (!repChangeCallbackSet_) { + gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) { + repToasts_.push_back({name, delta, standing, 0.0f}); + if (repToasts_.size() > 4) repToasts_.erase(repToasts_.begin()); + }); + repChangeCallbackSet_ = true; + } + + // Quest completion toast callback + if (!questCompleteCallbackSet_) { + gameHandler.setQuestCompleteCallback([this](uint32_t id, const std::string& title) { + questCompleteToasts_.push_back({id, title, 0.0f}); + if (questCompleteToasts_.size() > 3) questCompleteToasts_.erase(questCompleteToasts_.begin()); + }); + questCompleteCallbackSet_ = true; + } +} + +// --------------------------------------------------------------------------- +// Render early toasts (before action bars) +// --------------------------------------------------------------------------- +void ToastManager::renderEarlyToasts(float deltaTime, game::GameHandler& gameHandler) { + // Zone entry detection — fire a toast when the renderer's zone name changes + if (auto* rend = core::Application::getInstance().getRenderer()) { + const std::string& curZone = rend->getCurrentZoneName(); + if (!curZone.empty() && curZone != lastKnownZone_) { + if (!lastKnownZone_.empty()) { + zoneToasts_.push_back({curZone, 0.0f}); + if (zoneToasts_.size() > 3) + zoneToasts_.erase(zoneToasts_.begin()); + } + lastKnownZone_ = curZone; + } + } + + renderRepToasts(deltaTime); + renderQuestCompleteToasts(deltaTime); + renderZoneToasts(deltaTime); + renderAreaTriggerToasts(deltaTime, gameHandler); +} + +// --------------------------------------------------------------------------- +// Render late toasts (after escape menu / settings) +// --------------------------------------------------------------------------- +void ToastManager::renderLateToasts(game::GameHandler& gameHandler) { + renderDingEffect(); + renderAchievementToast(); + renderDiscoveryToast(); + renderWhisperToasts(); + renderQuestProgressToasts(); + renderPlayerLevelUpToasts(gameHandler); + renderPvpHonorToasts(); + renderItemLootToasts(); + renderResurrectFlash(); + renderZoneText(gameHandler); +} + +// ============================================================ +// Reputation change toasts +// ============================================================ + +void ToastManager::renderRepToasts(float deltaTime) { + for (auto& e : repToasts_) e.age += deltaTime; + repToasts_.erase( + std::remove_if(repToasts_.begin(), repToasts_.end(), + [](const RepToastEntry& e) { return e.age >= kRepToastLifetime; }), + repToasts_.end()); + + if (repToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Stack toasts in the lower-right corner (above the action bar), newest on top + const float toastW = 220.0f; + const float toastH = 26.0f; + const float padY = 4.0f; + const float rightEdge = screenW - 14.0f; + const float baseY = screenH - 180.0f; + + const int count = static_cast(repToasts_.size()); + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + // Compute standing tier label (Exalted, Revered, Honored, Friendly, Neutral, Unfriendly, Hostile, Hated) + auto standingLabel = [](int32_t s) -> const char* { + if (s >= 42000) return "Exalted"; + if (s >= 21000) return "Revered"; + if (s >= 9000) return "Honored"; + if (s >= 3000) return "Friendly"; + if (s >= 0) return "Neutral"; + if (s >= -3000) return "Unfriendly"; + if (s >= -6000) return "Hostile"; + return "Hated"; + }; + + for (int i = 0; i < count; ++i) { + const auto& e = repToasts_[i]; + // Slide in from right on appear, slide out at end + constexpr float kSlideDur = 0.3f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kRepToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + + float alpha = std::clamp(slide, 0.0f, 1.0f); + float xFull = rightEdge - toastW; + float xStart = screenW + 10.0f; + float toastX = xStart + (xFull - xStart) * slide; + float toastY = baseY - i * (toastH + padY); + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + // Background + draw->AddRectFilled(tl, br, IM_COL32(15, 15, 20, static_cast(alpha * 200)), 4.0f); + // Border: green for gain, red for loss + ImU32 borderCol = (e.delta > 0) + ? IM_COL32(80, 200, 80, static_cast(alpha * 220)) + : IM_COL32(200, 60, 60, static_cast(alpha * 220)); + draw->AddRect(tl, br, borderCol, 4.0f, 0, 1.5f); + + // Delta text: "+250" or "-250" + char deltaBuf[16]; + snprintf(deltaBuf, sizeof(deltaBuf), "%+d", e.delta); + ImU32 deltaCol = (e.delta > 0) ? IM_COL32(80, 220, 80, static_cast(alpha * 255)) + : IM_COL32(220, 70, 70, static_cast(alpha * 255)); + draw->AddText(font, fontSize, ImVec2(tl.x + 6.0f, tl.y + (toastH - fontSize) * 0.5f), + deltaCol, deltaBuf); + + // Faction name + standing + char nameBuf[64]; + snprintf(nameBuf, sizeof(nameBuf), "%s (%s)", e.factionName.c_str(), standingLabel(e.standing)); + draw->AddText(font, fontSize * 0.85f, ImVec2(tl.x + 44.0f, tl.y + (toastH - fontSize * 0.85f) * 0.5f), + IM_COL32(210, 210, 210, static_cast(alpha * 220)), nameBuf); + } +} + +void ToastManager::renderQuestCompleteToasts(float deltaTime) { + for (auto& e : questCompleteToasts_) e.age += deltaTime; + questCompleteToasts_.erase( + std::remove_if(questCompleteToasts_.begin(), questCompleteToasts_.end(), + [](const QuestCompleteToastEntry& e) { return e.age >= kQuestCompleteToastLifetime; }), + questCompleteToasts_.end()); + + if (questCompleteToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + const float toastW = 260.0f; + const float toastH = 40.0f; + const float padY = 4.0f; + const float baseY = screenH - 220.0f; // above rep toasts + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + for (int i = 0; i < static_cast(questCompleteToasts_.size()); ++i) { + const auto& e = questCompleteToasts_[i]; + constexpr float kSlideDur = 0.3f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kQuestCompleteToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + float xFull = screenW - 14.0f - toastW; + float xStart = screenW + 10.0f; + float toastX = xStart + (xFull - xStart) * slide; + float toastY = baseY - i * (toastH + padY); + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + // Background + gold border (quest completion) + draw->AddRectFilled(tl, br, IM_COL32(20, 18, 8, static_cast(alpha * 210)), 5.0f); + draw->AddRect(tl, br, IM_COL32(220, 180, 30, static_cast(alpha * 230)), 5.0f, 0, 1.5f); + + // Scroll icon placeholder (gold diamond) + float iconCx = tl.x + 18.0f; + float iconCy = tl.y + toastH * 0.5f; + draw->AddCircleFilled(ImVec2(iconCx, iconCy), 7.0f, IM_COL32(210, 170, 20, static_cast(alpha * 230))); + draw->AddCircle (ImVec2(iconCx, iconCy), 7.0f, IM_COL32(255, 220, 50, static_cast(alpha * 200))); + + // "Quest Complete" header in gold + const char* header = "Quest Complete"; + draw->AddText(font, fontSize * 0.78f, + ImVec2(tl.x + 34.0f, tl.y + 4.0f), + IM_COL32(240, 200, 40, static_cast(alpha * 240)), header); + + // Quest title in off-white + const char* titleStr = e.title.empty() ? "Unknown Quest" : e.title.c_str(); + draw->AddText(font, fontSize * 0.82f, + ImVec2(tl.x + 34.0f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(220, 215, 195, static_cast(alpha * 220)), titleStr); + } +} + +// ============================================================ +// Zone Entry Toast +// ============================================================ + +void ToastManager::renderZoneToasts(float deltaTime) { + for (auto& e : zoneToasts_) e.age += deltaTime; + zoneToasts_.erase( + std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), + [](const ZoneToastEntry& e) { return e.age >= kZoneToastLifetime; }), + zoneToasts_.end()); + + // Suppress toasts while the zone text overlay is showing the same zone — + // avoids duplicate "Entering: Stormwind City" messages. + if (zoneTextTimer_ > 0.0f) { + zoneToasts_.erase( + std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), + [this](const ZoneToastEntry& e) { return e.zoneName == zoneTextName_; }), + zoneToasts_.end()); + } + + if (zoneToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + + for (int i = 0; i < static_cast(zoneToasts_.size()); ++i) { + const auto& e = zoneToasts_[i]; + constexpr float kSlideDur = 0.35f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kZoneToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + // Measure text to size the toast + ImVec2 nameSz = font->CalcTextSizeA(14.0f, FLT_MAX, 0.0f, e.zoneName.c_str()); + const char* header = "Entering:"; + ImVec2 hdrSz = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, header); + + float toastW = std::max(nameSz.x, hdrSz.x) + 28.0f; + float toastH = 42.0f; + + // Center the toast horizontally, appear just below the zone name area (top-center) + float toastX = (screenW - toastW) * 0.5f; + float toastY = 56.0f + i * (toastH + 4.0f); + // Slide down from above + float offY = (1.0f - slide) * (-toastH - 10.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(10, 10, 16, static_cast(alpha * 200)), 6.0f); + draw->AddRect(tl, br, IM_COL32(160, 140, 80, static_cast(alpha * 220)), 6.0f, 0, 1.2f); + + float cx = tl.x + toastW * 0.5f; + draw->AddText(font, 11.0f, + ImVec2(cx - hdrSz.x * 0.5f, tl.y + 5.0f), + IM_COL32(180, 170, 120, static_cast(alpha * 200)), header); + draw->AddText(font, 14.0f, + ImVec2(cx - nameSz.x * 0.5f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(255, 230, 140, static_cast(alpha * 240)), e.zoneName.c_str()); + } +} + +// ─── Area Trigger Message Toasts ───────────────────────────────────────────── +void ToastManager::renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler) { + // Drain any pending messages from GameHandler + while (gameHandler.hasAreaTriggerMsg()) { + AreaTriggerToast t; + t.text = gameHandler.popAreaTriggerMsg(); + t.age = 0.0f; + areaTriggerToasts_.push_back(std::move(t)); + if (areaTriggerToasts_.size() > 4) + areaTriggerToasts_.erase(areaTriggerToasts_.begin()); + } + + // Age and prune + constexpr float kLifetime = 4.5f; + for (auto& t : areaTriggerToasts_) t.age += deltaTime; + areaTriggerToasts_.erase( + std::remove_if(areaTriggerToasts_.begin(), areaTriggerToasts_.end(), + [](const AreaTriggerToast& t) { return t.age >= kLifetime; }), + areaTriggerToasts_.end()); + if (areaTriggerToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + constexpr float kSlideDur = 0.35f; + + for (int i = 0; i < static_cast(areaTriggerToasts_.size()); ++i) { + const auto& t = areaTriggerToasts_[i]; + + float slideIn = std::min(t.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kLifetime - t.age), kSlideDur) / kSlideDur; + float alpha = std::clamp(std::min(slideIn, slideOut), 0.0f, 1.0f); + + // Measure text + ImVec2 txtSz = font->CalcTextSizeA(13.0f, FLT_MAX, 0.0f, t.text.c_str()); + float toastW = txtSz.x + 30.0f; + float toastH = 30.0f; + + // Center horizontally, place below zone text (center of lower-third) + float toastX = (screenW - toastW) * 0.5f; + float toastY = screenH * 0.62f + i * (toastH + 3.0f); + // Slide up from below + float offY = (1.0f - std::min(slideIn, slideOut)) * (toastH + 12.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(8, 12, 22, static_cast(alpha * 190)), 5.0f); + draw->AddRect(tl, br, IM_COL32(100, 160, 220, static_cast(alpha * 200)), 5.0f, 0, 1.0f); + + float cx = tl.x + toastW * 0.5f; + // Shadow + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f + 1, tl.y + (toastH - txtSz.y) * 0.5f + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 180)), t.text.c_str()); + // Text in light blue + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f, tl.y + (toastH - txtSz.y) * 0.5f), + IM_COL32(180, 220, 255, static_cast(alpha * 240)), t.text.c_str()); + } +} + +// ============================================================ +// Level-Up Ding Animation +// ============================================================ + +void ToastManager::triggerDing(uint32_t newLevel, uint32_t hpDelta, uint32_t manaDelta, + uint32_t str, uint32_t agi, uint32_t sta, + uint32_t intel, uint32_t spi) { + // Set golden burst overlay state (consumed by GameScreen) + levelUpFlashAlpha = 1.0f; + levelUpDisplayLevel = newLevel; + + dingTimer_ = DING_DURATION; + dingLevel_ = newLevel; + dingHpDelta_ = hpDelta; + dingManaDelta_ = manaDelta; + dingStats_[0] = str; + dingStats_[1] = agi; + dingStats_[2] = sta; + dingStats_[3] = intel; + dingStats_[4] = spi; + + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + if (auto* sfx = renderer->getUiSoundManager()) { + sfx->playLevelUp(); + } + renderer->playEmote("cheer"); + } +} + +void ToastManager::renderDingEffect() { + if (dingTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + dingTimer_ -= dt; + if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; + + // Show "You have reached level X!" for the first 2.5s, fade out over last 0.5s. + // The 3D visual effect is handled by Renderer::triggerLevelUpEffect (LevelUp.m2). + constexpr float kFadeTime = 0.5f; + float alpha = dingTimer_ < kFadeTime ? (dingTimer_ / kFadeTime) : 1.0f; + if (alpha <= 0.0f) return; + + ImGuiIO& io = ImGui::GetIO(); + float cx = io.DisplaySize.x * 0.5f; + float cy = io.DisplaySize.y * 0.38f; // Upper-center, like WoW + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float baseSize = ImGui::GetFontSize(); + float fontSize = baseSize * 1.8f; + + char buf[64]; + snprintf(buf, sizeof(buf), "You have reached level %u!", dingLevel_); + + ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); + float tx = cx - sz.x * 0.5f; + float ty = cy - sz.y * 0.5f; + + // Slight black outline for readability + draw->AddText(font, fontSize, ImVec2(tx + 2, ty + 2), + IM_COL32(0, 0, 0, static_cast(alpha * 180)), buf); + // Gold text + draw->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 210, 0, static_cast(alpha * 255)), buf); + + // Stat gains below the main text (shown only if server sent deltas) + bool hasStatGains = (dingHpDelta_ > 0 || dingManaDelta_ > 0 || + dingStats_[0] || dingStats_[1] || dingStats_[2] || + dingStats_[3] || dingStats_[4]); + if (hasStatGains) { + float smallSize = baseSize * 0.95f; + float yOff = ty + sz.y + 6.0f; + + // Build stat delta string: "+150 HP +80 Mana +2 Str +2 Agi ..." + static constexpr const char* kStatLabels[] = { "Str", "Agi", "Sta", "Int", "Spi" }; + char statBuf[128]; + int written = 0; + if (dingHpDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u HP ", dingHpDelta_); + if (dingManaDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u Mana ", dingManaDelta_); + for (int i = 0; i < 5 && written < static_cast(sizeof(statBuf)) - 1; ++i) { + if (dingStats_[i] > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u %s ", dingStats_[i], kStatLabels[i]); + } + // Trim trailing spaces + while (written > 0 && statBuf[written - 1] == ' ') --written; + statBuf[written] = '\0'; + + if (written > 0) { + ImVec2 ssz = font->CalcTextSizeA(smallSize, FLT_MAX, 0.0f, statBuf); + float stx = cx - ssz.x * 0.5f; + draw->AddText(font, smallSize, ImVec2(stx + 1, yOff + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 160)), statBuf); + draw->AddText(font, smallSize, ImVec2(stx, yOff), + IM_COL32(100, 220, 100, static_cast(alpha * 230)), statBuf); + } + } +} + +void ToastManager::triggerAchievementToast(uint32_t achievementId, std::string name) { + achievementToastId_ = achievementId; + achievementToastName_ = std::move(name); + achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION; + + // Play a UI sound if available + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + if (auto* sfx = renderer->getUiSoundManager()) { + sfx->playAchievementAlert(); + } + } +} + +void ToastManager::renderAchievementToast() { + if (achievementToastTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + achievementToastTimer_ -= dt; + if (achievementToastTimer_ < 0.0f) achievementToastTimer_ = 0.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; + + // Slide in from the right — fully visible for most of the duration, slides out at end + constexpr float SLIDE_TIME = 0.4f; + float slideIn = std::min(achievementToastTimer_, ACHIEVEMENT_TOAST_DURATION - achievementToastTimer_); + float slideFrac = (ACHIEVEMENT_TOAST_DURATION > 0.0f && SLIDE_TIME > 0.0f) + ? std::min(slideIn / SLIDE_TIME, 1.0f) + : 1.0f; + + constexpr float TOAST_W = 280.0f; + constexpr float TOAST_H = 60.0f; + float xFull = screenW - TOAST_W - 20.0f; + float xHidden = screenW + 10.0f; + float toastX = xHidden + (xFull - xHidden) * slideFrac; + float toastY = screenH - TOAST_H - 80.0f; // above action bar area + + float alpha = std::min(1.0f, achievementToastTimer_ / 0.5f); // fade at very end + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + // Background panel (gold border, dark fill) + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + TOAST_W, toastY + TOAST_H); + draw->AddRectFilled(tl, br, IM_COL32(30, 20, 10, static_cast(alpha * 230)), 6.0f); + draw->AddRect(tl, br, IM_COL32(200, 170, 50, static_cast(alpha * 255)), 6.0f, 0, 2.0f); + + // Title + ImFont* font = ImGui::GetFont(); + float titleSize = 14.0f; + float bodySize = 12.0f; + const char* title = "Achievement Earned!"; + float titleW = font->CalcTextSizeA(titleSize, FLT_MAX, 0.0f, title).x; + float titleX = toastX + (TOAST_W - titleW) * 0.5f; + draw->AddText(font, titleSize, ImVec2(titleX + 1, toastY + 8 + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 180)), title); + draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8), + IM_COL32(255, 215, 0, static_cast(alpha * 255)), title); + + // Achievement name (falls back to ID if name not available) + char idBuf[256]; + const char* achText = achievementToastName_.empty() + ? nullptr : achievementToastName_.c_str(); + if (achText) { + std::snprintf(idBuf, sizeof(idBuf), "%s", achText); + } else { + std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + } + float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x; + float idX = toastX + (TOAST_W - idW) * 0.5f; + draw->AddText(font, bodySize, ImVec2(idX, toastY + 28), + IM_COL32(220, 200, 150, static_cast(alpha * 255)), idBuf); +} + +// --------------------------------------------------------------------------- +// Area discovery toast — "Discovered: ! (+XP XP)" centered on screen +// --------------------------------------------------------------------------- + +void ToastManager::renderDiscoveryToast() { + if (discoveryToastTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + discoveryToastTimer_ -= dt; + if (discoveryToastTimer_ < 0.0f) discoveryToastTimer_ = 0.0f; + + // Fade: ramp up in first 0.4s, hold, fade out in last 1.0s + float alpha; + if (discoveryToastTimer_ > DISCOVERY_TOAST_DURATION - 0.4f) + alpha = 1.0f - (discoveryToastTimer_ - (DISCOVERY_TOAST_DURATION - 0.4f)) / 0.4f; + else if (discoveryToastTimer_ < 1.0f) + alpha = discoveryToastTimer_; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.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; + + ImFont* font = ImGui::GetFont(); + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + const char* header = "Discovered!"; + float headerSize = 16.0f; + float nameSize = 28.0f; + float xpSize = 14.0f; + + ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); + ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, discoveryToastName_.c_str()); + + char xpBuf[48]; + if (discoveryToastXP_ > 0) + snprintf(xpBuf, sizeof(xpBuf), "+%u XP", discoveryToastXP_); + else + xpBuf[0] = '\0'; + ImVec2 xpDim = font->CalcTextSizeA(xpSize, FLT_MAX, 0.0f, xpBuf); + + // Position slightly below zone text (at 37% down screen) + float centreY = screenH * 0.37f; + float headerX = (screenW - headerDim.x) * 0.5f; + float nameX = (screenW - nameDim.x) * 0.5f; + float xpX = (screenW - xpDim.x) * 0.5f; + float headerY = centreY; + float nameY = centreY + headerDim.y + 4.0f; + float xpY = nameY + nameDim.y + 4.0f; + + // "Discovered!" in gold + draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 160)), header); + draw->AddText(font, headerSize, ImVec2(headerX, headerY), + IM_COL32(255, 215, 0, static_cast(alpha * 255)), header); + + // Area name in white + draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 160)), discoveryToastName_.c_str()); + draw->AddText(font, nameSize, ImVec2(nameX, nameY), + IM_COL32(255, 255, 255, static_cast(alpha * 255)), discoveryToastName_.c_str()); + + // XP gain in light green (if any) + if (xpBuf[0] != '\0') { + draw->AddText(font, xpSize, ImVec2(xpX + 1, xpY + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 140)), xpBuf); + draw->AddText(font, xpSize, ImVec2(xpX, xpY), + IM_COL32(100, 220, 100, static_cast(alpha * 230)), xpBuf); + } +} + +// --------------------------------------------------------------------------- +// Quest objective progress toasts — shown at screen bottom-right on kill/item updates +// --------------------------------------------------------------------------- + +void ToastManager::renderQuestProgressToasts() { + if (questToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : questToasts_) t.age += dt; + questToasts_.erase( + std::remove_if(questToasts_.begin(), questToasts_.end(), + [](const QuestProgressToastEntry& t) { return t.age >= QUEST_TOAST_DURATION; }), + questToasts_.end()); + if (questToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack at bottom-right, just above action bar area + constexpr float TOAST_W = 240.0f; + constexpr float TOAST_H = 48.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = screenW - TOAST_W - 14.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(questToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = questToasts_[i]; + + float remaining = QUEST_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark amber tint (quest color convention) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(35, 25, 5, bgA), 5.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 160, 30, static_cast(160 * alpha)), 5.0f, 0, 1.5f); + + // Quest title (gold, small) + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 5.0f), + IM_COL32(220, 180, 50, fgA), toast.questTitle.c_str()); + + // Progress bar + text: "ObjectiveName X / Y" + float barY = ty + 21.0f; + float barX0 = toastX + 8.0f; + float barX1 = toastX + TOAST_W - 8.0f; + float barH = 8.0f; + float pct = (toast.required > 0) + ? std::min(1.0f, static_cast(toast.current) / static_cast(toast.required)) + : 1.0f; + // Bar background + bgDL->AddRectFilled(ImVec2(barX0, barY), ImVec2(barX1, barY + barH), + IM_COL32(50, 40, 10, static_cast(180 * alpha)), 3.0f); + // Bar fill — green when complete, amber otherwise + ImU32 barCol = (pct >= 1.0f) ? IM_COL32(60, 220, 80, fgA) : IM_COL32(200, 160, 30, fgA); + bgDL->AddRectFilled(ImVec2(barX0, barY), + ImVec2(barX0 + (barX1 - barX0) * pct, barY + barH), + barCol, 3.0f); + + // Objective name + count + char progBuf[48]; + if (!toast.objectiveName.empty()) + snprintf(progBuf, sizeof(progBuf), "%.22s: %u/%u", + toast.objectiveName.c_str(), toast.current, toast.required); + else + snprintf(progBuf, sizeof(progBuf), "%u/%u", toast.current, toast.required); + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 32.0f), + IM_COL32(220, 220, 200, static_cast(210 * alpha)), progBuf); + } +} + +// --------------------------------------------------------------------------- +// Item loot toasts — quality-coloured strip at bottom-left when item received +// --------------------------------------------------------------------------- + +void ToastManager::renderItemLootToasts() { + if (itemLootToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : itemLootToasts_) t.age += dt; + itemLootToasts_.erase( + std::remove_if(itemLootToasts_.begin(), itemLootToasts_.end(), + [](const ItemLootToastEntry& t) { return t.age >= ITEM_LOOT_TOAST_DURATION; }), + itemLootToasts_.end()); + if (itemLootToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Quality colours (matching WoW convention) + static const ImU32 kQualityColors[] = { + IM_COL32(157, 157, 157, 255), // 0 grey (poor) + IM_COL32(255, 255, 255, 255), // 1 white (common) + IM_COL32( 30, 255, 30, 255), // 2 green (uncommon) + IM_COL32( 0, 112, 221, 255), // 3 blue (rare) + IM_COL32(163, 53, 238, 255), // 4 purple (epic) + IM_COL32(255, 128, 0, 255), // 5 orange (legendary) + IM_COL32(230, 204, 128, 255), // 6 light gold (artifact) + IM_COL32(230, 204, 128, 255), // 7 light gold (heirloom) + }; + + // Stack at bottom-left above action bars; each item is 24 px tall + constexpr float TOAST_W = 260.0f; + constexpr float TOAST_H = 24.0f; + constexpr float TOAST_GAP = 2.0f; + constexpr float TOAST_X = 14.0f; + float baseY = screenH * 0.68f; // slightly above the whisper toasts + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(itemLootToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = itemLootToasts_[i]; + + float remaining = ITEM_LOOT_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.7f) + alpha = remaining / 0.7f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left + float slideX = (toast.age < 0.15f) ? (TOAST_W * (1.0f - toast.age / 0.15f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(180 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: very dark with quality-tinted left border accent + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(12, 12, 12, bgA), 3.0f); + + // Quality colour accent bar on left edge (3px wide) + ImU32 qualCol = kQualityColors[std::min(static_cast(7u), toast.quality)]; + ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast(fgA) << 24u); + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f); + + // "Loot:" label in dim white + bgDL->AddText(ImVec2(tx + 7.0f, ty + 5.0f), + IM_COL32(160, 160, 160, static_cast(200 * alpha)), "Loot:"); + + // Item name in quality colour + std::string displayName = toast.name.empty() ? ("Item #" + std::to_string(toast.itemId)) : toast.name; + if (displayName.size() > 26) { displayName.resize(23); displayName += "..."; } + bgDL->AddText(ImVec2(tx + 42.0f, ty + 5.0f), qualColA, displayName.c_str()); + + // Count (if > 1) + if (toast.count > 1) { + char countBuf[12]; + snprintf(countBuf, sizeof(countBuf), "x%u", toast.count); + bgDL->AddText(ImVec2(tx + TOAST_W - 34.0f, ty + 5.0f), + IM_COL32(200, 200, 200, static_cast(200 * alpha)), countBuf); + } + } +} + +// --------------------------------------------------------------------------- +// PvP honor credit toasts — shown at screen top-right on honorable kill +// --------------------------------------------------------------------------- + +void ToastManager::renderPvpHonorToasts() { + if (pvpHonorToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : pvpHonorToasts_) t.age += dt; + pvpHonorToasts_.erase( + std::remove_if(pvpHonorToasts_.begin(), pvpHonorToasts_.end(), + [](const PvpHonorToastEntry& t) { return t.age >= PVP_HONOR_TOAST_DURATION; }), + pvpHonorToasts_.end()); + if (pvpHonorToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + + // Stack toasts at top-right, below any minimap area + constexpr float TOAST_W = 180.0f; + constexpr float TOAST_H = 30.0f; + constexpr float TOAST_GAP = 3.0f; + constexpr float TOAST_TOP = 10.0f; + float toastX = screenW - TOAST_W - 10.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(pvpHonorToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = pvpHonorToasts_[i]; + + float remaining = PVP_HONOR_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.8f) + alpha = remaining / 0.8f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = TOAST_TOP + i * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(190 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark red (PvP theme) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(28, 5, 5, bgA), 4.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 50, 50, static_cast(160 * alpha)), 4.0f, 0, 1.2f); + + // Sword ⚔ icon (U+2694, UTF-8: e2 9a 94) + bgDL->AddText(ImVec2(toastX + 7.0f, ty + 7.0f), + IM_COL32(220, 80, 80, fgA), "\xe2\x9a\x94"); + + // "+N Honor" text in gold + char buf[40]; + snprintf(buf, sizeof(buf), "+%u Honor", toast.honor); + bgDL->AddText(ImVec2(toastX + 24.0f, ty + 8.0f), + IM_COL32(255, 210, 50, fgA), buf); + } +} + +// --------------------------------------------------------------------------- +// Nearby player level-up toasts — shown at screen bottom-centre +// --------------------------------------------------------------------------- + +void ToastManager::renderPlayerLevelUpToasts(game::GameHandler& gameHandler) { + if (playerLevelUpToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : playerLevelUpToasts_) { + t.age += dt; + // Lazy name resolution — fill in once the name cache has it + if (t.playerName.empty() && t.guid != 0) { + t.playerName = gameHandler.lookupName(t.guid); + } + } + playerLevelUpToasts_.erase( + std::remove_if(playerLevelUpToasts_.begin(), playerLevelUpToasts_.end(), + [](const PlayerLevelUpToastEntry& t) { + return t.age >= PLAYER_LEVELUP_TOAST_DURATION; + }), + playerLevelUpToasts_.end()); + if (playerLevelUpToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack toasts at screen bottom-centre, above action bars + constexpr float TOAST_W = 230.0f; + constexpr float TOAST_H = 38.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = (screenW - TOAST_W) * 0.5f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(playerLevelUpToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = playerLevelUpToasts_[i]; + + float remaining = PLAYER_LEVELUP_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Subtle pop-up from below during first 0.2s + float slideY = (toast.age < 0.2f) ? (TOAST_H * (1.0f - toast.age / 0.2f)) : 0.0f; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP) + slideY; + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark gold tint + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(30, 22, 5, bgA), 5.0f); + // Gold border with glow at peak + float glowStr = (toast.age < 0.5f) ? (1.0f - toast.age / 0.5f) : 0.0f; + uint8_t borderA = static_cast((160 + 80 * glowStr) * alpha); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(255, 210, 50, borderA), 5.0f, 0, 1.5f + glowStr * 1.5f); + + // Star ★ icon on left + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 10.0f), + IM_COL32(255, 220, 60, fgA), "\xe2\x98\x85"); // UTF-8 ★ + + // " is now level X!" text + const char* displayName = toast.playerName.empty() ? "A player" : toast.playerName.c_str(); + char buf[64]; + snprintf(buf, sizeof(buf), "%.18s is now level %u!", displayName, toast.newLevel); + bgDL->AddText(ImVec2(toastX + 26.0f, ty + 11.0f), + IM_COL32(255, 230, 100, fgA), buf); + } +} + +// --------------------------------------------------------------------------- +// Resurrection flash — brief screen brightening + "You have been resurrected!" +// banner when the player transitions from ghost back to alive. +// --------------------------------------------------------------------------- + +void ToastManager::renderResurrectFlash() { + if (resurrectFlashTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + resurrectFlashTimer_ -= dt; + if (resurrectFlashTimer_ <= 0.0f) { + resurrectFlashTimer_ = 0.0f; + return; + } + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Normalised age in [0, 1] (0 = just fired, 1 = fully elapsed) + float t = 1.0f - resurrectFlashTimer_ / kResurrectFlashDuration; + + // Alpha envelope: fast fade-in (first 0.15s), hold, then fade-out (last 0.8s) + float alpha; + const float fadeIn = 0.15f / kResurrectFlashDuration; // ~5% of lifetime + const float fadeOut = 0.8f / kResurrectFlashDuration; // ~27% of lifetime + if (t < fadeIn) + alpha = t / fadeIn; + else if (t < 1.0f - fadeOut) + alpha = 1.0f; + else + alpha = (1.0f - t) / fadeOut; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + ImDrawList* bg = ImGui::GetBackgroundDrawList(); + + // Soft golden/white vignette — brightening instead of darkening + uint8_t vigA = static_cast(50 * alpha); + bg->AddRectFilled(ImVec2(0, 0), ImVec2(screenW, screenH), + IM_COL32(200, 230, 255, vigA)); + + // Centered banner panel + constexpr float PANEL_W = 360.0f; + constexpr float PANEL_H = 52.0f; + float px = (screenW - PANEL_W) * 0.5f; + float py = screenH * 0.34f; + + uint8_t bgA = static_cast(210 * alpha); + uint8_t borderA = static_cast(255 * alpha); + uint8_t textA = static_cast(255 * alpha); + + // Background: deep blue-black + bg->AddRectFilled(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(10, 18, 40, bgA), 8.0f); + + // Border glow: bright holy gold + bg->AddRect(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(200, 230, 100, borderA), 8.0f, 0, 2.0f); + // Inner halo line + bg->AddRect(ImVec2(px + 3.0f, py + 3.0f), ImVec2(px + PANEL_W - 3.0f, py + PANEL_H - 3.0f), + IM_COL32(255, 255, 180, static_cast(80 * alpha)), 6.0f, 0, 1.0f); + + // "✦ You have been resurrected! ✦" centered + // UTF-8 heavy four-pointed star U+2726: \xe2\x9c\xa6 + const char* banner = "\xe2\x9c\xa6 You have been resurrected! \xe2\x9c\xa6"; + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, banner); + float tx = px + (PANEL_W - textSz.x) * 0.5f; + float ty = py + (PANEL_H - textSz.y) * 0.5f; + + // Drop shadow + bg->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, static_cast(180 * alpha)), banner); + // Main text in warm gold + bg->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 240, 120, textA), banner); +} + +// --------------------------------------------------------------------------- +// Whisper toast notifications — brief overlay when a player whispers you +// --------------------------------------------------------------------------- + +void ToastManager::renderWhisperToasts() { + if (whisperToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + + // Age and prune expired toasts + for (auto& t : whisperToasts_) t.age += dt; + whisperToasts_.erase( + std::remove_if(whisperToasts_.begin(), whisperToasts_.end(), + [](const WhisperToastEntry& t) { return t.age >= WHISPER_TOAST_DURATION; }), + whisperToasts_.end()); + if (whisperToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack toasts at bottom-left, above the action bars (y ≈ screenH * 0.72) + // Each toast is ~56px tall with a 4px gap between them. + constexpr float TOAST_W = 280.0f; + constexpr float TOAST_H = 56.0f; + constexpr float TOAST_GAP = 4.0f; + constexpr float TOAST_X = 14.0f; // left edge (won't cover action bars) + float baseY = screenH * 0.72f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + + const int count = static_cast(whisperToasts_.size()); + for (int i = 0; i < count; ++i) { + auto& toast = whisperToasts_[i]; + + // Fade in over 0.25s; fade out in last 1.0s + float alpha; + float remaining = WHISPER_TOAST_DURATION - toast.age; + if (toast.age < 0.25f) + alpha = toast.age / 0.25f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left: offset 0→0 after 0.25s + float slideX = (toast.age < 0.25f) ? (TOAST_W * (1.0f - toast.age / 0.25f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(210 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background panel — dark purple tint (whisper color convention) + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(25, 10, 40, bgA), 6.0f); + // Purple border + bgDL->AddRect(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(160, 80, 220, static_cast(180 * alpha)), 6.0f, 0, 1.5f); + + // "Whisper" label (small, purple-ish) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 6.0f), + IM_COL32(190, 110, 255, fgA), "Whisper from:"); + + // Sender name (gold) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 20.0f), + IM_COL32(255, 210, 50, fgA), toast.sender.c_str()); + + // Message preview (white, dimmer) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 36.0f), + IM_COL32(220, 220, 220, static_cast(200 * alpha)), + toast.preview.c_str()); + } +} + +// Zone discovery text — "Entering: " fades in/out at screen centre +// --------------------------------------------------------------------------- + +void ToastManager::renderZoneText(game::GameHandler& gameHandler) { + // Poll worldStateZoneId for server-driven zone changes (fires on every zone crossing, + // including sub-zones like Ironforge within Dun Morogh). + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0 && wsZoneId != lastKnownWorldStateZoneId_) { + lastKnownWorldStateZoneId_ = wsZoneId; + std::string wsName = gameHandler.getWhoAreaName(wsZoneId); + if (!wsName.empty()) { + zoneTextName_ = wsName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } + } + + // Also poll the renderer for zone name changes (covers map-level transitions + // where worldStateZoneId may not change immediately). + auto* appRenderer = core::Application::getInstance().getRenderer(); + if (appRenderer) { + const std::string& zoneName = appRenderer->getCurrentZoneName(); + if (!zoneName.empty() && zoneName != lastKnownZoneName_) { + lastKnownZoneName_ = zoneName; + // Only override if the worldState hasn't already queued this zone + if (zoneTextName_ != zoneName) { + zoneTextName_ = zoneName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } + } + } + + if (zoneTextTimer_ <= 0.0f || zoneTextName_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + zoneTextTimer_ -= dt; + if (zoneTextTimer_ < 0.0f) zoneTextTimer_ = 0.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; + + // Fade: ramp up in first 0.5 s, hold, fade out in last 1.0 s + float alpha; + if (zoneTextTimer_ > ZONE_TEXT_DURATION - 0.5f) + alpha = 1.0f - (zoneTextTimer_ - (ZONE_TEXT_DURATION - 0.5f)) / 0.5f; + else if (zoneTextTimer_ < 1.0f) + alpha = zoneTextTimer_; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + ImFont* font = ImGui::GetFont(); + + // "Entering:" header + const char* header = "Entering:"; + float headerSize = 16.0f; + float nameSize = 26.0f; + + ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); + ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, zoneTextName_.c_str()); + + float centreY = screenH * 0.30f; // upper third, like WoW + float headerX = (screenW - headerDim.x) * 0.5f; + float nameX = (screenW - nameDim.x) * 0.5f; + float headerY = centreY; + float nameY = centreY + headerDim.y + 4.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + // "Entering:" in gold + draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 160)), header); + draw->AddText(font, headerSize, ImVec2(headerX, headerY), + IM_COL32(255, 215, 0, static_cast(alpha * 255)), header); + + // Zone name in white + draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), + IM_COL32(0, 0, 0, static_cast(alpha * 160)), zoneTextName_.c_str()); + draw->AddText(font, nameSize, ImVec2(nameX, nameY), + IM_COL32(255, 255, 255, static_cast(alpha * 255)), zoneTextName_.c_str()); +} + +} } // namespace wowee::ui From af9874484a98e3e2e40b4659ea7f145641c73b5d Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 31 Mar 2026 10:07:58 +0300 Subject: [PATCH 3/5] `chore(game-ui): extract GameScreen domains into DialogManager + SettingsPanel` - Add `DialogManager` + `SettingsPanel` UI modules - Refactor `GameScreen` to delegate dialogs and settings to new domains - Update CMakeLists.txt to include new sources - Include header/source files: - dialog_manager.hpp - settings_panel.hpp - dialog_manager.cpp - settings_panel.cpp - game_screen.cpp - Keep `GameScreen` surface behavior while decoupling feature responsibilities --- CMakeLists.txt | 2 + include/ui/dialog_manager.hpp | 71 + include/ui/game_screen.hpp | 140 +- include/ui/settings_panel.hpp | 169 ++ src/ui/dialog_manager.cpp | 1115 +++++++++++++ src/ui/game_screen.cpp | 2773 +++------------------------------ src/ui/settings_panel.cpp | 1258 +++++++++++++++ 7 files changed, 2871 insertions(+), 2657 deletions(-) create mode 100644 include/ui/dialog_manager.hpp create mode 100644 include/ui/settings_panel.hpp create mode 100644 src/ui/dialog_manager.cpp create mode 100644 src/ui/settings_panel.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index da19c235..8aa8666f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -558,6 +558,8 @@ set(WOWEE_SOURCES src/ui/game_screen.cpp src/ui/chat_panel.cpp src/ui/toast_manager.cpp + src/ui/dialog_manager.cpp + src/ui/settings_panel.cpp src/ui/inventory_screen.cpp src/ui/quest_log_screen.cpp src/ui/spellbook_screen.cpp diff --git a/include/ui/dialog_manager.hpp b/include/ui/dialog_manager.hpp new file mode 100644 index 00000000..2fd0fb19 --- /dev/null +++ b/include/ui/dialog_manager.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace ui { + +class ChatPanel; +class InventoryScreen; + +/** + * Dialog / popup overlay manager + * + * Owns all yes/no popup rendering: + * group invite, duel request, duel countdown, loot roll, trade request, + * trade window, summon request, shared quest, item text, guild invite, + * ready check, BG invite, BF manager invite, LFG proposal, LFG role check, + * resurrect, talent wipe confirm, pet unlearn confirm. + */ +class DialogManager { +public: + DialogManager() = default; + + /// Render "early" dialogs (group invite through LFG role check) + /// called in render() before guild roster / social frame + void renderDialogs(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + + /// Render "late" dialogs (resurrect, talent wipe, pet unlearn) + /// called in render() after reclaim corpse button + void renderLateDialogs(game::GameHandler& gameHandler); + +private: + // Common ImGui window flags for popup dialogs + static constexpr ImGuiWindowFlags kDialogFlags = + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + // ---- LFG role state ---- + uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) + + // ---- Individual dialog renderers ---- + void renderGroupInvitePopup(game::GameHandler& gameHandler); + void renderDuelRequestPopup(game::GameHandler& gameHandler); + void renderDuelCountdown(game::GameHandler& gameHandler); + void renderItemTextWindow(game::GameHandler& gameHandler); + void renderSharedQuestPopup(game::GameHandler& gameHandler); + void renderSummonRequestPopup(game::GameHandler& gameHandler); + void renderTradeRequestPopup(game::GameHandler& gameHandler); + void renderTradeWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + void renderLootRollPopup(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel); + void renderGuildInvitePopup(game::GameHandler& gameHandler); + void renderReadyCheckPopup(game::GameHandler& gameHandler); + void renderBgInvitePopup(game::GameHandler& gameHandler); + void renderBfMgrInvitePopup(game::GameHandler& gameHandler); + void renderLfgProposalPopup(game::GameHandler& gameHandler); + void renderLfgRoleCheckPopup(game::GameHandler& gameHandler); + void renderResurrectDialog(game::GameHandler& gameHandler); + void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler); + void renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 2260fb97..7e7e8032 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -11,6 +11,8 @@ #include "ui/keybinding_manager.hpp" #include "ui/chat_panel.hpp" #include "ui/toast_manager.hpp" +#include "ui/dialog_manager.hpp" +#include "ui/settings_panel.hpp" #include #include #include @@ -43,7 +45,6 @@ public: void saveSettings(); void loadSettings(); - void applyAudioVolumes(rendering::Renderer* renderer); private: // Chat panel (extracted from GameScreen — owns all chat state and rendering) @@ -52,6 +53,12 @@ private: // Toast manager (extracted from GameScreen — owns all toast/notification state and rendering) ToastManager toastManager_; + // Dialog manager (extracted from GameScreen — owns all popup/dialog rendering) + DialogManager dialogManager_; + + // 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_; @@ -61,14 +68,10 @@ private: bool showChatWindow = true; bool showMinimap_ = true; // M key toggles minimap bool showNameplates_ = true; // V key toggles enemy/NPC nameplates - bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player nameplates - float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none) ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) - bool damageFlashEnabled_ = true; - bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP // Raid Warning / Boss Emote big-text overlay (center-screen, fades after 5s) @@ -119,104 +122,11 @@ private: float questTrackerRightOffset_ = -1.0f; // pixels from right edge; <0 = use default bool questTrackerPosInit_ = false; bool showEscapeMenu = false; - bool showEscapeSettingsNotice = false; - bool showSettingsWindow = false; - bool settingsInit = false; - bool pendingFullscreen = false; - bool pendingVsync = false; - int pendingResIndex = 0; - bool pendingShadows = true; - float pendingShadowDistance = 300.0f; - bool pendingWaterRefraction = true; - int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default) - int pendingMasterVolume = 100; - int pendingMusicVolume = 30; - int pendingAmbientVolume = 100; - int pendingUiVolume = 100; - int pendingCombatVolume = 100; - int pendingSpellVolume = 100; - int pendingMovementVolume = 100; - int pendingFootstepVolume = 100; - int pendingNpcVoiceVolume = 100; - int pendingMountVolume = 100; - int pendingActivityVolume = 100; - float pendingMouseSensitivity = 0.2f; - bool pendingInvertMouse = false; - bool pendingExtendedZoom = false; - float pendingCameraStiffness = 30.0f; // Camera smooth speed (higher = tighter, less sway) - float pendingPivotHeight = 1.6f; // Camera pivot height above feet (lower = less detached feel) - float pendingFov = 70.0f; // degrees, default matches WoW's ~70° horizontal FOV - int pendingUiOpacity = 65; - bool pendingMinimapRotate = false; - bool pendingMinimapSquare = false; - bool pendingMinimapNpcDots = false; - bool pendingShowLatencyMeter = true; - bool pendingSeparateBags = true; - bool pendingShowKeyring = true; - bool pendingAutoLoot = false; - bool pendingAutoSellGrey = false; - bool pendingAutoRepair = false; - // Keybinding customization - int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index - bool awaitingKeyPress = false; // Macro editor popup state uint32_t macroEditorId_ = 0; // macro index being edited bool macroEditorOpen_ = false; // deferred OpenPopup flag char macroEditorBuf_[256] = {}; // edit buffer - bool pendingUseOriginalSoundtrack = true; - bool pendingShowActionBar2 = true; // Show second action bar above main bar - float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5) - float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position - float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1) - bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35) - bool pendingShowLeftBar = false; // Left-edge vertical action bar (bar 4, slots 36-47) - float pendingRightBarOffsetY = 0.0f; // Vertical offset from screen center - float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center - int pendingGroundClutterDensity = 100; - int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x - bool pendingFXAA = false; // FXAA post-process (combinable with MSAA) - bool pendingNormalMapping = true; // on by default - float pendingNormalMapStrength = 0.8f; // 0.0-2.0 - bool pendingPOM = true; // on by default - int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64) - bool pendingFSR = false; - int pendingUpscalingMode = 0; // 0=Off, 1=FSR1, 2=FSR3 - int pendingFSRQuality = 3; // 0=UltraQuality, 1=Quality, 2=Balanced, 3=Native(100%) - float pendingFSRSharpness = 1.6f; - float pendingFSR2JitterSign = 0.38f; - float pendingFSR2MotionVecScaleX = 1.0f; - float pendingFSR2MotionVecScaleY = 1.0f; - bool pendingAMDFramegen = false; - bool fsrSettingsApplied_ = false; - - // Graphics quality presets - enum class GraphicsPreset : int { - CUSTOM = 0, - LOW = 1, - MEDIUM = 2, - HIGH = 3, - ULTRA = 4 - }; - GraphicsPreset currentGraphicsPreset = GraphicsPreset::CUSTOM; - GraphicsPreset pendingGraphicsPreset = GraphicsPreset::CUSTOM; - - // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) - float uiOpacity_ = 0.65f; - bool minimapRotate_ = false; - bool minimapSquare_ = false; - bool minimapNpcDots_ = false; - bool showLatencyMeter_ = true; // Show server latency indicator - bool minimapSettingsApplied_ = false; - bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers - bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer - bool fxaaSettingsApplied_ = false; // True once saved FXAA setting applied to renderer - bool waterRefractionApplied_ = false; - bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied - - // Mute state: mute bypasses master volume without touching slider values - bool soundMuted_ = false; - float preMuteVolume_ = 1.0f; // AudioEngine master volume before muting /** * Render player info window @@ -275,15 +185,6 @@ private: void renderBossFrames(game::GameHandler& gameHandler); void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); - void renderGroupInvitePopup(game::GameHandler& gameHandler); - void renderDuelRequestPopup(game::GameHandler& gameHandler); - void renderDuelCountdown(game::GameHandler& gameHandler); - void renderLootRollPopup(game::GameHandler& gameHandler); - void renderTradeRequestPopup(game::GameHandler& gameHandler); - void renderTradeWindow(game::GameHandler& gameHandler); - void renderSummonRequestPopup(game::GameHandler& gameHandler); - void renderSharedQuestPopup(game::GameHandler& gameHandler); - void renderItemTextWindow(game::GameHandler& gameHandler); void renderBuffBar(game::GameHandler& gameHandler); void renderSocialFrame(game::GameHandler& gameHandler); void renderLootWindow(game::GameHandler& gameHandler); @@ -299,28 +200,11 @@ private: void renderLogoutCountdown(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); - void renderResurrectDialog(game::GameHandler& gameHandler); - void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler); - void renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); - void renderSettingsWindow(); - void renderSettingsAudioTab(); - void renderSettingsAboutTab(); - void renderSettingsInterfaceTab(); - void renderSettingsGameplayTab(); - void renderSettingsControlsTab(); - void applyGraphicsPreset(GraphicsPreset preset); - void updateGraphicsPresetFromCurrentSettings(); void renderQuestMarkers(game::GameHandler& gameHandler); void renderMinimapMarkers(game::GameHandler& gameHandler); void renderQuestObjectiveTracker(game::GameHandler& gameHandler); void renderGuildRoster(game::GameHandler& gameHandler); - void renderGuildInvitePopup(game::GameHandler& gameHandler); - void renderReadyCheckPopup(game::GameHandler& gameHandler); - void renderBgInvitePopup(game::GameHandler& gameHandler); - void renderBfMgrInvitePopup(game::GameHandler& gameHandler); - void renderLfgProposalPopup(game::GameHandler& gameHandler); - void renderLfgRoleCheckPopup(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); void renderBankWindow(game::GameHandler& gameHandler); @@ -445,10 +329,6 @@ private: uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) - static std::string getSettingsPath(); - - - // Mail compose state char mailRecipientBuffer_[256] = ""; char mailSubjectBuffer_[256] = ""; @@ -510,11 +390,7 @@ private: void renderWeatherOverlay(game::GameHandler& gameHandler); - // Cooldown tracker - bool showCooldownTracker_ = false; - // DPS / HPS meter - bool showDPSMeter_ = false; float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) bool dpsWasInCombat_ = false; float dpsEncounterDamage_ = 0.0f; // total player damage this combat diff --git a/include/ui/settings_panel.hpp b/include/ui/settings_panel.hpp new file mode 100644 index 00000000..36d92f85 --- /dev/null +++ b/include/ui/settings_panel.hpp @@ -0,0 +1,169 @@ +#pragma once + +#include +#include +#include +#include + +namespace wowee { +namespace rendering { class Renderer; } +namespace ui { + +class InventoryScreen; +class ChatPanel; + +/** + * Settings panel (extracted from GameScreen) + * + * Owns all settings UI rendering, settings state variables, and + * graphics preset logic. Save/load remains in GameScreen since + * it serialises cross-cutting state (chat, quest tracker, etc.). + */ +class SettingsPanel { +public: + // ---- Settings UI visibility flags (written by EscapeMenu / Escape key) ---- + bool showEscapeSettingsNotice = false; + bool showSettingsWindow = false; + bool settingsInit = false; + + // ---- Pending video / graphics settings ---- + bool pendingFullscreen = false; + bool pendingVsync = false; + int pendingResIndex = 0; + bool pendingShadows = true; + float pendingShadowDistance = 300.0f; + bool pendingWaterRefraction = true; + int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default) + + // ---- Pending audio settings ---- + int pendingMasterVolume = 100; + int pendingMusicVolume = 30; + int pendingAmbientVolume = 100; + int pendingUiVolume = 100; + int pendingCombatVolume = 100; + int pendingSpellVolume = 100; + int pendingMovementVolume = 100; + int pendingFootstepVolume = 100; + int pendingNpcVoiceVolume = 100; + int pendingMountVolume = 100; + int pendingActivityVolume = 100; + + // ---- Pending camera / controls ---- + float pendingMouseSensitivity = 0.2f; + bool pendingInvertMouse = false; + bool pendingExtendedZoom = false; + float pendingCameraStiffness = 30.0f; // Camera smooth speed (higher = tighter, less sway) + float pendingPivotHeight = 1.6f; // Camera pivot height above feet (lower = less detached feel) + float pendingFov = 70.0f; // degrees, default matches WoW's ~70° horizontal FOV + + // ---- Pending UI / interface ---- + int pendingUiOpacity = 65; + bool pendingMinimapRotate = false; + bool pendingMinimapSquare = false; + bool pendingMinimapNpcDots = false; + bool pendingShowLatencyMeter = true; + bool pendingSeparateBags = true; + bool pendingShowKeyring = true; + + // ---- Pending gameplay ---- + bool pendingAutoLoot = false; + bool pendingAutoSellGrey = false; + bool pendingAutoRepair = false; + + // ---- Pending soundtrack ---- + bool pendingUseOriginalSoundtrack = true; + + // ---- Pending action bar layout ---- + bool pendingShowActionBar2 = true; // Show second action bar above main bar + float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5) + float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position + float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1) + bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35) + bool pendingShowLeftBar = false; // Left-edge vertical action bar (bar 4, slots 36-47) + float pendingRightBarOffsetY = 0.0f; // Vertical offset from screen center + float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center + + // ---- Pending graphics quality ---- + int pendingGroundClutterDensity = 100; + int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x + bool pendingFXAA = false; // FXAA post-process (combinable with MSAA) + bool pendingNormalMapping = true; // on by default + float pendingNormalMapStrength = 0.8f; // 0.0-2.0 + bool pendingPOM = true; // on by default + int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64) + bool pendingFSR = false; + int pendingUpscalingMode = 0; // 0=Off, 1=FSR1, 2=FSR3 + int pendingFSRQuality = 3; // 0=UltraQuality, 1=Quality, 2=Balanced, 3=Native(100%) + float pendingFSRSharpness = 1.6f; + float pendingFSR2JitterSign = 0.38f; + float pendingFSR2MotionVecScaleX = 1.0f; + float pendingFSR2MotionVecScaleY = 1.0f; + bool pendingAMDFramegen = false; + + // ---- Graphics quality presets ---- + enum class GraphicsPreset : int { + CUSTOM = 0, + LOW = 1, + MEDIUM = 2, + HIGH = 3, + ULTRA = 4 + }; + GraphicsPreset currentGraphicsPreset = GraphicsPreset::CUSTOM; + GraphicsPreset pendingGraphicsPreset = GraphicsPreset::CUSTOM; + + // ---- Applied-once flags (used by GameScreen::render() one-time-apply blocks) ---- + bool fsrSettingsApplied_ = false; + float uiOpacity_ = 0.65f; // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) + bool minimapRotate_ = false; + bool minimapSquare_ = false; + bool minimapNpcDots_ = false; + bool showLatencyMeter_ = true; // Show server latency indicator + bool minimapSettingsApplied_ = false; + bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers + bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer + bool fxaaSettingsApplied_ = false; // True once saved FXAA setting applied to renderer + bool waterRefractionApplied_ = false; + bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied + + // ---- Mute state: mute bypasses master volume without touching slider values ---- + bool soundMuted_ = false; + float preMuteVolume_ = 1.0f; // AudioEngine master volume before muting + + // ---- Config toggles (read by GameScreen rendering, edited by Interface tab) ---- + float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions + bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player nameplates + bool showDPSMeter_ = false; + bool showCooldownTracker_ = false; + bool damageFlashEnabled_ = true; + bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP + + // ---- Public methods ---- + + /// Render the settings window (call from GameScreen::render) + void renderSettingsWindow(InventoryScreen& inventoryScreen, ChatPanel& chatPanel, + std::function saveCallback); + + /// Apply audio volume levels to all renderer sound managers + void applyAudioVolumes(rendering::Renderer* renderer); + + /// Return the platform-specific settings file path + static std::string getSettingsPath(); + +private: + // Keybinding customization (private — only used in Controls tab) + int pendingRebindAction_ = -1; // -1 = not rebinding, otherwise action index + bool awaitingKeyPress_ = false; + + // Settings tab rendering + void renderSettingsInterfaceTab(std::function saveCallback); + void renderSettingsGameplayTab(InventoryScreen& inventoryScreen, + std::function saveCallback); + void renderSettingsControlsTab(std::function saveCallback); + void renderSettingsAudioTab(std::function saveCallback); + void renderSettingsAboutTab(); + void applyGraphicsPreset(GraphicsPreset preset); + void updateGraphicsPresetFromCurrentSettings(); +}; + +} // namespace ui +} // namespace wowee diff --git a/src/ui/dialog_manager.cpp b/src/ui/dialog_manager.cpp new file mode 100644 index 00000000..dd90a990 --- /dev/null +++ b/src/ui/dialog_manager.cpp @@ -0,0 +1,1115 @@ +#include "ui/dialog_manager.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/chat_panel.hpp" +#include "ui/ui_colors.hpp" +#include "game/game_handler.hpp" +#include "core/application.hpp" + +#include +#include +#include +#include +#include + +namespace wowee { namespace ui { + +namespace { + using namespace wowee::ui::colors; + constexpr auto& kColorDarkGray = kDarkGray; + constexpr auto& kColorGreen = kGreen; +} // namespace + +// Build a WoW-format item link string for chat insertion. +// Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r +static 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; +} + +// --------------------------------------------------------------------------- +// Render early dialogs (group invite through LFG role check) +// --------------------------------------------------------------------------- +void DialogManager::renderDialogs(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + renderGroupInvitePopup(gameHandler); + renderDuelRequestPopup(gameHandler); + renderDuelCountdown(gameHandler); + renderLootRollPopup(gameHandler, inventoryScreen, chatPanel); + renderTradeRequestPopup(gameHandler); + renderTradeWindow(gameHandler, inventoryScreen, chatPanel); + renderSummonRequestPopup(gameHandler); + renderSharedQuestPopup(gameHandler); + renderItemTextWindow(gameHandler); + renderGuildInvitePopup(gameHandler); + renderReadyCheckPopup(gameHandler); + renderBgInvitePopup(gameHandler); + renderBfMgrInvitePopup(gameHandler); + renderLfgProposalPopup(gameHandler); + renderLfgRoleCheckPopup(gameHandler); +} + +// --------------------------------------------------------------------------- +// Render late dialogs (resurrect, talent wipe, pet unlearn) +// --------------------------------------------------------------------------- +void DialogManager::renderLateDialogs(game::GameHandler& gameHandler) { + renderResurrectDialog(gameHandler); + renderTalentWipeConfirmDialog(gameHandler); + renderPetUnlearnConfirmDialog(gameHandler); +} + +// ============================================================ +// Group Invite Popup (Phase 4) +// ============================================================ + +void DialogManager::renderGroupInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingGroupInvite()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + if (ImGui::Begin("Group Invite", nullptr, kDialogFlags)) { + ImGui::Text("%s has invited you to a group.", gameHandler.getPendingInviterName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptGroupInvite(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineGroupInvite(); + } + } + ImGui::End(); +} + +void DialogManager::renderDuelRequestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingDuelRequest()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 250), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + if (ImGui::Begin("Duel Request", nullptr, kDialogFlags)) { + ImGui::Text("%s challenges you to a duel!", gameHandler.getDuelChallengerName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptDuel(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.forfeitDuel(); + } + } + ImGui::End(); +} + +void DialogManager::renderDuelCountdown(game::GameHandler& gameHandler) { + float remaining = gameHandler.getDuelCountdownRemaining(); + if (remaining <= 0.0f) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + auto* dl = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + // Show integer countdown or "Fight!" when under 0.5s + char buf[32]; + if (remaining > 0.5f) { + snprintf(buf, sizeof(buf), "%d", static_cast(std::ceil(remaining))); + } else { + snprintf(buf, sizeof(buf), "Fight!"); + } + + // Large font by scaling — use 4x font size for dramatic effect + float scale = 4.0f; + float scaledSize = fontSize * scale; + ImVec2 textSz = font->CalcTextSizeA(scaledSize, FLT_MAX, 0.0f, buf); + float tx = (screenW - textSz.x) * 0.5f; + float ty = screenH * 0.35f - textSz.y * 0.5f; + + // Pulsing alpha: fades in and out per second + float pulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 6.28f); + uint8_t alpha = static_cast(255 * pulse); + + // Color: golden countdown, red "Fight!" + ImU32 color = (remaining > 0.5f) + ? IM_COL32(255, 200, 50, alpha) + : IM_COL32(255, 60, 60, alpha); + + // Drop shadow + dl->AddText(font, scaledSize, ImVec2(tx + 2.0f, ty + 2.0f), IM_COL32(0, 0, 0, alpha / 2), buf); + dl->AddText(font, scaledSize, ImVec2(tx, ty), color, buf); +} + +void DialogManager::renderItemTextWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isItemTextOpen()) 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 - 200, screenH * 0.15f), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(400, 300), ImGuiCond_FirstUseEver); + + bool open = true; + if (!ImGui::Begin("Book", &open, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + if (!open) gameHandler.closeItemText(); + return; + } + if (!open) { + ImGui::End(); + gameHandler.closeItemText(); + return; + } + + // Parchment-toned background text + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.2f, 0.1f, 0.0f, 1.0f)); + ImGui::TextWrapped("%s", gameHandler.getItemText().c_str()); + ImGui::PopStyleColor(); + + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(80, 0))) { + gameHandler.closeItemText(); + } + + ImGui::End(); +} + +void DialogManager::renderSharedQuestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingSharedQuest()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 490), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Shared Quest", nullptr, kDialogFlags)) { + ImGui::Text("%s has shared a quest with you:", gameHandler.getSharedQuestSharerName().c_str()); + ImGui::TextColored(colors::kBrightGold, "\"%s\"", gameHandler.getSharedQuestTitle().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptSharedQuest(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineSharedQuest(); + } + } + ImGui::End(); +} + +void DialogManager::renderSummonRequestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingSummonRequest()) return; + + // Tick the timeout down + float dt = ImGui::GetIO().DeltaTime; + gameHandler.tickSummonTimeout(dt); + if (!gameHandler.hasPendingSummonRequest()) return; // expired + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 430), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Summon Request", nullptr, kDialogFlags)) { + ImGui::Text("%s is summoning you.", gameHandler.getSummonerName().c_str()); + float t = gameHandler.getSummonTimeoutSec(); + if (t > 0.0f) { + ImGui::Text("Time remaining: %.0fs", t); + } + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptSummon(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineSummon(); + } + } + ImGui::End(); +} + +void DialogManager::renderTradeRequestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingTradeRequest()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 370), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + if (ImGui::Begin("Trade Request", nullptr, kDialogFlags)) { + ImGui::Text("%s wants to trade with you.", gameHandler.getTradePeerName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptTradeRequest(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineTradeRequest(); + } + } + ImGui::End(); +} + +void DialogManager::renderTradeWindow(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.isTradeOpen()) return; + + const auto& mySlots = gameHandler.getMyTradeSlots(); + const auto& peerSlots = gameHandler.getPeerTradeSlots(); + const uint64_t myGold = gameHandler.getMyTradeGold(); + const uint64_t peerGold = gameHandler.getPeerTradeGold(); + const auto& peerName = gameHandler.getTradePeerName(); + + 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(("Trade with " + peerName).c_str(), &open, + kDialogFlags)) { + + auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) { + uint64_t g = copper / 10000; + uint64_t s = (copper % 10000) / 100; + uint64_t c = copper % 100; + if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc", + (unsigned long long)g, (unsigned long long)s, (unsigned long long)c); + else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc", + (unsigned long long)s, (unsigned long long)c); + else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c); + }; + + auto renderSlotColumn = [&](const char* label, + const std::array& slots, + uint64_t gold, bool isMine) { + ImGui::Text("%s", label); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) { + const auto& slot = slots[i]; + ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100)); + + if (slot.occupied && slot.itemId != 0) { + const auto* info = gameHandler.getItemInfo(slot.itemId); + std::string name = (info && info->valid && !info->name.empty()) + ? info->name + : ("Item " + std::to_string(slot.itemId)); + if (slot.stackCount > 1) + name += " x" + std::to_string(slot.stackCount); + ImVec4 qc = (info && info->valid) + ? InventoryScreen::getQualityColor(static_cast(info->quality)) + : ImVec4(1.0f, 0.9f, 0.5f, 1.0f); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(qc, "%d. %s", i + 1, name.c_str()); + if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + gameHandler.clearTradeItem(static_cast(i)); + } + if (ImGui::IsItemHovered()) { + if (info && info->valid) inventoryScreen.renderItemTooltip(*info); + else if (isMine) ImGui::SetTooltip("Double-click to remove"); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + chatPanel.insertChatLink(link); + } + } else { + ImGui::TextDisabled(" %d. (empty)", i + 1); + + // Allow dragging inventory items into trade slots via right-click context menu + char addItemId[16]; snprintf(addItemId, sizeof(addItemId), "##additem%d", i); + if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup(addItemId); + } + } + + if (isMine) { + char addItemId[16]; snprintf(addItemId, sizeof(addItemId), "##additem%d", i); + // Drag-from-inventory: show small popup listing bag items + if (ImGui::BeginPopup(addItemId)) { + ImGui::TextDisabled("Add from inventory:"); + const auto& inv = gameHandler.getInventory(); + // Backpack slots 0-15 (bag=255) + for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) { + const auto& slot = inv.getBackpackSlot(si); + if (slot.empty()) continue; + const auto* ii = gameHandler.getItemInfo(slot.item.itemId); + std::string iname = (ii && ii->valid && !ii->name.empty()) + ? ii->name + : (!slot.item.name.empty() ? slot.item.name + : ("Item " + std::to_string(slot.item.itemId))); + if (ImGui::Selectable(iname.c_str())) { + // bag=255 = main backpack + gameHandler.setTradeItem(static_cast(i), 255u, + static_cast(si)); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } + ImGui::PopID(); + } + + // Gold row + char gbuf[48]; + formatGold(gold, gbuf, sizeof(gbuf)); + ImGui::Spacing(); + if (isMine) { + ImGui::Text("Gold offered: %s", gbuf); + static char goldInput[32] = "0"; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput), + ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) { + uint64_t copper = std::strtoull(goldInput, nullptr, 10); + gameHandler.setTradeGold(copper); + } + ImGui::SameLine(); + ImGui::TextDisabled("(copper, Enter to set)"); + } else { + ImGui::Text("Gold offered: %s", gbuf); + } + }; + + // Two-column layout: my offer | peer offer + float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f; + ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true); + renderSlotColumn("Your offer", mySlots, myGold, true); + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true); + renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false); + ImGui::EndChild(); + + // Buttons + ImGui::Spacing(); + ImGui::Separator(); + float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) { + gameHandler.acceptTrade(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(bw, 0))) { + gameHandler.cancelTrade(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.cancelTrade(); + } +} + +void DialogManager::renderLootRollPopup(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + ChatPanel& chatPanel) { + if (!gameHandler.hasPendingLootRoll()) return; + + const auto& roll = gameHandler.getPendingLootRoll(); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 310), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Loot Roll", nullptr, kDialogFlags)) { + // Quality color for item name + uint8_t q = roll.itemQuality; + ImVec4 col = ui::getQualityColor(static_cast(q)); + + // Countdown bar + { + auto now = std::chrono::steady_clock::now(); + float elapsedMs = static_cast( + std::chrono::duration_cast(now - roll.rollStartedAt).count()); + float totalMs = static_cast(roll.rollCountdownMs > 0 ? roll.rollCountdownMs : 60000); + float fraction = 1.0f - std::min(elapsedMs / totalMs, 1.0f); + float remainSec = (totalMs - elapsedMs) / 1000.0f; + if (remainSec < 0.0f) remainSec = 0.0f; + + // Color: green → yellow → red + ImVec4 barColor; + if (fraction > 0.5f) + barColor = ImVec4(0.2f + (1.0f - fraction) * 1.4f, 0.85f, 0.2f, 1.0f); + else if (fraction > 0.2f) + barColor = ImVec4(1.0f, fraction * 1.7f, 0.1f, 1.0f); + else { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 6.0f); + barColor = ImVec4(pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); + } + + char timeBuf[16]; + std::snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remainSec); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + ImGui::ProgressBar(fraction, ImVec2(-1, 12), timeBuf); + ImGui::PopStyleColor(); + } + + ImGui::Text("An item is up for rolls:"); + + // Show item icon if available + const auto* rollInfo = gameHandler.getItemInfo(roll.itemId); + uint32_t rollDisplayId = rollInfo ? rollInfo->displayInfoId : 0; + VkDescriptorSet rollIcon = rollDisplayId ? inventoryScreen.getItemIcon(rollDisplayId) : VK_NULL_HANDLE; + if (rollIcon) { + ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24)); + ImGui::SameLine(); + } + // Prefer live item info (arrives via SMSG_ITEM_QUERY_SINGLE_RESPONSE after the + // roll popup opens); fall back to the name cached at SMSG_LOOT_START_ROLL time. + const char* displayName = (rollInfo && rollInfo->valid && !rollInfo->name.empty()) + ? rollInfo->name.c_str() + : roll.itemName.c_str(); + if (rollInfo && rollInfo->valid) + col = ui::getQualityColor(static_cast(rollInfo->quality)); + ImGui::TextColored(col, "[%s]", displayName); + if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { + inventoryScreen.renderItemTooltip(*rollInfo); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && rollInfo && rollInfo->valid && !rollInfo->name.empty()) { + std::string link = buildItemChatLink(rollInfo->entry, rollInfo->quality, rollInfo->name); + chatPanel.insertChatLink(link); + } + ImGui::Spacing(); + + // voteMask bits: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant + const uint8_t vm = roll.voteMask; + bool first = true; + if (vm & 0x02) { + if (ImGui::Button("Need", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + first = false; + } + if (vm & 0x04) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Greed", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + first = false; + } + if (vm & 0x08) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Disenchant", ImVec2(95, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + first = false; + } + if (vm & 0x01) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Pass", ImVec2(70, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); + } + + // Live roll results from group members + if (!roll.playerRolls.empty()) { + ImGui::Separator(); + ImGui::TextDisabled("Rolls so far:"); + // Roll-type label + color + static constexpr const char* kRollLabels[] = {"Need", "Greed", "Disenchant", "Pass"}; + static constexpr ImVec4 kRollColors[] = { + ImVec4(0.2f, 0.9f, 0.2f, 1.0f), // Need — green + ImVec4(0.3f, 0.6f, 1.0f, 1.0f), // Greed — blue + ImVec4(0.7f, 0.3f, 0.9f, 1.0f), // Disenchant — purple + kColorDarkGray, // Pass — gray + }; + auto rollTypeIndex = [](uint8_t t) -> int { + if (t == 0) return 0; + if (t == 1) return 1; + if (t == 2) return 2; + return 3; // pass (96 or unknown) + }; + + if (ImGui::BeginTable("##lootrolls", 3, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Roll", ImGuiTableColumnFlags_WidthFixed, 32.0f); + for (const auto& r : roll.playerRolls) { + int ri = rollTypeIndex(r.rollType); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.playerName.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(kRollColors[ri], "%s", kRollLabels[ri]); + ImGui::TableSetColumnIndex(2); + if (r.rollType != 96) { + ImGui::TextColored(kRollColors[ri], "%d", static_cast(r.rollNum)); + } else { + ImGui::TextDisabled("—"); + } + } + ImGui::EndTable(); + } + } + } + ImGui::End(); +} + +void DialogManager::renderGuildInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingGuildInvite()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 250), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Guild Invite", nullptr, kDialogFlags)) { + ImGui::TextWrapped("%s has invited you to join %s.", + gameHandler.getPendingGuildInviterName().c_str(), + gameHandler.getPendingGuildInviteGuildName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(155, 30))) { + gameHandler.acceptGuildInvite(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(155, 30))) { + gameHandler.declineGuildInvite(); + } + } + ImGui::End(); +} + +void DialogManager::renderReadyCheckPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingReadyCheck()) 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 - 175, screenH / 2 - 60), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Ready Check", nullptr, kDialogFlags)) { + const std::string& initiator = gameHandler.getReadyCheckInitiator(); + if (initiator.empty()) { + ImGui::Text("A ready check has been initiated!"); + } else { + ImGui::TextWrapped("%s has initiated a ready check!", initiator.c_str()); + } + ImGui::Spacing(); + + if (ImGui::Button("Ready", ImVec2(155, 30))) { + gameHandler.respondToReadyCheck(true); + gameHandler.dismissReadyCheck(); + } + ImGui::SameLine(); + if (ImGui::Button("Not Ready", ImVec2(155, 30))) { + gameHandler.respondToReadyCheck(false); + gameHandler.dismissReadyCheck(); + } + + // Live player responses + const auto& results = gameHandler.getReadyCheckResults(); + if (!results.empty()) { + ImGui::Separator(); + if (ImGui::BeginTable("##rcresults", 2, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 72.0f); + for (const auto& r : results) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.name.c_str()); + ImGui::TableSetColumnIndex(1); + if (r.ready) { + ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "Ready"); + } else { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "Not Ready"); + } + } + ImGui::EndTable(); + } + } + } + ImGui::End(); +} + +void DialogManager::renderBgInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingBgInvite()) return; + + const auto& queues = gameHandler.getBgQueues(); + // Find the first WAIT_JOIN slot + const game::GameHandler::BgQueueSlot* slot = nullptr; + for (const auto& s : queues) { + if (s.statusId == 2) { slot = &s; break; } + } + if (!slot) return; + + // Compute time remaining + auto now = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(now - slot->inviteReceivedTime).count(); + double remaining = static_cast(slot->inviteTimeout) - elapsed; + + // If invite has expired, clear it silently (server will handle the queue) + if (remaining <= 0.0) { + gameHandler.declineBattlefield(slot->queueSlot); + 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 - 70), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags popupFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) { + // BG name from stored queue data + std::string bgName = slot->bgName.empty() ? "Battleground" : slot->bgName; + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str()); + ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast(remaining)); + ImGui::Spacing(); + + // Countdown progress bar + float frac = static_cast(remaining / static_cast(slot->inviteTimeout)); + frac = std::clamp(frac, 0.0f, 1.0f); + ImVec4 barColor = frac > 0.5f ? colors::kHealthGreen + : frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f) + : colors::kDarkRed; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + char countdownLabel[32]; + snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast(remaining)); + ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel); + ImGui::PopStyleColor(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnGreen); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kFriendlyGreen); + if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) { + gameHandler.acceptBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnRed); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kDangerRed); + if (ImGui::Button("Leave Queue", ImVec2(175, 30))) { + gameHandler.declineBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void DialogManager::renderBfMgrInvitePopup(game::GameHandler& gameHandler) { + // Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager) + if (!gameHandler.hasBfMgrInvite()) 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 - 190.0f, screenH / 2.0f - 55.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.10f, 0.20f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.45f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battlefield", nullptr, flags)) { + // Resolve zone name for Wintergrasp (zoneId 4197) + uint32_t zoneId = gameHandler.getBfMgrZoneId(); + const char* zoneName = nullptr; + if (zoneId == 4197) zoneName = "Wintergrasp"; + else if (zoneId == 5095) zoneName = "Tol Barad"; + + if (zoneName) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", zoneName); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Outdoor Battlefield"); + } + ImGui::Spacing(); + ImGui::TextWrapped("You are invited to join the outdoor battlefield. Do you want to enter?"); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnGreen); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kFriendlyGreen); + if (ImGui::Button("Enter Battlefield", ImVec2(178, 28))) { + gameHandler.acceptBfMgrInvite(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnRed); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kDangerRed); + if (ImGui::Button("Decline", ImVec2(175, 28))) { + gameHandler.declineBfMgrInvite(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void DialogManager::renderLfgProposalPopup(game::GameHandler& gameHandler) { + using LfgState = game::GameHandler::LfgState; + if (gameHandler.getLfgState() != LfgState::Proposal) 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 - 175.0f, screenH / 2.0f - 65.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.14f, 0.08f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.8f, 0.3f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.3f, 0.1f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Dungeon Finder", nullptr, flags)) { + ImGui::TextColored(kColorGreen, "A group has been found!"); + ImGui::Spacing(); + ImGui::TextWrapped("Please accept or decline to join the dungeon."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnGreen); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kFriendlyGreen); + if (ImGui::Button("Accept", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnRed); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kDangerRed); + if (ImGui::Button("Decline", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void DialogManager::renderLfgRoleCheckPopup(game::GameHandler& gameHandler) { + using LfgState = game::GameHandler::LfgState; + if (gameHandler.getLfgState() != LfgState::RoleCheck) 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 - 160.0f, screenH / 2.0f - 80.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(320.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.1f, 0.3f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Role Check##LfgRoleCheck", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Confirm your role:"); + ImGui::Spacing(); + + // Role checkboxes + 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(120.0f); + if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0); + ImGui::SameLine(220.0f); + if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + bool hasRole = (lfgRoles_ & 0x0E) != 0; + if (!hasRole) ImGui::BeginDisabled(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.4f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.6f, 0.2f, 1.0f)); + if (ImGui::Button("Accept", ImVec2(140.0f, 28.0f))) { + gameHandler.lfgSetRoles(lfgRoles_); + } + ImGui::PopStyleColor(2); + + if (!hasRole) ImGui::EndDisabled(); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.4f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.6f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Leave Queue", ImVec2(140.0f, 28.0f))) { + gameHandler.lfgLeave(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void DialogManager::renderResurrectDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showResurrectDialog()) 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 dlgW = 300.0f; + float dlgH = 110.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.8f, 1.0f)); + + if (ImGui::Begin("##ResurrectDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + const std::string& casterName = gameHandler.getResurrectCasterName(); + std::string text = casterName.empty() + ? "Return to life?" + : casterName + " wishes to resurrect you."; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::Spacing(); + + float btnW = 100.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkGreen); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkGreenHover); + if (ImGui::Button("Accept", ImVec2(btnW, 30))) { + gameHandler.acceptResurrect(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkRed); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkRedHover); + if (ImGui::Button("Decline", ImVec2(btnW, 30))) { + gameHandler.declineResurrect(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +// ============================================================ +// Talent Wipe Confirm Dialog +// ============================================================ + +void DialogManager::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showTalentWipeConfirmDialog()) 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 dlgW = 340.0f; + float dlgH = 130.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); + + if (ImGui::Begin("##TalentWipeDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getTalentWipeCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + char costStr[64]; + if (gold > 0) + std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); + else + std::snprintf(costStr, sizeof(costStr), "%uc", copper); + + std::string text = "Reset your talents for "; + text += costStr; + text += "?"; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::SetCursorPosX(8.0f); + ImGui::TextDisabled("All talent points will be refunded."); + ImGui::Spacing(); + + float btnW = 110.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkGreen); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkGreenHover); + if (ImGui::Button("Confirm", ImVec2(btnW, 30))) { + gameHandler.confirmTalentWipe(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkRed); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkRedHover); + if (ImGui::Button("Cancel", ImVec2(btnW, 30))) { + gameHandler.cancelTalentWipe(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void DialogManager::renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showPetUnlearnDialog()) 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 dlgW = 340.0f; + float dlgH = 130.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); + + if (ImGui::Begin("##PetUnlearnDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getPetUnlearnCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + char costStr[64]; + if (gold > 0) + std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); + else + std::snprintf(costStr, sizeof(costStr), "%uc", copper); + + std::string text = std::string("Reset your pet's talents for ") + costStr + "?"; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::SetCursorPosX(8.0f); + ImGui::TextDisabled("All pet talent points will be refunded."); + ImGui::Spacing(); + + float btnW = 110.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkGreen); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkGreenHover); + if (ImGui::Button("Confirm##petunlearn", ImVec2(btnW, 30))) { + gameHandler.confirmPetUnlearn(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkRed); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkRedHover); + if (ImGui::Button("Cancel##petunlearn", ImVec2(btnW, 30))) { + gameHandler.cancelPetUnlearn(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index abead7ec..25db1ec7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -287,118 +287,118 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; - ImGui::GetStyle().Alpha = uiOpacity_; + ImGui::GetStyle().Alpha = settingsPanel_.uiOpacity_; // Sync minimap opacity with UI opacity { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { if (auto* minimap = renderer->getMinimap()) { - minimap->setOpacity(uiOpacity_); + minimap->setOpacity(settingsPanel_.uiOpacity_); } } } // Apply initial settings when renderer becomes available - if (!minimapSettingsApplied_) { + if (!settingsPanel_.minimapSettingsApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { if (auto* minimap = renderer->getMinimap()) { - minimapRotate_ = false; - pendingMinimapRotate = false; + settingsPanel_.minimapRotate_ = false; + settingsPanel_.pendingMinimapRotate = false; minimap->setRotateWithCamera(false); - minimap->setSquareShape(minimapSquare_); - minimapSettingsApplied_ = true; + minimap->setSquareShape(settingsPanel_.minimapSquare_); + settingsPanel_.minimapSettingsApplied_ = true; } if (auto* zm = renderer->getZoneManager()) { - zm->setUseOriginalSoundtrack(pendingUseOriginalSoundtrack); + zm->setUseOriginalSoundtrack(settingsPanel_.pendingUseOriginalSoundtrack); } if (auto* tm = renderer->getTerrainManager()) { - tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); + tm->setGroundClutterDensityScale(static_cast(settingsPanel_.pendingGroundClutterDensity) / 100.0f); } // Restore mute state: save actual master volume first, then apply mute - if (soundMuted_) { + if (settingsPanel_.soundMuted_) { float actual = audio::AudioEngine::instance().getMasterVolume(); - preMuteVolume_ = (actual > 0.0f) ? actual - : static_cast(pendingMasterVolume) / 100.0f; + settingsPanel_.preMuteVolume_ = (actual > 0.0f) ? actual + : static_cast(settingsPanel_.pendingMasterVolume) / 100.0f; audio::AudioEngine::instance().setMasterVolume(0.0f); } } } // Apply saved volume settings once when audio managers first become available - if (!volumeSettingsApplied_) { + if (!settingsPanel_.volumeSettingsApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer && renderer->getUiSoundManager()) { - applyAudioVolumes(renderer); - volumeSettingsApplied_ = true; + settingsPanel_.applyAudioVolumes(renderer); + settingsPanel_.volumeSettingsApplied_ = true; } } // Apply saved MSAA setting once when renderer is available - if (!msaaSettingsApplied_ && pendingAntiAliasing > 0) { + if (!settingsPanel_.msaaSettingsApplied_ && settingsPanel_.pendingAntiAliasing > 0) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { static const VkSampleCountFlagBits aaSamples[] = { VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT, VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT }; - renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]); - msaaSettingsApplied_ = true; + renderer->setMsaaSamples(aaSamples[settingsPanel_.pendingAntiAliasing]); + settingsPanel_.msaaSettingsApplied_ = true; } } else { - msaaSettingsApplied_ = true; + settingsPanel_.msaaSettingsApplied_ = true; } // Apply saved FXAA setting once when renderer is available - if (!fxaaSettingsApplied_) { + if (!settingsPanel_.fxaaSettingsApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { - renderer->setFXAAEnabled(pendingFXAA); - fxaaSettingsApplied_ = true; + renderer->setFXAAEnabled(settingsPanel_.pendingFXAA); + settingsPanel_.fxaaSettingsApplied_ = true; } } // Apply saved water refraction setting once when renderer is available - if (!waterRefractionApplied_) { + if (!settingsPanel_.waterRefractionApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { - renderer->setWaterRefractionEnabled(pendingWaterRefraction); - waterRefractionApplied_ = true; + renderer->setWaterRefractionEnabled(settingsPanel_.pendingWaterRefraction); + settingsPanel_.waterRefractionApplied_ = true; } } // Apply saved normal mapping / POM settings once when WMO renderer is available - if (!normalMapSettingsApplied_) { + if (!settingsPanel_.normalMapSettingsApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMappingEnabled(pendingNormalMapping); - wr->setNormalMapStrength(pendingNormalMapStrength); - wr->setPOMEnabled(pendingPOM); - wr->setPOMQuality(pendingPOMQuality); + wr->setNormalMappingEnabled(settingsPanel_.pendingNormalMapping); + wr->setNormalMapStrength(settingsPanel_.pendingNormalMapStrength); + wr->setPOMEnabled(settingsPanel_.pendingPOM); + wr->setPOMQuality(settingsPanel_.pendingPOMQuality); if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMappingEnabled(pendingNormalMapping); - cr->setNormalMapStrength(pendingNormalMapStrength); - cr->setPOMEnabled(pendingPOM); - cr->setPOMQuality(pendingPOMQuality); + cr->setNormalMappingEnabled(settingsPanel_.pendingNormalMapping); + cr->setNormalMapStrength(settingsPanel_.pendingNormalMapStrength); + cr->setPOMEnabled(settingsPanel_.pendingPOM); + cr->setPOMQuality(settingsPanel_.pendingPOMQuality); } - normalMapSettingsApplied_ = true; + settingsPanel_.normalMapSettingsApplied_ = true; } } } // Apply saved upscaling setting once when renderer is available - if (!fsrSettingsApplied_) { + if (!settingsPanel_.fsrSettingsApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { static constexpr float fsrScales[] = { 0.77f, 0.67f, 0.59f, 1.00f }; - pendingFSRQuality = std::clamp(pendingFSRQuality, 0, 3); - renderer->setFSRQuality(fsrScales[pendingFSRQuality]); - renderer->setFSRSharpness(pendingFSRSharpness); - renderer->setFSR2DebugTuning(pendingFSR2JitterSign, pendingFSR2MotionVecScaleX, pendingFSR2MotionVecScaleY); - renderer->setAmdFsr3FramegenEnabled(pendingAMDFramegen); - int effectiveMode = pendingUpscalingMode; + settingsPanel_.pendingFSRQuality = std::clamp(settingsPanel_.pendingFSRQuality, 0, 3); + renderer->setFSRQuality(fsrScales[settingsPanel_.pendingFSRQuality]); + renderer->setFSRSharpness(settingsPanel_.pendingFSRSharpness); + renderer->setFSR2DebugTuning(settingsPanel_.pendingFSR2JitterSign, settingsPanel_.pendingFSR2MotionVecScaleX, settingsPanel_.pendingFSR2MotionVecScaleY); + renderer->setAmdFsr3FramegenEnabled(settingsPanel_.pendingAMDFramegen); + int effectiveMode = settingsPanel_.pendingUpscalingMode; // Defer FSR2/FSR3 activation until fully in-world to avoid // init issues during login/character selection screens. @@ -408,15 +408,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { } else { renderer->setFSREnabled(effectiveMode == 1); renderer->setFSR2Enabled(effectiveMode == 2); - fsrSettingsApplied_ = true; + settingsPanel_.fsrSettingsApplied_ = true; } } } // Apply auto-loot / auto-sell settings to GameHandler every frame (cheap bool sync) - gameHandler.setAutoLoot(pendingAutoLoot); - gameHandler.setAutoSellGrey(pendingAutoSellGrey); - gameHandler.setAutoRepair(pendingAutoRepair); + gameHandler.setAutoLoot(settingsPanel_.pendingAutoLoot); + gameHandler.setAutoSellGrey(settingsPanel_.pendingAutoSellGrey); + gameHandler.setAutoRepair(settingsPanel_.pendingAutoRepair); // Sync chat auto-join settings to GameHandler gameHandler.chatAutoJoin.general = chatPanel_.chatAutoJoinGeneral; @@ -504,21 +504,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPartyFrames(gameHandler); } renderBossFrames(gameHandler); - renderGroupInvitePopup(gameHandler); - renderDuelRequestPopup(gameHandler); - renderDuelCountdown(gameHandler); - renderLootRollPopup(gameHandler); - renderTradeRequestPopup(gameHandler); - renderTradeWindow(gameHandler); - renderSummonRequestPopup(gameHandler); - renderSharedQuestPopup(gameHandler); - renderItemTextWindow(gameHandler); - renderGuildInvitePopup(gameHandler); - renderReadyCheckPopup(gameHandler); - renderBgInvitePopup(gameHandler); - renderBfMgrInvitePopup(gameHandler); - renderLfgProposalPopup(gameHandler); - renderLfgRoleCheckPopup(gameHandler); + dialogManager_.renderDialogs(gameHandler, inventoryScreen, chatPanel_); renderGuildRoster(gameHandler); renderSocialFrame(gameHandler); renderBuffBar(gameHandler); @@ -556,12 +542,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderLogoutCountdown(gameHandler); renderDeathScreen(gameHandler); renderReclaimCorpseButton(gameHandler); - renderResurrectDialog(gameHandler); - renderTalentWipeConfirmDialog(gameHandler); - renderPetUnlearnConfirmDialog(gameHandler); + dialogManager_.renderLateDialogs(gameHandler); chatPanel_.renderBubbles(gameHandler); renderEscapeMenu(); - renderSettingsWindow(); + settingsPanel_.renderSettingsWindow(inventoryScreen, chatPanel_, [this]() { saveSettings(); }); toastManager_.renderLateToasts(gameHandler); renderWeatherOverlay(gameHandler); @@ -764,7 +748,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { } // Detect HP drop (ignore transitions from 0 — entity just spawned or uninitialized) - if (damageFlashEnabled_ && lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0) + if (settingsPanel_.damageFlashEnabled_ && lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0) damageFlashAlpha_ = 1.0f; lastPlayerHp_ = currentHp; @@ -812,7 +796,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { } // Only show when alive and below 20% HP; intensity increases as HP drops - if (lowHealthVignetteEnabled_ && !isDead && hpPct < 0.20f && hpPct > 0.0f) { + if (settingsPanel_.lowHealthVignetteEnabled_ && !isDead && hpPct < 0.20f && hpPct > 0.0f) { // Base intensity from HP deficit (0 at 20%, 1 at 0%); pulse at ~1.5 Hz float danger = (0.20f - hpPct) / 0.20f; float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 9.4f); @@ -1047,11 +1031,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SETTINGS, true)) { - if (showSettingsWindow) { - showSettingsWindow = false; + if (settingsPanel_.showSettingsWindow) { + settingsPanel_.showSettingsWindow = false; } else if (showEscapeMenu) { showEscapeMenu = false; - showEscapeSettingsNotice = false; + settingsPanel_.showEscapeSettingsNotice = false; } else if (gameHandler.isCasting()) { gameHandler.cancelCast(); } else if (gameHandler.isLootWindowOpen()) { @@ -1117,7 +1101,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { if (ImGui::GetIO().KeyShift) - showFriendlyNameplates_ = !showFriendlyNameplates_; + settingsPanel_.showFriendlyNameplates_ = !settingsPanel_.showFriendlyNameplates_; else showNameplates_ = !showNameplates_; } @@ -4439,7 +4423,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* assetMgr = core::Application::getInstance().getAssetManager(); - float slotSize = 48.0f * pendingActionBarScale; + float slotSize = 48.0f * settingsPanel_.pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -5142,13 +5126,13 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { }; // Bar 2 (slots 12-23) — only show if at least one slot is populated - if (pendingShowActionBar2) { + 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 + pendingActionBar2OffsetX; - float bar2Y = barY - barH - 2.0f + pendingActionBar2OffsetY; + 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); @@ -5204,7 +5188,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(4); // Right side vertical bar (bar 3, slots 24-35) - if (pendingShowRightBar) { + 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; } @@ -5212,7 +5196,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { 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 + pendingRightBarOffsetY; + float sideBarY = (screenH - sideBarH) / 2.0f + settingsPanel_.pendingRightBarOffsetY; ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); @@ -5233,7 +5217,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } // Left side vertical bar (bar 4, slots 36-47) - if (pendingShowLeftBar) { + 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; } @@ -5241,7 +5225,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { 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 + pendingLeftBarOffsetY; + float sideBarY = (screenH - sideBarH) / 2.0f + settingsPanel_.pendingLeftBarOffsetY; ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); @@ -5389,7 +5373,7 @@ void GameScreen::renderStanceBar(game::GameHandler& gameHandler) { float barH = slotSize + padding * 2.0f; // Position the stance bar immediately to the left of the action bar - float actionSlot = 48.0f * pendingActionBarScale; + 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; @@ -5762,7 +5746,7 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { (void)window; // Not used for positioning; kept for AssetManager if needed // Position just above both action bars (bar1 at screenH-barH, bar2 above that) - float slotSize = 48.0f * pendingActionBarScale; + float slotSize = 48.0f * settingsPanel_.pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -5776,8 +5760,8 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { // bar2 top edge (when visible): bar1 top - barH - 2 + bar2 vertical offset float bar1TopY = screenH - barH; float xpBarY; - if (pendingShowActionBar2) { - float bar2TopY = bar1TopY - barH - 2.0f + pendingActionBar2OffsetY; + if (settingsPanel_.pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH - 2.0f + settingsPanel_.pendingActionBar2OffsetY; xpBarY = bar2TopY - xpBarH - 2.0f; } else { xpBarY = bar1TopY - xpBarH - 2.0f; @@ -5952,7 +5936,7 @@ void GameScreen::renderRepBar(game::GameHandler& gameHandler) { float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - float slotSize = 48.0f * pendingActionBarScale; + float slotSize = 48.0f * settingsPanel_.pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -5964,8 +5948,8 @@ void GameScreen::renderRepBar(game::GameHandler& gameHandler) { float bar1TopY = screenH - barH_ab; float xpBarY; - if (pendingShowActionBar2) { - float bar2TopY = bar1TopY - barH_ab - 2.0f + pendingActionBar2OffsetY; + if (settingsPanel_.pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH_ab - 2.0f + settingsPanel_.pendingActionBar2OffsetY; xpBarY = bar2TopY - xpBarH - 2.0f; } else { xpBarY = bar1TopY - xpBarH - 2.0f; @@ -6188,7 +6172,7 @@ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderCooldownTracker(game::GameHandler& gameHandler) { - if (!showCooldownTracker_) return; + if (!settingsPanel_.showCooldownTracker_) return; const auto& cooldowns = gameHandler.getSpellCooldowns(); if (cooldowns.empty()) return; @@ -6936,7 +6920,7 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { - if (!showDPSMeter_) return; + if (!settingsPanel_.showDPSMeter_) return; if (gameHandler.getState() != game::WorldState::IN_WORLD) return; const float dt = ImGui::GetIO().DeltaTime; @@ -7148,7 +7132,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { bool isTarget = (guid == targetGuid); // Player nameplates use Shift+V toggle; NPC/enemy nameplates use V toggle - if (isPlayer && !showFriendlyNameplates_) continue; + if (isPlayer && !settingsPanel_.showFriendlyNameplates_) continue; if (!isPlayer && !showNameplates_) continue; // For corpses (dead units), only show a minimal grey nameplate if selected @@ -7253,8 +7237,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { : IM_COL32(20, 20, 20, A(180)); // Bar geometry - const float barW = 80.0f * nameplateScale_; - const float barH = 8.0f * nameplateScale_; + const float barW = 80.0f * settingsPanel_.nameplateScale_; + const float barH = 8.0f * settingsPanel_.nameplateScale_; const float barX = sx - barW * 0.5f; // Guard against division by zero when maxHealth hasn't been populated yet @@ -7308,7 +7292,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { const auto* cs = gameHandler.getUnitCastState(guid); if (cs && cs->casting && cs->timeTotal > 0.0f) { float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f); - const float cbH = 6.0f * nameplateScale_; + const float cbH = 6.0f * settingsPanel_.nameplateScale_; // Spell icon + name above the cast bar const std::string& spellName = gameHandler.getSpellName(cs->spellId); @@ -7377,7 +7361,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { if (isTarget && unit->isHostile() && !isCorpse) { const auto& auras = gameHandler.getTargetAuras(); const uint64_t pguid = gameHandler.getPlayerGuid(); - const float dotSize = 6.0f * nameplateScale_; + const float dotSize = 6.0f * settingsPanel_.nameplateScale_; const float dotGap = 2.0f; float dotX = barX; for (const auto& aura : auras) { @@ -8811,852 +8795,6 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } -// ============================================================ -// Group Invite Popup (Phase 4) -// ============================================================ - -void GameScreen::renderGroupInvitePopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingGroupInvite()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); - - if (ImGui::Begin("Group Invite", nullptr, kDialogFlags)) { - ImGui::Text("%s has invited you to a group.", gameHandler.getPendingInviterName().c_str()); - ImGui::Spacing(); - - if (ImGui::Button("Accept", ImVec2(130, 30))) { - gameHandler.acceptGroupInvite(); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(130, 30))) { - gameHandler.declineGroupInvite(); - } - } - ImGui::End(); -} - -void GameScreen::renderDuelRequestPopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingDuelRequest()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 250), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); - - if (ImGui::Begin("Duel Request", nullptr, kDialogFlags)) { - ImGui::Text("%s challenges you to a duel!", gameHandler.getDuelChallengerName().c_str()); - ImGui::Spacing(); - - if (ImGui::Button("Accept", ImVec2(130, 30))) { - gameHandler.acceptDuel(); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(130, 30))) { - gameHandler.forfeitDuel(); - } - } - ImGui::End(); -} - -void GameScreen::renderDuelCountdown(game::GameHandler& gameHandler) { - float remaining = gameHandler.getDuelCountdownRemaining(); - if (remaining <= 0.0f) return; - - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - auto* dl = ImGui::GetForegroundDrawList(); - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - - // Show integer countdown or "Fight!" when under 0.5s - char buf[32]; - if (remaining > 0.5f) { - snprintf(buf, sizeof(buf), "%d", static_cast(std::ceil(remaining))); - } else { - snprintf(buf, sizeof(buf), "Fight!"); - } - - // Large font by scaling — use 4x font size for dramatic effect - float scale = 4.0f; - float scaledSize = fontSize * scale; - ImVec2 textSz = font->CalcTextSizeA(scaledSize, FLT_MAX, 0.0f, buf); - float tx = (screenW - textSz.x) * 0.5f; - float ty = screenH * 0.35f - textSz.y * 0.5f; - - // Pulsing alpha: fades in and out per second - float pulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 6.28f); - uint8_t alpha = static_cast(255 * pulse); - - // Color: golden countdown, red "Fight!" - ImU32 color = (remaining > 0.5f) - ? IM_COL32(255, 200, 50, alpha) - : IM_COL32(255, 60, 60, alpha); - - // Drop shadow - dl->AddText(font, scaledSize, ImVec2(tx + 2.0f, ty + 2.0f), IM_COL32(0, 0, 0, alpha / 2), buf); - dl->AddText(font, scaledSize, ImVec2(tx, ty), color, buf); -} - -void GameScreen::renderItemTextWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isItemTextOpen()) return; - - 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 - 200, screenH * 0.15f), - ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(400, 300), ImGuiCond_FirstUseEver); - - bool open = true; - if (!ImGui::Begin("Book", &open, ImGuiWindowFlags_NoCollapse)) { - ImGui::End(); - if (!open) gameHandler.closeItemText(); - return; - } - if (!open) { - ImGui::End(); - gameHandler.closeItemText(); - return; - } - - // Parchment-toned background text - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.2f, 0.1f, 0.0f, 1.0f)); - ImGui::TextWrapped("%s", gameHandler.getItemText().c_str()); - ImGui::PopStyleColor(); - - ImGui::Spacing(); - if (ImGui::Button("Close", ImVec2(80, 0))) { - gameHandler.closeItemText(); - } - - ImGui::End(); -} - -void GameScreen::renderSharedQuestPopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingSharedQuest()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 490), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); - - if (ImGui::Begin("Shared Quest", nullptr, kDialogFlags)) { - ImGui::Text("%s has shared a quest with you:", gameHandler.getSharedQuestSharerName().c_str()); - ImGui::TextColored(colors::kBrightGold, "\"%s\"", gameHandler.getSharedQuestTitle().c_str()); - ImGui::Spacing(); - - if (ImGui::Button("Accept", ImVec2(130, 30))) { - gameHandler.acceptSharedQuest(); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(130, 30))) { - gameHandler.declineSharedQuest(); - } - } - ImGui::End(); -} - -void GameScreen::renderSummonRequestPopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingSummonRequest()) return; - - // Tick the timeout down - float dt = ImGui::GetIO().DeltaTime; - gameHandler.tickSummonTimeout(dt); - if (!gameHandler.hasPendingSummonRequest()) return; // expired - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 430), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); - - if (ImGui::Begin("Summon Request", nullptr, kDialogFlags)) { - ImGui::Text("%s is summoning you.", gameHandler.getSummonerName().c_str()); - float t = gameHandler.getSummonTimeoutSec(); - if (t > 0.0f) { - ImGui::Text("Time remaining: %.0fs", t); - } - ImGui::Spacing(); - - if (ImGui::Button("Accept", ImVec2(130, 30))) { - gameHandler.acceptSummon(); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(130, 30))) { - gameHandler.declineSummon(); - } - } - ImGui::End(); -} - -void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingTradeRequest()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 370), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); - - if (ImGui::Begin("Trade Request", nullptr, kDialogFlags)) { - ImGui::Text("%s wants to trade with you.", gameHandler.getTradePeerName().c_str()); - ImGui::Spacing(); - - if (ImGui::Button("Accept", ImVec2(130, 30))) { - gameHandler.acceptTradeRequest(); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(130, 30))) { - gameHandler.declineTradeRequest(); - } - } - ImGui::End(); -} - -void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { - if (!gameHandler.isTradeOpen()) return; - - const auto& mySlots = gameHandler.getMyTradeSlots(); - const auto& peerSlots = gameHandler.getPeerTradeSlots(); - const uint64_t myGold = gameHandler.getMyTradeGold(); - const uint64_t peerGold = gameHandler.getPeerTradeGold(); - const auto& peerName = gameHandler.getTradePeerName(); - - 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(("Trade with " + peerName).c_str(), &open, - kDialogFlags)) { - - auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) { - uint64_t g = copper / 10000; - uint64_t s = (copper % 10000) / 100; - uint64_t c = copper % 100; - if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc", - (unsigned long long)g, (unsigned long long)s, (unsigned long long)c); - else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc", - (unsigned long long)s, (unsigned long long)c); - else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c); - }; - - auto renderSlotColumn = [&](const char* label, - const std::array& slots, - uint64_t gold, bool isMine) { - ImGui::Text("%s", label); - ImGui::Separator(); - - for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) { - const auto& slot = slots[i]; - ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100)); - - if (slot.occupied && slot.itemId != 0) { - const auto* info = gameHandler.getItemInfo(slot.itemId); - std::string name = (info && info->valid && !info->name.empty()) - ? info->name - : ("Item " + std::to_string(slot.itemId)); - if (slot.stackCount > 1) - name += " x" + std::to_string(slot.stackCount); - ImVec4 qc = (info && info->valid) - ? InventoryScreen::getQualityColor(static_cast(info->quality)) - : ImVec4(1.0f, 0.9f, 0.5f, 1.0f); - if (info && info->valid && info->displayInfoId != 0) { - VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); - if (iconTex) { - ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(16, 16)); - ImGui::SameLine(); - } - } - ImGui::TextColored(qc, "%d. %s", i + 1, name.c_str()); - if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { - gameHandler.clearTradeItem(static_cast(i)); - } - if (ImGui::IsItemHovered()) { - if (info && info->valid) inventoryScreen.renderItemTooltip(*info); - else if (isMine) ImGui::SetTooltip("Double-click to remove"); - } - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { - std::string link = buildItemChatLink(info->entry, info->quality, info->name); - chatPanel_.insertChatLink(link); - } - } else { - ImGui::TextDisabled(" %d. (empty)", i + 1); - - // Allow dragging inventory items into trade slots via right-click context menu - char addItemId[16]; snprintf(addItemId, sizeof(addItemId), "##additem%d", i); - if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - ImGui::OpenPopup(addItemId); - } - } - - if (isMine) { - char addItemId[16]; snprintf(addItemId, sizeof(addItemId), "##additem%d", i); - // Drag-from-inventory: show small popup listing bag items - if (ImGui::BeginPopup(addItemId)) { - ImGui::TextDisabled("Add from inventory:"); - const auto& inv = gameHandler.getInventory(); - // Backpack slots 0-15 (bag=255) - for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) { - const auto& slot = inv.getBackpackSlot(si); - if (slot.empty()) continue; - const auto* ii = gameHandler.getItemInfo(slot.item.itemId); - std::string iname = (ii && ii->valid && !ii->name.empty()) - ? ii->name - : (!slot.item.name.empty() ? slot.item.name - : ("Item " + std::to_string(slot.item.itemId))); - if (ImGui::Selectable(iname.c_str())) { - // bag=255 = main backpack - gameHandler.setTradeItem(static_cast(i), 255u, - static_cast(si)); - ImGui::CloseCurrentPopup(); - } - } - ImGui::EndPopup(); - } - } - ImGui::PopID(); - } - - // Gold row - char gbuf[48]; - formatGold(gold, gbuf, sizeof(gbuf)); - ImGui::Spacing(); - if (isMine) { - ImGui::Text("Gold offered: %s", gbuf); - static char goldInput[32] = "0"; - ImGui::SetNextItemWidth(120.0f); - if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput), - ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) { - uint64_t copper = std::strtoull(goldInput, nullptr, 10); - gameHandler.setTradeGold(copper); - } - ImGui::SameLine(); - ImGui::TextDisabled("(copper, Enter to set)"); - } else { - ImGui::Text("Gold offered: %s", gbuf); - } - }; - - // Two-column layout: my offer | peer offer - float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f; - ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true); - renderSlotColumn("Your offer", mySlots, myGold, true); - ImGui::EndChild(); - - ImGui::SameLine(); - - ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true); - renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false); - ImGui::EndChild(); - - // Buttons - ImGui::Spacing(); - ImGui::Separator(); - float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) { - gameHandler.acceptTrade(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(bw, 0))) { - gameHandler.cancelTrade(); - } - } - ImGui::End(); - - if (!open) { - gameHandler.cancelTrade(); - } -} - -void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingLootRoll()) return; - - const auto& roll = gameHandler.getPendingLootRoll(); - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 310), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); - - if (ImGui::Begin("Loot Roll", nullptr, kDialogFlags)) { - // Quality color for item name - uint8_t q = roll.itemQuality; - ImVec4 col = ui::getQualityColor(static_cast(q)); - - // Countdown bar - { - auto now = std::chrono::steady_clock::now(); - float elapsedMs = static_cast( - std::chrono::duration_cast(now - roll.rollStartedAt).count()); - float totalMs = static_cast(roll.rollCountdownMs > 0 ? roll.rollCountdownMs : 60000); - float fraction = 1.0f - std::min(elapsedMs / totalMs, 1.0f); - float remainSec = (totalMs - elapsedMs) / 1000.0f; - if (remainSec < 0.0f) remainSec = 0.0f; - - // Color: green → yellow → red - ImVec4 barColor; - if (fraction > 0.5f) - barColor = ImVec4(0.2f + (1.0f - fraction) * 1.4f, 0.85f, 0.2f, 1.0f); - else if (fraction > 0.2f) - barColor = ImVec4(1.0f, fraction * 1.7f, 0.1f, 1.0f); - else { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 6.0f); - barColor = ImVec4(pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); - } - - char timeBuf[16]; - std::snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remainSec); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); - ImGui::ProgressBar(fraction, ImVec2(-1, 12), timeBuf); - ImGui::PopStyleColor(); - } - - ImGui::Text("An item is up for rolls:"); - - // Show item icon if available - const auto* rollInfo = gameHandler.getItemInfo(roll.itemId); - uint32_t rollDisplayId = rollInfo ? rollInfo->displayInfoId : 0; - VkDescriptorSet rollIcon = rollDisplayId ? inventoryScreen.getItemIcon(rollDisplayId) : VK_NULL_HANDLE; - if (rollIcon) { - ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24)); - ImGui::SameLine(); - } - // Prefer live item info (arrives via SMSG_ITEM_QUERY_SINGLE_RESPONSE after the - // roll popup opens); fall back to the name cached at SMSG_LOOT_START_ROLL time. - const char* displayName = (rollInfo && rollInfo->valid && !rollInfo->name.empty()) - ? rollInfo->name.c_str() - : roll.itemName.c_str(); - if (rollInfo && rollInfo->valid) - col = ui::getQualityColor(static_cast(rollInfo->quality)); - ImGui::TextColored(col, "[%s]", displayName); - if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { - inventoryScreen.renderItemTooltip(*rollInfo); - } - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - ImGui::GetIO().KeyShift && rollInfo && rollInfo->valid && !rollInfo->name.empty()) { - std::string link = buildItemChatLink(rollInfo->entry, rollInfo->quality, rollInfo->name); - chatPanel_.insertChatLink(link); - } - ImGui::Spacing(); - - // voteMask bits: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant - const uint8_t vm = roll.voteMask; - bool first = true; - if (vm & 0x02) { - if (ImGui::Button("Need", ImVec2(80, 30))) - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); - first = false; - } - if (vm & 0x04) { - if (!first) ImGui::SameLine(); - if (ImGui::Button("Greed", ImVec2(80, 30))) - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); - first = false; - } - if (vm & 0x08) { - if (!first) ImGui::SameLine(); - if (ImGui::Button("Disenchant", ImVec2(95, 30))) - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); - first = false; - } - if (vm & 0x01) { - if (!first) ImGui::SameLine(); - if (ImGui::Button("Pass", ImVec2(70, 30))) - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); - } - - // Live roll results from group members - if (!roll.playerRolls.empty()) { - ImGui::Separator(); - ImGui::TextDisabled("Rolls so far:"); - // Roll-type label + color - static constexpr const char* kRollLabels[] = {"Need", "Greed", "Disenchant", "Pass"}; - static constexpr ImVec4 kRollColors[] = { - ImVec4(0.2f, 0.9f, 0.2f, 1.0f), // Need — green - ImVec4(0.3f, 0.6f, 1.0f, 1.0f), // Greed — blue - ImVec4(0.7f, 0.3f, 0.9f, 1.0f), // Disenchant — purple - kColorDarkGray, // Pass — gray - }; - auto rollTypeIndex = [](uint8_t t) -> int { - if (t == 0) return 0; - if (t == 1) return 1; - if (t == 2) return 2; - return 3; // pass (96 or unknown) - }; - - if (ImGui::BeginTable("##lootrolls", 3, - ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 72.0f); - ImGui::TableSetupColumn("Roll", ImGuiTableColumnFlags_WidthFixed, 32.0f); - for (const auto& r : roll.playerRolls) { - int ri = rollTypeIndex(r.rollType); - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::TextUnformatted(r.playerName.c_str()); - ImGui::TableSetColumnIndex(1); - ImGui::TextColored(kRollColors[ri], "%s", kRollLabels[ri]); - ImGui::TableSetColumnIndex(2); - if (r.rollType != 96) { - ImGui::TextColored(kRollColors[ri], "%d", static_cast(r.rollNum)); - } else { - ImGui::TextDisabled("—"); - } - } - ImGui::EndTable(); - } - } - } - ImGui::End(); -} - -void GameScreen::renderGuildInvitePopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingGuildInvite()) return; - - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 250), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); - - if (ImGui::Begin("Guild Invite", nullptr, kDialogFlags)) { - ImGui::TextWrapped("%s has invited you to join %s.", - gameHandler.getPendingGuildInviterName().c_str(), - gameHandler.getPendingGuildInviteGuildName().c_str()); - ImGui::Spacing(); - - if (ImGui::Button("Accept", ImVec2(155, 30))) { - gameHandler.acceptGuildInvite(); - } - ImGui::SameLine(); - if (ImGui::Button("Decline", ImVec2(155, 30))) { - gameHandler.declineGuildInvite(); - } - } - ImGui::End(); -} - -void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingReadyCheck()) 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 - 175, screenH / 2 - 60), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); - - if (ImGui::Begin("Ready Check", nullptr, kDialogFlags)) { - const std::string& initiator = gameHandler.getReadyCheckInitiator(); - if (initiator.empty()) { - ImGui::Text("A ready check has been initiated!"); - } else { - ImGui::TextWrapped("%s has initiated a ready check!", initiator.c_str()); - } - ImGui::Spacing(); - - if (ImGui::Button("Ready", ImVec2(155, 30))) { - gameHandler.respondToReadyCheck(true); - gameHandler.dismissReadyCheck(); - } - ImGui::SameLine(); - if (ImGui::Button("Not Ready", ImVec2(155, 30))) { - gameHandler.respondToReadyCheck(false); - gameHandler.dismissReadyCheck(); - } - - // Live player responses - const auto& results = gameHandler.getReadyCheckResults(); - if (!results.empty()) { - ImGui::Separator(); - if (ImGui::BeginTable("##rcresults", 2, - ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 72.0f); - for (const auto& r : results) { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::TextUnformatted(r.name.c_str()); - ImGui::TableSetColumnIndex(1); - if (r.ready) { - ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "Ready"); - } else { - ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "Not Ready"); - } - } - ImGui::EndTable(); - } - } - } - ImGui::End(); -} - -void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { - if (!gameHandler.hasPendingBgInvite()) return; - - const auto& queues = gameHandler.getBgQueues(); - // Find the first WAIT_JOIN slot - const game::GameHandler::BgQueueSlot* slot = nullptr; - for (const auto& s : queues) { - if (s.statusId == 2) { slot = &s; break; } - } - if (!slot) return; - - // Compute time remaining - auto now = std::chrono::steady_clock::now(); - double elapsed = std::chrono::duration(now - slot->inviteReceivedTime).count(); - double remaining = static_cast(slot->inviteTimeout) - elapsed; - - // If invite has expired, clear it silently (server will handle the queue) - if (remaining <= 0.0) { - gameHandler.declineBattlefield(slot->queueSlot); - 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 - 70), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always); - - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - - const ImGuiWindowFlags popupFlags = - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; - - if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) { - // BG name from stored queue data - std::string bgName = slot->bgName.empty() ? "Battleground" : slot->bgName; - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str()); - ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast(remaining)); - ImGui::Spacing(); - - // Countdown progress bar - float frac = static_cast(remaining / static_cast(slot->inviteTimeout)); - frac = std::clamp(frac, 0.0f, 1.0f); - ImVec4 barColor = frac > 0.5f ? colors::kHealthGreen - : frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f) - : colors::kDarkRed; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); - char countdownLabel[32]; - snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast(remaining)); - ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel); - ImGui::PopStyleColor(); - ImGui::Spacing(); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnGreen); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kFriendlyGreen); - if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) { - gameHandler.acceptBattlefield(slot->queueSlot); - } - ImGui::PopStyleColor(2); - - ImGui::SameLine(); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnRed); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kDangerRed); - if (ImGui::Button("Leave Queue", ImVec2(175, 30))) { - gameHandler.declineBattlefield(slot->queueSlot); - } - ImGui::PopStyleColor(2); - } - ImGui::End(); - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(3); -} - -void GameScreen::renderBfMgrInvitePopup(game::GameHandler& gameHandler) { - // Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager) - if (!gameHandler.hasBfMgrInvite()) 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 - 190.0f, screenH / 2.0f - 55.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_Always); - - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.10f, 0.20f, 0.96f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 1.0f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.45f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - - const ImGuiWindowFlags flags = - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; - - if (ImGui::Begin("Battlefield", nullptr, flags)) { - // Resolve zone name for Wintergrasp (zoneId 4197) - uint32_t zoneId = gameHandler.getBfMgrZoneId(); - const char* zoneName = nullptr; - if (zoneId == 4197) zoneName = "Wintergrasp"; - else if (zoneId == 5095) zoneName = "Tol Barad"; - - if (zoneName) { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", zoneName); - } else { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Outdoor Battlefield"); - } - ImGui::Spacing(); - ImGui::TextWrapped("You are invited to join the outdoor battlefield. Do you want to enter?"); - ImGui::Spacing(); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnGreen); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kFriendlyGreen); - if (ImGui::Button("Enter Battlefield", ImVec2(178, 28))) { - gameHandler.acceptBfMgrInvite(); - } - ImGui::PopStyleColor(2); - - ImGui::SameLine(); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnRed); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kDangerRed); - if (ImGui::Button("Decline", ImVec2(175, 28))) { - gameHandler.declineBfMgrInvite(); - } - ImGui::PopStyleColor(2); - } - ImGui::End(); - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(3); -} - -void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { - using LfgState = game::GameHandler::LfgState; - if (gameHandler.getLfgState() != LfgState::Proposal) 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 - 175.0f, screenH / 2.0f - 65.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(350.0f, 0.0f), ImGuiCond_Always); - - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.14f, 0.08f, 0.96f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.8f, 0.3f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.3f, 0.1f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - - const ImGuiWindowFlags flags = - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; - - if (ImGui::Begin("Dungeon Finder", nullptr, flags)) { - ImGui::TextColored(kColorGreen, "A group has been found!"); - ImGui::Spacing(); - ImGui::TextWrapped("Please accept or decline to join the dungeon."); - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnGreen); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kFriendlyGreen); - if (ImGui::Button("Accept", ImVec2(155.0f, 30.0f))) { - gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); - } - ImGui::PopStyleColor(2); - - ImGui::SameLine(); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnRed); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kDangerRed); - if (ImGui::Button("Decline", ImVec2(155.0f, 30.0f))) { - gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); - } - ImGui::PopStyleColor(2); - } - ImGui::End(); - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(3); -} - -void GameScreen::renderLfgRoleCheckPopup(game::GameHandler& gameHandler) { - using LfgState = game::GameHandler::LfgState; - if (gameHandler.getLfgState() != LfgState::RoleCheck) 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 - 160.0f, screenH / 2.0f - 80.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(320.0f, 0.0f), ImGuiCond_Always); - - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.96f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.1f, 0.3f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); - - const ImGuiWindowFlags flags = - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; - - if (ImGui::Begin("Role Check##LfgRoleCheck", nullptr, flags)) { - ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Confirm your role:"); - ImGui::Spacing(); - - // Role checkboxes - 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(120.0f); - if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0); - ImGui::SameLine(220.0f); - if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0); - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - bool hasRole = (lfgRoles_ & 0x0E) != 0; - if (!hasRole) ImGui::BeginDisabled(); - - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.4f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.6f, 0.2f, 1.0f)); - if (ImGui::Button("Accept", ImVec2(140.0f, 28.0f))) { - gameHandler.lfgSetRoles(lfgRoles_); - } - ImGui::PopStyleColor(2); - - if (!hasRole) ImGui::EndDisabled(); - - ImGui::SameLine(); - - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.4f, 0.15f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.6f, 0.2f, 0.2f, 1.0f)); - if (ImGui::Button("Leave Queue", ImVec2(140.0f, 28.0f))) { - gameHandler.lfgLeave(); - } - ImGui::PopStyleColor(2); - } - ImGui::End(); - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(3); -} - void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Guild Roster toggle (customizable keybind) if (!chatPanel_.isChatInputActive() && !ImGui::GetIO().WantTextInput && @@ -12613,7 +11751,7 @@ void GameScreen::renderEscapeMenu() { if (ImGui::Button("Logout", ImVec2(-1, 0))) { core::Application::getInstance().logoutToLogin(); showEscapeMenu = false; - showEscapeSettingsNotice = false; + settingsPanel_.showEscapeSettingsNotice = false; } if (ImGui::Button("Quit", ImVec2(-1, 0))) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -12625,9 +11763,9 @@ void GameScreen::renderEscapeMenu() { core::Application::getInstance().shutdown(); } if (ImGui::Button("Settings", ImVec2(-1, 0))) { - showEscapeSettingsNotice = false; - showSettingsWindow = true; - settingsInit = false; + settingsPanel_.showEscapeSettingsNotice = false; + settingsPanel_.showSettingsWindow = true; + settingsPanel_.settingsInit = false; showEscapeMenu = false; } if (ImGui::Button("Instance Lockouts", ImVec2(-1, 0))) { @@ -12643,7 +11781,7 @@ void GameScreen::renderEscapeMenu() { ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); if (ImGui::Button("Back to Game", ImVec2(-1, 0))) { showEscapeMenu = false; - showEscapeSettingsNotice = false; + settingsPanel_.showEscapeSettingsNotice = false; } ImGui::PopStyleVar(); } @@ -13204,1383 +12342,6 @@ void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } -void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { - if (!gameHandler.showResurrectDialog()) 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 dlgW = 300.0f; - float dlgH = 110.0f; - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.8f, 1.0f)); - - if (ImGui::Begin("##ResurrectDialog", nullptr, - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { - - ImGui::Spacing(); - const std::string& casterName = gameHandler.getResurrectCasterName(); - std::string text = casterName.empty() - ? "Return to life?" - : casterName + " wishes to resurrect you."; - float textW = ImGui::CalcTextSize(text.c_str()).x; - ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text.c_str()); - - ImGui::Spacing(); - ImGui::Spacing(); - - float btnW = 100.0f; - float spacing = 20.0f; - ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkGreen); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkGreenHover); - if (ImGui::Button("Accept", ImVec2(btnW, 30))) { - gameHandler.acceptResurrect(); - } - ImGui::PopStyleColor(2); - - ImGui::SameLine(0, spacing); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkRed); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkRedHover); - if (ImGui::Button("Decline", ImVec2(btnW, 30))) { - gameHandler.declineResurrect(); - } - ImGui::PopStyleColor(2); - } - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} - -// ============================================================ -// Talent Wipe Confirm Dialog -// ============================================================ - -void GameScreen::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) { - if (!gameHandler.showTalentWipeConfirmDialog()) 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 dlgW = 340.0f; - float dlgH = 130.0f; - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); - - if (ImGui::Begin("##TalentWipeDialog", nullptr, - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { - - ImGui::Spacing(); - uint32_t cost = gameHandler.getTalentWipeCost(); - uint32_t gold = cost / 10000; - uint32_t silver = (cost % 10000) / 100; - uint32_t copper = cost % 100; - char costStr[64]; - if (gold > 0) - std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); - else if (silver > 0) - std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); - else - std::snprintf(costStr, sizeof(costStr), "%uc", copper); - - std::string text = "Reset your talents for "; - text += costStr; - text += "?"; - float textW = ImGui::CalcTextSize(text.c_str()).x; - ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); - - ImGui::Spacing(); - ImGui::SetCursorPosX(8.0f); - ImGui::TextDisabled("All talent points will be refunded."); - ImGui::Spacing(); - - float btnW = 110.0f; - float spacing = 20.0f; - ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkGreen); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkGreenHover); - if (ImGui::Button("Confirm", ImVec2(btnW, 30))) { - gameHandler.confirmTalentWipe(); - } - ImGui::PopStyleColor(2); - - ImGui::SameLine(0, spacing); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkRed); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkRedHover); - if (ImGui::Button("Cancel", ImVec2(btnW, 30))) { - gameHandler.cancelTalentWipe(); - } - ImGui::PopStyleColor(2); - } - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} - -void GameScreen::renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler) { - if (!gameHandler.showPetUnlearnDialog()) 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 dlgW = 340.0f; - float dlgH = 130.0f; - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); - - if (ImGui::Begin("##PetUnlearnDialog", nullptr, - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { - - ImGui::Spacing(); - uint32_t cost = gameHandler.getPetUnlearnCost(); - uint32_t gold = cost / 10000; - uint32_t silver = (cost % 10000) / 100; - uint32_t copper = cost % 100; - char costStr[64]; - if (gold > 0) - std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); - else if (silver > 0) - std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); - else - std::snprintf(costStr, sizeof(costStr), "%uc", copper); - - std::string text = std::string("Reset your pet's talents for ") + costStr + "?"; - float textW = ImGui::CalcTextSize(text.c_str()).x; - ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); - - ImGui::Spacing(); - ImGui::SetCursorPosX(8.0f); - ImGui::TextDisabled("All pet talent points will be refunded."); - ImGui::Spacing(); - - float btnW = 110.0f; - float spacing = 20.0f; - ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkGreen); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkGreenHover); - if (ImGui::Button("Confirm##petunlearn", ImVec2(btnW, 30))) { - gameHandler.confirmPetUnlearn(); - } - ImGui::PopStyleColor(2); - - ImGui::SameLine(0, spacing); - - ImGui::PushStyleColor(ImGuiCol_Button, colors::kBtnDkRed); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, colors::kBtnDkRedHover); - if (ImGui::Button("Cancel##petunlearn", ImVec2(btnW, 30))) { - gameHandler.cancelPetUnlearn(); - } - ImGui::PopStyleColor(2); - } - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} - -// ============================================================ -// Settings Window -// ============================================================ - -void GameScreen::renderSettingsInterfaceTab() { -ImGui::Spacing(); -ImGui::BeginChild("InterfaceSettings", ImVec2(0, 360), true); - -ImGui::SeparatorText("Action Bars"); -ImGui::Spacing(); -ImGui::SetNextItemWidth(200.0f); -if (ImGui::SliderFloat("Action Bar Scale", &pendingActionBarScale, 0.5f, 1.5f, "%.2fx")) { - saveSettings(); -} -ImGui::Spacing(); - -if (ImGui::Checkbox("Show Second Action Bar", &pendingShowActionBar2)) { - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(Shift+1 through Shift+=)"); - -if (pendingShowActionBar2) { - ImGui::Spacing(); - ImGui::TextUnformatted("Second Bar Position Offset"); - ImGui::SetNextItemWidth(160.0f); - if (ImGui::SliderFloat("Horizontal##bar2x", &pendingActionBar2OffsetX, -600.0f, 600.0f, "%.0f px")) { - saveSettings(); - } - ImGui::SetNextItemWidth(160.0f); - if (ImGui::SliderFloat("Vertical##bar2y", &pendingActionBar2OffsetY, -400.0f, 400.0f, "%.0f px")) { - saveSettings(); - } - if (ImGui::Button("Reset Position##bar2")) { - pendingActionBar2OffsetX = 0.0f; - pendingActionBar2OffsetY = 0.0f; - saveSettings(); - } -} - -ImGui::Spacing(); -if (ImGui::Checkbox("Show Right Side Bar", &pendingShowRightBar)) { - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(Slots 25-36)"); -if (pendingShowRightBar) { - ImGui::SetNextItemWidth(160.0f); - if (ImGui::SliderFloat("Vertical Offset##rbar", &pendingRightBarOffsetY, -400.0f, 400.0f, "%.0f px")) { - saveSettings(); - } -} - -ImGui::Spacing(); -if (ImGui::Checkbox("Show Left Side Bar", &pendingShowLeftBar)) { - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(Slots 37-48)"); -if (pendingShowLeftBar) { - ImGui::SetNextItemWidth(160.0f); - if (ImGui::SliderFloat("Vertical Offset##lbar", &pendingLeftBarOffsetY, -400.0f, 400.0f, "%.0f px")) { - saveSettings(); - } -} - -ImGui::Spacing(); -ImGui::SeparatorText("Nameplates"); -ImGui::Spacing(); -ImGui::SetNextItemWidth(200.0f); -if (ImGui::SliderFloat("Nameplate Scale", &nameplateScale_, 0.5f, 2.0f, "%.2fx")) { - saveSettings(); -} - -ImGui::Spacing(); -ImGui::SeparatorText("Network"); -ImGui::Spacing(); -if (ImGui::Checkbox("Show Latency Meter", &pendingShowLatencyMeter)) { - showLatencyMeter_ = pendingShowLatencyMeter; - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(ms indicator near minimap)"); - -if (ImGui::Checkbox("Show DPS/HPS Meter", &showDPSMeter_)) { - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(damage/healing per second above action bar)"); - -if (ImGui::Checkbox("Show Cooldown Tracker", &showCooldownTracker_)) { - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(active spell cooldowns near action bar)"); - -ImGui::Spacing(); -ImGui::SeparatorText("Screen Effects"); -ImGui::Spacing(); -if (ImGui::Checkbox("Damage Flash", &damageFlashEnabled_)) { - if (!damageFlashEnabled_) damageFlashAlpha_ = 0.0f; - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(red vignette on taking damage)"); - -if (ImGui::Checkbox("Low Health Vignette", &lowHealthVignetteEnabled_)) { - saveSettings(); -} -ImGui::SameLine(); -ImGui::TextDisabled("(pulsing red edges below 20%% HP)"); - -ImGui::EndChild(); -} - -void GameScreen::renderSettingsGameplayTab() { - auto* renderer = core::Application::getInstance().getRenderer(); -ImGui::Spacing(); - -ImGui::Text("Controls"); -ImGui::Separator(); -if (ImGui::SliderFloat("Mouse Sensitivity", &pendingMouseSensitivity, 0.05f, 1.0f, "%.2f")) { - if (renderer) { - if (auto* cameraController = renderer->getCameraController()) { - cameraController->setMouseSensitivity(pendingMouseSensitivity); - } - } - saveSettings(); -} -if (ImGui::Checkbox("Invert Mouse", &pendingInvertMouse)) { - if (renderer) { - if (auto* cameraController = renderer->getCameraController()) { - cameraController->setInvertMouse(pendingInvertMouse); - } - } - saveSettings(); -} -if (ImGui::Checkbox("Extended Camera Zoom", &pendingExtendedZoom)) { - if (renderer) { - if (auto* cameraController = renderer->getCameraController()) { - cameraController->setExtendedZoom(pendingExtendedZoom); - } - } - saveSettings(); -} -if (ImGui::SliderFloat("Camera Stiffness", &pendingCameraStiffness, 5.0f, 100.0f, "%.0f")) { - if (renderer) { - if (auto* cameraController = renderer->getCameraController()) { - cameraController->setCameraSmoothSpeed(pendingCameraStiffness); - } - } - saveSettings(); -} -ImGui::SetItemTooltip("Higher = tighter camera with less sway. Default: 30"); -if (ImGui::SliderFloat("Camera Pivot Height", &pendingPivotHeight, 0.0f, 3.0f, "%.1f")) { - if (renderer) { - if (auto* cameraController = renderer->getCameraController()) { - cameraController->setPivotHeight(pendingPivotHeight); - } - } - saveSettings(); -} -ImGui::SetItemTooltip("Height of camera orbit point above feet. Lower = less detached feel. Default: 1.8"); -if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Allow the camera to zoom out further than normal"); - -if (ImGui::SliderFloat("Field of View", &pendingFov, 45.0f, 110.0f, "%.0f°")) { - if (renderer) { - if (auto* camera = renderer->getCamera()) { - camera->setFov(pendingFov); - } - } - saveSettings(); -} -if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Camera field of view in degrees (default: 70)"); - -ImGui::Spacing(); -ImGui::Spacing(); - -ImGui::Text("Interface"); -ImGui::Separator(); -if (ImGui::SliderInt("UI Opacity", &pendingUiOpacity, 20, 100, "%d%%")) { - uiOpacity_ = static_cast(pendingUiOpacity) / 100.0f; - saveSettings(); -} -if (ImGui::Checkbox("Rotate Minimap", &pendingMinimapRotate)) { - // Force north-up minimap. - minimapRotate_ = false; - pendingMinimapRotate = false; - if (renderer) { - if (auto* minimap = renderer->getMinimap()) { - minimap->setRotateWithCamera(false); - } - } - saveSettings(); -} -if (ImGui::Checkbox("Square Minimap", &pendingMinimapSquare)) { - minimapSquare_ = pendingMinimapSquare; - if (renderer) { - if (auto* minimap = renderer->getMinimap()) { - minimap->setSquareShape(minimapSquare_); - } - } - saveSettings(); -} -if (ImGui::Checkbox("Show Nearby NPC Dots", &pendingMinimapNpcDots)) { - minimapNpcDots_ = pendingMinimapNpcDots; - saveSettings(); -} -// Zoom controls -ImGui::Text("Minimap Zoom:"); -ImGui::SameLine(); -if (ImGui::Button(" - ")) { - if (renderer) { - if (auto* minimap = renderer->getMinimap()) { - minimap->zoomOut(); - saveSettings(); - } - } -} -ImGui::SameLine(); -if (ImGui::Button(" + ")) { - if (renderer) { - if (auto* minimap = renderer->getMinimap()) { - minimap->zoomIn(); - saveSettings(); - } - } -} - -ImGui::Spacing(); -ImGui::Text("Loot"); -ImGui::Separator(); -if (ImGui::Checkbox("Auto Loot", &pendingAutoLoot)) { - saveSettings(); // per-frame sync applies pendingAutoLoot to gameHandler -} -if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Automatically pick up all items when looting"); -if (ImGui::Checkbox("Auto Sell Greys", &pendingAutoSellGrey)) { - saveSettings(); -} -if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Automatically sell all grey (poor quality) items when opening a vendor"); -if (ImGui::Checkbox("Auto Repair", &pendingAutoRepair)) { - saveSettings(); -} -if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Automatically repair all damaged equipment when opening an armorer vendor"); - -ImGui::Spacing(); -ImGui::Text("Bags"); -ImGui::Separator(); -if (ImGui::Checkbox("Separate Bag Windows", &pendingSeparateBags)) { - inventoryScreen.setSeparateBags(pendingSeparateBags); - saveSettings(); -} -if (ImGui::Checkbox("Show Key Ring", &pendingShowKeyring)) { - inventoryScreen.setShowKeyring(pendingShowKeyring); - saveSettings(); -} - -ImGui::Spacing(); -ImGui::Separator(); -ImGui::Spacing(); - -if (ImGui::Button("Restore Gameplay Defaults", ImVec2(-1, 0))) { - pendingMouseSensitivity = 0.2f; - pendingInvertMouse = false; - pendingExtendedZoom = false; - pendingUiOpacity = 65; - pendingMinimapRotate = false; - pendingMinimapSquare = false; - pendingMinimapNpcDots = false; - pendingSeparateBags = true; - inventoryScreen.setSeparateBags(true); - pendingShowKeyring = true; - inventoryScreen.setShowKeyring(true); - uiOpacity_ = 0.65f; - minimapRotate_ = false; - minimapSquare_ = false; - minimapNpcDots_ = false; - if (renderer) { - if (auto* cameraController = renderer->getCameraController()) { - cameraController->setMouseSensitivity(pendingMouseSensitivity); - cameraController->setInvertMouse(pendingInvertMouse); - cameraController->setExtendedZoom(pendingExtendedZoom); - } - if (auto* minimap = renderer->getMinimap()) { - minimap->setRotateWithCamera(minimapRotate_); - minimap->setSquareShape(minimapSquare_); - } - } - saveSettings(); -} - -} - -void GameScreen::renderSettingsControlsTab() { -ImGui::Spacing(); - -ImGui::Text("Keybindings"); -ImGui::Separator(); - -auto& km = ui::KeybindingManager::getInstance(); -int numActions = km.getActionCount(); - -for (int i = 0; i < numActions; ++i) { - auto action = static_cast(i); - const char* actionName = km.getActionName(action); - ImGuiKey currentKey = km.getKeyForAction(action); - - // Display current binding - ImGui::Text("%s:", actionName); - ImGui::SameLine(200); - - // Get human-readable key name (basic implementation) - const char* keyName = "Unknown"; - if (currentKey >= ImGuiKey_A && currentKey <= ImGuiKey_Z) { - static char keyBuf[16]; - snprintf(keyBuf, sizeof(keyBuf), "%c", 'A' + (currentKey - ImGuiKey_A)); - keyName = keyBuf; - } else if (currentKey >= ImGuiKey_0 && currentKey <= ImGuiKey_9) { - static char keyBuf[16]; - snprintf(keyBuf, sizeof(keyBuf), "%c", '0' + (currentKey - ImGuiKey_0)); - keyName = keyBuf; - } else if (currentKey == ImGuiKey_Escape) { - keyName = "Escape"; - } else if (currentKey == ImGuiKey_Enter) { - keyName = "Enter"; - } else if (currentKey == ImGuiKey_Tab) { - keyName = "Tab"; - } else if (currentKey == ImGuiKey_Space) { - keyName = "Space"; - } else if (currentKey >= ImGuiKey_F1 && currentKey <= ImGuiKey_F12) { - static char keyBuf[16]; - snprintf(keyBuf, sizeof(keyBuf), "F%d", 1 + (currentKey - ImGuiKey_F1)); - keyName = keyBuf; - } - - ImGui::Text("[%s]", keyName); - - // Rebind button - ImGui::SameLine(350); - if (ImGui::Button(awaitingKeyPress && pendingRebindAction == i ? "Waiting..." : "Rebind", ImVec2(100, 0))) { - pendingRebindAction = i; - awaitingKeyPress = true; - } -} - -// Handle key press during rebinding -if (awaitingKeyPress && pendingRebindAction >= 0) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Text("Press any key to bind to this action (Esc to cancel)..."); - - // Check for any key press - bool foundKey = false; - ImGuiKey newKey = ImGuiKey_None; - for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k) { - if (ImGui::IsKeyPressed(static_cast(k), false)) { - if (k == ImGuiKey_Escape) { - // Cancel rebinding - awaitingKeyPress = false; - pendingRebindAction = -1; - foundKey = true; - break; - } - newKey = static_cast(k); - foundKey = true; - break; - } - } - - if (foundKey && newKey != ImGuiKey_None) { - auto action = static_cast(pendingRebindAction); - km.setKeyForAction(action, newKey); - awaitingKeyPress = false; - pendingRebindAction = -1; - saveSettings(); - } -} - -ImGui::Spacing(); -ImGui::Separator(); -ImGui::Spacing(); - -if (ImGui::Button("Reset to Defaults", ImVec2(-1, 0))) { - km.resetToDefaults(); - awaitingKeyPress = false; - pendingRebindAction = -1; - saveSettings(); -} - -} - -void GameScreen::renderSettingsAudioTab() { - auto* renderer = core::Application::getInstance().getRenderer(); -ImGui::Spacing(); -ImGui::BeginChild("AudioSettings", ImVec2(0, 360), true); - -// Helper lambda to apply audio settings -auto applyAudioSettings = [&]() { - applyAudioVolumes(renderer); - saveSettings(); -}; - -ImGui::Text("Master Volume"); -if (ImGui::SliderInt("##MasterVolume", &pendingMasterVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} -ImGui::Separator(); - -if (ImGui::Checkbox("Enable WoWee Music", &pendingUseOriginalSoundtrack)) { - if (renderer) { - if (auto* zm = renderer->getZoneManager()) { - zm->setUseOriginalSoundtrack(pendingUseOriginalSoundtrack); - } - } - saveSettings(); -} -if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Include WoWee music tracks in zone music rotation"); -ImGui::Separator(); - -ImGui::Text("Music"); -if (ImGui::SliderInt("##MusicVolume", &pendingMusicVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} - -ImGui::Spacing(); -ImGui::Text("Ambient Sounds"); -if (ImGui::SliderInt("##AmbientVolume", &pendingAmbientVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} -ImGui::TextWrapped("Weather, zones, cities, emitters"); - -ImGui::Spacing(); -ImGui::Text("UI Sounds"); -if (ImGui::SliderInt("##UiVolume", &pendingUiVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} -ImGui::TextWrapped("Buttons, loot, quest complete"); - -ImGui::Spacing(); -ImGui::Text("Combat Sounds"); -if (ImGui::SliderInt("##CombatVolume", &pendingCombatVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} -ImGui::TextWrapped("Weapon swings, impacts, grunts"); - -ImGui::Spacing(); -ImGui::Text("Spell Sounds"); -if (ImGui::SliderInt("##SpellVolume", &pendingSpellVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} -ImGui::TextWrapped("Magic casting and impacts"); - -ImGui::Spacing(); -ImGui::Text("Movement Sounds"); -if (ImGui::SliderInt("##MovementVolume", &pendingMovementVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} -ImGui::TextWrapped("Water splashes, jump/land"); - -ImGui::Spacing(); -ImGui::Text("Footsteps"); -if (ImGui::SliderInt("##FootstepVolume", &pendingFootstepVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} - -ImGui::Spacing(); -ImGui::Text("NPC Voices"); -if (ImGui::SliderInt("##NpcVoiceVolume", &pendingNpcVoiceVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} - -ImGui::Spacing(); -ImGui::Text("Mount Sounds"); -if (ImGui::SliderInt("##MountVolume", &pendingMountVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} - -ImGui::Spacing(); -ImGui::Text("Activity Sounds"); -if (ImGui::SliderInt("##ActivityVolume", &pendingActivityVolume, 0, 100, "%d%%")) { - applyAudioSettings(); -} -ImGui::TextWrapped("Swimming, eating, drinking"); - -ImGui::EndChild(); - -if (ImGui::Button("Restore Audio Defaults", ImVec2(-1, 0))) { - pendingMasterVolume = 100; - pendingMusicVolume = 30; // default music volume - pendingAmbientVolume = 100; - pendingUiVolume = 100; - pendingCombatVolume = 100; - pendingSpellVolume = 100; - pendingMovementVolume = 100; - pendingFootstepVolume = 100; - pendingNpcVoiceVolume = 100; - pendingMountVolume = 100; - pendingActivityVolume = 100; - applyAudioSettings(); -} - -} - -void GameScreen::renderSettingsAboutTab() { -ImGui::Spacing(); -ImGui::Spacing(); - -ImGui::TextWrapped("WoWee - World of Warcraft Client Emulator"); -ImGui::Spacing(); -ImGui::Separator(); -ImGui::Spacing(); - -ImGui::Text("Developer"); -ImGui::Indent(); -ImGui::Text("Kelsi Davis"); -ImGui::Unindent(); -ImGui::Spacing(); - -ImGui::Text("GitHub"); -ImGui::Indent(); -ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "https://github.com/Kelsidavis/WoWee"); -if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::SetTooltip("Click to copy"); -} -if (ImGui::IsItemClicked()) { - ImGui::SetClipboardText("https://github.com/Kelsidavis/WoWee"); -} -ImGui::Unindent(); -ImGui::Spacing(); - -ImGui::Text("Contact"); -ImGui::Indent(); -ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "github.com/Kelsidavis"); -if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::SetTooltip("Click to copy"); -} -if (ImGui::IsItemClicked()) { - ImGui::SetClipboardText("https://github.com/Kelsidavis"); -} -ImGui::Unindent(); - -ImGui::Spacing(); -ImGui::Separator(); -ImGui::Spacing(); - -ImGui::TextWrapped("A multi-expansion WoW client supporting Classic, TBC, and WotLK (3.3.5a)."); -ImGui::Spacing(); -ImGui::TextDisabled("Built with Vulkan, SDL2, and ImGui"); - -} - -void GameScreen::renderSettingsWindow() { - if (!showSettingsWindow) return; - - auto* window = core::Application::getInstance().getWindow(); - auto* renderer = core::Application::getInstance().getRenderer(); - if (!window) return; - - static constexpr int kResolutions[][2] = { - {1280, 720}, - {1600, 900}, - {1920, 1080}, - {2560, 1440}, - {3840, 2160}, - }; - static constexpr int kResCount = sizeof(kResolutions) / sizeof(kResolutions[0]); - constexpr int kDefaultResW = 1920; - constexpr int kDefaultResH = 1080; - constexpr bool kDefaultFullscreen = false; - constexpr bool kDefaultVsync = true; - constexpr bool kDefaultShadows = true; - constexpr int kDefaultGroundClutterDensity = 100; - - int defaultResIndex = 0; - for (int i = 0; i < kResCount; i++) { - if (kResolutions[i][0] == kDefaultResW && kResolutions[i][1] == kDefaultResH) { - defaultResIndex = i; - break; - } - } - - if (!settingsInit) { - pendingFullscreen = window->isFullscreen(); - pendingVsync = window->isVsyncEnabled(); - if (renderer) { - renderer->setShadowsEnabled(pendingShadows); - renderer->setShadowDistance(pendingShadowDistance); - // Read non-volume settings from actual state (volumes come from saved settings) - if (auto* cameraController = renderer->getCameraController()) { - pendingMouseSensitivity = cameraController->getMouseSensitivity(); - pendingInvertMouse = cameraController->isInvertMouse(); - cameraController->setExtendedZoom(pendingExtendedZoom); - } - } - pendingResIndex = 0; - int curW = window->getWidth(); - int curH = window->getHeight(); - for (int i = 0; i < kResCount; i++) { - if (kResolutions[i][0] == curW && kResolutions[i][1] == curH) { - pendingResIndex = i; - break; - } - } - pendingUiOpacity = static_cast(std::lround(uiOpacity_ * 100.0f)); - pendingMinimapRotate = minimapRotate_; - pendingMinimapSquare = minimapSquare_; - pendingMinimapNpcDots = minimapNpcDots_; - pendingShowLatencyMeter = showLatencyMeter_; - if (renderer) { - if (auto* minimap = renderer->getMinimap()) { - minimap->setRotateWithCamera(minimapRotate_); - minimap->setSquareShape(minimapSquare_); - } - if (auto* zm = renderer->getZoneManager()) { - pendingUseOriginalSoundtrack = zm->getUseOriginalSoundtrack(); - } - } - settingsInit = true; - } - - ImGuiIO& io = ImGui::GetIO(); - float screenW = io.DisplaySize.x; - float screenH = io.DisplaySize.y; - ImVec2 size(520.0f, std::min(screenH * 0.9f, 720.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("##SettingsWindow", nullptr, flags)) { - ImGui::Text("Settings"); - ImGui::Separator(); - - if (ImGui::BeginTabBar("SettingsTabs", ImGuiTabBarFlags_None)) { - // ============================================================ - // VIDEO TAB - // ============================================================ - if (ImGui::BeginTabItem("Video")) { - ImGui::Spacing(); - - // Graphics Quality Presets - { - const char* presetLabels[] = { "Custom", "Low", "Medium", "High", "Ultra" }; - int presetIdx = static_cast(pendingGraphicsPreset); - if (ImGui::Combo("Quality Preset", &presetIdx, presetLabels, 5)) { - pendingGraphicsPreset = static_cast(presetIdx); - if (pendingGraphicsPreset != GraphicsPreset::CUSTOM) { - applyGraphicsPreset(pendingGraphicsPreset); - saveSettings(); - } - } - ImGui::TextDisabled("Adjust these for custom settings"); - } - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - if (ImGui::Checkbox("Fullscreen", &pendingFullscreen)) { - window->setFullscreen(pendingFullscreen); - updateGraphicsPresetFromCurrentSettings(); - saveSettings(); - } - if (ImGui::Checkbox("VSync", &pendingVsync)) { - window->setVsync(pendingVsync); - updateGraphicsPresetFromCurrentSettings(); - saveSettings(); - } - if (ImGui::Checkbox("Shadows", &pendingShadows)) { - if (renderer) renderer->setShadowsEnabled(pendingShadows); - updateGraphicsPresetFromCurrentSettings(); - saveSettings(); - } - if (pendingShadows) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(150.0f); - if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 500.0f, "%.0f")) { - if (renderer) renderer->setShadowDistance(pendingShadowDistance); - updateGraphicsPresetFromCurrentSettings(); - saveSettings(); - } - } - { - if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { - if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); - updateGraphicsPresetFromCurrentSettings(); - saveSettings(); - } - } - { - const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; - bool fsr2Active = renderer && renderer->isFSR2Enabled(); - if (fsr2Active) { - ImGui::BeginDisabled(); - int disabled = 0; - ImGui::Combo("Anti-Aliasing (FSR3)", &disabled, "Off (FSR3 active)\0", 1); - ImGui::EndDisabled(); - } else if (ImGui::Combo("Anti-Aliasing", &pendingAntiAliasing, aaLabels, 4)) { - static const VkSampleCountFlagBits aaSamples[] = { - VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT, - VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT - }; - if (renderer) renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]); - updateGraphicsPresetFromCurrentSettings(); - saveSettings(); - } - // FXAA — post-process, combinable with MSAA or FSR3 - { - if (ImGui::Checkbox("FXAA (post-process)", &pendingFXAA)) { - if (renderer) renderer->setFXAAEnabled(pendingFXAA); - updateGraphicsPresetFromCurrentSettings(); - saveSettings(); - } - if (ImGui::IsItemHovered()) { - if (fsr2Active) - ImGui::SetTooltip("FXAA applies spatial anti-aliasing after FSR3 upscaling.\nFSR3 + FXAA is the recommended ultra-quality combination."); - else - ImGui::SetTooltip("FXAA smooths jagged edges as a post-process pass.\nCan be combined with MSAA for extra quality."); - } - } - } - // FSR Upscaling - { - // FSR mode selection: Off, FSR 1.0 (Spatial), FSR 3.x (Temporal) - const char* fsrModeLabels[] = { "Off", "FSR 1.0 (Spatial)", "FSR 3.x (Temporal)" }; - int fsrMode = pendingUpscalingMode; - if (ImGui::Combo("Upscaling", &fsrMode, fsrModeLabels, 3)) { - pendingUpscalingMode = fsrMode; - pendingFSR = (fsrMode == 1); - if (renderer) { - renderer->setFSREnabled(fsrMode == 1); - renderer->setFSR2Enabled(fsrMode == 2); - } - saveSettings(); - } - if (fsrMode > 0) { - if (fsrMode == 2 && renderer) { - ImGui::TextDisabled("FSR3 backend: %s", - renderer->isAmdFsr2SdkAvailable() ? "AMD FidelityFX SDK" : "Internal fallback"); - if (renderer->isAmdFsr3FramegenSdkAvailable()) { - if (ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &pendingAMDFramegen)) { - renderer->setAmdFsr3FramegenEnabled(pendingAMDFramegen); - saveSettings(); - } - const char* runtimeStatus = "Unavailable"; - if (renderer->isAmdFsr3FramegenRuntimeActive()) { - runtimeStatus = "Active"; - } else if (renderer->isAmdFsr3FramegenRuntimeReady()) { - runtimeStatus = "Ready"; - } else { - runtimeStatus = "Unavailable"; - } - ImGui::TextDisabled("Runtime: %s (%s)", - runtimeStatus, renderer->getAmdFsr3FramegenRuntimePath()); - if (!renderer->isAmdFsr3FramegenRuntimeReady()) { - const std::string& runtimeErr = renderer->getAmdFsr3FramegenRuntimeError(); - if (!runtimeErr.empty()) { - ImGui::TextDisabled("Reason: %s", runtimeErr.c_str()); - } - } - } else { - ImGui::BeginDisabled(); - bool disabledFg = false; - ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &disabledFg); - ImGui::EndDisabled(); - ImGui::TextDisabled("Requires FidelityFX-SDK framegen headers."); - } - } - const char* fsrQualityLabels[] = { "Native (100%)", "Ultra Quality (77%)", "Quality (67%)", "Balanced (59%)" }; - static constexpr float fsrScaleFactors[] = { 0.77f, 0.67f, 0.59f, 1.00f }; - static constexpr int displayToInternal[] = { 3, 0, 1, 2 }; - pendingFSRQuality = std::clamp(pendingFSRQuality, 0, 3); - int fsrQualityDisplay = 0; - for (int i = 0; i < 4; ++i) { - if (displayToInternal[i] == pendingFSRQuality) { - fsrQualityDisplay = i; - break; - } - } - if (ImGui::Combo("FSR Quality", &fsrQualityDisplay, fsrQualityLabels, 4)) { - pendingFSRQuality = displayToInternal[fsrQualityDisplay]; - if (renderer) renderer->setFSRQuality(fsrScaleFactors[pendingFSRQuality]); - saveSettings(); - } - if (ImGui::SliderFloat("FSR Sharpness", &pendingFSRSharpness, 0.0f, 2.0f, "%.1f")) { - if (renderer) renderer->setFSRSharpness(pendingFSRSharpness); - saveSettings(); - } - if (fsrMode == 2) { - ImGui::SeparatorText("FSR3 Tuning"); - if (ImGui::SliderFloat("Jitter Sign", &pendingFSR2JitterSign, -2.0f, 2.0f, "%.2f")) { - if (renderer) { - renderer->setFSR2DebugTuning( - pendingFSR2JitterSign, - pendingFSR2MotionVecScaleX, - pendingFSR2MotionVecScaleY); - } - saveSettings(); - } - ImGui::TextDisabled("Tip: 0.38 is the current recommended default."); - } - } - } - if (ImGui::SliderInt("Ground Clutter Density", &pendingGroundClutterDensity, 0, 150, "%d%%")) { - if (renderer) { - if (auto* tm = renderer->getTerrainManager()) { - tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); - } - } - saveSettings(); - } - if (ImGui::Checkbox("Normal Mapping", &pendingNormalMapping)) { - if (renderer) { - if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMappingEnabled(pendingNormalMapping); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMappingEnabled(pendingNormalMapping); - } - } - saveSettings(); - } - if (pendingNormalMapping) { - if (ImGui::SliderFloat("Normal Map Strength", &pendingNormalMapStrength, 0.0f, 2.0f, "%.1f")) { - if (renderer) { - if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMapStrength(pendingNormalMapStrength); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMapStrength(pendingNormalMapStrength); - } - } - saveSettings(); - } - } - if (ImGui::Checkbox("Parallax Mapping", &pendingPOM)) { - if (renderer) { - if (auto* wr = renderer->getWMORenderer()) { - wr->setPOMEnabled(pendingPOM); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setPOMEnabled(pendingPOM); - } - } - saveSettings(); - } - if (pendingPOM) { - const char* pomLabels[] = { "Low", "Medium", "High" }; - if (ImGui::Combo("Parallax Quality", &pendingPOMQuality, pomLabels, 3)) { - if (renderer) { - if (auto* wr = renderer->getWMORenderer()) { - wr->setPOMQuality(pendingPOMQuality); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setPOMQuality(pendingPOMQuality); - } - } - saveSettings(); - } - } - - const char* resLabel = "Resolution"; - const char* resItems[kResCount]; - char resBuf[kResCount][16]; - for (int i = 0; i < kResCount; i++) { - snprintf(resBuf[i], sizeof(resBuf[i]), "%dx%d", kResolutions[i][0], kResolutions[i][1]); - resItems[i] = resBuf[i]; - } - if (ImGui::Combo(resLabel, &pendingResIndex, resItems, kResCount)) { - window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); - saveSettings(); - } - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - ImGui::SetNextItemWidth(200.0f); - if (ImGui::SliderInt("Brightness", &pendingBrightness, 0, 100, "%d%%")) { - if (renderer) renderer->setBrightness(static_cast(pendingBrightness) / 50.0f); - saveSettings(); - } - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - if (ImGui::Button("Restore Video Defaults", ImVec2(-1, 0))) { - pendingFullscreen = kDefaultFullscreen; - pendingVsync = kDefaultVsync; - pendingShadows = kDefaultShadows; - pendingShadowDistance = 300.0f; - pendingGroundClutterDensity = kDefaultGroundClutterDensity; - pendingAntiAliasing = 0; - pendingNormalMapping = true; - pendingNormalMapStrength = 0.8f; - pendingPOM = true; - pendingPOMQuality = 1; - pendingResIndex = defaultResIndex; - pendingBrightness = 50; - window->setFullscreen(pendingFullscreen); - window->setVsync(pendingVsync); - window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); - if (renderer) renderer->setBrightness(1.0f); - pendingWaterRefraction = false; - if (renderer) { - renderer->setShadowsEnabled(pendingShadows); - renderer->setShadowDistance(pendingShadowDistance); - } - if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); - if (renderer) renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); - if (renderer) { - if (auto* tm = renderer->getTerrainManager()) { - tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); - } - } - if (renderer) { - if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMappingEnabled(pendingNormalMapping); - wr->setNormalMapStrength(pendingNormalMapStrength); - wr->setPOMEnabled(pendingPOM); - wr->setPOMQuality(pendingPOMQuality); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMappingEnabled(pendingNormalMapping); - cr->setNormalMapStrength(pendingNormalMapStrength); - cr->setPOMEnabled(pendingPOM); - cr->setPOMQuality(pendingPOMQuality); - } - } - saveSettings(); - } - - ImGui::EndTabItem(); - } - - // ============================================================ - // INTERFACE TAB - // ============================================================ - if (ImGui::BeginTabItem("Interface")) { - renderSettingsInterfaceTab(); - ImGui::EndTabItem(); - } - - // ============================================================ - // AUDIO TAB - // ============================================================ - if (ImGui::BeginTabItem("Audio")) { - renderSettingsAudioTab(); - ImGui::EndTabItem(); - } - - // ============================================================ - // GAMEPLAY TAB - // ============================================================ - if (ImGui::BeginTabItem("Gameplay")) { - renderSettingsGameplayTab(); - ImGui::EndTabItem(); - } - - // ============================================================ - // CONTROLS TAB - // ============================================================ - if (ImGui::BeginTabItem("Controls")) { - renderSettingsControlsTab(); - ImGui::EndTabItem(); - } - - // ============================================================ - // CHAT TAB - // ============================================================ - if (ImGui::BeginTabItem("Chat")) { - chatPanel_.renderSettingsTab([this]{ saveSettings(); }); - ImGui::EndTabItem(); - } - - // ============================================================ - // ABOUT TAB - // ============================================================ - if (ImGui::BeginTabItem("About")) { - renderSettingsAboutTab(); - ImGui::EndTabItem(); - } - - ImGui::EndTabBar(); - } - - ImGui::Spacing(); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); - if (ImGui::Button("Back to Game", ImVec2(-1, 0))) { - showSettingsWindow = false; - } - ImGui::PopStyleVar(); - } - ImGui::End(); -} - -void GameScreen::applyGraphicsPreset(GraphicsPreset preset) { - auto* renderer = core::Application::getInstance().getRenderer(); - - // Define preset values based on quality level - switch (preset) { - case GraphicsPreset::LOW: { - pendingShadows = false; - pendingShadowDistance = 100.0f; - pendingAntiAliasing = 0; // Off - pendingNormalMapping = false; - pendingPOM = false; - pendingGroundClutterDensity = 25; - if (renderer) { - renderer->setShadowsEnabled(false); - renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); - if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMappingEnabled(false); - wr->setPOMEnabled(false); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMappingEnabled(false); - cr->setPOMEnabled(false); - } - if (auto* tm = renderer->getTerrainManager()) { - tm->setGroundClutterDensityScale(0.25f); - } - } - break; - } - case GraphicsPreset::MEDIUM: { - pendingShadows = true; - pendingShadowDistance = 200.0f; - pendingAntiAliasing = 1; // 2x MSAA - pendingNormalMapping = true; - pendingNormalMapStrength = 0.6f; - pendingPOM = true; - pendingPOMQuality = 0; // Low - pendingGroundClutterDensity = 60; - if (renderer) { - renderer->setShadowsEnabled(true); - renderer->setShadowDistance(200.0f); - renderer->setMsaaSamples(VK_SAMPLE_COUNT_2_BIT); - if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMappingEnabled(true); - wr->setNormalMapStrength(0.6f); - wr->setPOMEnabled(true); - wr->setPOMQuality(0); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMappingEnabled(true); - cr->setNormalMapStrength(0.6f); - cr->setPOMEnabled(true); - cr->setPOMQuality(0); - } - if (auto* tm = renderer->getTerrainManager()) { - tm->setGroundClutterDensityScale(0.60f); - } - } - break; - } - case GraphicsPreset::HIGH: { - pendingShadows = true; - pendingShadowDistance = 350.0f; - pendingAntiAliasing = 2; // 4x MSAA - pendingNormalMapping = true; - pendingNormalMapStrength = 0.8f; - pendingPOM = true; - pendingPOMQuality = 1; // Medium - pendingGroundClutterDensity = 100; - if (renderer) { - renderer->setShadowsEnabled(true); - renderer->setShadowDistance(350.0f); - renderer->setMsaaSamples(VK_SAMPLE_COUNT_4_BIT); - if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMappingEnabled(true); - wr->setNormalMapStrength(0.8f); - wr->setPOMEnabled(true); - wr->setPOMQuality(1); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMappingEnabled(true); - cr->setNormalMapStrength(0.8f); - cr->setPOMEnabled(true); - cr->setPOMQuality(1); - } - if (auto* tm = renderer->getTerrainManager()) { - tm->setGroundClutterDensityScale(1.0f); - } - } - break; - } - case GraphicsPreset::ULTRA: { - pendingShadows = true; - pendingShadowDistance = 500.0f; - pendingAntiAliasing = 3; // 8x MSAA - pendingFXAA = true; // FXAA on top of MSAA for maximum smoothness - pendingNormalMapping = true; - pendingNormalMapStrength = 1.2f; - pendingPOM = true; - pendingPOMQuality = 2; // High - pendingGroundClutterDensity = 150; - if (renderer) { - renderer->setShadowsEnabled(true); - renderer->setShadowDistance(500.0f); - renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT); - renderer->setFXAAEnabled(true); - if (auto* wr = renderer->getWMORenderer()) { - wr->setNormalMappingEnabled(true); - wr->setNormalMapStrength(1.2f); - wr->setPOMEnabled(true); - wr->setPOMQuality(2); - } - if (auto* cr = renderer->getCharacterRenderer()) { - cr->setNormalMappingEnabled(true); - cr->setNormalMapStrength(1.2f); - cr->setPOMEnabled(true); - cr->setPOMQuality(2); - } - if (auto* tm = renderer->getTerrainManager()) { - tm->setGroundClutterDensityScale(1.5f); - } - } - break; - } - default: - break; - } - - currentGraphicsPreset = preset; - pendingGraphicsPreset = preset; -} - -void GameScreen::updateGraphicsPresetFromCurrentSettings() { - // Check if current settings match any preset, otherwise mark as CUSTOM - // This is a simplified check; could be enhanced with more detailed matching - - auto matchesPreset = [this](GraphicsPreset preset) -> bool { - switch (preset) { - case GraphicsPreset::LOW: - return !pendingShadows && pendingAntiAliasing == 0 && !pendingNormalMapping && !pendingPOM && - pendingGroundClutterDensity <= 30; - case GraphicsPreset::MEDIUM: - return pendingShadows && pendingShadowDistance >= 180 && pendingShadowDistance <= 220 && - pendingAntiAliasing == 1 && pendingNormalMapping && pendingPOM && - pendingGroundClutterDensity >= 50 && pendingGroundClutterDensity <= 70; - case GraphicsPreset::HIGH: - return pendingShadows && pendingShadowDistance >= 330 && pendingShadowDistance <= 370 && - pendingAntiAliasing == 2 && pendingNormalMapping && pendingPOM && - pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110; - case GraphicsPreset::ULTRA: - return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 && - pendingFXAA && pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140; - default: - return false; - } - }; - - // Try to match a preset, otherwise mark as custom - if (matchesPreset(GraphicsPreset::LOW)) { - pendingGraphicsPreset = GraphicsPreset::LOW; - } else if (matchesPreset(GraphicsPreset::MEDIUM)) { - pendingGraphicsPreset = GraphicsPreset::MEDIUM; - } else if (matchesPreset(GraphicsPreset::HIGH)) { - pendingGraphicsPreset = GraphicsPreset::HIGH; - } else if (matchesPreset(GraphicsPreset::ULTRA)) { - pendingGraphicsPreset = GraphicsPreset::ULTRA; - } else { - pendingGraphicsPreset = GraphicsPreset::CUSTOM; - } -} - void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) { const auto& statuses = gameHandler.getNpcQuestStatuses(); if (statuses.empty()) return; @@ -14754,7 +12515,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } // Optional base nearby NPC dots (independent of quest status packets). - if (minimapNpcDots_) { + if (settingsPanel_.minimapNpcDots_) { ImVec2 mouse = ImGui::GetMousePos(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::UNIT) continue; @@ -14786,7 +12547,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { // Nearby other-player dots — shown when NPC dots are enabled. // Party members are already drawn as squares above; other players get a small circle. - if (minimapNpcDots_) { + if (settingsPanel_.minimapNpcDots_) { const uint64_t selfGuid = gameHandler.getPlayerGuid(); const auto& partyData = gameHandler.getPartyData(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { @@ -14849,7 +12610,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { // Interactable game object dots (chests, resource nodes) when NPC dots are enabled. // Shown as small orange triangles to distinguish from unit dots and loot corpses. - if (minimapNpcDots_) { + if (settingsPanel_.minimapNpcDots_) { ImVec2 mouse = ImGui::GetMousePos(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue; @@ -15533,9 +13294,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { minimap->setSquareShape(!squareShape); } - bool npcDots = minimapNpcDots_; + bool npcDots = settingsPanel_.minimapNpcDots_; if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) { - minimapNpcDots_ = !minimapNpcDots_; + settingsPanel_.minimapNpcDots_ = !settingsPanel_.minimapNpcDots_; } ImGui::EndPopup(); @@ -15544,38 +13305,38 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); - float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; + float masterScale = settingsPanel_.soundMuted_ ? 0.0f : static_cast(settingsPanel_.pendingMasterVolume) / 100.0f; audio::AudioEngine::instance().setMasterVolume(masterScale); if (!activeRenderer) return; if (auto* music = activeRenderer->getMusicManager()) { - music->setVolume(pendingMusicVolume); + music->setVolume(settingsPanel_.pendingMusicVolume); } if (auto* ambient = activeRenderer->getAmbientSoundManager()) { - ambient->setVolumeScale(pendingAmbientVolume / 100.0f); + ambient->setVolumeScale(settingsPanel_.pendingAmbientVolume / 100.0f); } if (auto* ui = activeRenderer->getUiSoundManager()) { - ui->setVolumeScale(pendingUiVolume / 100.0f); + ui->setVolumeScale(settingsPanel_.pendingUiVolume / 100.0f); } if (auto* combat = activeRenderer->getCombatSoundManager()) { - combat->setVolumeScale(pendingCombatVolume / 100.0f); + combat->setVolumeScale(settingsPanel_.pendingCombatVolume / 100.0f); } if (auto* spell = activeRenderer->getSpellSoundManager()) { - spell->setVolumeScale(pendingSpellVolume / 100.0f); + spell->setVolumeScale(settingsPanel_.pendingSpellVolume / 100.0f); } if (auto* movement = activeRenderer->getMovementSoundManager()) { - movement->setVolumeScale(pendingMovementVolume / 100.0f); + movement->setVolumeScale(settingsPanel_.pendingMovementVolume / 100.0f); } if (auto* footstep = activeRenderer->getFootstepManager()) { - footstep->setVolumeScale(pendingFootstepVolume / 100.0f); + footstep->setVolumeScale(settingsPanel_.pendingFootstepVolume / 100.0f); } if (auto* npcVoice = activeRenderer->getNpcVoiceManager()) { - npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f); + npcVoice->setVolumeScale(settingsPanel_.pendingNpcVoiceVolume / 100.0f); } if (auto* mount = activeRenderer->getMountSoundManager()) { - mount->setVolumeScale(pendingMountVolume / 100.0f); + mount->setVolumeScale(settingsPanel_.pendingMountVolume / 100.0f); } if (auto* activity = activeRenderer->getActivitySoundManager()) { - activity->setVolumeScale(pendingActivityVolume / 100.0f); + activity->setVolumeScale(settingsPanel_.pendingActivityVolume / 100.0f); } }; @@ -15641,16 +13402,16 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { ImVec2 p = ImGui::GetCursorScreenPos(); ImVec2 size(20.0f, 20.0f); if (ImGui::InvisibleButton("##MinimapMuteButton", size)) { - soundMuted_ = !soundMuted_; - if (soundMuted_) { - preMuteVolume_ = audio::AudioEngine::instance().getMasterVolume(); + settingsPanel_.soundMuted_ = !settingsPanel_.soundMuted_; + if (settingsPanel_.soundMuted_) { + settingsPanel_.preMuteVolume_ = audio::AudioEngine::instance().getMasterVolume(); } applyMuteState(); saveSettings(); } bool hovered = ImGui::IsItemHovered(); - ImU32 bg = soundMuted_ ? IM_COL32(135, 42, 42, 230) : IM_COL32(38, 38, 38, 210); - if (hovered) bg = soundMuted_ ? IM_COL32(160, 58, 58, 230) : IM_COL32(65, 65, 65, 220); + ImU32 bg = settingsPanel_.soundMuted_ ? IM_COL32(135, 42, 42, 230) : IM_COL32(38, 38, 38, 210); + if (hovered) bg = settingsPanel_.soundMuted_ ? IM_COL32(160, 58, 58, 230) : IM_COL32(65, 65, 65, 220); ImU32 fg = IM_COL32(255, 255, 255, 245); draw->AddRectFilled(p, ImVec2(p.x + size.x, p.y + size.y), bg, 4.0f); draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), ImVec2(p.x + size.x - 0.5f, p.y + size.y - 0.5f), @@ -15659,7 +13420,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { draw->AddTriangleFilled(ImVec2(p.x + 7.0f, p.y + 7.0f), ImVec2(p.x + 7.0f, p.y + 13.0f), ImVec2(p.x + 11.8f, p.y + 10.0f), fg); - if (soundMuted_) { + if (settingsPanel_.soundMuted_) { draw->AddLine(ImVec2(p.x + 13.5f, p.y + 6.2f), ImVec2(p.x + 17.2f, p.y + 13.8f), fg, 1.8f); draw->AddLine(ImVec2(p.x + 17.2f, p.y + 6.2f), ImVec2(p.x + 13.5f, p.y + 13.8f), fg, 1.8f); } else { @@ -15668,7 +13429,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 5.5f, -0.7f, 0.7f, 12); draw->PathStroke(fg, 0, 1.2f); } - if (hovered) ImGui::SetTooltip(soundMuted_ ? "Unmute" : "Mute"); + if (hovered) ImGui::SetTooltip(settingsPanel_.soundMuted_ ? "Unmute" : "Mute"); } ImGui::End(); @@ -15918,7 +13679,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { // Latency + FPS indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); - if (showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) { + if (settingsPanel_.showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) { float currentFps = ImGui::GetIO().Framerate; ImVec4 latColor; if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); @@ -16020,46 +13781,8 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } -std::string GameScreen::getSettingsPath() { - std::string dir; -#ifdef _WIN32 - const char* appdata = std::getenv("APPDATA"); - dir = appdata ? std::string(appdata) + "\\wowee" : "."; -#else - const char* home = std::getenv("HOME"); - dir = home ? std::string(home) + "/.wowee" : "."; -#endif - return dir + "/settings.cfg"; -} - -void GameScreen::applyAudioVolumes(rendering::Renderer* renderer) { - if (!renderer) return; - float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; - audio::AudioEngine::instance().setMasterVolume(masterScale); - if (auto* music = renderer->getMusicManager()) - music->setVolume(pendingMusicVolume); - if (auto* ambient = renderer->getAmbientSoundManager()) - ambient->setVolumeScale(pendingAmbientVolume / 100.0f); - if (auto* ui = renderer->getUiSoundManager()) - ui->setVolumeScale(pendingUiVolume / 100.0f); - if (auto* combat = renderer->getCombatSoundManager()) - combat->setVolumeScale(pendingCombatVolume / 100.0f); - if (auto* spell = renderer->getSpellSoundManager()) - spell->setVolumeScale(pendingSpellVolume / 100.0f); - if (auto* movement = renderer->getMovementSoundManager()) - movement->setVolumeScale(pendingMovementVolume / 100.0f); - if (auto* footstep = renderer->getFootstepManager()) - footstep->setVolumeScale(pendingFootstepVolume / 100.0f); - if (auto* npcVoice = renderer->getNpcVoiceManager()) - npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f); - if (auto* mount = renderer->getMountSoundManager()) - mount->setVolumeScale(pendingMountVolume / 100.0f); - if (auto* activity = renderer->getActivitySoundManager()) - activity->setVolumeScale(pendingActivityVolume / 100.0f); -} - void GameScreen::saveSettings() { - std::string path = getSettingsPath(); + std::string path = SettingsPanel::getSettingsPath(); std::filesystem::path dir = std::filesystem::path(path).parent_path(); std::error_code ec; std::filesystem::create_directories(dir, ec); @@ -16071,75 +13794,75 @@ void GameScreen::saveSettings() { } // Interface - out << "ui_opacity=" << pendingUiOpacity << "\n"; - out << "minimap_rotate=" << (pendingMinimapRotate ? 1 : 0) << "\n"; - out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n"; - out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; - out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n"; - out << "show_dps_meter=" << (showDPSMeter_ ? 1 : 0) << "\n"; - out << "show_cooldown_tracker=" << (showCooldownTracker_ ? 1 : 0) << "\n"; - out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; - out << "show_keyring=" << (pendingShowKeyring ? 1 : 0) << "\n"; - out << "action_bar_scale=" << pendingActionBarScale << "\n"; - out << "nameplate_scale=" << nameplateScale_ << "\n"; - out << "show_friendly_nameplates=" << (showFriendlyNameplates_ ? 1 : 0) << "\n"; - out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; - out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; - out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; - out << "show_right_bar=" << (pendingShowRightBar ? 1 : 0) << "\n"; - out << "show_left_bar=" << (pendingShowLeftBar ? 1 : 0) << "\n"; - out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n"; - out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n"; - out << "damage_flash=" << (damageFlashEnabled_ ? 1 : 0) << "\n"; - out << "low_health_vignette=" << (lowHealthVignetteEnabled_ ? 1 : 0) << "\n"; + out << "ui_opacity=" << settingsPanel_.pendingUiOpacity << "\n"; + out << "minimap_rotate=" << (settingsPanel_.pendingMinimapRotate ? 1 : 0) << "\n"; + out << "minimap_square=" << (settingsPanel_.pendingMinimapSquare ? 1 : 0) << "\n"; + out << "minimap_npc_dots=" << (settingsPanel_.pendingMinimapNpcDots ? 1 : 0) << "\n"; + out << "show_latency_meter=" << (settingsPanel_.pendingShowLatencyMeter ? 1 : 0) << "\n"; + out << "show_dps_meter=" << (settingsPanel_.showDPSMeter_ ? 1 : 0) << "\n"; + out << "show_cooldown_tracker=" << (settingsPanel_.showCooldownTracker_ ? 1 : 0) << "\n"; + out << "separate_bags=" << (settingsPanel_.pendingSeparateBags ? 1 : 0) << "\n"; + out << "show_keyring=" << (settingsPanel_.pendingShowKeyring ? 1 : 0) << "\n"; + out << "action_bar_scale=" << settingsPanel_.pendingActionBarScale << "\n"; + out << "nameplate_scale=" << settingsPanel_.nameplateScale_ << "\n"; + out << "show_friendly_nameplates=" << (settingsPanel_.showFriendlyNameplates_ ? 1 : 0) << "\n"; + out << "show_action_bar2=" << (settingsPanel_.pendingShowActionBar2 ? 1 : 0) << "\n"; + out << "action_bar2_offset_x=" << settingsPanel_.pendingActionBar2OffsetX << "\n"; + out << "action_bar2_offset_y=" << settingsPanel_.pendingActionBar2OffsetY << "\n"; + out << "show_right_bar=" << (settingsPanel_.pendingShowRightBar ? 1 : 0) << "\n"; + out << "show_left_bar=" << (settingsPanel_.pendingShowLeftBar ? 1 : 0) << "\n"; + out << "right_bar_offset_y=" << settingsPanel_.pendingRightBarOffsetY << "\n"; + out << "left_bar_offset_y=" << settingsPanel_.pendingLeftBarOffsetY << "\n"; + out << "damage_flash=" << (settingsPanel_.damageFlashEnabled_ ? 1 : 0) << "\n"; + out << "low_health_vignette=" << (settingsPanel_.lowHealthVignetteEnabled_ ? 1 : 0) << "\n"; // Audio - out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; - out << "use_original_soundtrack=" << (pendingUseOriginalSoundtrack ? 1 : 0) << "\n"; - out << "master_volume=" << pendingMasterVolume << "\n"; - out << "music_volume=" << pendingMusicVolume << "\n"; - out << "ambient_volume=" << pendingAmbientVolume << "\n"; - out << "ui_volume=" << pendingUiVolume << "\n"; - out << "combat_volume=" << pendingCombatVolume << "\n"; - out << "spell_volume=" << pendingSpellVolume << "\n"; - out << "movement_volume=" << pendingMovementVolume << "\n"; - out << "footstep_volume=" << pendingFootstepVolume << "\n"; - out << "npc_voice_volume=" << pendingNpcVoiceVolume << "\n"; - out << "mount_volume=" << pendingMountVolume << "\n"; - out << "activity_volume=" << pendingActivityVolume << "\n"; + out << "sound_muted=" << (settingsPanel_.soundMuted_ ? 1 : 0) << "\n"; + out << "use_original_soundtrack=" << (settingsPanel_.pendingUseOriginalSoundtrack ? 1 : 0) << "\n"; + out << "master_volume=" << settingsPanel_.pendingMasterVolume << "\n"; + out << "music_volume=" << settingsPanel_.pendingMusicVolume << "\n"; + out << "ambient_volume=" << settingsPanel_.pendingAmbientVolume << "\n"; + out << "ui_volume=" << settingsPanel_.pendingUiVolume << "\n"; + out << "combat_volume=" << settingsPanel_.pendingCombatVolume << "\n"; + out << "spell_volume=" << settingsPanel_.pendingSpellVolume << "\n"; + out << "movement_volume=" << settingsPanel_.pendingMovementVolume << "\n"; + out << "footstep_volume=" << settingsPanel_.pendingFootstepVolume << "\n"; + out << "npc_voice_volume=" << settingsPanel_.pendingNpcVoiceVolume << "\n"; + out << "mount_volume=" << settingsPanel_.pendingMountVolume << "\n"; + out << "activity_volume=" << settingsPanel_.pendingActivityVolume << "\n"; // Gameplay - out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; - out << "auto_sell_grey=" << (pendingAutoSellGrey ? 1 : 0) << "\n"; - out << "auto_repair=" << (pendingAutoRepair ? 1 : 0) << "\n"; - out << "graphics_preset=" << static_cast(currentGraphicsPreset) << "\n"; - out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; - out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; - out << "shadow_distance=" << pendingShadowDistance << "\n"; - out << "brightness=" << pendingBrightness << "\n"; - out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n"; - out << "antialiasing=" << pendingAntiAliasing << "\n"; - out << "fxaa=" << (pendingFXAA ? 1 : 0) << "\n"; - out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; - out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; - out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; - out << "pom_quality=" << pendingPOMQuality << "\n"; - out << "upscaling_mode=" << pendingUpscalingMode << "\n"; - out << "fsr=" << (pendingFSR ? 1 : 0) << "\n"; - out << "fsr_quality=" << pendingFSRQuality << "\n"; - out << "fsr_sharpness=" << pendingFSRSharpness << "\n"; - out << "fsr2_jitter_sign=" << pendingFSR2JitterSign << "\n"; - out << "fsr2_mv_scale_x=" << pendingFSR2MotionVecScaleX << "\n"; - out << "fsr2_mv_scale_y=" << pendingFSR2MotionVecScaleY << "\n"; - out << "amd_fsr3_framegen=" << (pendingAMDFramegen ? 1 : 0) << "\n"; + out << "auto_loot=" << (settingsPanel_.pendingAutoLoot ? 1 : 0) << "\n"; + out << "auto_sell_grey=" << (settingsPanel_.pendingAutoSellGrey ? 1 : 0) << "\n"; + out << "auto_repair=" << (settingsPanel_.pendingAutoRepair ? 1 : 0) << "\n"; + out << "graphics_preset=" << static_cast(settingsPanel_.currentGraphicsPreset) << "\n"; + out << "ground_clutter_density=" << settingsPanel_.pendingGroundClutterDensity << "\n"; + out << "shadows=" << (settingsPanel_.pendingShadows ? 1 : 0) << "\n"; + out << "shadow_distance=" << settingsPanel_.pendingShadowDistance << "\n"; + out << "brightness=" << settingsPanel_.pendingBrightness << "\n"; + out << "water_refraction=" << (settingsPanel_.pendingWaterRefraction ? 1 : 0) << "\n"; + out << "antialiasing=" << settingsPanel_.pendingAntiAliasing << "\n"; + out << "fxaa=" << (settingsPanel_.pendingFXAA ? 1 : 0) << "\n"; + out << "normal_mapping=" << (settingsPanel_.pendingNormalMapping ? 1 : 0) << "\n"; + out << "normal_map_strength=" << settingsPanel_.pendingNormalMapStrength << "\n"; + out << "pom=" << (settingsPanel_.pendingPOM ? 1 : 0) << "\n"; + out << "pom_quality=" << settingsPanel_.pendingPOMQuality << "\n"; + out << "upscaling_mode=" << settingsPanel_.pendingUpscalingMode << "\n"; + out << "fsr=" << (settingsPanel_.pendingFSR ? 1 : 0) << "\n"; + out << "fsr_quality=" << settingsPanel_.pendingFSRQuality << "\n"; + out << "fsr_sharpness=" << settingsPanel_.pendingFSRSharpness << "\n"; + out << "fsr2_jitter_sign=" << settingsPanel_.pendingFSR2JitterSign << "\n"; + out << "fsr2_mv_scale_x=" << settingsPanel_.pendingFSR2MotionVecScaleX << "\n"; + out << "fsr2_mv_scale_y=" << settingsPanel_.pendingFSR2MotionVecScaleY << "\n"; + out << "amd_fsr3_framegen=" << (settingsPanel_.pendingAMDFramegen ? 1 : 0) << "\n"; // Controls - out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; - out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n"; - out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; - out << "camera_stiffness=" << pendingCameraStiffness << "\n"; - out << "camera_pivot_height=" << pendingPivotHeight << "\n"; - out << "fov=" << pendingFov << "\n"; + out << "mouse_sensitivity=" << settingsPanel_.pendingMouseSensitivity << "\n"; + out << "invert_mouse=" << (settingsPanel_.pendingInvertMouse ? 1 : 0) << "\n"; + out << "extended_zoom=" << (settingsPanel_.pendingExtendedZoom ? 1 : 0) << "\n"; + out << "camera_stiffness=" << settingsPanel_.pendingCameraStiffness << "\n"; + out << "camera_pivot_height=" << settingsPanel_.pendingPivotHeight << "\n"; + out << "fov=" << settingsPanel_.pendingFov << "\n"; // Quest tracker position/size out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n"; @@ -16166,7 +13889,7 @@ void GameScreen::saveSettings() { } void GameScreen::loadSettings() { - std::string path = getSettingsPath(); + std::string path = SettingsPanel::getSettingsPath(); std::ifstream in(path); if (!in.is_open()) return; @@ -16182,127 +13905,127 @@ void GameScreen::loadSettings() { if (key == "ui_opacity") { int v = std::stoi(val); if (v >= 20 && v <= 100) { - pendingUiOpacity = v; - uiOpacity_ = static_cast(v) / 100.0f; + settingsPanel_.pendingUiOpacity = v; + settingsPanel_.uiOpacity_ = static_cast(v) / 100.0f; } } else if (key == "minimap_rotate") { // Ignore persisted rotate state; keep north-up. - minimapRotate_ = false; - pendingMinimapRotate = false; + settingsPanel_.minimapRotate_ = false; + settingsPanel_.pendingMinimapRotate = false; } else if (key == "minimap_square") { int v = std::stoi(val); - minimapSquare_ = (v != 0); - pendingMinimapSquare = minimapSquare_; + settingsPanel_.minimapSquare_ = (v != 0); + settingsPanel_.pendingMinimapSquare = settingsPanel_.minimapSquare_; } else if (key == "minimap_npc_dots") { int v = std::stoi(val); - minimapNpcDots_ = (v != 0); - pendingMinimapNpcDots = minimapNpcDots_; + settingsPanel_.minimapNpcDots_ = (v != 0); + settingsPanel_.pendingMinimapNpcDots = settingsPanel_.minimapNpcDots_; } else if (key == "show_latency_meter") { - showLatencyMeter_ = (std::stoi(val) != 0); - pendingShowLatencyMeter = showLatencyMeter_; + settingsPanel_.showLatencyMeter_ = (std::stoi(val) != 0); + settingsPanel_.pendingShowLatencyMeter = settingsPanel_.showLatencyMeter_; } else if (key == "show_dps_meter") { - showDPSMeter_ = (std::stoi(val) != 0); + settingsPanel_.showDPSMeter_ = (std::stoi(val) != 0); } else if (key == "show_cooldown_tracker") { - showCooldownTracker_ = (std::stoi(val) != 0); + settingsPanel_.showCooldownTracker_ = (std::stoi(val) != 0); } else if (key == "separate_bags") { - pendingSeparateBags = (std::stoi(val) != 0); - inventoryScreen.setSeparateBags(pendingSeparateBags); + settingsPanel_.pendingSeparateBags = (std::stoi(val) != 0); + inventoryScreen.setSeparateBags(settingsPanel_.pendingSeparateBags); } else if (key == "show_keyring") { - pendingShowKeyring = (std::stoi(val) != 0); - inventoryScreen.setShowKeyring(pendingShowKeyring); + settingsPanel_.pendingShowKeyring = (std::stoi(val) != 0); + inventoryScreen.setShowKeyring(settingsPanel_.pendingShowKeyring); } else if (key == "action_bar_scale") { - pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); + settingsPanel_.pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); } else if (key == "nameplate_scale") { - nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); + settingsPanel_.nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); } else if (key == "show_friendly_nameplates") { - showFriendlyNameplates_ = (std::stoi(val) != 0); + settingsPanel_.showFriendlyNameplates_ = (std::stoi(val) != 0); } else if (key == "show_action_bar2") { - pendingShowActionBar2 = (std::stoi(val) != 0); + settingsPanel_.pendingShowActionBar2 = (std::stoi(val) != 0); } else if (key == "action_bar2_offset_x") { - pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); + settingsPanel_.pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); } else if (key == "action_bar2_offset_y") { - pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + settingsPanel_.pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } else if (key == "show_right_bar") { - pendingShowRightBar = (std::stoi(val) != 0); + settingsPanel_.pendingShowRightBar = (std::stoi(val) != 0); } else if (key == "show_left_bar") { - pendingShowLeftBar = (std::stoi(val) != 0); + settingsPanel_.pendingShowLeftBar = (std::stoi(val) != 0); } else if (key == "right_bar_offset_y") { - pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + settingsPanel_.pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } else if (key == "left_bar_offset_y") { - pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + settingsPanel_.pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } else if (key == "damage_flash") { - damageFlashEnabled_ = (std::stoi(val) != 0); + settingsPanel_.damageFlashEnabled_ = (std::stoi(val) != 0); } else if (key == "low_health_vignette") { - lowHealthVignetteEnabled_ = (std::stoi(val) != 0); + settingsPanel_.lowHealthVignetteEnabled_ = (std::stoi(val) != 0); } // Audio else if (key == "sound_muted") { - soundMuted_ = (std::stoi(val) != 0); - if (soundMuted_) { - // Apply mute on load; preMuteVolume_ will be set when AudioEngine is available + settingsPanel_.soundMuted_ = (std::stoi(val) != 0); + if (settingsPanel_.soundMuted_) { + // Apply mute on load; settingsPanel_.preMuteVolume_ will be set when AudioEngine is available audio::AudioEngine::instance().setMasterVolume(0.0f); } } - else if (key == "use_original_soundtrack") pendingUseOriginalSoundtrack = (std::stoi(val) != 0); - else if (key == "master_volume") pendingMasterVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "music_volume") pendingMusicVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "ambient_volume") pendingAmbientVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "ui_volume") pendingUiVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "combat_volume") pendingCombatVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "spell_volume") pendingSpellVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "movement_volume") pendingMovementVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "footstep_volume") pendingFootstepVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "npc_voice_volume") pendingNpcVoiceVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "mount_volume") pendingMountVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "use_original_soundtrack") settingsPanel_.pendingUseOriginalSoundtrack = (std::stoi(val) != 0); + else if (key == "master_volume") settingsPanel_.pendingMasterVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "music_volume") settingsPanel_.pendingMusicVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "ambient_volume") settingsPanel_.pendingAmbientVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "ui_volume") settingsPanel_.pendingUiVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "combat_volume") settingsPanel_.pendingCombatVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "spell_volume") settingsPanel_.pendingSpellVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "movement_volume") settingsPanel_.pendingMovementVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "footstep_volume") settingsPanel_.pendingFootstepVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "npc_voice_volume") settingsPanel_.pendingNpcVoiceVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "mount_volume") settingsPanel_.pendingMountVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "activity_volume") settingsPanel_.pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); // Gameplay - else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); - else if (key == "auto_sell_grey") pendingAutoSellGrey = (std::stoi(val) != 0); - else if (key == "auto_repair") pendingAutoRepair = (std::stoi(val) != 0); + else if (key == "auto_loot") settingsPanel_.pendingAutoLoot = (std::stoi(val) != 0); + else if (key == "auto_sell_grey") settingsPanel_.pendingAutoSellGrey = (std::stoi(val) != 0); + else if (key == "auto_repair") settingsPanel_.pendingAutoRepair = (std::stoi(val) != 0); else if (key == "graphics_preset") { int presetVal = std::clamp(std::stoi(val), 0, 4); - currentGraphicsPreset = static_cast(presetVal); - pendingGraphicsPreset = currentGraphicsPreset; + settingsPanel_.currentGraphicsPreset = static_cast(presetVal); + settingsPanel_.pendingGraphicsPreset = settingsPanel_.currentGraphicsPreset; } - else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); - else if (key == "shadows") pendingShadows = (std::stoi(val) != 0); - else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); + else if (key == "ground_clutter_density") settingsPanel_.pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); + else if (key == "shadows") settingsPanel_.pendingShadows = (std::stoi(val) != 0); + else if (key == "shadow_distance") settingsPanel_.pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); else if (key == "brightness") { - pendingBrightness = std::clamp(std::stoi(val), 0, 100); + settingsPanel_.pendingBrightness = std::clamp(std::stoi(val), 0, 100); if (auto* r = core::Application::getInstance().getRenderer()) - r->setBrightness(static_cast(pendingBrightness) / 50.0f); + r->setBrightness(static_cast(settingsPanel_.pendingBrightness) / 50.0f); } - else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0); - else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); - else if (key == "fxaa") pendingFXAA = (std::stoi(val) != 0); - else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); - else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); - else if (key == "pom") pendingPOM = (std::stoi(val) != 0); - else if (key == "pom_quality") pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); + else if (key == "water_refraction") settingsPanel_.pendingWaterRefraction = (std::stoi(val) != 0); + else if (key == "antialiasing") settingsPanel_.pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); + else if (key == "fxaa") settingsPanel_.pendingFXAA = (std::stoi(val) != 0); + else if (key == "normal_mapping") settingsPanel_.pendingNormalMapping = (std::stoi(val) != 0); + else if (key == "normal_map_strength") settingsPanel_.pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); + else if (key == "pom") settingsPanel_.pendingPOM = (std::stoi(val) != 0); + else if (key == "pom_quality") settingsPanel_.pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); else if (key == "upscaling_mode") { - pendingUpscalingMode = std::clamp(std::stoi(val), 0, 2); - pendingFSR = (pendingUpscalingMode == 1); + settingsPanel_.pendingUpscalingMode = std::clamp(std::stoi(val), 0, 2); + settingsPanel_.pendingFSR = (settingsPanel_.pendingUpscalingMode == 1); } else if (key == "fsr") { - pendingFSR = (std::stoi(val) != 0); + settingsPanel_.pendingFSR = (std::stoi(val) != 0); // Backward compatibility: old configs only had fsr=0/1. - if (pendingUpscalingMode == 0 && pendingFSR) pendingUpscalingMode = 1; + if (settingsPanel_.pendingUpscalingMode == 0 && settingsPanel_.pendingFSR) settingsPanel_.pendingUpscalingMode = 1; } - else if (key == "fsr_quality") pendingFSRQuality = std::clamp(std::stoi(val), 0, 3); - else if (key == "fsr_sharpness") pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f); - else if (key == "fsr2_jitter_sign") pendingFSR2JitterSign = std::clamp(std::stof(val), -2.0f, 2.0f); - else if (key == "fsr2_mv_scale_x") pendingFSR2MotionVecScaleX = std::clamp(std::stof(val), -2.0f, 2.0f); - else if (key == "fsr2_mv_scale_y") pendingFSR2MotionVecScaleY = std::clamp(std::stof(val), -2.0f, 2.0f); - else if (key == "amd_fsr3_framegen") pendingAMDFramegen = (std::stoi(val) != 0); + else if (key == "fsr_quality") settingsPanel_.pendingFSRQuality = std::clamp(std::stoi(val), 0, 3); + else if (key == "fsr_sharpness") settingsPanel_.pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f); + else if (key == "fsr2_jitter_sign") settingsPanel_.pendingFSR2JitterSign = std::clamp(std::stof(val), -2.0f, 2.0f); + else if (key == "fsr2_mv_scale_x") settingsPanel_.pendingFSR2MotionVecScaleX = std::clamp(std::stof(val), -2.0f, 2.0f); + else if (key == "fsr2_mv_scale_y") settingsPanel_.pendingFSR2MotionVecScaleY = std::clamp(std::stof(val), -2.0f, 2.0f); + else if (key == "amd_fsr3_framegen") settingsPanel_.pendingAMDFramegen = (std::stoi(val) != 0); // Controls - else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); - else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0); - else if (key == "extended_zoom") pendingExtendedZoom = (std::stoi(val) != 0); - else if (key == "camera_stiffness") pendingCameraStiffness = std::clamp(std::stof(val), 5.0f, 100.0f); - else if (key == "camera_pivot_height") pendingPivotHeight = std::clamp(std::stof(val), 0.0f, 3.0f); + else if (key == "mouse_sensitivity") settingsPanel_.pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); + else if (key == "invert_mouse") settingsPanel_.pendingInvertMouse = (std::stoi(val) != 0); + else if (key == "extended_zoom") settingsPanel_.pendingExtendedZoom = (std::stoi(val) != 0); + else if (key == "camera_stiffness") settingsPanel_.pendingCameraStiffness = std::clamp(std::stof(val), 5.0f, 100.0f); + else if (key == "camera_pivot_height") settingsPanel_.pendingPivotHeight = std::clamp(std::stof(val), 0.0f, 3.0f); else if (key == "fov") { - pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f); + settingsPanel_.pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f); if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov); + if (auto* camera = renderer->getCamera()) camera->setFov(settingsPanel_.pendingFov); } } // Quest tracker position/size diff --git a/src/ui/settings_panel.cpp b/src/ui/settings_panel.cpp new file mode 100644 index 00000000..a7676d61 --- /dev/null +++ b/src/ui/settings_panel.cpp @@ -0,0 +1,1258 @@ +// ============================================================ +// SettingsPanel — extracted from GameScreen +// Owns all settings UI rendering, settings state, and +// graphics preset logic. +// ============================================================ +#include "ui/settings_panel.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/chat_panel.hpp" +#include "ui/keybinding_manager.hpp" +#include "core/application.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "rendering/minimap.hpp" +#include "rendering/terrain_manager.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/character_renderer.hpp" +#include "game/zone_manager.hpp" +#include "audio/audio_engine.hpp" +#include "audio/music_manager.hpp" +#include "audio/ambient_sound_manager.hpp" +#include "audio/ui_sound_manager.hpp" +#include "audio/combat_sound_manager.hpp" +#include "audio/spell_sound_manager.hpp" +#include "audio/movement_sound_manager.hpp" +#include "audio/footstep_manager.hpp" +#include "audio/npc_voice_manager.hpp" +#include "audio/mount_sound_manager.hpp" +#include "audio/activity_sound_manager.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { namespace ui { + +void SettingsPanel::renderSettingsInterfaceTab(std::function saveCallback) { +ImGui::Spacing(); +ImGui::BeginChild("InterfaceSettings", ImVec2(0, 360), true); + +ImGui::SeparatorText("Action Bars"); +ImGui::Spacing(); +ImGui::SetNextItemWidth(200.0f); +if (ImGui::SliderFloat("Action Bar Scale", &pendingActionBarScale, 0.5f, 1.5f, "%.2fx")) { + saveCallback(); +} +ImGui::Spacing(); + +if (ImGui::Checkbox("Show Second Action Bar", &pendingShowActionBar2)) { + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(Shift+1 through Shift+=)"); + +if (pendingShowActionBar2) { + ImGui::Spacing(); + ImGui::TextUnformatted("Second Bar Position Offset"); + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Horizontal##bar2x", &pendingActionBar2OffsetX, -600.0f, 600.0f, "%.0f px")) { + saveCallback(); + } + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical##bar2y", &pendingActionBar2OffsetY, -400.0f, 400.0f, "%.0f px")) { + saveCallback(); + } + if (ImGui::Button("Reset Position##bar2")) { + pendingActionBar2OffsetX = 0.0f; + pendingActionBar2OffsetY = 0.0f; + saveCallback(); + } +} + +ImGui::Spacing(); +if (ImGui::Checkbox("Show Right Side Bar", &pendingShowRightBar)) { + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(Slots 25-36)"); +if (pendingShowRightBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##rbar", &pendingRightBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveCallback(); + } +} + +ImGui::Spacing(); +if (ImGui::Checkbox("Show Left Side Bar", &pendingShowLeftBar)) { + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(Slots 37-48)"); +if (pendingShowLeftBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##lbar", &pendingLeftBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveCallback(); + } +} + +ImGui::Spacing(); +ImGui::SeparatorText("Nameplates"); +ImGui::Spacing(); +ImGui::SetNextItemWidth(200.0f); +if (ImGui::SliderFloat("Nameplate Scale", &nameplateScale_, 0.5f, 2.0f, "%.2fx")) { + saveCallback(); +} + +ImGui::Spacing(); +ImGui::SeparatorText("Network"); +ImGui::Spacing(); +if (ImGui::Checkbox("Show Latency Meter", &pendingShowLatencyMeter)) { + showLatencyMeter_ = pendingShowLatencyMeter; + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(ms indicator near minimap)"); + +if (ImGui::Checkbox("Show DPS/HPS Meter", &showDPSMeter_)) { + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(damage/healing per second above action bar)"); + +if (ImGui::Checkbox("Show Cooldown Tracker", &showCooldownTracker_)) { + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(active spell cooldowns near action bar)"); + +ImGui::Spacing(); +ImGui::SeparatorText("Screen Effects"); +ImGui::Spacing(); +if (ImGui::Checkbox("Damage Flash", &damageFlashEnabled_)) { + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(red vignette on taking damage)"); + +if (ImGui::Checkbox("Low Health Vignette", &lowHealthVignetteEnabled_)) { + saveCallback(); +} +ImGui::SameLine(); +ImGui::TextDisabled("(pulsing red edges below 20%% HP)"); + +ImGui::EndChild(); +} + +void SettingsPanel::renderSettingsGameplayTab(InventoryScreen& inventoryScreen, + std::function saveCallback) { + auto* renderer = core::Application::getInstance().getRenderer(); +ImGui::Spacing(); + +ImGui::Text("Controls"); +ImGui::Separator(); +if (ImGui::SliderFloat("Mouse Sensitivity", &pendingMouseSensitivity, 0.05f, 1.0f, "%.2f")) { + if (renderer) { + if (auto* cameraController = renderer->getCameraController()) { + cameraController->setMouseSensitivity(pendingMouseSensitivity); + } + } + saveCallback(); +} +if (ImGui::Checkbox("Invert Mouse", &pendingInvertMouse)) { + if (renderer) { + if (auto* cameraController = renderer->getCameraController()) { + cameraController->setInvertMouse(pendingInvertMouse); + } + } + saveCallback(); +} +if (ImGui::Checkbox("Extended Camera Zoom", &pendingExtendedZoom)) { + if (renderer) { + if (auto* cameraController = renderer->getCameraController()) { + cameraController->setExtendedZoom(pendingExtendedZoom); + } + } + saveCallback(); +} +if (ImGui::SliderFloat("Camera Stiffness", &pendingCameraStiffness, 5.0f, 100.0f, "%.0f")) { + if (renderer) { + if (auto* cameraController = renderer->getCameraController()) { + cameraController->setCameraSmoothSpeed(pendingCameraStiffness); + } + } + saveCallback(); +} +ImGui::SetItemTooltip("Higher = tighter camera with less sway. Default: 30"); +if (ImGui::SliderFloat("Camera Pivot Height", &pendingPivotHeight, 0.0f, 3.0f, "%.1f")) { + if (renderer) { + if (auto* cameraController = renderer->getCameraController()) { + cameraController->setPivotHeight(pendingPivotHeight); + } + } + saveCallback(); +} +ImGui::SetItemTooltip("Height of camera orbit point above feet. Lower = less detached feel. Default: 1.8"); +if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Allow the camera to zoom out further than normal"); + +if (ImGui::SliderFloat("Field of View", &pendingFov, 45.0f, 110.0f, "%.0f°")) { + if (renderer) { + if (auto* camera = renderer->getCamera()) { + camera->setFov(pendingFov); + } + } + saveCallback(); +} +if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Camera field of view in degrees (default: 70)"); + +ImGui::Spacing(); +ImGui::Spacing(); + +ImGui::Text("Interface"); +ImGui::Separator(); +if (ImGui::SliderInt("UI Opacity", &pendingUiOpacity, 20, 100, "%d%%")) { + uiOpacity_ = static_cast(pendingUiOpacity) / 100.0f; + saveCallback(); +} +if (ImGui::Checkbox("Rotate Minimap", &pendingMinimapRotate)) { + // Force north-up minimap. + minimapRotate_ = false; + pendingMinimapRotate = false; + if (renderer) { + if (auto* minimap = renderer->getMinimap()) { + minimap->setRotateWithCamera(false); + } + } + saveCallback(); +} +if (ImGui::Checkbox("Square Minimap", &pendingMinimapSquare)) { + minimapSquare_ = pendingMinimapSquare; + if (renderer) { + if (auto* minimap = renderer->getMinimap()) { + minimap->setSquareShape(minimapSquare_); + } + } + saveCallback(); +} +if (ImGui::Checkbox("Show Nearby NPC Dots", &pendingMinimapNpcDots)) { + minimapNpcDots_ = pendingMinimapNpcDots; + saveCallback(); +} +// Zoom controls +ImGui::Text("Minimap Zoom:"); +ImGui::SameLine(); +if (ImGui::Button(" - ")) { + if (renderer) { + if (auto* minimap = renderer->getMinimap()) { + minimap->zoomOut(); + saveCallback(); + } + } +} +ImGui::SameLine(); +if (ImGui::Button(" + ")) { + if (renderer) { + if (auto* minimap = renderer->getMinimap()) { + minimap->zoomIn(); + saveCallback(); + } + } +} + +ImGui::Spacing(); +ImGui::Text("Loot"); +ImGui::Separator(); +if (ImGui::Checkbox("Auto Loot", &pendingAutoLoot)) { + saveCallback(); // per-frame sync applies pendingAutoLoot to gameHandler +} +if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically pick up all items when looting"); +if (ImGui::Checkbox("Auto Sell Greys", &pendingAutoSellGrey)) { + saveCallback(); +} +if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically sell all grey (poor quality) items when opening a vendor"); +if (ImGui::Checkbox("Auto Repair", &pendingAutoRepair)) { + saveCallback(); +} +if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically repair all damaged equipment when opening an armorer vendor"); + +ImGui::Spacing(); +ImGui::Text("Bags"); +ImGui::Separator(); +if (ImGui::Checkbox("Separate Bag Windows", &pendingSeparateBags)) { + inventoryScreen.setSeparateBags(pendingSeparateBags); + saveCallback(); +} +if (ImGui::Checkbox("Show Key Ring", &pendingShowKeyring)) { + inventoryScreen.setShowKeyring(pendingShowKeyring); + saveCallback(); +} + +ImGui::Spacing(); +ImGui::Separator(); +ImGui::Spacing(); + +if (ImGui::Button("Restore Gameplay Defaults", ImVec2(-1, 0))) { + pendingMouseSensitivity = 0.2f; + pendingInvertMouse = false; + pendingExtendedZoom = false; + pendingUiOpacity = 65; + pendingMinimapRotate = false; + pendingMinimapSquare = false; + pendingMinimapNpcDots = false; + pendingSeparateBags = true; + inventoryScreen.setSeparateBags(true); + pendingShowKeyring = true; + inventoryScreen.setShowKeyring(true); + uiOpacity_ = 0.65f; + minimapRotate_ = false; + minimapSquare_ = false; + minimapNpcDots_ = false; + if (renderer) { + if (auto* cameraController = renderer->getCameraController()) { + cameraController->setMouseSensitivity(pendingMouseSensitivity); + cameraController->setInvertMouse(pendingInvertMouse); + cameraController->setExtendedZoom(pendingExtendedZoom); + } + if (auto* minimap = renderer->getMinimap()) { + minimap->setRotateWithCamera(minimapRotate_); + minimap->setSquareShape(minimapSquare_); + } + } + saveCallback(); +} + +} + +void SettingsPanel::renderSettingsControlsTab(std::function saveCallback) { +ImGui::Spacing(); + +ImGui::Text("Keybindings"); +ImGui::Separator(); + +auto& km = ui::KeybindingManager::getInstance(); +int numActions = km.getActionCount(); + +for (int i = 0; i < numActions; ++i) { + auto action = static_cast(i); + const char* actionName = km.getActionName(action); + ImGuiKey currentKey = km.getKeyForAction(action); + + // Display current binding + ImGui::Text("%s:", actionName); + ImGui::SameLine(200); + + // Get human-readable key name (basic implementation) + const char* keyName = "Unknown"; + if (currentKey >= ImGuiKey_A && currentKey <= ImGuiKey_Z) { + static char keyBuf[16]; + snprintf(keyBuf, sizeof(keyBuf), "%c", 'A' + (currentKey - ImGuiKey_A)); + keyName = keyBuf; + } else if (currentKey >= ImGuiKey_0 && currentKey <= ImGuiKey_9) { + static char keyBuf[16]; + snprintf(keyBuf, sizeof(keyBuf), "%c", '0' + (currentKey - ImGuiKey_0)); + keyName = keyBuf; + } else if (currentKey == ImGuiKey_Escape) { + keyName = "Escape"; + } else if (currentKey == ImGuiKey_Enter) { + keyName = "Enter"; + } else if (currentKey == ImGuiKey_Tab) { + keyName = "Tab"; + } else if (currentKey == ImGuiKey_Space) { + keyName = "Space"; + } else if (currentKey >= ImGuiKey_F1 && currentKey <= ImGuiKey_F12) { + static char keyBuf[16]; + snprintf(keyBuf, sizeof(keyBuf), "F%d", 1 + (currentKey - ImGuiKey_F1)); + keyName = keyBuf; + } + + ImGui::Text("[%s]", keyName); + + // Rebind button + ImGui::SameLine(350); + if (ImGui::Button(awaitingKeyPress_ && pendingRebindAction_ == i ? "Waiting..." : "Rebind", ImVec2(100, 0))) { + pendingRebindAction_ = i; + awaitingKeyPress_ = true; + } +} + +// Handle key press during rebinding +if (awaitingKeyPress_ && pendingRebindAction_ >= 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Text("Press any key to bind to this action (Esc to cancel)..."); + + // Check for any key press + bool foundKey = false; + ImGuiKey newKey = ImGuiKey_None; + for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k) { + if (ImGui::IsKeyPressed(static_cast(k), false)) { + if (k == ImGuiKey_Escape) { + // Cancel rebinding + awaitingKeyPress_ = false; + pendingRebindAction_ = -1; + foundKey = true; + break; + } + newKey = static_cast(k); + foundKey = true; + break; + } + } + + if (foundKey && newKey != ImGuiKey_None) { + auto action = static_cast(pendingRebindAction_); + km.setKeyForAction(action, newKey); + awaitingKeyPress_ = false; + pendingRebindAction_ = -1; + saveCallback(); + } +} + +ImGui::Spacing(); +ImGui::Separator(); +ImGui::Spacing(); + +if (ImGui::Button("Reset to Defaults", ImVec2(-1, 0))) { + km.resetToDefaults(); + awaitingKeyPress_ = false; + pendingRebindAction_ = -1; + saveCallback(); +} + +} + +void SettingsPanel::renderSettingsAudioTab(std::function saveCallback) { + auto* renderer = core::Application::getInstance().getRenderer(); +ImGui::Spacing(); +ImGui::BeginChild("AudioSettings", ImVec2(0, 360), true); + +// Helper lambda to apply audio settings +auto applyAudioSettings = [&]() { + applyAudioVolumes(renderer); + saveCallback(); +}; + +ImGui::Text("Master Volume"); +if (ImGui::SliderInt("##MasterVolume", &pendingMasterVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} +ImGui::Separator(); + +if (ImGui::Checkbox("Enable WoWee Music", &pendingUseOriginalSoundtrack)) { + if (renderer) { + if (auto* zm = renderer->getZoneManager()) { + zm->setUseOriginalSoundtrack(pendingUseOriginalSoundtrack); + } + } + saveCallback(); +} +if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Include WoWee music tracks in zone music rotation"); +ImGui::Separator(); + +ImGui::Text("Music"); +if (ImGui::SliderInt("##MusicVolume", &pendingMusicVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} + +ImGui::Spacing(); +ImGui::Text("Ambient Sounds"); +if (ImGui::SliderInt("##AmbientVolume", &pendingAmbientVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} +ImGui::TextWrapped("Weather, zones, cities, emitters"); + +ImGui::Spacing(); +ImGui::Text("UI Sounds"); +if (ImGui::SliderInt("##UiVolume", &pendingUiVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} +ImGui::TextWrapped("Buttons, loot, quest complete"); + +ImGui::Spacing(); +ImGui::Text("Combat Sounds"); +if (ImGui::SliderInt("##CombatVolume", &pendingCombatVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} +ImGui::TextWrapped("Weapon swings, impacts, grunts"); + +ImGui::Spacing(); +ImGui::Text("Spell Sounds"); +if (ImGui::SliderInt("##SpellVolume", &pendingSpellVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} +ImGui::TextWrapped("Magic casting and impacts"); + +ImGui::Spacing(); +ImGui::Text("Movement Sounds"); +if (ImGui::SliderInt("##MovementVolume", &pendingMovementVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} +ImGui::TextWrapped("Water splashes, jump/land"); + +ImGui::Spacing(); +ImGui::Text("Footsteps"); +if (ImGui::SliderInt("##FootstepVolume", &pendingFootstepVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} + +ImGui::Spacing(); +ImGui::Text("NPC Voices"); +if (ImGui::SliderInt("##NpcVoiceVolume", &pendingNpcVoiceVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} + +ImGui::Spacing(); +ImGui::Text("Mount Sounds"); +if (ImGui::SliderInt("##MountVolume", &pendingMountVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} + +ImGui::Spacing(); +ImGui::Text("Activity Sounds"); +if (ImGui::SliderInt("##ActivityVolume", &pendingActivityVolume, 0, 100, "%d%%")) { + applyAudioSettings(); +} +ImGui::TextWrapped("Swimming, eating, drinking"); + +ImGui::EndChild(); + +if (ImGui::Button("Restore Audio Defaults", ImVec2(-1, 0))) { + pendingMasterVolume = 100; + pendingMusicVolume = 30; // default music volume + pendingAmbientVolume = 100; + pendingUiVolume = 100; + pendingCombatVolume = 100; + pendingSpellVolume = 100; + pendingMovementVolume = 100; + pendingFootstepVolume = 100; + pendingNpcVoiceVolume = 100; + pendingMountVolume = 100; + pendingActivityVolume = 100; + applyAudioSettings(); +} + +} + +void SettingsPanel::renderSettingsAboutTab() { +ImGui::Spacing(); +ImGui::Spacing(); + +ImGui::TextWrapped("WoWee - World of Warcraft Client Emulator"); +ImGui::Spacing(); +ImGui::Separator(); +ImGui::Spacing(); + +ImGui::Text("Developer"); +ImGui::Indent(); +ImGui::Text("Kelsi Davis"); +ImGui::Unindent(); +ImGui::Spacing(); + +ImGui::Text("GitHub"); +ImGui::Indent(); +ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "https://github.com/Kelsidavis/WoWee"); +if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Click to copy"); +} +if (ImGui::IsItemClicked()) { + ImGui::SetClipboardText("https://github.com/Kelsidavis/WoWee"); +} +ImGui::Unindent(); +ImGui::Spacing(); + +ImGui::Text("Contact"); +ImGui::Indent(); +ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "github.com/Kelsidavis"); +if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Click to copy"); +} +if (ImGui::IsItemClicked()) { + ImGui::SetClipboardText("https://github.com/Kelsidavis"); +} +ImGui::Unindent(); + +ImGui::Spacing(); +ImGui::Separator(); +ImGui::Spacing(); + +ImGui::TextWrapped("A multi-expansion WoW client supporting Classic, TBC, and WotLK (3.3.5a)."); +ImGui::Spacing(); +ImGui::TextDisabled("Built with Vulkan, SDL2, and ImGui"); + +} + +void SettingsPanel::renderSettingsWindow(InventoryScreen& inventoryScreen, ChatPanel& chatPanel, + std::function saveCallback) { + if (!showSettingsWindow) return; + + auto* window = core::Application::getInstance().getWindow(); + auto* renderer = core::Application::getInstance().getRenderer(); + if (!window) return; + + static constexpr int kResolutions[][2] = { + {1280, 720}, + {1600, 900}, + {1920, 1080}, + {2560, 1440}, + {3840, 2160}, + }; + static constexpr int kResCount = sizeof(kResolutions) / sizeof(kResolutions[0]); + constexpr int kDefaultResW = 1920; + constexpr int kDefaultResH = 1080; + constexpr bool kDefaultFullscreen = false; + constexpr bool kDefaultVsync = true; + constexpr bool kDefaultShadows = true; + constexpr int kDefaultGroundClutterDensity = 100; + + int defaultResIndex = 0; + for (int i = 0; i < kResCount; i++) { + if (kResolutions[i][0] == kDefaultResW && kResolutions[i][1] == kDefaultResH) { + defaultResIndex = i; + break; + } + } + + if (!settingsInit) { + pendingFullscreen = window->isFullscreen(); + pendingVsync = window->isVsyncEnabled(); + if (renderer) { + renderer->setShadowsEnabled(pendingShadows); + renderer->setShadowDistance(pendingShadowDistance); + // Read non-volume settings from actual state (volumes come from saved settings) + if (auto* cameraController = renderer->getCameraController()) { + pendingMouseSensitivity = cameraController->getMouseSensitivity(); + pendingInvertMouse = cameraController->isInvertMouse(); + cameraController->setExtendedZoom(pendingExtendedZoom); + } + } + pendingResIndex = 0; + int curW = window->getWidth(); + int curH = window->getHeight(); + for (int i = 0; i < kResCount; i++) { + if (kResolutions[i][0] == curW && kResolutions[i][1] == curH) { + pendingResIndex = i; + break; + } + } + pendingUiOpacity = static_cast(std::lround(uiOpacity_ * 100.0f)); + pendingMinimapRotate = minimapRotate_; + pendingMinimapSquare = minimapSquare_; + pendingMinimapNpcDots = minimapNpcDots_; + pendingShowLatencyMeter = showLatencyMeter_; + if (renderer) { + if (auto* minimap = renderer->getMinimap()) { + minimap->setRotateWithCamera(minimapRotate_); + minimap->setSquareShape(minimapSquare_); + } + if (auto* zm = renderer->getZoneManager()) { + pendingUseOriginalSoundtrack = zm->getUseOriginalSoundtrack(); + } + } + settingsInit = true; + } + + ImGuiIO& io = ImGui::GetIO(); + float screenW = io.DisplaySize.x; + float screenH = io.DisplaySize.y; + ImVec2 size(520.0f, std::min(screenH * 0.9f, 720.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("##SettingsWindow", nullptr, flags)) { + ImGui::Text("Settings"); + ImGui::Separator(); + + if (ImGui::BeginTabBar("SettingsTabs", ImGuiTabBarFlags_None)) { + // ============================================================ + // VIDEO TAB + // ============================================================ + if (ImGui::BeginTabItem("Video")) { + ImGui::Spacing(); + + // Graphics Quality Presets + { + const char* presetLabels[] = { "Custom", "Low", "Medium", "High", "Ultra" }; + int presetIdx = static_cast(pendingGraphicsPreset); + if (ImGui::Combo("Quality Preset", &presetIdx, presetLabels, 5)) { + pendingGraphicsPreset = static_cast(presetIdx); + if (pendingGraphicsPreset != GraphicsPreset::CUSTOM) { + applyGraphicsPreset(pendingGraphicsPreset); + saveCallback(); + } + } + ImGui::TextDisabled("Adjust these for custom settings"); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Checkbox("Fullscreen", &pendingFullscreen)) { + window->setFullscreen(pendingFullscreen); + updateGraphicsPresetFromCurrentSettings(); + saveCallback(); + } + if (ImGui::Checkbox("VSync", &pendingVsync)) { + window->setVsync(pendingVsync); + updateGraphicsPresetFromCurrentSettings(); + saveCallback(); + } + if (ImGui::Checkbox("Shadows", &pendingShadows)) { + if (renderer) renderer->setShadowsEnabled(pendingShadows); + updateGraphicsPresetFromCurrentSettings(); + saveCallback(); + } + if (pendingShadows) { + ImGui::SameLine(); + ImGui::SetNextItemWidth(150.0f); + if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 500.0f, "%.0f")) { + if (renderer) renderer->setShadowDistance(pendingShadowDistance); + updateGraphicsPresetFromCurrentSettings(); + saveCallback(); + } + } + { + if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { + if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + updateGraphicsPresetFromCurrentSettings(); + saveCallback(); + } + } + { + const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; + bool fsr2Active = renderer && renderer->isFSR2Enabled(); + if (fsr2Active) { + ImGui::BeginDisabled(); + int disabled = 0; + ImGui::Combo("Anti-Aliasing (FSR3)", &disabled, "Off (FSR3 active)\0", 1); + ImGui::EndDisabled(); + } else if (ImGui::Combo("Anti-Aliasing", &pendingAntiAliasing, aaLabels, 4)) { + static const VkSampleCountFlagBits aaSamples[] = { + VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT, + VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT + }; + if (renderer) renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]); + updateGraphicsPresetFromCurrentSettings(); + saveCallback(); + } + // FXAA — post-process, combinable with MSAA or FSR3 + { + if (ImGui::Checkbox("FXAA (post-process)", &pendingFXAA)) { + if (renderer) renderer->setFXAAEnabled(pendingFXAA); + updateGraphicsPresetFromCurrentSettings(); + saveCallback(); + } + if (ImGui::IsItemHovered()) { + if (fsr2Active) + ImGui::SetTooltip("FXAA applies spatial anti-aliasing after FSR3 upscaling.\nFSR3 + FXAA is the recommended ultra-quality combination."); + else + ImGui::SetTooltip("FXAA smooths jagged edges as a post-process pass.\nCan be combined with MSAA for extra quality."); + } + } + } + // FSR Upscaling + { + // FSR mode selection: Off, FSR 1.0 (Spatial), FSR 3.x (Temporal) + const char* fsrModeLabels[] = { "Off", "FSR 1.0 (Spatial)", "FSR 3.x (Temporal)" }; + int fsrMode = pendingUpscalingMode; + if (ImGui::Combo("Upscaling", &fsrMode, fsrModeLabels, 3)) { + pendingUpscalingMode = fsrMode; + pendingFSR = (fsrMode == 1); + if (renderer) { + renderer->setFSREnabled(fsrMode == 1); + renderer->setFSR2Enabled(fsrMode == 2); + } + saveCallback(); + } + if (fsrMode > 0) { + if (fsrMode == 2 && renderer) { + ImGui::TextDisabled("FSR3 backend: %s", + renderer->isAmdFsr2SdkAvailable() ? "AMD FidelityFX SDK" : "Internal fallback"); + if (renderer->isAmdFsr3FramegenSdkAvailable()) { + if (ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &pendingAMDFramegen)) { + renderer->setAmdFsr3FramegenEnabled(pendingAMDFramegen); + saveCallback(); + } + const char* runtimeStatus = "Unavailable"; + if (renderer->isAmdFsr3FramegenRuntimeActive()) { + runtimeStatus = "Active"; + } else if (renderer->isAmdFsr3FramegenRuntimeReady()) { + runtimeStatus = "Ready"; + } else { + runtimeStatus = "Unavailable"; + } + ImGui::TextDisabled("Runtime: %s (%s)", + runtimeStatus, renderer->getAmdFsr3FramegenRuntimePath()); + if (!renderer->isAmdFsr3FramegenRuntimeReady()) { + const std::string& runtimeErr = renderer->getAmdFsr3FramegenRuntimeError(); + if (!runtimeErr.empty()) { + ImGui::TextDisabled("Reason: %s", runtimeErr.c_str()); + } + } + } else { + ImGui::BeginDisabled(); + bool disabledFg = false; + ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &disabledFg); + ImGui::EndDisabled(); + ImGui::TextDisabled("Requires FidelityFX-SDK framegen headers."); + } + } + const char* fsrQualityLabels[] = { "Native (100%)", "Ultra Quality (77%)", "Quality (67%)", "Balanced (59%)" }; + static constexpr float fsrScaleFactors[] = { 0.77f, 0.67f, 0.59f, 1.00f }; + static constexpr int displayToInternal[] = { 3, 0, 1, 2 }; + pendingFSRQuality = std::clamp(pendingFSRQuality, 0, 3); + int fsrQualityDisplay = 0; + for (int i = 0; i < 4; ++i) { + if (displayToInternal[i] == pendingFSRQuality) { + fsrQualityDisplay = i; + break; + } + } + if (ImGui::Combo("FSR Quality", &fsrQualityDisplay, fsrQualityLabels, 4)) { + pendingFSRQuality = displayToInternal[fsrQualityDisplay]; + if (renderer) renderer->setFSRQuality(fsrScaleFactors[pendingFSRQuality]); + saveCallback(); + } + if (ImGui::SliderFloat("FSR Sharpness", &pendingFSRSharpness, 0.0f, 2.0f, "%.1f")) { + if (renderer) renderer->setFSRSharpness(pendingFSRSharpness); + saveCallback(); + } + if (fsrMode == 2) { + ImGui::SeparatorText("FSR3 Tuning"); + if (ImGui::SliderFloat("Jitter Sign", &pendingFSR2JitterSign, -2.0f, 2.0f, "%.2f")) { + if (renderer) { + renderer->setFSR2DebugTuning( + pendingFSR2JitterSign, + pendingFSR2MotionVecScaleX, + pendingFSR2MotionVecScaleY); + } + saveCallback(); + } + ImGui::TextDisabled("Tip: 0.38 is the current recommended default."); + } + } + } + if (ImGui::SliderInt("Ground Clutter Density", &pendingGroundClutterDensity, 0, 150, "%d%%")) { + if (renderer) { + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); + } + } + saveCallback(); + } + if (ImGui::Checkbox("Normal Mapping", &pendingNormalMapping)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + } + } + saveCallback(); + } + if (pendingNormalMapping) { + if (ImGui::SliderFloat("Normal Map Strength", &pendingNormalMapStrength, 0.0f, 2.0f, "%.1f")) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMapStrength(pendingNormalMapStrength); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMapStrength(pendingNormalMapStrength); + } + } + saveCallback(); + } + } + if (ImGui::Checkbox("Parallax Mapping", &pendingPOM)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setPOMEnabled(pendingPOM); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setPOMEnabled(pendingPOM); + } + } + saveCallback(); + } + if (pendingPOM) { + const char* pomLabels[] = { "Low", "Medium", "High" }; + if (ImGui::Combo("Parallax Quality", &pendingPOMQuality, pomLabels, 3)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setPOMQuality(pendingPOMQuality); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setPOMQuality(pendingPOMQuality); + } + } + saveCallback(); + } + } + + const char* resLabel = "Resolution"; + const char* resItems[kResCount]; + char resBuf[kResCount][16]; + for (int i = 0; i < kResCount; i++) { + snprintf(resBuf[i], sizeof(resBuf[i]), "%dx%d", kResolutions[i][0], kResolutions[i][1]); + resItems[i] = resBuf[i]; + } + if (ImGui::Combo(resLabel, &pendingResIndex, resItems, kResCount)) { + window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); + saveCallback(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::SetNextItemWidth(200.0f); + if (ImGui::SliderInt("Brightness", &pendingBrightness, 0, 100, "%d%%")) { + if (renderer) renderer->setBrightness(static_cast(pendingBrightness) / 50.0f); + saveCallback(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Button("Restore Video Defaults", ImVec2(-1, 0))) { + pendingFullscreen = kDefaultFullscreen; + pendingVsync = kDefaultVsync; + pendingShadows = kDefaultShadows; + pendingShadowDistance = 300.0f; + pendingGroundClutterDensity = kDefaultGroundClutterDensity; + pendingAntiAliasing = 0; + pendingNormalMapping = true; + pendingNormalMapStrength = 0.8f; + pendingPOM = true; + pendingPOMQuality = 1; + pendingResIndex = defaultResIndex; + pendingBrightness = 50; + window->setFullscreen(pendingFullscreen); + window->setVsync(pendingVsync); + window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); + if (renderer) renderer->setBrightness(1.0f); + pendingWaterRefraction = false; + if (renderer) { + renderer->setShadowsEnabled(pendingShadows); + renderer->setShadowDistance(pendingShadowDistance); + } + if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + if (renderer) renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); + if (renderer) { + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); + } + } + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + wr->setNormalMapStrength(pendingNormalMapStrength); + wr->setPOMEnabled(pendingPOM); + wr->setPOMQuality(pendingPOMQuality); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + cr->setNormalMapStrength(pendingNormalMapStrength); + cr->setPOMEnabled(pendingPOM); + cr->setPOMQuality(pendingPOMQuality); + } + } + saveCallback(); + } + + ImGui::EndTabItem(); + } + + // ============================================================ + // INTERFACE TAB + // ============================================================ + if (ImGui::BeginTabItem("Interface")) { + renderSettingsInterfaceTab(saveCallback); + ImGui::EndTabItem(); + } + + // ============================================================ + // AUDIO TAB + // ============================================================ + if (ImGui::BeginTabItem("Audio")) { + renderSettingsAudioTab(saveCallback); + ImGui::EndTabItem(); + } + + // ============================================================ + // GAMEPLAY TAB + // ============================================================ + if (ImGui::BeginTabItem("Gameplay")) { + renderSettingsGameplayTab(inventoryScreen, saveCallback); + ImGui::EndTabItem(); + } + + // ============================================================ + // CONTROLS TAB + // ============================================================ + if (ImGui::BeginTabItem("Controls")) { + renderSettingsControlsTab(saveCallback); + ImGui::EndTabItem(); + } + + // ============================================================ + // CHAT TAB + // ============================================================ + if (ImGui::BeginTabItem("Chat")) { + chatPanel.renderSettingsTab(saveCallback); + ImGui::EndTabItem(); + } + + // ============================================================ + // ABOUT TAB + // ============================================================ + if (ImGui::BeginTabItem("About")) { + renderSettingsAboutTab(); + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } + + ImGui::Spacing(); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); + if (ImGui::Button("Back to Game", ImVec2(-1, 0))) { + showSettingsWindow = false; + } + ImGui::PopStyleVar(); + } + ImGui::End(); +} + +void SettingsPanel::applyGraphicsPreset(GraphicsPreset preset) { + auto* renderer = core::Application::getInstance().getRenderer(); + + // Define preset values based on quality level + switch (preset) { + case GraphicsPreset::LOW: { + pendingShadows = false; + pendingShadowDistance = 100.0f; + pendingAntiAliasing = 0; // Off + pendingNormalMapping = false; + pendingPOM = false; + pendingGroundClutterDensity = 25; + if (renderer) { + renderer->setShadowsEnabled(false); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(false); + wr->setPOMEnabled(false); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(false); + cr->setPOMEnabled(false); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(0.25f); + } + } + break; + } + case GraphicsPreset::MEDIUM: { + pendingShadows = true; + pendingShadowDistance = 200.0f; + pendingAntiAliasing = 1; // 2x MSAA + pendingNormalMapping = true; + pendingNormalMapStrength = 0.6f; + pendingPOM = true; + pendingPOMQuality = 0; // Low + pendingGroundClutterDensity = 60; + if (renderer) { + renderer->setShadowsEnabled(true); + renderer->setShadowDistance(200.0f); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_2_BIT); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(true); + wr->setNormalMapStrength(0.6f); + wr->setPOMEnabled(true); + wr->setPOMQuality(0); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(true); + cr->setNormalMapStrength(0.6f); + cr->setPOMEnabled(true); + cr->setPOMQuality(0); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(0.60f); + } + } + break; + } + case GraphicsPreset::HIGH: { + pendingShadows = true; + pendingShadowDistance = 350.0f; + pendingAntiAliasing = 2; // 4x MSAA + pendingNormalMapping = true; + pendingNormalMapStrength = 0.8f; + pendingPOM = true; + pendingPOMQuality = 1; // Medium + pendingGroundClutterDensity = 100; + if (renderer) { + renderer->setShadowsEnabled(true); + renderer->setShadowDistance(350.0f); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_4_BIT); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(true); + wr->setNormalMapStrength(0.8f); + wr->setPOMEnabled(true); + wr->setPOMQuality(1); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(true); + cr->setNormalMapStrength(0.8f); + cr->setPOMEnabled(true); + cr->setPOMQuality(1); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(1.0f); + } + } + break; + } + case GraphicsPreset::ULTRA: { + pendingShadows = true; + pendingShadowDistance = 500.0f; + pendingAntiAliasing = 3; // 8x MSAA + pendingFXAA = true; // FXAA on top of MSAA for maximum smoothness + pendingNormalMapping = true; + pendingNormalMapStrength = 1.2f; + pendingPOM = true; + pendingPOMQuality = 2; // High + pendingGroundClutterDensity = 150; + if (renderer) { + renderer->setShadowsEnabled(true); + renderer->setShadowDistance(500.0f); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT); + renderer->setFXAAEnabled(true); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(true); + wr->setNormalMapStrength(1.2f); + wr->setPOMEnabled(true); + wr->setPOMQuality(2); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(true); + cr->setNormalMapStrength(1.2f); + cr->setPOMEnabled(true); + cr->setPOMQuality(2); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(1.5f); + } + } + break; + } + default: + break; + } + + currentGraphicsPreset = preset; + pendingGraphicsPreset = preset; +} + +void SettingsPanel::updateGraphicsPresetFromCurrentSettings() { + // Check if current settings match any preset, otherwise mark as CUSTOM + // This is a simplified check; could be enhanced with more detailed matching + + auto matchesPreset = [this](GraphicsPreset preset) -> bool { + switch (preset) { + case GraphicsPreset::LOW: + return !pendingShadows && pendingAntiAliasing == 0 && !pendingNormalMapping && !pendingPOM && + pendingGroundClutterDensity <= 30; + case GraphicsPreset::MEDIUM: + return pendingShadows && pendingShadowDistance >= 180 && pendingShadowDistance <= 220 && + pendingAntiAliasing == 1 && pendingNormalMapping && pendingPOM && + pendingGroundClutterDensity >= 50 && pendingGroundClutterDensity <= 70; + case GraphicsPreset::HIGH: + return pendingShadows && pendingShadowDistance >= 330 && pendingShadowDistance <= 370 && + pendingAntiAliasing == 2 && pendingNormalMapping && pendingPOM && + pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110; + case GraphicsPreset::ULTRA: + return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 && + pendingFXAA && pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140; + default: + return false; + } + }; + + // Try to match a preset, otherwise mark as custom + if (matchesPreset(GraphicsPreset::LOW)) { + pendingGraphicsPreset = GraphicsPreset::LOW; + } else if (matchesPreset(GraphicsPreset::MEDIUM)) { + pendingGraphicsPreset = GraphicsPreset::MEDIUM; + } else if (matchesPreset(GraphicsPreset::HIGH)) { + pendingGraphicsPreset = GraphicsPreset::HIGH; + } else if (matchesPreset(GraphicsPreset::ULTRA)) { + pendingGraphicsPreset = GraphicsPreset::ULTRA; + } else { + pendingGraphicsPreset = GraphicsPreset::CUSTOM; + } +} + +std::string SettingsPanel::getSettingsPath() { + std::string dir; +#ifdef _WIN32 + const char* appdata = std::getenv("APPDATA"); + dir = appdata ? std::string(appdata) + "\\wowee" : "."; +#else + const char* home = std::getenv("HOME"); + dir = home ? std::string(home) + "/.wowee" : "."; +#endif + return dir + "/settings.cfg"; +} + +void SettingsPanel::applyAudioVolumes(rendering::Renderer* renderer) { + if (!renderer) return; + float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; + audio::AudioEngine::instance().setMasterVolume(masterScale); + if (auto* music = renderer->getMusicManager()) + music->setVolume(pendingMusicVolume); + if (auto* ambient = renderer->getAmbientSoundManager()) + ambient->setVolumeScale(pendingAmbientVolume / 100.0f); + if (auto* ui = renderer->getUiSoundManager()) + ui->setVolumeScale(pendingUiVolume / 100.0f); + if (auto* combat = renderer->getCombatSoundManager()) + combat->setVolumeScale(pendingCombatVolume / 100.0f); + if (auto* spell = renderer->getSpellSoundManager()) + spell->setVolumeScale(pendingSpellVolume / 100.0f); + if (auto* movement = renderer->getMovementSoundManager()) + movement->setVolumeScale(pendingMovementVolume / 100.0f); + if (auto* footstep = renderer->getFootstepManager()) + footstep->setVolumeScale(pendingFootstepVolume / 100.0f); + if (auto* npcVoice = renderer->getNpcVoiceManager()) + npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f); + if (auto* mount = renderer->getMountSoundManager()) + mount->setVolumeScale(pendingMountVolume / 100.0f); + if (auto* activity = renderer->getActivitySoundManager()) + activity->setVolumeScale(pendingActivityVolume / 100.0f); +} + + +} // namespace ui +} // namespace wowee From c9353853f8d9e2ea1a05ad6fe1db28a6aab0c59d Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 31 Mar 2026 19:49:52 +0300 Subject: [PATCH 4/5] `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 From 0e6aaeb44ec15dbc50157c1757c615e1c987c741 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 31 Mar 2026 20:11:28 +0300 Subject: [PATCH 5/5] fix warnings, remove phases from commentaries --- include/game/entity_controller.hpp | 38 ++++++++++++------------------ include/game/game_handler.hpp | 24 ++++--------------- include/game/world_packets.hpp | 14 +++++------ src/game/entity_controller.cpp | 4 ++-- src/ui/action_bar_panel.cpp | 2 +- src/ui/chat_panel.cpp | 6 ++--- 6 files changed, 33 insertions(+), 55 deletions(-) diff --git a/include/game/entity_controller.hpp b/include/game/entity_controller.hpp index ee63504c..17af614b 100644 --- a/include/game/entity_controller.hpp +++ b/include/game/entity_controller.hpp @@ -143,28 +143,25 @@ private: void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated); void finalizeUpdateObjectBatch(bool newItemCreated); - // --- Phase 1: Extracted helper methods --- bool extractPlayerAppearance(const std::map& fields, uint8_t& outRace, uint8_t& outGender, uint32_t& outAppearanceBytes, uint8_t& outFacial) const; void maybeDetectCoinageIndex(const std::map& oldFields, const std::map& newFields); - // --- Phase 2: Update type handlers --- void handleCreateObject(const UpdateBlock& block, bool& newItemCreated); void handleValuesUpdate(const UpdateBlock& block); void handleMovementUpdate(const UpdateBlock& block); - // --- Phase 3: Concern-specific helpers --- - // 3i: Update transport-relative child attachment (non-player entities). + // Update transport-relative child attachment (non-player entities). // Consolidates identical logic from CREATE/VALUES/MOVEMENT handlers. void updateNonPlayerTransportAttachment(const UpdateBlock& block, const std::shared_ptr& entity, ObjectType entityType); - // 3f: Rebuild playerAuras_ from UNIT_FIELD_AURAS (Classic/vanilla only). + // Rebuild playerAuras_ from UNIT_FIELD_AURAS (Classic/vanilla only). // Consolidates identical logic from CREATE and VALUES handlers. void syncClassicAurasFromFields(const std::shared_ptr& entity); - // 3h: Detect mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes (self-player only). + // Detect mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes (self-player only). // Consolidates identical logic from CREATE and VALUES handlers. void detectPlayerMountChange(uint32_t newMountDisplayId, const std::map& blockFields); @@ -172,7 +169,6 @@ private: // Shared player-death handler: caches corpse position, sets death state. void markPlayerDead(const char* source); - // --- Phase 4: Field index cache structs --- // Cached field indices resolved once per handler call to avoid repeated lookups. struct UnitFieldIndices { uint16_t health, maxHealth, powerBase, maxPowerBase; @@ -202,44 +198,42 @@ private: uint32_t oldDisplayId = 0; }; - // --- Phase 3: Extracted concern-specific helpers (continued) --- - // 3a: Entity factory — creates the correct Entity subclass for the given block. + // Entity factory — creates the correct Entity subclass for the given block. std::shared_ptr createEntityFromBlock(const UpdateBlock& block); - // 3b: Track player-on-transport state from movement blocks. + // Track player-on-transport state from movement blocks. void applyPlayerTransportState(const UpdateBlock& block, const std::shared_ptr& entity, const glm::vec3& canonicalPos, float oCanonical, bool updateMovementInfoPos); - // 3c: Apply unit fields during CREATE — returns true if entity is initially dead. + // Apply unit fields during CREATE — returns true if entity is initially dead. bool applyUnitFieldsOnCreate(const UpdateBlock& block, std::shared_ptr& unit, const UnitFieldIndices& ufi); - // 3c: Apply unit fields during VALUES — returns change tracking result. + // Apply unit fields during VALUES — returns change tracking result. UnitFieldUpdateResult applyUnitFieldsOnUpdate(const UpdateBlock& block, const std::shared_ptr& entity, std::shared_ptr& unit, const UnitFieldIndices& ufi); - // 3d: Apply player stat fields (XP, inventory, skills, etc.). isCreate=true for CREATE path. + // Apply player stat fields (XP, inventory, skills, etc.). isCreate=true for CREATE path. bool applyPlayerStatFields(const std::map& fields, const PlayerFieldIndices& pfi, bool isCreate); - // 3e: Dispatch spawn callbacks (creature/player) — deduplicates CREATE and VALUES paths. + // Dispatch spawn callbacks (creature/player) — deduplicates CREATE and VALUES paths. void dispatchEntitySpawn(uint64_t guid, ObjectType objectType, const std::shared_ptr& entity, const std::shared_ptr& unit, bool isDead); - // 3g: Track item/container on CREATE. + // Track item/container on CREATE. void trackItemOnCreate(const UpdateBlock& block, bool& newItemCreated); - // 3g: Update item fields on VALUES update. + // Update item fields on VALUES update. void updateItemOnValuesUpdate(const UpdateBlock& block, const std::shared_ptr& entity); - // --- Phase 5: Strategy pattern — object-type handler interface --- // Allows extending object-type handling without modifying handler dispatch. struct IObjectTypeHandler { virtual ~IObjectTypeHandler() = default; - virtual void onCreate(const UpdateBlock& block, std::shared_ptr& entity, - bool& newItemCreated) {} - virtual void onValuesUpdate(const UpdateBlock& block, std::shared_ptr& entity) {} - virtual void onMovementUpdate(const UpdateBlock& block, std::shared_ptr& entity) {} + virtual void onCreate(const UpdateBlock& /*block*/, std::shared_ptr& /*entity*/, + bool& /*newItemCreated*/) {} + virtual void onValuesUpdate(const UpdateBlock& /*block*/, std::shared_ptr& /*entity*/) {} + virtual void onMovementUpdate(const UpdateBlock& /*block*/, std::shared_ptr& /*entity*/) {} }; struct UnitTypeHandler; struct PlayerTypeHandler; @@ -250,7 +244,6 @@ private: void initTypeHandlers(); IObjectTypeHandler* getTypeHandler(ObjectType type) const; - // --- Phase 5: Type-specific handler implementations (trampolined from handlers) --- void onCreateUnit(const UpdateBlock& block, std::shared_ptr& entity); void onCreatePlayer(const UpdateBlock& block, std::shared_ptr& entity); void onCreateGameObject(const UpdateBlock& block, std::shared_ptr& entity); @@ -265,7 +258,6 @@ private: void onValuesUpdateItem(const UpdateBlock& block, std::shared_ptr& entity); void onValuesUpdateGameObject(const UpdateBlock& block, std::shared_ptr& entity); - // --- Phase 6: Deferred event bus --- // Collects addon events during block processing, flushes at the end. struct PendingEvents { std::vector>> events; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1f68c834..b6c86794 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -627,7 +627,6 @@ public: void resetWardenState(); // clear all warden module/crypto state for connect/disconnect void clearUnitCaches(); // clear per-unit cast states and aura caches - // ---- Phase 1: Name queries (delegated to EntityController) ---- void queryPlayerName(uint64_t guid); void queryCreatureInfo(uint32_t entry, uint64_t guid); void queryGameObjectInfo(uint32_t entry, uint64_t guid); @@ -661,7 +660,6 @@ public: return entityController_->getCreatureFamily(entry); } - // ---- Phase 2: Combat (delegated to CombatHandler) ---- void startAutoAttack(uint64_t targetGuid); void stopAutoAttack(); bool isAutoAttacking() const; @@ -696,7 +694,6 @@ public: const std::vector* getThreatList(uint64_t unitGuid) const; const std::vector* getTargetThreatList() const; - // ---- Phase 3: Spells ---- void castSpell(uint32_t spellId, uint64_t targetGuid = 0); void cancelCast(); void cancelAura(uint32_t spellId); @@ -1239,7 +1236,7 @@ public: void acceptResurrect(); void declineResurrect(); - // ---- Phase 4: Group ---- + // ---- Group ---- void inviteToGroup(const std::string& playerName); void acceptGroupInvite(); void declineGroupInvite(); @@ -1396,7 +1393,7 @@ public: return nullptr; } - // ---- Phase 5: Loot ---- + // ---- Loot ---- void lootTarget(uint64_t guid); void lootItem(uint8_t slotIndex); void closeLoot(); @@ -2177,7 +2174,6 @@ private: */ void handlePong(network::Packet& packet); - // ---- Phase 1 handlers (entity queries delegated to EntityController) ---- void handleItemQueryResponse(network::Packet& packet); void queryItemInfo(uint32_t entry, uint64_t guid); void rebuildOnlineInventory(); @@ -2190,7 +2186,6 @@ private: void extractContainerFields(uint64_t containerGuid, const std::map& fields); uint64_t resolveOnlineItemGuid(uint32_t itemId) const; - // ---- Phase 2 handlers (dead — dispatched via CombatHandler) ---- // handleAttackStart, handleAttackStop, handleAttackerStateUpdate, // handleSpellDamageLog, handleSpellHealLog removed @@ -2198,12 +2193,6 @@ private: void handleUpdateAuraDuration(uint8_t slot, uint32_t durationMs); // handleSetForcedReactions — dispatched via CombatHandler - // ---- Phase 3 handlers ---- - - // ---- Talent handlers ---- - - // ---- Phase 4 handlers ---- - // ---- Guild handlers ---- void handlePetSpells(network::Packet& packet); @@ -2217,7 +2206,6 @@ private: // ---- Other player movement (MSG_MOVE_* from server) ---- - // ---- Phase 5 handlers ---- void clearPendingQuestAccept(uint32_t questId); void triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason); bool hasQuestInLog(uint32_t questId) const; @@ -2411,7 +2399,6 @@ private: uint32_t homeBindZoneId_ = 0; glm::vec3 homeBindPos_{0.0f}; - // ---- Phase 1: Name caches (moved to EntityController) ---- // ---- Friend/contact list cache ---- std::unordered_map friendsCache; // name -> guid @@ -2510,11 +2497,10 @@ private: std::unordered_set pendingAutoInspect_; float inspectRateLimit_ = 0.0f; - // ---- Phase 2: Combat (state moved to CombatHandler) ---- + // ---- Combat ---- bool wasCombat_ = false; // Previous frame combat state for PLAYER_REGEN edge detection std::deque areaTriggerMsgs_; - // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; KnockBackCallback knockBackCallback_; CameraShakeCallback cameraShakeCallback_; @@ -2674,7 +2660,7 @@ private: void loadFactionNameCache() const; std::string getFactionName(uint32_t factionId) const; - // ---- Phase 4: Group ---- + // ---- Group ---- GroupListData partyData; bool pendingGroupInvite = false; std::string pendingInviterName; @@ -2745,7 +2731,7 @@ private: // Barber shop bool barberShopOpen_ = false; - // ---- Phase 5: Loot ---- + // ---- Loot ---- bool lootWindowOpen = false; bool autoLoot_ = false; bool autoSellGrey_ = false; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index a8be1060..d0f98577 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1460,7 +1460,7 @@ public: }; // ============================================================ -// Phase 1: Foundation — Targeting, Name Queries +// Foundation — Targeting, Name Queries // ============================================================ /** CMSG_SET_SELECTION packet builder */ @@ -1663,7 +1663,7 @@ public: }; // ============================================================ -// Phase 2: Combat Core +// Combat Core // ============================================================ /** SMSG_MONSTER_MOVE data */ @@ -1805,7 +1805,7 @@ public: }; // ============================================================ -// Phase 3: Spells, Action Bar, Auras +// Spells, Action Bar, Auras // ============================================================ /** SMSG_INITIAL_SPELLS data */ @@ -1930,7 +1930,7 @@ public: }; // ============================================================ -// Phase 4: Group/Party System +// Group/Party System // ============================================================ /** CMSG_GROUP_INVITE packet builder */ @@ -1998,7 +1998,7 @@ public: }; // ============================================================ -// Phase 5: Loot System +// Loot System // ============================================================ /** Loot item entry */ @@ -2100,7 +2100,7 @@ public: }; // ============================================================ -// Phase 5: NPC Gossip +// NPC Gossip // ============================================================ /** Gossip menu option */ @@ -2278,7 +2278,7 @@ public: }; // ============================================================ -// Phase 5: Vendor +// Vendor // ============================================================ /** Vendor item entry */ diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp index 6f74f46e..6a9cc826 100644 --- a/src/game/entity_controller.cpp +++ b/src/game/entity_controller.cpp @@ -1266,14 +1266,14 @@ struct EntityController::GameObjectTypeHandler : EntityController::IObjectTypeHa struct EntityController::ItemTypeHandler : EntityController::IObjectTypeHandler { EntityController& ctl_; explicit ItemTypeHandler(EntityController& c) : ctl_(c) {} - void onCreate(const UpdateBlock& block, std::shared_ptr& entity, bool& newItemCreated) override { ctl_.onCreateItem(block, newItemCreated); } + void onCreate(const UpdateBlock& block, std::shared_ptr& /*entity*/, bool& newItemCreated) override { ctl_.onCreateItem(block, newItemCreated); } void onValuesUpdate(const UpdateBlock& block, std::shared_ptr& entity) override { ctl_.onValuesUpdateItem(block, entity); } }; struct EntityController::CorpseTypeHandler : EntityController::IObjectTypeHandler { EntityController& ctl_; explicit CorpseTypeHandler(EntityController& c) : ctl_(c) {} - void onCreate(const UpdateBlock& block, std::shared_ptr& entity, bool&) override { ctl_.onCreateCorpse(block); } + void onCreate(const UpdateBlock& block, std::shared_ptr& /*entity*/, bool&) override { ctl_.onCreateCorpse(block); } }; // ============================================================ diff --git a/src/ui/action_bar_panel.cpp b/src/ui/action_bar_panel.cpp index 59873507..7ab39af4 100644 --- a/src/ui/action_bar_panel.cpp +++ b/src/ui/action_bar_panel.cpp @@ -1191,7 +1191,7 @@ void ActionBarPanel::renderStanceBar(game::GameHandler& gameHandler, } void ActionBarPanel::renderBagBar(game::GameHandler& gameHandler, - SettingsPanel& settingsPanel, + SettingsPanel& /*settingsPanel*/, InventoryScreen& inventoryScreen) { ImVec2 displaySize = ImGui::GetIO().DisplaySize; float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; diff --git a/src/ui/chat_panel.cpp b/src/ui/chat_panel.cpp index eb5f68dc..8a6d0ef3 100644 --- a/src/ui/chat_panel.cpp +++ b/src/ui/chat_panel.cpp @@ -2110,9 +2110,9 @@ std::unordered_map s_castSeqStates; void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - SpellbookScreen& spellbookScreen, - QuestLogScreen& questLogScreen) { + InventoryScreen& /*inventoryScreen*/, + SpellbookScreen& /*spellbookScreen*/, + QuestLogScreen& /*questLogScreen*/) { if (strlen(chatInputBuffer_) > 0) { std::string input(chatInputBuffer_);