diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index adbd0e33..79460a17 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -332,10 +332,25 @@ public: // Inspection void inspectTarget(); + struct InspectResult { + uint64_t guid = 0; + std::string playerName; + uint32_t totalTalents = 0; + uint32_t unspentTalents = 0; + uint8_t talentGroups = 0; + uint8_t activeTalentGroup = 0; + std::array itemEntries{}; // 0=head…18=ranged + }; + const InspectResult* getInspectResult() const { + return inspectResult_.guid ? &inspectResult_ : nullptr; + } + // Server info commands void queryServerTime(); void requestPlayedTime(); void queryWho(const std::string& playerName = ""); + uint32_t getTotalTimePlayed() const { return totalTimePlayed_; } + uint32_t getLevelTimePlayed() const { return levelTimePlayed_; } // Social commands void addFriend(const std::string& playerName, const std::string& note = ""); @@ -343,6 +358,7 @@ public: void setFriendNote(const std::string& playerName, const std::string& note); void addIgnore(const std::string& playerName); void removeIgnore(const std::string& playerName); + const std::unordered_map& getIgnoreCache() const { return ignoreCache; } // Random roll void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); @@ -380,6 +396,8 @@ public: // Display toggles void toggleHelm(); void toggleCloak(); + bool isHelmVisible() const { return helmVisible_; } + bool isCloakVisible() const { return cloakVisible_; } // Follow/Assist void followTarget(); @@ -388,6 +406,9 @@ public: // PvP void togglePvp(); + // Minimap ping (Ctrl+click on minimap; wowX/wowY in canonical WoW coords) + void sendMinimapPing(float wowX, float wowY); + // Guild commands void requestGuildInfo(); void requestGuildRoster(); @@ -403,6 +424,10 @@ public: void setGuildOfficerNote(const std::string& name, const std::string& note); void acceptGuildInvite(); void declineGuildInvite(); + + // GM Ticket + void submitGmTicket(const std::string& text); + void deleteGmTicket(); void queryGuildInfo(uint32_t guildId); void createGuild(const std::string& guildName); void addGuildRank(const std::string& rankName); @@ -444,6 +469,8 @@ public: // AFK/DND status void toggleAfk(const std::string& message = ""); void toggleDnd(const std::string& message = ""); + bool isAfk() const { return afkStatus_; } + bool isDnd() const { return dndStatus_; } void replyToLastWhisper(const std::string& message); std::string getLastWhisperSender() const { return lastWhisperSender_; } void setLastWhisperSender(const std::string& name) { lastWhisperSender_ = name; } @@ -489,6 +516,21 @@ public: const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); + // Threat + struct ThreatEntry { + uint64_t victimGuid = 0; + uint32_t threat = 0; + }; + // Returns the current threat list for a given unit GUID (from last SMSG_THREAT_UPDATE) + const std::vector* getThreatList(uint64_t unitGuid) const { + auto it = threatLists_.find(unitGuid); + return (it != threatLists_.end()) ? &it->second : nullptr; + } + // Returns the threat list for the player's current target, or nullptr + const std::vector* getTargetThreatList() const { + return targetGuid ? getThreatList(targetGuid) : nullptr; + } + // ---- Phase 3: Spells ---- void castSpell(uint32_t spellId, uint64_t targetGuid = 0); void cancelCast(); @@ -546,6 +588,7 @@ public: } bool isCasting() const { return casting; } + bool isChanneling() const { return casting && castIsChannel; } bool isGameObjectInteractionCasting() const { return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0; } @@ -888,6 +931,22 @@ public: void cancelTalentWipe() { talentWipePending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; + /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ + float getCorpseDistance() const { + if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f; + float dx = movementInfo.x - corpseX_; + float dy = movementInfo.y - corpseY_; + float dz = movementInfo.z - corpseZ_; + return std::sqrt(dx*dx + dy*dy + dz*dz); + } + /** Corpse position in canonical WoW coords (X=north, Y=west). + * Returns false if no corpse data or on a different map. */ + bool getCorpseCanonicalPos(float& outX, float& outY) const { + if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return false; + outX = corpseY_; // server Y = canonical X (north) + outY = corpseX_; // server X = canonical Y (west) + return true; + } /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ void reclaimCorpse(); void releaseSpirit(); @@ -1008,6 +1067,8 @@ public: if (raidTargetGuids_[i] == guid) return static_cast(i); return 0xFF; } + // Set or clear a raid mark on a guid (icon 0-7, or 0xFF to clear) + void setRaidMark(uint64_t guid, uint8_t icon); // ---- LFG / Dungeon Finder ---- enum class LfgState : uint8_t { @@ -1034,6 +1095,22 @@ public: uint32_t getLfgProposalId() const { return lfgProposalId_; } int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; } uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; } + uint32_t getLfgBootVotes() const { return lfgBootVotes_; } + uint32_t getLfgBootTotal() const { return lfgBootTotal_; } + uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; } + uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; } + + // ---- Arena Team Stats ---- + struct ArenaTeamStats { + uint32_t teamId = 0; + uint32_t rating = 0; + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + uint32_t rank = 0; + }; + const std::vector& getArenaTeamStats() const { return arenaTeamStats_; } // ---- Phase 5: Loot ---- void lootTarget(uint64_t guid); @@ -1131,6 +1208,10 @@ public: uint32_t required = 0; }; std::array itemObjectives{}; // zeroed by default + // Reward data parsed from SMSG_QUEST_QUERY_RESPONSE + int32_t rewardMoney = 0; // copper; positive=reward, negative=cost + std::array rewardItems{}; // guaranteed reward items + std::array rewardChoiceItems{}; // player picks one of these }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); @@ -1215,6 +1296,14 @@ public: using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + const std::unordered_map& getCriteriaProgress() const { return criteriaProgress_; } + /// Returns the name of an achievement by ID, or empty string if unknown. + const std::string& getAchievementName(uint32_t id) const { + auto it = achievementNameCache_.find(id); + if (it != achievementNameCache_.end()) return it->second; + static const std::string kEmpty; + return kEmpty; + } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -1232,6 +1321,15 @@ public: using PlayPositionalSoundCallback = std::function; void setPlayPositionalSoundCallback(PlayPositionalSoundCallback cb) { playPositionalSoundCallback_ = std::move(cb); } + // UI error frame: prominent on-screen error messages (spell can't be cast, etc.) + using UIErrorCallback = std::function; + void setUIErrorCallback(UIErrorCallback cb) { uiErrorCallback_ = std::move(cb); } + void addUIError(const std::string& msg) { if (uiErrorCallback_) uiErrorCallback_(msg); } + + // Reputation change toast: factionName, delta, new standing + using RepChangeCallback = std::function; + void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -1719,6 +1817,7 @@ private: void handleArenaTeamQueryResponse(network::Packet& packet); void handleArenaTeamInvite(network::Packet& packet); void handleArenaTeamEvent(network::Packet& packet); + void handleArenaTeamStats(network::Packet& packet); void handleArenaError(network::Packet& packet); // ---- Bank handlers ---- @@ -1951,6 +2050,7 @@ private: // Inspect fallback (when visible item fields are missing/unreliable) std::unordered_map> inspectedPlayerItemEntries_; + InspectResult inspectResult_; // most-recently received inspect response std::unordered_set pendingAutoInspect_; float inspectRateLimit_ = 0.0f; @@ -1965,6 +2065,8 @@ private: float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing std::unordered_set hostileAttackers_; std::vector combatText; + // unitGuid → sorted threat list (descending by threat value) + std::unordered_map> threatLists_; // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; @@ -2010,6 +2112,7 @@ private: std::vector minimapPings_; uint8_t castCount = 0; bool casting = false; + bool castIsChannel = false; uint32_t currentCastSpellId = 0; float castTimeRemaining = 0.0f; // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) @@ -2071,6 +2174,9 @@ private: // Instance / raid lockouts std::vector instanceLockouts_; + // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) + std::vector arenaTeamStats_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot @@ -2080,6 +2186,10 @@ private: uint32_t lfgProposalId_ = 0; // pending proposal id (0 = none) int32_t lfgAvgWaitSec_ = -1; // estimated wait, -1=unknown uint32_t lfgTimeInQueueMs_= 0; // ms already in queue + uint32_t lfgBootVotes_ = 0; // current boot-yes votes + uint32_t lfgBootTotal_ = 0; // total votes cast + uint32_t lfgBootTimeLeft_ = 0; // seconds remaining + uint32_t lfgBootNeeded_ = 0; // votes needed to kick // Ready check state bool pendingReadyCheck_ = false; @@ -2116,6 +2226,8 @@ private: uint64_t summonerGuid_ = 0; std::string summonerName_; float summonTimeoutSec_ = 0.0f; + uint32_t totalTimePlayed_ = 0; + uint32_t levelTimePlayed_ = 0; // Trade state TradeStatus tradeStatus_ = TradeStatus::None; @@ -2332,6 +2444,8 @@ private: void loadAchievementNameCache(); // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) std::unordered_set earnedAchievements_; + // Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE) + std::unordered_map criteriaProgress_; void handleAllAchievementData(network::Packet& packet); // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) @@ -2510,6 +2624,12 @@ private: PlayMusicCallback playMusicCallback_; PlaySoundCallback playSoundCallback_; PlayPositionalSoundCallback playPositionalSoundCallback_; + + // ---- UI error frame callback ---- + UIErrorCallback uiErrorCallback_; + + // ---- Reputation change callback ---- + RepChangeCallback repChangeCallback_; }; } // namespace game diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index d81b69a3..709dc502 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -50,6 +50,10 @@ private: int lastChatType = 0; // Track chat type changes bool chatInputMoveCursorToEnd = false; + // Chat sent-message history (Up/Down arrow recall) + std::vector chatSentHistory_; + int chatHistoryIdx_ = -1; // -1 = not browsing history + // Chat tabs int activeChatTab_ = 0; struct ChatTab { @@ -65,6 +69,37 @@ private: bool showChatWindow = true; bool showMinimap_ = true; // M key toggles minimap bool showNameplates_ = true; // V key toggles 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; + 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 { + 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; + + // 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; bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; @@ -82,6 +117,8 @@ private: 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; @@ -109,6 +146,7 @@ private: float pendingMouseSensitivity = 0.2f; bool pendingInvertMouse = false; bool pendingExtendedZoom = false; + float pendingFov = 70.0f; // degrees, default matches WoW's ~70° horizontal FOV int pendingUiOpacity = 65; bool pendingMinimapRotate = false; bool pendingMinimapSquare = false; @@ -122,6 +160,7 @@ private: bool awaitingKeyPress = false; 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) @@ -239,8 +278,11 @@ private: void renderCastBar(game::GameHandler& gameHandler); void renderMirrorTimers(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 renderRepToasts(float deltaTime); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); @@ -282,9 +324,11 @@ private: void renderGuildBankWindow(game::GameHandler& gameHandler); void renderAuctionHouseWindow(game::GameHandler& gameHandler); void renderDungeonFinderWindow(game::GameHandler& gameHandler); + void renderObjectiveTracker(game::GameHandler& gameHandler); void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); + void renderDPSMeter(game::GameHandler& gameHandler); /** * Inventory screen @@ -325,6 +369,24 @@ private: // Dungeon Finder state bool showDungeonFinder_ = false; + + // Achievements window + bool showAchievementWindow_ = false; + char achievementSearchBuf_[128] = {}; + void renderAchievementWindow(game::GameHandler& gameHandler); + + // GM Ticket window + bool showGmTicketWindow_ = false; + char gmTicketBuf_[2048] = {}; + void renderGmTicketWindow(game::GameHandler& gameHandler); + + // Inspect window + bool showInspectWindow_ = false; + void renderInspectWindow(game::GameHandler& gameHandler); + + // Threat window + bool showThreatWindow_ = false; + void renderThreatWindow(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) @@ -355,6 +417,8 @@ private: }; std::vector chatBubbles_; bool chatBubbleCallbackSet_ = false; + bool levelUpCallbackSet_ = false; + bool achievementCallbackSet_ = false; // Mail compose state char mailRecipientBuffer_[256] = ""; @@ -362,6 +426,12 @@ private: char mailBodyBuffer_[2048] = ""; int mailComposeMoney_[3] = {0, 0, 0}; // gold, silver, copper + // Vendor search filter + char vendorSearchFilter_[128] = ""; + + // Trainer search filter + char trainerSearchFilter_[128] = ""; + // Auction house UI state char auctionSearchName_[256] = ""; int auctionLevelMin_ = 0; @@ -403,6 +473,11 @@ private: std::string lastKnownZoneName_; void renderZoneText(); + // DPS / HPS meter + bool showDPSMeter_ = false; + float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) + bool dpsWasInCombat_ = false; + public: void triggerDing(uint32_t newLevel); void triggerAchievementToast(uint32_t achievementId, std::string name = {}); diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index bfca779f..3453e966 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -171,11 +171,21 @@ private: void renderHeldItem(); bool bagHasAnyItems(const game::Inventory& inventory, int bagIndex) const; - // Drop confirmation + // Drop confirmation (drag-outside-window destroy) bool dropConfirmOpen_ = false; int dropBackpackIndex_ = -1; std::string dropItemName_; + // Destroy confirmation (Shift+right-click destroy) + bool destroyConfirmOpen_ = false; + uint8_t destroyBag_ = 0xFF; + uint8_t destroySlot_ = 0; + uint8_t destroyCount_ = 1; + std::string destroyItemName_; + + // Pending chat item link from shift-click + std::string pendingChatItemLink_; + public: static ImVec4 getQualityColor(game::ItemQuality quality); @@ -190,6 +200,13 @@ public: /// Drop the currently held item into a specific equipment slot. /// Returns true if the drop was accepted and consumed. bool dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipSlot slot); + /// Returns a WoW item link string if the user shift-clicked a bag item, then clears it. + std::string getAndClearPendingChatLink() { + std::string out = std::move(pendingChatItemLink_); + pendingChatItemLink_.clear(); + return out; + } + /// Drop the currently held item into a bank slot via CMSG_SWAP_ITEM. void dropIntoBankSlot(game::GameHandler& gh, uint8_t dstBag, uint8_t dstSlot); /// Pick up an item from main bank slot (click-and-hold from bank window). diff --git a/include/ui/keybinding_manager.hpp b/include/ui/keybinding_manager.hpp index 09c9ac05..385340ab 100644 --- a/include/ui/keybinding_manager.hpp +++ b/include/ui/keybinding_manager.hpp @@ -30,6 +30,7 @@ public: TOGGLE_NAMEPLATES, TOGGLE_RAID_FRAMES, TOGGLE_QUEST_LOG, + TOGGLE_ACHIEVEMENTS, ACTION_COUNT }; diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp index d86abedc..0bb791e0 100644 --- a/include/ui/quest_log_screen.hpp +++ b/include/ui/quest_log_screen.hpp @@ -7,9 +7,11 @@ namespace wowee { namespace ui { +class InventoryScreen; + class QuestLogScreen { public: - void render(game::GameHandler& gameHandler); + void render(game::GameHandler& gameHandler, InventoryScreen& invScreen); bool isOpen() const { return open; } void toggle() { open = !open; } void setOpen(bool o) { open = o; } @@ -29,6 +31,10 @@ private: uint32_t lastDetailRequestQuestId_ = 0; double lastDetailRequestAt_ = 0.0; std::unordered_set questDetailQueryNoResponse_; + // Search / filter + char questSearchFilter_[64] = {}; + // 0=all, 1=active only, 2=complete only + int questFilterMode_ = 0; }; }} // namespace wowee::ui diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 470cb233..6cc13270 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -54,6 +54,13 @@ public: uint32_t getDragSpellId() const { return dragSpellId_; } void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; } + /// Returns a WoW spell link string if the user shift-clicked a spell, then clears it. + std::string getAndClearPendingChatLink() { + std::string out = std::move(pendingChatSpellLink_); + pendingChatSpellLink_.clear(); + return out; + } + private: bool open = false; bool pKeyWasDown = false; @@ -87,6 +94,9 @@ private: uint32_t dragSpellId_ = 0; VkDescriptorSet dragSpellIconTex_ = VK_NULL_HANDLE; + // Pending chat spell link from shift-click + std::string pendingChatSpellLink_; + void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); void loadSkillLineDBCs(pipeline::AssetManager* assetManager); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2b29ef80..b3e57ccc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -438,6 +438,49 @@ QuestQueryObjectives extractQuestQueryObjectives(const std::vector& dat } } +// Parse quest reward fields from SMSG_QUEST_QUERY_RESPONSE fixed header. +// Classic/TBC: 40 fixed fields; WotLK: 55 fixed fields. +struct QuestQueryRewards { + int32_t rewardMoney = 0; + std::array itemId{}; + std::array itemCount{}; + std::array choiceItemId{}; + std::array choiceItemCount{}; + bool valid = false; +}; + +static QuestQueryRewards tryParseQuestRewards(const std::vector& data, + bool classicLayout) { + const size_t base = 8; // after questId(4) + questMethod(4) + const size_t fieldCount = classicLayout ? 40u : 55u; + const size_t headerEnd = base + fieldCount * 4u; + if (data.size() < headerEnd) return {}; + + // Field indices (0-based) for each expansion: + // Classic/TBC: rewardMoney=[14], rewardItemId[4]=[20..23], rewardItemCount[4]=[24..27], + // rewardChoiceItemId[6]=[28..33], rewardChoiceItemCount[6]=[34..39] + // WotLK: rewardMoney=[17], rewardItemId[4]=[30..33], rewardItemCount[4]=[34..37], + // rewardChoiceItemId[6]=[38..43], rewardChoiceItemCount[6]=[44..49] + const size_t moneyField = classicLayout ? 14u : 17u; + const size_t itemIdField = classicLayout ? 20u : 30u; + const size_t itemCountField = classicLayout ? 24u : 34u; + const size_t choiceIdField = classicLayout ? 28u : 38u; + const size_t choiceCntField = classicLayout ? 34u : 44u; + + QuestQueryRewards out; + out.rewardMoney = static_cast(readU32At(data, base + moneyField * 4u)); + for (size_t i = 0; i < 4; ++i) { + out.itemId[i] = readU32At(data, base + (itemIdField + i) * 4u); + out.itemCount[i] = readU32At(data, base + (itemCountField + i) * 4u); + } + for (size_t i = 0; i < 6; ++i) { + out.choiceItemId[i] = readU32At(data, base + (choiceIdField + i) * 4u); + out.choiceItemCount[i] = readU32At(data, base + (choiceCntField + i) * 4u); + } + out.valid = true; + return out; +} + } // namespace @@ -861,8 +904,10 @@ void GameHandler::update(float deltaTime) { (autoAttacking || autoAttackRequested_)) { pendingGameObjectInteractGuid_ = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + addUIError("Interrupted."); addSystemChatMessage("Interrupted."); } if (casting && castTimeRemaining > 0.0f) { @@ -874,6 +919,7 @@ void GameHandler::update(float deltaTime) { performGameObjectInteractionNow(interactGuid); } casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; } @@ -1079,6 +1125,7 @@ void GameHandler::update(float deltaTime) { autoAttackOutOfRangeTime_ += deltaTime; if (autoAttackRangeWarnCooldown_ <= 0.0f) { addSystemChatMessage("Target is too far away."); + addUIError("Target is too far away."); autoAttackRangeWarnCooldown_ = 1.25f; } // Stop chasing stale swings when the target remains out of range. @@ -1904,6 +1951,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) { if (castResult != 0) { casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; // Pass player's power type so result 85 says "Not enough rage/energy/etc." @@ -1913,11 +1961,13 @@ void GameHandler::handlePacket(network::Packet& packet) { playerPowerType = static_cast(pu->getPowerType()); } const char* reason = getSpellCastResultString(castResult, playerPowerType); + std::string errMsg = reason ? reason + : ("Spell cast failed (error " + std::to_string(castResult) + ")"); + addUIError(errMsg); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - msg.message = reason ? reason - : ("Spell cast failed (error " + std::to_string(castResult) + ")"); + msg.message = errMsg; addLocalChatMessage(msg); } } @@ -2163,9 +2213,11 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FORCE_ANIM: { // packed_guid + uint32 animId — force entity to play animation if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); + uint64_t animGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t animId =*/ packet.readUInt32(); + uint32_t animId = packet.readUInt32(); + if (emoteAnimCallback_) + emoteAnimCallback_(animGuid, animId); } } break; @@ -2226,6 +2278,7 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); break; case Opcode::SMSG_FEIGN_DEATH_RESISTED: + addUIError("Your Feign Death was resisted."); addSystemChatMessage("Your Feign Death attempt was resisted."); LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED"); break; @@ -2278,24 +2331,51 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_THREAT_CLEAR: // All threat dropped on the local player (e.g. Vanish, Feign Death) - // No local state to clear — informational + threatLists_.clear(); LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped"); break; case Opcode::SMSG_THREAT_REMOVE: { // packed_guid (unit) + packed_guid (victim whose threat was removed) - if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); - } + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + auto it = threatLists_.find(unitGuid); + if (it != threatLists_.end()) { + auto& list = it->second; + list.erase(std::remove_if(list.begin(), list.end(), + [victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }), + list.end()); + if (list.empty()) threatLists_.erase(it); } break; } - case Opcode::SMSG_HIGHEST_THREAT_UPDATE: { - // packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count - // + count × (packed_guid victim + uint32 threat) - // Informational — no threat UI yet; consume to suppress warnings - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_HIGHEST_THREAT_UPDATE: + case Opcode::SMSG_THREAT_UPDATE: { + // Both packets share the same format: + // packed_guid (unit) + packed_guid (highest-threat target or target, unused here) + // + uint32 count + count × (packed_guid victim + uint32 threat) + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t cnt = packet.readUInt32(); + if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity + std::vector list; + list.reserve(cnt); + for (uint32_t i = 0; i < cnt; ++i) { + if (packet.getSize() - packet.getReadPos() < 1) break; + ThreatEntry entry; + entry.victimGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + entry.threat = packet.readUInt32(); + list.push_back(entry); + } + // Sort descending by threat so highest is first + std::sort(list.begin(), list.end(), + [](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; }); + threatLists_[unitGuid] = std::move(list); break; } @@ -2776,13 +2856,14 @@ void GameHandler::handlePacket(network::Packet& packet) { // Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table uint8_t failReason = isClassic ? static_cast(rawFailReason + 1) : rawFailReason; if (failGuid == playerGuid && failReason != 0) { - // Show interruption/failure reason in chat for player + // Show interruption/failure reason in chat and error overlay for player int pt = -1; if (auto pe = entityManager.getEntity(playerGuid)) if (auto pu = std::dynamic_pointer_cast(pe)) pt = static_cast(pu->getPowerType()); const char* reason = getSpellCastResultString(failReason, pt); if (reason) { + addUIError(reason); MessageChatData emsg; emsg.type = ChatType::SYSTEM; emsg.language = ChatLanguage::UNIVERSAL; @@ -2794,6 +2875,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed casting = false; + castIsChannel = false; currentCastSpellId = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { @@ -2862,18 +2944,26 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint64 itemGuid + uint32 spellId + uint32 cooldownMs size_t rem = packet.getSize() - packet.getReadPos(); if (rem >= 16) { - /*uint64_t itemGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); - uint32_t cdMs = packet.readUInt32(); + uint64_t itemGuid = packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + uint32_t cdMs = packet.readUInt32(); float cdSec = cdMs / 1000.0f; - if (spellId != 0 && cdSec > 0.0f) { - spellCooldowns[spellId] = cdSec; + if (cdSec > 0.0f) { + if (spellId != 0) spellCooldowns[spellId] = cdSec; + // Resolve itemId from the GUID so item-type slots are also updated + uint32_t itemId = 0; + auto iit = onlineItems_.find(itemGuid); + if (iit != onlineItems_.end()) itemId = iit->second.entry; for (auto& slot : actionBar) { - if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId) + || (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId); + if (match) { + slot.cooldownTotal = cdSec; slot.cooldownRemaining = cdSec; } } - LOG_DEBUG("SMSG_ITEM_COOLDOWN: spellId=", spellId, " cd=", cdSec, "s"); + LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec, + " spellId=", spellId, " itemId=", itemId, " cd=", cdSec, "s"); } } break; @@ -3116,6 +3206,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleDuelWinner(packet); break; case Opcode::SMSG_DUEL_OUTOFBOUNDS: + addUIError("You are out of the duel area!"); addSystemChatMessage("You are out of the duel area!"); break; case Opcode::SMSG_DUEL_INBOUNDS: @@ -3453,6 +3544,7 @@ void GameHandler::handlePacket(network::Packet& packet) { delta > 0 ? "increased" : "decreased", std::abs(delta)); addSystemChatMessage(buf); + if (repChangeCallback_) repChangeCallback_(name, delta, standing); } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); } @@ -3728,11 +3820,22 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_SERVER_MESSAGE: { - // uint32 type, string message + // uint32 type + string message + // Types: 1=shutdown_time, 2=restart_time, 3=string, 4=shutdown_cancelled, 5=restart_cancelled if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t msgType =*/ packet.readUInt32(); + uint32_t msgType = packet.readUInt32(); std::string msg = packet.readString(); - if (!msg.empty()) addSystemChatMessage("[Server] " + msg); + if (!msg.empty()) { + std::string prefix; + switch (msgType) { + case 1: prefix = "[Shutdown] "; addUIError("Server shutdown: " + msg); break; + case 2: prefix = "[Restart] "; addUIError("Server restart: " + msg); break; + case 4: prefix = "[Shutdown cancelled] "; break; + case 5: prefix = "[Restart cancelled] "; break; + default: prefix = "[Server] "; break; + } + addSystemChatMessage(prefix + msg); + } } break; } @@ -4050,12 +4153,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_CRITERIA_UPDATE: { // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime - // Achievement criteria progress (informational — no criteria UI yet). if (packet.getSize() - packet.getReadPos() >= 20) { uint32_t criteriaId = packet.readUInt32(); uint64_t progress = packet.readUInt64(); - /*uint32_t elapsedTime =*/ packet.readUInt32(); - /*uint32_t createTime =*/ packet.readUInt32(); + packet.readUInt32(); // elapsedTime + packet.readUInt32(); // creationTime + criteriaProgress_[criteriaId] = progress; LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); } break; @@ -4528,6 +4631,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout); + const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout); for (auto& q : questLog_) { if (q.questId != questId) continue; @@ -4583,6 +4687,21 @@ void GameHandler::handlePacket(network::Packet& packet) { objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ", objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]"); } + + // Store reward data and pre-fetch item info for icons. + if (rwds.valid) { + q.rewardMoney = rwds.rewardMoney; + for (int i = 0; i < 4; ++i) { + q.rewardItems[i].itemId = rwds.itemId[i]; + q.rewardItems[i].count = (rwds.itemId[i] != 0) ? rwds.itemCount[i] : 0; + if (rwds.itemId[i] != 0) queryItemInfo(rwds.itemId[i], 0); + } + for (int i = 0; i < 6; ++i) { + q.rewardChoiceItems[i].itemId = rwds.choiceItemId[i]; + q.rewardChoiceItems[i].count = (rwds.choiceItemId[i] != 0) ? rwds.choiceItemCount[i] : 0; + if (rwds.choiceItemId[i] != 0) queryItemInfo(rwds.choiceItemId[i], 0); + } + } break; } @@ -4832,7 +4951,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaTeamEvent(packet); break; case Opcode::SMSG_ARENA_TEAM_STATS: - LOG_INFO("Received SMSG_ARENA_TEAM_STATS"); + handleArenaTeamStats(packet); break; case Opcode::SMSG_ARENA_ERROR: handleArenaError(packet); @@ -4909,10 +5028,34 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_QUERY_NEXT_MAIL_TIME: handleQueryNextMailTime(packet); break; - case Opcode::SMSG_CHANNEL_LIST: - // Channel member listing currently not rendered in UI. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_CHANNEL_LIST: { + // string channelName + uint8 flags + uint32 count + count×(uint64 guid + uint8 memberFlags) + std::string chanName = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 5) break; + /*uint8_t chanFlags =*/ packet.readUInt8(); + uint32_t memberCount = packet.readUInt32(); + memberCount = std::min(memberCount, 200u); + addSystemChatMessage(chanName + " has " + std::to_string(memberCount) + " member(s):"); + for (uint32_t i = 0; i < memberCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 9) break; + uint64_t memberGuid = packet.readUInt64(); + uint8_t memberFlags = packet.readUInt8(); + // Look up the name from our entity manager + auto entity = entityManager.getEntity(memberGuid); + std::string name = "(unknown)"; + if (entity) { + auto player = std::dynamic_pointer_cast(entity); + if (player && !player->getName().empty()) name = player->getName(); + } + std::string entry = " " + name; + if (memberFlags & 0x01) entry += " [Moderator]"; + if (memberFlags & 0x02) entry += " [Muted]"; + addSystemChatMessage(entry); + LOG_DEBUG(" channel member: 0x", std::hex, memberGuid, std::dec, + " flags=", (int)memberFlags, " name=", name); + } break; + } case Opcode::SMSG_INSPECT_RESULTS_UPDATE: handleInspectResults(packet); break; @@ -5468,6 +5611,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (totalMs > 0) { if (caster == playerGuid) { casting = true; + castIsChannel = false; currentCastSpellId = spellId; castTimeTotal = totalMs / 1000.0f; castTimeRemaining = remainMs / 1000.0f; @@ -5496,6 +5640,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (chanTotalMs > 0 && chanCaster != 0) { if (chanCaster == playerGuid) { casting = true; + castIsChannel = true; currentCastSpellId = chanSpellId; castTimeTotal = chanTotalMs / 1000.0f; castTimeRemaining = castTimeTotal; @@ -5523,6 +5668,7 @@ void GameHandler::handlePacket(network::Packet& packet) { castTimeRemaining = chanRemainMs / 1000.0f; if (chanRemainMs == 0) { casting = false; + castIsChannel = false; currentCastSpellId = 0; } } else if (chanCaster2 != 0) { @@ -5537,22 +5683,6 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } - case Opcode::SMSG_THREAT_UPDATE: { - // packed_guid (unit) + packed_guid (target) + uint32 count - // + count × (packed_guid victim + uint32 threat) — consume to suppress warnings - if (packet.getSize() - packet.getReadPos() < 1) break; - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 1) break; - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 4) break; - uint32_t cnt = packet.readUInt32(); - for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) { - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() >= 4) - packet.readUInt32(); - } - break; - } case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: { // uint32 slot + packed_guid unit (0 packed = clear slot) if (packet.getSize() - packet.getReadPos() < 5) { @@ -6030,10 +6160,33 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: - case Opcode::SMSG_PLAY_TIME_WARNING: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PLAY_TIME_WARNING: { + // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t warnType = packet.readUInt32(); + uint32_t minutesPlayed = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readUInt32() : 0; + const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] "; + char buf[128]; + if (minutesPlayed > 0) { + uint32_t h = minutesPlayed / 60; + uint32_t m = minutesPlayed % 60; + if (h > 0) + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m); + else + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m); + } else { + std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity); + } + addSystemChatMessage(buf); + addUIError(buf); + } + break; + } + // ---- Item query multiple (same format as single, re-use handler) ---- case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE: handleItemQueryResponse(packet); @@ -6525,6 +6678,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { autoAttacking = false; autoAttackTarget = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; castTimeRemaining = 0.0f; @@ -7650,6 +7804,29 @@ void GameHandler::sendPing() { socket->send(packet); } +void GameHandler::sendMinimapPing(float wowX, float wowY) { + if (state != WorldState::IN_WORLD) return; + + // MSG_MINIMAP_PING (CMSG direction): float posX + float posY + // Server convention: posX = east/west axis = canonical Y (west) + // posY = north/south axis = canonical X (north) + const float serverX = wowY; // canonical Y (west) → server posX + const float serverY = wowX; // canonical X (north) → server posY + + network::Packet pkt(wireOpcode(Opcode::MSG_MINIMAP_PING)); + pkt.writeFloat(serverX); + pkt.writeFloat(serverY); + socket->send(pkt); + + // Add ping locally so the sender sees their own ping immediately + MinimapPing localPing; + localPing.senderGuid = activeCharacterGuid_; + localPing.wowX = wowX; + localPing.wowY = wowY; + localPing.age = 0.0f; + minimapPings_.push_back(localPing); +} + void GameHandler::handlePong(network::Packet& packet) { LOG_DEBUG("Handling SMSG_PONG"); @@ -10463,6 +10640,29 @@ void GameHandler::clearMainAssist() { LOG_INFO("Cleared main assist"); } +void GameHandler::setRaidMark(uint64_t guid, uint8_t icon) { + if (state != WorldState::IN_WORLD || !socket) return; + + static const char* kMarkNames[] = { + "Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull" + }; + + if (icon == 0xFF) { + // Clear mark: find which slot this guid holds and send 0 GUID + for (int i = 0; i < 8; ++i) { + if (raidTargetGuids_[i] == guid) { + auto packet = RaidTargetUpdatePacket::build(static_cast(i), 0); + socket->send(packet); + break; + } + } + } else if (icon < 8) { + auto packet = RaidTargetUpdatePacket::build(icon, guid); + socket->send(packet); + LOG_INFO("Set raid mark %s on guid %llu", kMarkNames[icon], (unsigned long long)guid); + } +} + void GameHandler::requestRaidInfo() { if (state != WorldState::IN_WORLD || !socket) { LOG_WARNING("Cannot request raid info: not in world or not connected"); @@ -10527,6 +10727,7 @@ void GameHandler::stopCasting() { // Reset casting state casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; castTimeRemaining = 0.0f; @@ -10991,8 +11192,51 @@ void GameHandler::handleInspectResults(network::Packet& packet) { uint8_t talentType = packet.readUInt8(); if (talentType == 0) { - // Own talent info — silently consume (sent on login, talent changes, respecs) - LOG_DEBUG("SMSG_TALENTS_INFO: received own talent data, ignoring"); + // Own talent info (type 0): uint32 unspentTalents, uint8 groupCount, uint8 activeGroup + // Per group: uint8 talentCount, [talentId(4)+rank(1)]..., uint8 glyphCount, [glyphId(2)]... + if (packet.getSize() - packet.getReadPos() < 6) { + LOG_DEBUG("SMSG_TALENTS_INFO type=0: too short"); + return; + } + uint32_t unspentTalents = packet.readUInt32(); + uint8_t talentGroupCount = packet.readUInt8(); + uint8_t activeTalentGroup = packet.readUInt8(); + + if (activeTalentGroup > 1) activeTalentGroup = 0; + activeTalentSpec_ = activeTalentGroup; + + for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t talentCount = packet.readUInt8(); + learnedTalents_[g].clear(); + for (uint8_t t = 0; t < talentCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint32_t talentId = packet.readUInt32(); + uint8_t rank = packet.readUInt8(); + learnedTalents_[g][talentId] = rank; + } + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t glyphCount = packet.readUInt8(); + for (uint8_t gl = 0; gl < glyphCount; ++gl) { + if (packet.getSize() - packet.getReadPos() < 2) break; + packet.readUInt16(); // glyphId (skip) + } + } + + unspentTalentPoints_[activeTalentGroup] = static_cast( + unspentTalents > 255 ? 255 : unspentTalents); + + if (!talentsInitialized_) { + talentsInitialized_ = true; + if (unspentTalents > 0) { + addSystemChatMessage("You have " + std::to_string(unspentTalents) + + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); + } + } + + LOG_INFO("SMSG_TALENTS_INFO type=0: unspent=", unspentTalents, + " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, + " learned=", learnedTalents_[activeTalentGroup].size()); return; } @@ -11068,16 +11312,21 @@ void GameHandler::handleInspectResults(network::Packet& packet) { } } - // Display inspect results - std::string msg = "Inspect: " + playerName; - msg += " - " + std::to_string(totalTalents) + " talent points spent"; - if (unspentTalents > 0) { - msg += ", " + std::to_string(unspentTalents) + " unspent"; + // Store inspect result for UI display + inspectResult_.guid = guid; + inspectResult_.playerName = playerName; + inspectResult_.totalTalents = totalTalents; + inspectResult_.unspentTalents = unspentTalents; + inspectResult_.talentGroups = talentGroupCount; + inspectResult_.activeTalentGroup = activeTalentGroup; + + // Merge any gear we already have from a prior inspect request + auto gearIt = inspectedPlayerItemEntries_.find(guid); + if (gearIt != inspectedPlayerItemEntries_.end()) { + inspectResult_.itemEntries = gearIt->second; + } else { + inspectResult_.itemEntries = {}; } - if (talentGroupCount > 1) { - msg += " (dual spec, active: " + std::to_string(activeTalentGroup + 1) + ")"; - } - addSystemChatMessage(msg); LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ", unspentTalents, " unspent, ", (int)talentGroupCount, " specs"); @@ -12805,15 +13054,18 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { uint32_t timeLeft = packet.readUInt32(); uint32_t votesNeeded = packet.readUInt32(); - (void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded; + (void)myVote; + + lfgBootVotes_ = bootVotes; + lfgBootTotal_ = totalVotes; + lfgBootTimeLeft_ = timeLeft; + lfgBootNeeded_ = votesNeeded; if (inProgress) { lfgState_ = LfgState::Boot; - addSystemChatMessage( - std::string("Dungeon Finder: Vote to kick in progress (") + - std::to_string(timeLeft) + "s remaining)."); } else { // Boot vote ended — return to InDungeon state regardless of outcome + lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; lfgState_ = LfgState::InDungeon; if (myAnswer) { addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); @@ -13083,6 +13335,35 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2); } +void GameHandler::handleArenaTeamStats(network::Packet& packet) { + // SMSG_ARENA_TEAM_STATS (WotLK 3.3.5a): + // uint32 teamId, uint32 rating, uint32 weekGames, uint32 weekWins, + // uint32 seasonGames, uint32 seasonWins, uint32 rank + if (packet.getSize() - packet.getReadPos() < 28) return; + + ArenaTeamStats stats; + stats.teamId = packet.readUInt32(); + stats.rating = packet.readUInt32(); + stats.weekGames = packet.readUInt32(); + stats.weekWins = packet.readUInt32(); + stats.seasonGames = packet.readUInt32(); + stats.seasonWins = packet.readUInt32(); + stats.rank = packet.readUInt32(); + + // Update or insert for this team + for (auto& s : arenaTeamStats_) { + if (s.teamId == stats.teamId) { + s = stats; + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, + " rating=", stats.rating, " rank=", stats.rank); + return; + } + } + arenaTeamStats_.push_back(stats); + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, + " rating=", stats.rating, " rank=", stats.rank); +} + void GameHandler::handleArenaError(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t error = packet.readUInt32(); @@ -13831,6 +14112,7 @@ void GameHandler::cancelCast() { } pendingGameObjectInteractGuid_ = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; } @@ -13969,6 +14251,7 @@ void GameHandler::handleCastFailed(network::Packet& packet) { if (!ok) return; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; @@ -14027,6 +14310,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) { // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { casting = true; + castIsChannel = false; currentCastSpellId = data.spellId; castTimeTotal = data.castTime / 1000.0f; castTimeRemaining = castTimeTotal; @@ -14097,6 +14381,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; @@ -14167,14 +14452,17 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) { const size_t entrySize = isClassicFormat ? 12u : 8u; while (packet.getSize() - packet.getReadPos() >= entrySize) { uint32_t spellId = packet.readUInt32(); - if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used + uint32_t cdItemId = 0; + if (isClassicFormat) cdItemId = packet.readUInt32(); // itemId in Classic format uint32_t cooldownMs = packet.readUInt32(); float seconds = cooldownMs / 1000.0f; spellCooldowns[spellId] = seconds; for (auto& slot : actionBar) { - if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { - slot.cooldownTotal = seconds; + bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) + || (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId); + if (match) { + slot.cooldownTotal = seconds; slot.cooldownRemaining = seconds; } } @@ -14768,6 +15056,34 @@ void GameHandler::declineGuildInvite() { LOG_INFO("Declined guild invite"); } +void GameHandler::submitGmTicket(const std::string& text) { + if (state != WorldState::IN_WORLD || !socket) return; + + // CMSG_GMTICKET_CREATE (WotLK 3.3.5a): + // string ticket_text + // float[3] position (server coords) + // float facing + // uint32 mapId + // uint8 need_response (1 = yes) + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_CREATE)); + pkt.writeString(text); + pkt.writeFloat(movementInfo.x); + pkt.writeFloat(movementInfo.y); + pkt.writeFloat(movementInfo.z); + pkt.writeFloat(movementInfo.orientation); + pkt.writeUInt32(currentMapId_); + pkt.writeUInt8(1); // need_response = yes + socket->send(pkt); + LOG_INFO("Submitted GM ticket: '", text, "'"); +} + +void GameHandler::deleteGmTicket() { + if (state != WorldState::IN_WORLD || !socket) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); + socket->send(pkt); + LOG_INFO("Deleting GM ticket"); +} + void GameHandler::queryGuildInfo(uint32_t guildId) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GuildQueryPacket::build(guildId); @@ -17100,6 +17416,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { areaTriggerSuppressFirst_ = true; // first check just marks active triggers, doesn't fire stopAutoAttack(); casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; castTimeRemaining = 0.0f; @@ -17875,6 +18192,9 @@ void GameHandler::handlePlayedTime(network::Packet& packet) { return; } + totalTimePlayed_ = data.totalTimePlayed; + levelTimePlayed_ = data.levelTimePlayed; + if (data.triggerMessage) { // Format total time played uint32_t totalDays = data.totalTimePlayed / 86400; @@ -19806,18 +20126,21 @@ void GameHandler::handleAllAchievementData(network::Packet& packet) { earnedAchievements_.insert(id); } - // Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF) + // Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF + criteriaProgress_.clear(); while (packet.getSize() - packet.getReadPos() >= 4) { uint32_t id = packet.readUInt32(); if (id == 0xFFFFFFFF) break; // counter(8) + date(4) + unknown(4) = 16 bytes if (packet.getSize() - packet.getReadPos() < 16) break; - packet.readUInt64(); // counter + uint64_t counter = packet.readUInt64(); packet.readUInt32(); // date packet.readUInt32(); // unknown / flags + criteriaProgress_[id] = counter; } - LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements"); + LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), + " achievements, ", criteriaProgress_.size(), " criteria"); } // --------------------------------------------------------------------------- diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index cf4c70fd..9c30a3b5 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -752,7 +752,6 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { return (serverExplorationMask[word] & (1u << (bitIndex % 32))) != 0; }; - bool markedAny = false; if (hasServerExplorationMask) { exploredZones.clear(); for (int i = 0; i < static_cast(zones.size()); i++) { @@ -761,15 +760,19 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { for (uint32_t bit : z.exploreBits) { if (isBitSet(bit)) { exploredZones.insert(i); - markedAny = true; break; } } } + // Always trust the server mask when available — even if empty (unexplored character). + // Also reveal the zone the player is currently standing in so the map isn't pitch-black + // the moment they first enter a new zone (the server bit arrives on the next update). + int curZone = findZoneForPlayer(playerRenderPos); + if (curZone >= 0) exploredZones.insert(curZone); + return; } - if (markedAny) return; - // Server mask unavailable or empty — fall back to locally-accumulated position tracking. + // Server mask unavailable — fall back to locally-accumulated position tracking. // Add the zone the player is currently in to the local set and display that. float wowX = playerRenderPos.y; float wowY = playerRenderPos.x; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index acb6cf22..2ba78329 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -46,6 +46,17 @@ #include 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 + std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) { + static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000"}; + uint8_t qi = quality < 6 ? quality : 1; + char buf[512]; + snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + kQualHex[qi], itemId, name.c_str()); + return buf; + } + std::string trim(const std::string& s) { size_t first = s.find_first_not_of(" \t\r\n"); if (first == std::string::npos) return ""; @@ -133,9 +144,15 @@ void GameScreen::initChatTabs() { chatTabs_.clear(); // General tab: shows everything chatTabs_.push_back({"General", 0xFFFFFFFF}); - // Combat tab: system + loot messages + // Combat tab: system, loot, skills, achievements, and NPC speech/emotes chatTabs_.push_back({"Combat", (1u << static_cast(game::ChatType::SYSTEM)) | - (1u << static_cast(game::ChatType::LOOT))}); + (1u << static_cast(game::ChatType::LOOT)) | + (1u << static_cast(game::ChatType::SKILL)) | + (1u << static_cast(game::ChatType::ACHIEVEMENT)) | + (1u << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | + (1u << static_cast(game::ChatType::MONSTER_SAY)) | + (1u << static_cast(game::ChatType::MONSTER_YELL)) | + (1u << static_cast(game::ChatType::MONSTER_EMOTE))}); // Whispers tab chatTabs_.push_back({"Whispers", (1u << static_cast(game::ChatType::WHISPER)) | (1u << static_cast(game::ChatType::WHISPER_INFORM))}); @@ -192,6 +209,42 @@ void GameScreen::render(game::GameHandler& gameHandler) { chatBubbleCallbackSet_ = true; } + // Set up level-up callback (once) + if (!levelUpCallbackSet_) { + gameHandler.setLevelUpCallback([this](uint32_t newLevel) { + levelUpFlashAlpha_ = 1.0f; + levelUpDisplayLevel_ = newLevel; + triggerDing(newLevel); + }); + 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 UI error frame callback (once) + if (!uiErrorCallbackSet_) { + gameHandler.setUIErrorCallback([this](const std::string& msg) { + uiErrors_.push_back({msg, 0.0f}); + if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin()); + }); + uiErrorCallbackSet_ = 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; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -407,7 +460,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestObjectiveTracker(gameHandler); renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); + renderRaidWarningOverlay(gameHandler); renderCombatText(gameHandler); + renderDPSMeter(gameHandler); + renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); + renderRepToasts(ImGui::GetIO().DeltaTime); if (showRaidFrames_) { renderPartyFrames(gameHandler); } @@ -425,6 +482,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBgInvitePopup(gameHandler); renderLfgProposalPopup(gameHandler); renderGuildRoster(gameHandler); + renderSocialFrame(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); renderGossipWindow(gameHandler); @@ -441,6 +499,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAuctionHouseWindow(gameHandler); renderDungeonFinderWindow(gameHandler); renderInstanceLockouts(gameHandler); + renderAchievementWindow(gameHandler); + renderGmTicketWindow(gameHandler); + renderInspectWindow(gameHandler); + renderThreatWindow(gameHandler); + renderObjectiveTracker(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -460,11 +523,24 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderWorldMap(gameHandler); // Quest Log (L key toggle handled inside) - questLogScreen.render(gameHandler); + questLogScreen.render(gameHandler, inventoryScreen); // Spellbook (P key toggle handled inside) spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager()); + // Insert spell link into chat if player shift-clicked a spellbook entry + { + 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; + } + } + } + // Talents (N key toggle handled inside) talentScreen.render(gameHandler); @@ -514,6 +590,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Character screen (C key toggle handled inside render()) inventoryScreen.renderCharacterScreen(gameHandler); + // Insert item link into chat if player shift-clicked any inventory/equipment slot + { + std::string pendingLink = inventoryScreen.getAndClearPendingChatLink(); + if (!pendingLink.empty()) { + 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; + } + } + } + if (inventoryScreen.consumeEquipmentDirty() || gameHandler.consumeOnlineEquipmentDirty()) { updateCharacterGeosets(gameHandler.getInventory()); updateCharacterTextures(gameHandler.getInventory()); @@ -611,6 +700,89 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Screen edge damage flash — red vignette that fires on HP decrease + { + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + uint32_t currentHp = 0; + if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || + playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) + currentHp = unit->getHealth(); + } + + // Detect HP drop (ignore transitions from 0 — entity just spawned or uninitialized) + if (damageFlashEnabled_ && lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0) + damageFlashAlpha_ = 1.0f; + lastPlayerHp_ = currentHp; + + // Fade out over ~0.5 seconds + if (damageFlashAlpha_ > 0.0f) { + damageFlashAlpha_ -= ImGui::GetIO().DeltaTime * 2.0f; + if (damageFlashAlpha_ < 0.0f) damageFlashAlpha_ = 0.0f; + + // Draw four red gradient rectangles along each screen edge (vignette style) + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const int alpha = static_cast(damageFlashAlpha_ * 100.0f); + const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); + const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); + const float thickness = std::min(W, H) * 0.12f; + + // Top + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + edgeCol, edgeCol, fadeCol, fadeCol); + // Bottom + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + fadeCol, fadeCol, edgeCol, edgeCol); + // Left + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + edgeCol, fadeCol, fadeCol, edgeCol); + // Right + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + fadeCol, edgeCol, edgeCol, fadeCol); + } + } + + // 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; + + 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 ImU32 goldEdge = IM_COL32(255, 210, 50, alpha); + const ImU32 goldFade = IM_COL32(255, 210, 50, 0); + const float thickness = std::min(W, H) * 0.18f; + + // Four golden gradient edges + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + goldEdge, goldEdge, goldFade, goldFade); + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + goldFade, goldFade, goldEdge, goldEdge); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + goldEdge, goldFade, goldFade, goldEdge); + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + goldFade, goldEdge, goldEdge, goldFade); + + // "Level X!" text in the center during the first half of the animation + if (levelUpFlashAlpha_ > 0.5f && levelUpDisplayLevel_ > 0) { + char lvlText[32]; + snprintf(lvlText, sizeof(lvlText), "Level %u!", levelUpDisplayLevel_); + ImVec2 ts = ImGui::CalcTextSize(lvlText); + float tx = (W - ts.x) * 0.5f; + float ty = H * 0.35f; + // Large shadow + bright gold text + fg->AddText(nullptr, 28.0f, ImVec2(tx + 2, ty + 2), IM_COL32(0, 0, 0, alpha), lvlText); + fg->AddText(nullptr, 28.0f, ImVec2(tx, ty), IM_COL32(255, 230, 80, alpha), lvlText); + } + } + // Restore previous alpha ImGui::GetStyle().Alpha = prevAlpha; } @@ -1090,6 +1262,22 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { 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 = "[" + itemName + "]"; ImGui::PushStyleColor(ImGuiCol_Text, linkColor); @@ -1189,6 +1377,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } }; + int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); @@ -1226,46 +1415,35 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { else if (msg.chatTag & 0x01) tagPrefix = " "; else if (msg.chatTag & 0x02) tagPrefix = " "; - if (msg.type == game::ChatType::SYSTEM) { - renderTextWithLinks(tsPrefix + processedMessage, color); - } else if (msg.type == game::ChatType::TEXT_EMOTE) { - renderTextWithLinks(tsPrefix + processedMessage, color); + // 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) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; - renderTextWithLinks(fullMsg, color); + 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) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; } else if (msg.type == game::ChatType::WHISPER_INFORM) { - // Outgoing whisper — show "To Name: message" (WoW-style) const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; - std::string fullMsg = tsPrefix + "To " + target + ": " + processedMessage; - renderTextWithLinks(fullMsg, color); + 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) { - std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; - renderTextWithLinks(fullMsg, color); + 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 + "]"; - std::string fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; } } else { - // No sender name. For group/channel types show a bracket prefix; - // for sender-specific types (SAY, YELL, WHISPER, etc.) just show the - // raw message — these are server-side announcements without a speaker. bool isGroupType = msg.type == game::ChatType::PARTY || msg.type == game::ChatType::GUILD || @@ -1276,18 +1454,66 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { msg.type == game::ChatType::BATTLEGROUND || msg.type == game::ChatType::BATTLEGROUND_LEADER; if (isGroupType) { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; } else { - // SAY, YELL, WHISPER, unknown BG_SYSTEM_* types, etc. — no prefix - renderTextWithLinks(tsPrefix + processedMessage, color); + fullMsg = tsPrefix + processedMessage; } } + + // Render message in a group so we can attach a right-click context menu + ImGui::PushID(chatMsgIdx++); + ImGui::BeginGroup(); + renderTextWithLinks(fullMsg, color); + ImGui::EndGroup(); + + // 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 - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) { - ImGui::SetScrollHereY(1.0f); + // 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(); @@ -1295,6 +1521,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // 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(); @@ -1410,17 +1647,50 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { auto* self = static_cast(data->UserData); - if (self && self->chatInputMoveCursorToEnd) { + 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; } + + // Up/Down arrow: cycle through sent message history + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + 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 inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_CallbackAlways | + ImGuiInputTextFlags_CallbackHistory; if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. @@ -1495,6 +1765,14 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showRaidFrames_ = !showRaidFrames_; } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) { + questLogScreen.toggle(); + } + + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { + showAchievementWindow_ = !showAchievementWindow_; + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -1843,11 +2121,37 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { playerHp = playerMaxHp; } - // Name in green (friendly player color) — clickable for self-target + // Name in green (friendly player color) — clickable for self-target, right-click for menu ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 1.0f, 0.3f, 1.0f)); if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) { gameHandler.setTarget(gameHandler.getPlayerGuid()); } + if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) { + ImGui::TextDisabled("%s", playerName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open Character")) { + inventoryScreen.setCharacterOpen(true); + } + if (ImGui::MenuItem("Toggle PvP")) { + gameHandler.togglePvp(); + } + ImGui::Separator(); + bool afk = gameHandler.isAfk(); + bool dnd = gameHandler.isDnd(); + if (ImGui::MenuItem(afk ? "Cancel AFK" : "Set AFK")) { + gameHandler.toggleAfk(); + } + if (ImGui::MenuItem(dnd ? "Cancel DND" : "Set DND")) { + gameHandler.toggleDnd(); + } + if (gameHandler.isInGroup()) { + ImGui::Separator(); + if (ImGui::MenuItem("Leave Group")) { + gameHandler.leaveGroup(); + } + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextDisabled("Lv %u", playerLevel); @@ -1855,6 +2159,15 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD"); } + if (gameHandler.isAfk()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Away from keyboard — /afk to cancel"); + } else if (gameHandler.isDnd()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); + } // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); @@ -1866,9 +2179,21 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } } - // Health bar + // Health bar — color transitions green→yellow→red as HP drops float pct = static_cast(playerHp) / static_cast(playerMaxHp); - ImVec4 hpColor = isDead ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) : ImVec4(0.2f, 0.8f, 0.2f, 1.0f); + ImVec4 hpColor; + if (isDead) { + hpColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (pct > 0.5f) { + hpColor = ImVec4(0.2f, 0.8f, 0.2f, 1.0f); // green + } else if (pct > 0.2f) { + float t = (pct - 0.2f) / 0.3f; // 0 at 20%, 1 at 50% + hpColor = ImVec4(0.9f - 0.7f * t, 0.4f + 0.4f * t, 0.0f, 1.0f); // orange→yellow + } else { + // Critical — pulse red when < 20% + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.5f); + hpColor = ImVec4(0.9f * pulse, 0.05f, 0.05f, 1.0f); // pulsing red + } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor); char overlay[64]; snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp); @@ -2032,6 +2357,18 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) { gameHandler.setTarget(petGuid); } + // Right-click context menu on pet name + if (ImGui::BeginPopupContextItem("PetNameCtx")) { + ImGui::TextDisabled("%s", petLabel); + ImGui::Separator(); + if (ImGui::MenuItem("Target Pet")) { + gameHandler.setTarget(petGuid); + } + if (ImGui::MenuItem("Dismiss Pet")) { + gameHandler.dismissPet(); + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); if (petLevel > 0) { ImGui::SameLine(); @@ -2043,7 +2380,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { uint32_t maxHp = petUnit->getMaxHealth(); if (maxHp > 0) { float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); + ImVec4 petHpColor = pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) + : pct > 0.2f ? ImVec4(0.9f, 0.6f, 0.0f, 1.0f) + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, petHpColor); char hpText[32]; snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); @@ -2251,13 +2591,83 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); } - // Entity name and type + // Entity name and type — Selectable so we can attach a right-click context menu std::string name = getEntityName(target); ImVec4 nameColor = hostileColor; ImGui::SameLine(0.0f, 0.0f); - ImGui::TextColored(nameColor, "%s", name.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + // Right-click context menu on the target name + if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { + const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); + const uint64_t tGuid = target->getGuid(); + + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(tGuid); + } + if (ImGui::MenuItem("Clear Target")) { + gameHandler.clearTarget(); + } + if (isPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Follow")) { + gameHandler.followTarget(); + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(name); + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(tGuid); + } + if (ImGui::MenuItem("Duel")) { + gameHandler.proposeDuel(tGuid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(name); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(name); + } + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(tGuid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(tGuid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } // Level (for units/players) — colored by difficulty if (target->getType() == game::ObjectType::UNIT || target->getType() == game::ObjectType::PLAYER) { @@ -2337,6 +2747,15 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float distance = std::sqrt(dx*dx + dy*dy + dz*dz); ImGui::TextDisabled("%.1f yd", distance); + // Threat button (shown when in combat and threat data is available) + if (gameHandler.getTargetThreatList()) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f)); + if (ImGui::SmallButton("Threat")) showThreatWindow_ = !showThreatWindow_; + ImGui::PopStyleColor(2); + } + // Target auras (buffs/debuffs) const auto& targetAuras = gameHandler.getTargetAuras(); int activeAuras = 0; @@ -2481,7 +2900,27 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { std::string totName = getEntityName(totEntity); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "%s", totName.c_str()); + // Selectable so we can attach a right-click context menu + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.8f, 0.8f, 0.8f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + if (ImGui::Selectable(totName.c_str(), false, + ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(totName.c_str()).x, 0))) { + gameHandler.setTarget(totGuid); + } + ImGui::PopStyleColor(4); + + if (ImGui::BeginPopupContextItem("##ToTCtx")) { + ImGui::TextDisabled("%s", totName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(totGuid); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(totGuid); + ImGui::EndPopup(); + } if (totEntity->getType() == game::ObjectType::UNIT || totEntity->getType() == game::ObjectType::PLAYER) { @@ -2502,10 +2941,6 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } } - // Click to target the target-of-target - if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { - gameHandler.setTarget(totGuid); - } } ImGui::End(); ImGui::PopStyleColor(2); @@ -2570,7 +3005,50 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); std::string focusName = getEntityName(focus); - ImGui::TextColored(focusColor, "%s", focusName.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, focusColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(focusName.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { + const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); + const uint64_t fGuid = focus->getGuid(); + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(fGuid); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focusIsPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(fGuid); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(fGuid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(fGuid); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); + } + ImGui::EndPopup(); + } if (focus->getType() == game::ObjectType::UNIT || focus->getType() == game::ObjectType::PLAYER) { @@ -2646,6 +3124,22 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { 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; @@ -2681,6 +3175,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { // /inspect command if (cmdLower == "inspect") { gameHandler.inspectTarget(); + showInspectWindow_ = true; + chatInputBuffer[0] = '\0'; + return; + } + + // /threat command + if (cmdLower == "threat") { + showThreatWindow_ = !showThreatWindow_; chatInputBuffer[0] = '\0'; return; } @@ -2699,6 +3201,43 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /ticket command — open GM ticket window + if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { + showGmTicketWindow_ = true; + chatInputBuffer[0] = '\0'; + return; + } + + // /help command — list available slash commands + if (cmdLower == "help" || cmdLower == "?") { + static const char* kHelpLines[] = { + "--- Wowee Slash Commands ---", + "Chat: /s /y /p /g /raid /rw /o /bg /w [msg] /r [msg]", + "Social: /who [filter] /whois /friend add/remove ", + " /ignore /unignore ", + "Party: /invite /uninvite /leave /readycheck", + " /maintank /mainassist /roll [min-max]", + "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", + " /gleader /groster /ginfo /gcreate /gdisband", + "Combat: /startattack /stopattack /stopcasting /duel /pvp", + " /forfeit /follow /assist", + "Target: /target /cleartarget /focus /clearfocus", + "Movement: /sit /stand /kneel /dismount", + "Misc: /played /time /afk [msg] /dnd [msg] /inspect", + " /helm /cloak /trade /join /leave ", + " /unstuck /logout /ticket /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; @@ -3304,6 +3843,41 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "target" && spacePos != std::string::npos) { + // Search visible entities for name match (case-insensitive prefix) + std::string targetArg = command.substr(spacePos + 1); + std::string targetArgLower = targetArg; + for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + 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) { + bestGuid = guid; + if (nameLower == targetArgLower) break; // Exact match wins + } + } + 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'; @@ -4108,6 +4682,14 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage } } + // Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached + // (e.g., first login, after loading screen, or many new auras appearing at once). + static int gsLoadsThisFrame = 0; + static int gsLastImGuiFrame = -1; + int gsCurFrame = ImGui::GetFrameCount(); + if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; } + if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + // Look up spellId -> SpellIconID -> icon path auto iit = spellIconIds_.find(spellId); if (iit == spellIconIds_.end()) { @@ -4143,6 +4725,7 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage return VK_NULL_HANDLE; } + ++gsLoadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); spellIconCache_[spellId] = ds; return ds; @@ -4156,7 +4739,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; + float slotSize = 48.0f * pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -4276,7 +4859,6 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - bool rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseReleased(ImGuiMouseButton_Left); @@ -4303,9 +4885,40 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); } - } else if (rightClicked && !slot.isEmpty()) { - actionBarDragSlot_ = absSlot; - actionBarDragIcon_ = iconTex; + } + + // 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); + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Slot")) { + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::EMPTY, 0); + } + ImGui::EndPopup(); + } } // Tooltip @@ -4389,8 +5002,10 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { char cdText[16]; float cd = slot.cooldownRemaining; - if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm", (int)cd / 60); - else snprintf(cdText, sizeof(cdText), "%.0f", cd); + if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", (int)cd / 3600); + else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", (int)cd / 60, (int)cd % 60); + else if (cd >= 5.0f) snprintf(cdText, sizeof(cdText), "%ds", (int)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; @@ -4647,6 +5262,27 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { 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(); @@ -4737,6 +5373,24 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { 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(); @@ -4792,7 +5446,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; + float slotSize = 48.0f * pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -4921,19 +5575,28 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f)); if (ImGui::Begin("##CastBar", nullptr, flags)) { - float progress = gameHandler.getCastProgress(); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.6f, 0.2f, 1.0f)); + 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(); + + ImVec4 barColor = channeling + ? ImVec4(0.3f, 0.6f, 0.9f, 1.0f) // blue for channels + : ImVec4(0.8f, 0.6f, 0.2f, 1.0f); // gold for casts + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); char overlay[64]; uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); - if (gameHandler.getCurrentCastSpellId() == 0) { + 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"; if (!spellName.empty()) snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); else - snprintf(overlay, sizeof(overlay), "Casting... (%.1fs)", gameHandler.getCastTimeRemaining()); + snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); } ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); ImGui::PopStyleColor(); @@ -5056,13 +5719,40 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); ImGui::PushStyleColor(ImGuiCol_Text, titleCol); if (ImGui::Selectable(q.title.c_str(), false, - ImGuiSelectableFlags_None, ImVec2(TRACKER_W - 12.0f, 0))) { + ImGuiSelectableFlags_DontClosePopups, ImVec2(TRACKER_W - 12.0f, 0))) { questLogScreen.openAndSelectQuest(q.questId); } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Click to open Quest Log"); + if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) { + ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options"); } ImGui::PopStyleColor(); + + // Right-click context menu for quest tracker entry + if (ImGui::BeginPopupContextItem("##QTCtx")) { + ImGui::TextDisabled("%s", q.title.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open in Quest Log")) { + questLogScreen.openAndSelectQuest(q.questId); + } + bool tracked = gameHandler.isQuestTracked(q.questId); + if (tracked) { + if (ImGui::MenuItem("Stop Tracking")) { + gameHandler.setQuestTracked(q.questId, false); + } + } else { + if (ImGui::MenuItem("Track")) { + gameHandler.setQuestTracked(q.questId, true); + } + } + if (!q.complete) { + ImGui::Separator(); + if (ImGui::MenuItem("Abandon Quest")) { + gameHandler.abandonQuest(q.questId); + gameHandler.setQuestTracked(q.questId, false); + } + } + ImGui::EndPopup(); + } ImGui::PopID(); // Objectives line (condensed) @@ -5093,7 +5783,16 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; const auto* info = gameHandler.getItemInfo(itemId); const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; - if (itemName) { + + // Show small icon if available + uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12)); + ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + "%s: %u/%u", itemName ? itemName : "Item", count, required); + } else if (itemName) { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), " %s: %u/%u", itemName, count, required); } else { @@ -5124,6 +5823,96 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } +// ============================================================ +// 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; + 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()); + } + } + 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) // ============================================================ @@ -5260,6 +6049,108 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { ImGui::End(); } +// ============================================================ +// DPS / HPS Meter +// ============================================================ + +void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { + if (!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) { + dpsCombatAge_ += dt; + } else if (dpsWasInCombat_) { + // Just left combat — let meter show last reading for LIFETIME then reset + dpsCombatAge_ = 0.0f; + } + 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: + 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 + if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat) 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; + + constexpr float WIN_W = 90.0f; + constexpr float 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"); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); +} + // ============================================================ // Nameplates — world-space health bars projected to screen // ============================================================ @@ -5371,8 +6262,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { : IM_COL32(20, 20, 20, A(180)); // Bar geometry - constexpr float barW = 80.0f; - constexpr float barH = 8.0f; + const float barW = 80.0f * nameplateScale_; + const float barH = 8.0f * nameplateScale_; const float barX = sx - barW * 0.5f; float healthPct = std::clamp( @@ -5386,6 +6277,59 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); + // HP % text centered on health bar (non-corpse, non-full-health for readability) + if (!isCorpse && unit->getMaxHealth() > 0) { + int hpPct = static_cast(healthPct * 100.0f + 0.5f); + char hpBuf[8]; + snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct); + ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf); + float hpTx = sx - hpTextSz.x * 0.5f; + float hpTy = sy + (barH - hpTextSz.y) * 0.5f; + drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf); + drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf); + } + + // Cast bar below health bar when unit is casting + float castBarBaseY = sy + barH + 2.0f; + { + 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_; + + // Spell name above the cast bar + const std::string& spellName = gameHandler.getSpellName(cs->spellId); + if (!spellName.empty()) { + ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); + float snX = sx - snSz.x * 0.5f; + float snY = castBarBaseY; + drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); + drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); + castBarBaseY += snSz.y + 2.0f; + } + + // Cast bar background + fill + ImU32 cbBg = IM_COL32(40, 30, 60, A(180)); + ImU32 cbFill = IM_COL32(140, 80, 220, A(200)); // purple cast bar + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f); + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f); + drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f), + ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f), + IM_COL32(20, 10, 40, A(200)), 2.0f); + + // Time remaining text + char timeBuf[12]; + snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining); + ImVec2 timeSz = ImGui::CalcTextSize(timeBuf); + float timeX = sx - timeSz.x * 0.5f; + float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f; + drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf); + drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf); + } + } + // Name + level label above health bar uint32_t level = unit->getLevel(); const std::string& unitName = unit->getName(); @@ -5453,18 +6397,68 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } } - // Click to target: detect left-click inside the combined nameplate region - if (!ImGui::GetIO().WantCaptureMouse && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + // Click to target / right-click context: detect clicks inside the nameplate region + if (!ImGui::GetIO().WantCaptureMouse) { ImVec2 mouse = ImGui::GetIO().MousePos; float nx0 = nameX - 2.0f; float ny0 = nameY - 1.0f; float nx1 = nameX + textSize.x + 2.0f; float ny1 = sy + barH + 2.0f; if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { - gameHandler.setTarget(guid); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + gameHandler.setTarget(guid); + } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + nameplateCtxGuid_ = guid; + nameplateCtxPos_ = mouse; + ImGui::OpenPopup("##NameplateCtx"); + } } } } + + // Render nameplate context popup (uses a tiny overlay window as host) + if (nameplateCtxGuid_ != 0) { + ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always); + ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_AlwaysAutoResize; + if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) { + if (ImGui::BeginPopup("##NameplateCtx")) { + auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_); + std::string ctxName = entityPtr ? getEntityName(entityPtr) : ""; + if (!ctxName.empty()) { + ImGui::TextDisabled("%s", ctxName.c_str()); + ImGui::Separator(); + } + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(nameplateCtxGuid_); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(nameplateCtxGuid_); + bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER; + if (isPlayer && !ctxName.empty()) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, ctxName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(ctxName); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(ctxName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(ctxName); + } + ImGui::EndPopup(); + } else { + nameplateCtxGuid_ = 0; + } + } + ImGui::End(); + } } // ============================================================ @@ -5585,6 +6579,14 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { pct > 0.2f ? IM_COL32(200, 180, 50, 255) : IM_COL32(200, 60, 60, 255); draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); + // HP percentage text centered on bar + char hpPct[8]; + 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 @@ -5613,6 +6615,49 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { gameHandler.setTarget(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")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, m.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + 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")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + 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++; @@ -5700,7 +6745,13 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) : ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); - ImGui::ProgressBar(pct, ImVec2(-1, 12), ""); + char hpText[32]; + if (maxHp >= 10000) + snprintf(hpText, sizeof(hpText), "%dk/%dk", + (int)hp / 1000, (int)maxHp / 1000); + else + snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); ImGui::PopStyleColor(); } @@ -5754,12 +6805,52 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; refocusChatInput = true; } + 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")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + 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(); } @@ -5774,6 +6865,149 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// UI Error Frame (WoW-style center-bottom error overlay) +// ============================================================ + +void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) { + // Age out old entries + for (auto& e : uiErrors_) e.age += deltaTime; + uiErrors_.erase( + std::remove_if(uiErrors_.begin(), uiErrors_.end(), + [](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }), + uiErrors_.end()); + + if (uiErrors_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Fixed invisible overlay + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + if (ImGui::Begin("##UIErrors", nullptr, flags)) { + // Render messages stacked above the action bar (~200px from bottom) + // The newest message is on top; older ones fade below it. + const float baseY = screenH - 200.0f; + const float lineH = 20.0f; + const int count = static_cast(uiErrors_.size()); + + ImDrawList* draw = ImGui::GetWindowDrawList(); + for (int i = count - 1; i >= 0; --i) { + const auto& e = uiErrors_[i]; + float alpha = 1.0f - (e.age / kUIErrorLifetime); + alpha = std::max(0.0f, std::min(1.0f, alpha)); + + // Fade fast in the last 0.5 s + if (e.age > kUIErrorLifetime - 0.5f) + alpha *= (kUIErrorLifetime - e.age) / 0.5f; + + uint8_t a8 = static_cast(alpha * 255.0f); + ImU32 textCol = IM_COL32(255, 50, 50, a8); + ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast(alpha * 180)); + + const char* txt = e.text.c_str(); + ImVec2 sz = ImGui::CalcTextSize(txt); + float x = std::round((screenW - sz.x) * 0.5f); + float y = std::round(baseY - (count - 1 - i) * lineH); + + // Drop shadow + draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt); + draw->AddText(ImVec2(x, y), textCol, txt); + } + } + ImGui::End(); + ImGui::PopStyleVar(); +} + +// ============================================================ +// 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, (int)(alpha * 200)), 4.0f); + // Border: green for gain, red for loss + ImU32 borderCol = (e.delta > 0) + ? IM_COL32(80, 200, 80, (int)(alpha * 220)) + : IM_COL32(200, 60, 60, (int)(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, (int)(alpha * 255)) + : IM_COL32(220, 70, 70, (int)(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, (int)(alpha * 220)), nameBuf); + } +} + // ============================================================ // Boss Encounter Frames // ============================================================ @@ -6084,13 +7318,33 @@ void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { : ("Item " + std::to_string(slot.itemId)); if (slot.stackCount > 1) name += " x" + std::to_string(slot.stackCount); - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), " %d. %s", i + 1, name.c_str()); - + 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 (isMine && ImGui::IsItemHovered()) { - ImGui::SetTooltip("Double-click to remove"); + 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); + 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; + } } } else { ImGui::TextDisabled(" %d. (empty)", i + 1); @@ -6217,6 +7471,16 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { 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); + 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; + } + } ImGui::Spacing(); if (ImGui::Button("Need", ImVec2(80, 30))) { @@ -6614,8 +7878,33 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Context menu popup if (ImGui::BeginPopup("GuildMemberContext")) { - ImGui::Text("%s", selectedGuildMember_.c_str()); + 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")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, selectedGuildMember_.c_str(), + sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + 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_); } @@ -6816,12 +8105,31 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { 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 (const auto& c : contacts) { + 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) @@ -6832,33 +8140,140 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::Dummy(ImVec2(14.0f, 0.0f)); ImGui::SameLine(); - // Name + // Name as Selectable for right-click context menu const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); ImVec4 nameCol = c.isOnline() ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); - ImGui::TextColored(nameCol, "%s", displayName); + ImGui::PushStyleColor(ImGuiCol_Text, nameCol); + ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap, ImVec2(130.0f, 0.0f)); + ImGui::PopStyleColor(); - // Level and status on same line (right-aligned) + // 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; + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + 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; + } + 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 and status if (c.isOnline()) { - ImGui::SameLine(); + ImGui::SameLine(160.0f); const char* statusLabel = - (c.status == 2) ? "(AFK)" : - (c.status == 3) ? "(DND)" : ""; + (c.status == 2) ? " (AFK)" : + (c.status == 3) ? " (DND)" : ""; if (c.level > 0) { - ImGui::TextDisabled("Lv %u %s", c.level, statusLabel); + ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); } else if (*statusLabel) { - ImGui::TextDisabled("%s", statusLabel); + ImGui::TextDisabled("%s", statusLabel + 1); } } + + ImGui::PopID(); } if (friendCount == 0) { - ImGui::TextDisabled("No friends online."); + 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(); - ImGui::TextDisabled("Right-click a player's name in chat to add friends."); + + 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(); } @@ -6869,6 +8284,210 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { showGuildRoster_ = open; } +// ============================================================ +// Social Frame — compact online friends panel (toggled by 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)); + + 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)) { + + 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() + ? ImVec4(0.9f, 0.9f, 0.9f, 1.0f) + : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(nameCol, "%s", displayName); + + if (c.isOnline() && c.level > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv%u", c.level); + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + 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; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(c.name); + } + 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(); + 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(); + } + + ImGui::EndTabBar(); + } + } + ImGui::End(); + showSocialFrame_ = open; + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + // ============================================================ // Buff/Debuff Bar (Phase 3) // ============================================================ @@ -7088,7 +8707,18 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Invisible selectable for click handling if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) { - lootSlotClicked = item.slotIndex; + 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; + } + } else { + lootSlotClicked = item.slotIndex; + } } if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { lootSlotClicked = item.slotIndex; @@ -7153,6 +8783,15 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { } 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(); } @@ -7286,9 +8925,38 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { 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 = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + switch (quest.questIcon) { + case 5: // INCOMPLETE — in progress but not done + statusIcon = "?"; + statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray + break; + case 6: // REWARD_REP — repeatable, ready to turn in + case 10: // REWARD — ready to turn in + statusIcon = "?"; + statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + break; + case 7: // AVAILABLE_LOW — available but gray (low-level) + statusIcon = "!"; + statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray + break; + default: // AVAILABLE (8) and any others + statusIcon = "!"; + statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // 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, ImVec4(1.0f, 1.0f, 0.3f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, statusColor); if (ImGui::Selectable(qlabel)) { gameHandler.selectGossipQuest(quest.questId); } @@ -7363,21 +9031,22 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { if (iconTex) { ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(nameCol, "%s", info->name.c_str()); - if (!info->description.empty()) - ImGui::TextWrapped("%s", info->description.c_str()); - ImGui::EndTooltip(); - } + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); ImGui::SameLine(); } ImGui::TextColored(nameCol, " %s", label.c_str()); - if (ImGui::IsItemHovered() && info && info->valid && !info->description.empty()) { - ImGui::BeginTooltip(); - ImGui::TextColored(nameCol, "%s", info->name.c_str()); - ImGui::TextWrapped("%s", info->description.c_str()); - ImGui::EndTooltip(); + 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); + 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; + } } }; @@ -7491,14 +9160,34 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { for (const auto& item : quest.requiredItems) { uint32_t have = countItemInInventory(item.itemId); bool enough = have >= item.count; + ImVec4 textCol = enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : 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(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f), - " %s %u/%u", name, have, item.count); + ImGui::TextColored(textCol, "%s %u/%u", name, have, item.count); } else { - ImGui::TextColored(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f), - " Item %u %u/%u", item.itemId, have, item.count); + 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); + 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; + } } } } @@ -7622,7 +9311,17 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } ImGui::PushStyleColor(ImGuiCol_Text, qualityColor); if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) { - selectedChoice = static_cast(i); + 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; + } + } else { + selectedChoice = static_cast(i); + } } ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); @@ -7652,6 +9351,16 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } 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); + 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; + } + } } } @@ -7743,54 +9452,105 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "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(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Buy Back"); - if (ImGui::BeginTable("BuybackTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + 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 only the most recently sold item (LIFO). - const int i = 0; - const auto& entry = buyback[0]; - // Proactively ensure buyback item info is loaded - gameHandler.ensureItemInfo(entry.item.itemId); - uint32_t sellPrice = entry.item.sellPrice; - if (sellPrice == 0) { - if (auto* info = gameHandler.getItemInfo(entry.item.itemId); info && info->valid) { - sellPrice = info->sellPrice; + // 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; + 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); - const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); - if (entry.count > 1) { - ImGui::Text("%s x%u", name, entry.count); - } else { - ImGui::Text("%s", name); + 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) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); + ImGui::Text("%ug %us %uc", g, s, c); + if (!canAfford) ImGui::PopStyleColor(); + 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::TableSetColumnIndex(1); - if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); - ImGui::Text("%ug %us %uc", g, s, c); - if (!canAfford) ImGui::PopStyleColor(); - ImGui::TableSetColumnIndex(2); - if (!canAfford) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Buy Back##buyback_0")) { - gameHandler.buyBackItem(0); - } - if (!canAfford) ImGui::EndDisabled(); - ImGui::PopID(); ImGui::EndTable(); } ImGui::Separator(); @@ -7799,45 +9559,86 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (vendor.items.empty()) { ImGui::TextDisabled("This vendor has nothing for sale."); } else { - if (ImGui::BeginTable("VendorTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + // 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(); - // Quality colors (matching WoW) - static const ImVec4 qualityColors[] = { - ImVec4(0.6f, 0.6f, 0.6f, 1.0f), // 0 Poor (gray) - ImVec4(1.0f, 1.0f, 1.0f, 1.0f), // 1 Common (white) - ImVec4(0.12f, 1.0f, 0.0f, 1.0f), // 2 Uncommon (green) - ImVec4(0.0f, 0.44f, 0.87f, 1.0f), // 3 Rare (blue) - ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4 Epic (purple) - ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5 Legendary (orange) - }; + 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]; - ImGui::TableNextRow(); - ImGui::PushID(vi); // Proactively ensure vendor item info is loaded gameHandler.ensureItemInfo(item.itemId); - - ImGui::TableSetColumnIndex(0); 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) { - uint32_t q = info->quality < 6 ? info->quality : 1; - ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); - // Tooltip with stats on hover + ImVec4 qc = InventoryScreen::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qc, "%s", info->name.c_str()); if (ImGui::IsItemHovered()) { inventoryScreen.renderItemTooltip(*info); } + // 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; + } + } } else { ImGui::Text("Item %u", item.itemId); } - ImGui::TableSetColumnIndex(1); + ImGui::TableSetColumnIndex(2); if (item.buyPrice == 0 && item.extendedCost != 0) { // Token-only item (no gold cost) ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "[Tokens]"); @@ -7851,18 +9652,28 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (!canAfford) ImGui::PopStyleColor(); } - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(3); if (item.maxCount < 0) { - ImGui::Text("Inf"); + ImGui::TextDisabled("Inf"); + } else if (item.maxCount == 0) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "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(3); + 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())) { - gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, 1); + int qty = vendorBuyQty; + if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount; + gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, + static_cast(qty)); } + if (outOfStock) ImGui::EndDisabled(); ImGui::PopID(); } @@ -7887,6 +9698,7 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { 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); @@ -7917,9 +9729,12 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t mc = static_cast(money % 100); ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); - // Filter checkbox + // 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()) { @@ -7969,6 +9784,20 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { 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)); @@ -7986,8 +9815,23 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { statusLabel = "Unavailable"; } - // Spell name + // 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()) { @@ -8028,11 +9872,11 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } // Level - ImGui::TableSetColumnIndex(1); + ImGui::TableSetColumnIndex(2); ImGui::TextColored(color, "%u", spell->reqLevel); // Cost - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(3); if (spell->spellCost > 0) { uint32_t g = spell->spellCost / 10000; uint32_t s = (spell->spellCost / 100) % 100; @@ -8045,7 +9889,7 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } // Train button - only enabled if available, affordable, prereqs met - ImGui::TableSetColumnIndex(3); + ImGui::TableSetColumnIndex(4); // Use effectiveState so newly available spells (after learning prereqs) can be trained bool canTrain = !alreadyKnown && effectiveState == 0 && prereqsMet && levelMet @@ -8081,8 +9925,9 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { }; auto renderSpellTable = [&](const char* tableId, const std::vector& spells) { - if (ImGui::BeginTable(tableId, 4, + 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); @@ -8120,6 +9965,63 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } 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(); } } ImGui::End(); @@ -8143,7 +10045,7 @@ void GameScreen::renderEscapeMenu() { ImGuiIO& io = ImGui::GetIO(); float screenW = io.DisplaySize.x; float screenH = io.DisplaySize.y; - ImVec2 size(260.0f, 220.0f); + ImVec2 size(260.0f, 248.0f); ImVec2 pos((screenW - size.x) * 0.5f, (screenH - size.y) * 0.5f); ImGui::SetNextWindowPos(pos, ImGuiCond_Always); @@ -8179,6 +10081,10 @@ void GameScreen::renderEscapeMenu() { 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)); @@ -8381,6 +10287,14 @@ void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { 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(); @@ -8902,6 +10816,11 @@ void GameScreen::renderSettingsWindow() { 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(); @@ -8953,6 +10872,14 @@ void GameScreen::renderSettingsWindow() { } } + 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(); @@ -8963,6 +10890,22 @@ void GameScreen::renderSettingsWindow() { 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)"); + + 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)"); + ImGui::EndChild(); ImGui::EndTabItem(); } @@ -9150,6 +11093,17 @@ void GameScreen::renderSettingsWindow() { 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(); @@ -9792,6 +11746,27 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { return true; }; + // Player position marker — always drawn at minimap center with a directional arrow. + { + // The player is always at centerX, centerY on the minimap. + // Draw a yellow arrow pointing in the player's facing direction. + glm::vec3 fwd = camera->getForward(); + float facing = std::atan2(-fwd.x, fwd.y); // bearing relative to north + float cosF = std::cos(facing - bearing); + float sinF = std::sin(facing - bearing); + float arrowLen = 8.0f; + float arrowW = 4.0f; + ImVec2 tip(centerX + sinF * arrowLen, centerY - cosF * arrowLen); + ImVec2 left(centerX - cosF * arrowW - sinF * arrowLen * 0.3f, + centerY - sinF * arrowW + cosF * arrowLen * 0.3f); + ImVec2 right(centerX + cosF * arrowW - sinF * arrowLen * 0.3f, + centerY + sinF * arrowW + cosF * arrowLen * 0.3f); + drawList->AddTriangleFilled(tip, left, right, IM_COL32(255, 220, 0, 255)); + drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); + // White dot at player center + drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220)); + } + // Optional base nearby NPC dots (independent of quest status packets). if (minimapNpcDots_) { for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { @@ -9924,6 +11899,127 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Corpse direction indicator — shown when player is a ghost + if (gameHandler.isPlayerGhost()) { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + if (gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY)) { + glm::vec3 corpseRender = core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)); + float csx = 0.0f, csy = 0.0f; + bool onMap = projectToMinimap(corpseRender, csx, csy); + + if (onMap) { + // Draw a small skull-like X marker at the corpse position + const float r = 5.0f; + drawList->AddCircleFilled(ImVec2(csx, csy), r + 1.0f, IM_COL32(0, 0, 0, 140), 12); + drawList->AddCircle(ImVec2(csx, csy), r + 1.0f, IM_COL32(200, 200, 220, 220), 12, 1.5f); + // Draw an X in the circle + drawList->AddLine(ImVec2(csx - 3.0f, csy - 3.0f), ImVec2(csx + 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + drawList->AddLine(ImVec2(csx + 3.0f, csy - 3.0f), ImVec2(csx - 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - csx, mdy = mouse.y - csy; + if (mdx * mdx + mdy * mdy < 64.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } else { + // Corpse is outside minimap — draw an edge arrow pointing toward it + float dx = corpseRender.x - playerRender.x; + float dy = corpseRender.y - playerRender.y; + // Rotate delta into minimap frame (same as projectToMinimap) + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; + float len = std::sqrt(rx * rx + ry * ry); + if (len > 0.001f) { + float nx = rx / len; + float ny = ry / len; + // Place arrow at the minimap edge + float edgeR = mapRadius - 7.0f; + float ax = centerX + nx * edgeR; + float ay = centerY + ny * edgeR; + // Arrow pointing outward (toward corpse) + float arrowLen = 6.0f; + float arrowW = 3.5f; + ImVec2 tip(ax + nx * arrowLen, ay + ny * arrowLen); + ImVec2 left(ax - ny * arrowW - nx * arrowLen * 0.4f, + ay + nx * arrowW - ny * arrowLen * 0.4f); + ImVec2 right(ax + ny * arrowW - nx * arrowLen * 0.4f, + ay - nx * arrowW - ny * arrowLen * 0.4f); + drawList->AddTriangleFilled(tip, left, right, IM_COL32(180, 180, 240, 230)); + drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - ax, mdy = mouse.y - ay; + if (mdx * mdx + mdy * mdy < 100.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } + } + } + } + + // Scroll wheel over minimap → zoom in/out + { + float wheel = ImGui::GetIO().MouseWheel; + if (wheel != 0.0f) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { + if (wheel > 0.0f) + minimap->zoomIn(); + else + minimap->zoomOut(); + } + } + } + + // Ctrl+click on minimap → send minimap ping to party + if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + float distSq = mdx * mdx + mdy * mdy; + if (distSq <= mapRadius * mapRadius) { + // Invert projectToMinimap: px=mdx, py=mdy → rx=px*viewRadius/mapRadius + float rx = mdx * viewRadius / mapRadius; + float ry = mdy * viewRadius / mapRadius; + // rx/ry are in rotated frame; unrotate to get world dx/dy + // rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + // Solving: dx = -(rx*cosB - ry*sinB), dy = -(rx*sinB + ry*cosB) + float wdx = -(rx * cosB - ry * sinB); + float wdy = -(rx * sinB + ry * cosB); + // playerRender is in render coords; add delta to get render position then convert to canonical + glm::vec3 clickRender = playerRender + glm::vec3(wdx, wdy, 0.0f); + glm::vec3 clickCanon = core::coords::renderToCanonical(clickRender); + gameHandler.sendMinimapPing(clickCanon.x, clickCanon.y); + } + } + + // Hover tooltip: show player's WoW coordinates (canonical X=North, Y=West) + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { + glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.5f, 1.0f), + "%.1f, %.1f", playerCanon.x, playerCanon.y); + ImGui::TextDisabled("Ctrl+click to ping"); + ImGui::EndTooltip(); + } + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; @@ -10019,6 +12115,56 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); + // Friends button at top-left of minimap + { + const auto& contacts = gameHandler.getContacts(); + int onlineCount = 0; + for (const auto& c : contacts) + if (c.isFriend() && c.isOnline()) ++onlineCount; + + ImGui::SetNextWindowPos(ImVec2(centerX - mapRadius + 4.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always); + ImGuiWindowFlags friendsBtnFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##MinimapFriendsBtn", nullptr, friendsBtnFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 p = ImGui::GetCursorScreenPos(); + ImVec2 sz(20.0f, 20.0f); + if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) { + showSocialFrame_ = !showSocialFrame_; + } + bool hovered = ImGui::IsItemHovered(); + ImU32 bg = 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); + draw->AddRectFilled(p, ImVec2(p.x + sz.x, p.y + sz.y), bg, 4.0f); + draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), + ImVec2(p.x + sz.x - 0.5f, p.y + sz.y - 0.5f), + IM_COL32(255, 255, 255, 42), 4.0f); + // Simple smiley-face dots as "social" icon + ImU32 fg = IM_COL32(255, 255, 255, 245); + draw->AddCircle(ImVec2(p.x + 10.0f, p.y + 10.0f), 6.5f, fg, 16, 1.2f); + draw->AddCircleFilled(ImVec2(p.x + 7.5f, p.y + 8.0f), 1.2f, fg); + draw->AddCircleFilled(ImVec2(p.x + 12.5f, p.y + 8.0f), 1.2f, fg); + draw->PathArcTo(ImVec2(p.x + 10.0f, p.y + 11.5f), 3.0f, 0.2f, 2.9f, 8); + draw->PathStroke(fg, 0, 1.2f); + // Small green dot if friends online + if (onlineCount > 0) { + draw->AddCircleFilled(ImVec2(p.x + sz.x - 3.5f, p.y + 3.5f), + 3.5f, IM_COL32(50, 220, 50, 255)); + } + if (hovered) { + if (onlineCount > 0) + ImGui::SetTooltip("Friends (%d online)", onlineCount); + else + ImGui::SetTooltip("Friends"); + } + } + ImGui::End(); + } + // Zoom buttons at the bottom edge of the minimap ImGui::SetNextWindowPos(ImVec2(centerX - 22, centerY + mapRadius - 30), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(44, 24), ImGuiCond_Always); @@ -10091,19 +12237,88 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { break; // Show at most one queue slot indicator } - // Latency indicator (toggleable in Interface settings) + // Latency indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { ImVec4 latColor; - if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.8f); // Green < 100ms - else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.8f); // Yellow < 250ms - else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.8f); // Orange < 500ms - else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.8f); // Red >= 500ms + if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f); + else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f); + + char latBuf[32]; + snprintf(latBuf, sizeof(latBuf), "%u ms", latMs); + ImVec2 textSize = ImGui::CalcTextSize(latBuf); + float latW = textSize.x + 16.0f; + float latH = textSize.y + 8.0f; + ImGuiIO& lio = ImGui::GetIO(); + float latX = (lio.DisplaySize.x - latW) * 0.5f; + ImGui::SetNextWindowPos(ImVec2(latX, 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); + if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { + ImGui::TextColored(latColor, "%s", latBuf); + } + ImGui::End(); + } + + // Low durability warning — shown when any equipped item has < 20% durability + if (gameHandler.getState() == game::WorldState::IN_WORLD) { + const auto& inv = gameHandler.getInventory(); + float lowestDurPct = 1.0f; + for (int i = 0; i < game::Inventory::NUM_EQUIP_SLOTS; ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty()) continue; + const auto& it = slot.item; + if (it.maxDurability > 0) { + float pct = static_cast(it.curDurability) / static_cast(it.maxDurability); + if (pct < lowestDurPct) lowestDurPct = pct; + } + } + if (lowestDurPct < 0.20f) { + bool critical = (lowestDurPct < 0.05f); + float pulse = critical + ? (0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 4.0f)) + : 1.0f; + ImVec4 durWarnColor = critical + ? ImVec4(1.0f, 0.2f, 0.2f, pulse) + : ImVec4(1.0f, 0.65f, 0.1f, 0.9f); + const char* durWarnText = critical ? "Item breaking!" : "Low durability"; + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##DurabilityIndicator", nullptr, indicatorFlags)) { + ImGui::TextColored(durWarnColor, "%s", durWarnText); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + + // Local time clock — always visible below minimap indicators + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + struct tm tmBuf; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockStr[16]; + snprintf(clockStr, sizeof(clockStr), "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min); ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { - ImGui::TextColored(latColor, "%u ms", latMs); + ImGuiWindowFlags clockFlags = indicatorFlags & ~ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##ClockIndicator", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 0.75f), "%s", clockStr); + if (ImGui::IsItemHovered()) { + char fullTime[32]; + snprintf(fullTime, sizeof(fullTime), "%02d:%02d:%02d (local)", + tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec); + ImGui::SetTooltip("%s", fullTime); + } } ImGui::End(); } @@ -10352,7 +12567,10 @@ void GameScreen::saveSettings() { 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 << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; + out << "action_bar_scale=" << pendingActionBarScale << "\n"; + out << "nameplate_scale=" << nameplateScale_ << "\n"; out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; @@ -10360,6 +12578,7 @@ void GameScreen::saveSettings() { 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"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -10401,6 +12620,7 @@ void GameScreen::saveSettings() { out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n"; out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; + out << "fov=" << pendingFov << "\n"; // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; @@ -10455,9 +12675,15 @@ void GameScreen::loadSettings() { } else if (key == "show_latency_meter") { showLatencyMeter_ = (std::stoi(val) != 0); pendingShowLatencyMeter = showLatencyMeter_; + } else if (key == "show_dps_meter") { + showDPSMeter_ = (std::stoi(val) != 0); } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); + } else if (key == "action_bar_scale") { + 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); } else if (key == "show_action_bar2") { pendingShowActionBar2 = (std::stoi(val) != 0); } else if (key == "action_bar2_offset_x") { @@ -10472,6 +12698,8 @@ void GameScreen::loadSettings() { 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); + } else if (key == "damage_flash") { + damageFlashEnabled_ = (std::stoi(val) != 0); } // Audio else if (key == "sound_muted") { @@ -10527,6 +12755,12 @@ void GameScreen::loadSettings() { 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 == "fov") { + 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); + } + } // 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); @@ -10674,17 +12908,80 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { // 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) { - ImGui::BulletText("%s x%u", info->name.c_str(), att.stackCount); + quality = static_cast(info->quality); + name = info->name; + displayInfoId = info->displayInfoId; } else { - ImGui::BulletText("Item %u x%u", att.itemId, att.stackCount); 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); + 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; + } + } + 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); + 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; + } + } ImGui::SameLine(); if (ImGui::SmallButton("Take")) { gameHandler.mailTakeItem(mail.messageId, att.slot); @@ -10692,6 +12989,14 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { 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.slot); + } + } + } } ImGui::Spacing(); @@ -10973,10 +13278,32 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { // Tooltip if (ImGui::IsItemHovered() && !isHolding) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", item.name.c_str()); - if (item.stackCount > 1) ImGui::Text("Count: %u", item.stackCount); - ImGui::EndTooltip(); + 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); + 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; + } + } } } }; @@ -11087,35 +13414,81 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { 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(); + 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) { - ImGui::Button("##gb", ImVec2(34, 34)); + 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); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qc.x * 0.3f, qc.y * 0.3f, qc.z * 0.3f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qc.x * 0.5f, qc.y * 0.5f, qc.z * 0.5f, 0.9f)); - std::string lbl = item.stackCount > 1 ? std::to_string(item.stackCount) : ("##gi" + std::to_string(i)); - if (ImGui::Button(lbl.c_str(), ImVec2(34, 34))) { - // Withdraw: auto-store to first free bag slot + 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); } - ImGui::PopStyleColor(2); if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", name.c_str()); - if (item.stackCount > 1) ImGui::Text("Count: %u", item.stackCount); - ImGui::EndTooltip(); + 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); + 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; + } + } } } ImGui::PopID(); @@ -11369,37 +13742,19 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } ImGui::TextColored(qc, "%s", name.c_str()); - // Item tooltip on hover + // Item tooltip on hover; shift-click to insert chat link if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", info->name.c_str()); - if (info->inventoryType > 0) { - if (!info->subclassName.empty()) - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1), "%s", info->subclassName.c_str()); + 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); + 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; } - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); - } - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - std::string bonusLine; - auto appendStat = [](std::string& out, int32_t val, const char* n) { - if (val <= 0) return; - if (!out.empty()) out += " "; - out += "+" + std::to_string(val) + " " + n; - }; - appendStat(bonusLine, info->strength, "Str"); - appendStat(bonusLine, info->agility, "Agi"); - appendStat(bonusLine, info->stamina, "Sta"); - appendStat(bonusLine, info->intellect, "Int"); - appendStat(bonusLine, info->spirit, "Spi"); - if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); - if (info->sellPrice > 0) { - ImGui::TextColored(ImVec4(1, 0.84f, 0, 1), "Sell: %ug %us %uc", - info->sellPrice / 10000, (info->sellPrice / 100) % 100, info->sellPrice % 100); - } - ImGui::EndTooltip(); } ImGui::TableSetColumnIndex(1); @@ -11579,29 +13934,18 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } ImGui::TextColored(bqc, "%s", name.c_str()); - // Tooltip - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(bqc, "%s", info->name.c_str()); - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + // 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); + 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; } - std::string bl; - auto appS = [](std::string& o, int32_t v, const char* n) { - if (v <= 0) return; - if (!o.empty()) o += " "; - o += "+" + std::to_string(v) + " " + n; - }; - appS(bl, info->strength, "Str"); appS(bl, info->agility, "Agi"); - appS(bl, info->stamina, "Sta"); appS(bl, info->intellect, "Int"); - appS(bl, info->spirit, "Spi"); - if (!bl.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", bl.c_str()); - if (info->sellPrice > 0) - ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", - info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); - ImGui::EndTooltip(); } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); @@ -11665,28 +14009,17 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } ImGui::TextColored(oqc, "%s", name.c_str()); - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(oqc, "%s", info->name.c_str()); - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + 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); + 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; } - std::string ol; - auto appO = [](std::string& o, int32_t v, const char* n) { - if (v <= 0) return; - if (!o.empty()) o += " "; - o += "+" + std::to_string(v) + " " + n; - }; - appO(ol, info->strength, "Str"); appO(ol, info->agility, "Agi"); - appO(ol, info->stamina, "Sta"); appO(ol, info->intellect, "Int"); - appO(ol, info->spirit, "Spi"); - if (!ol.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", ol.c_str()); - if (info->sellPrice > 0) - ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", - info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); - ImGui::EndTooltip(); } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); @@ -12007,6 +14340,14 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // ---- Vote-to-kick buttons ---- if (state == LfgState::Boot) { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Vote to kick in progress:"); + 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); @@ -12355,4 +14696,401 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ─── 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", (unsigned)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(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "\xE2\x98\x85"); + ImGui::SameLine(); + ImGui::TextUnformatted(display.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("ID: %u", id); + ImGui::EndTooltip(); + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + + // --- Criteria progress tab --- + char critLabel[32]; + snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", (unsigned)criteria.size()); + if (ImGui::BeginTabItem(critLabel)) { + if (criteria.empty()) { + ImGui::TextDisabled("No criteria progress received yet."); + } else { + ImGui::BeginChild("##critlist", ImVec2(0, 0), false); + // Sort criteria by id for stable display + std::vector> clist(criteria.begin(), criteria.end()); + std::sort(clist.begin(), clist.end()); + for (const auto& [cid, cval] : clist) { + std::string label = std::to_string(cid); + if (!filter.empty()) { + std::string lower = label; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + if (lower.find(filter) == std::string::npos) continue; + } + ImGui::PushID(static_cast(cid)); + 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) { + if (!showGmTicketWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); + ImGui::Spacing(); + ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), + ImVec2(-1, 160)); + 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 (ImGui::Button("Delete Ticket", ImVec2(100, 0))) { + gameHandler.deleteGmTicket(); + } + + 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; + + ImGui::TextDisabled("%-19s Threat", "Player"); + ImGui::Separator(); + + uint64_t playerGuid = gameHandler.getPlayerGuid(); + 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 = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + if (rank == 1) col = ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // gold + if (isPlayer && rank == 1) col = ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // red — you have aggro + + // Threat bar + float pct = (maxThreat > 0) ? (float)entry.threat / (float)maxThreat : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f)); + char barLabel[48]; + snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f); + ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel); + ImGui::PopStyleColor(); + ImGui::SameLine(); + + ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat); + + if (rank >= 10) break; // cap display at 10 entries + } + + ImGui::End(); +} + +// ─── Quest Objective Tracker ────────────────────────────────────────────────── +void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { + if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + + const auto& questLog = gameHandler.getQuestLog(); + const auto& tracked = gameHandler.getTrackedQuestIds(); + + // Collect quests to show: tracked ones first, then in-progress quests up to a max of 5 total. + std::vector toShow; + for (const auto& q : questLog) { + if (q.questId == 0) continue; + if (tracked.count(q.questId)) toShow.push_back(&q); + } + if (toShow.empty()) { + // No explicitly tracked quests — show up to 5 in-progress quests + for (const auto& q : questLog) { + if (q.questId == 0) continue; + if (!tracked.count(q.questId)) toShow.push_back(&q); + if (toShow.size() >= 5) break; + } + } + + if (toShow.empty()) return; + + ImVec2 display = ImGui::GetIO().DisplaySize; + float screenW = display.x > 0.0f ? display.x : 1280.0f; + float trackerW = 220.0f; + float trackerX = screenW - trackerW - 12.0f; + float trackerY = 230.0f; // below minimap + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoFocusOnAppearing; + + ImGui::SetNextWindowPos(ImVec2(trackerX, trackerY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(trackerW, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##ObjectiveTracker", nullptr, flags)) { + for (const auto* q : toShow) { + // Quest title + ImVec4 titleColor = q->complete ? ImVec4(0.45f, 1.0f, 0.45f, 1.0f) + : ImVec4(1.0f, 0.84f, 0.0f, 1.0f); + std::string titleStr = q->title.empty() + ? ("Quest #" + std::to_string(q->questId)) : q->title; + // Truncate to fit + if (titleStr.size() > 26) { titleStr.resize(23); titleStr += "..."; } + ImGui::TextColored(titleColor, "%s", titleStr.c_str()); + + // Kill/entity objectives + bool hasObjectives = false; + for (const auto& ko : q->killObjectives) { + if (ko.npcOrGoId == 0 || ko.required == 0) continue; + hasObjectives = true; + uint32_t entry = (uint32_t)std::abs(ko.npcOrGoId); + auto it = q->killCounts.find(entry); + uint32_t cur = it != q->killCounts.end() ? it->second.first : 0; + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + if (ko.npcOrGoId < 0) { + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo) name = goInfo->name; + } + if (name.empty()) name = "Objective"; + } + if (name.size() > 20) { name.resize(17); name += "..."; } + bool done = (cur >= ko.required); + ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, ko.required); + } + + // Item objectives + for (const auto& io : q->itemObjectives) { + if (io.itemId == 0 || io.required == 0) continue; + hasObjectives = true; + auto it = q->itemCounts.find(io.itemId); + uint32_t cur = it != q->itemCounts.end() ? it->second : 0; + std::string name; + if (const auto* info = gameHandler.getItemInfo(io.itemId)) name = info->name; + if (name.empty()) name = "Item #" + std::to_string(io.itemId); + if (name.size() > 20) { name.resize(17); name += "..."; } + bool done = (cur >= io.required); + ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, io.required); + } + + if (!hasObjectives && q->complete) { + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), " Ready to turn in!"); + } + + ImGui::Dummy(ImVec2(0.0f, 2.0f)); + } + } + ImGui::End(); + ImGui::PopStyleVar(2); +} + +// ─── Inspect Window ─────────────────────────────────────────────────────────── +void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { + if (!showInspectWindow_) return; + + // Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server) + static 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; + } + + // Talent summary + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.82f, 0.0f, 1.0f)); // gold + ImGui::Text("%s", result->playerName.c_str()); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled(" %u talent pts", result->totalTalents); + if (result->unspentTalents > 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "(%u unspent)", result->unspentTalents); + } + if (result->talentGroups > 1) { + ImGui::SameLine(); + ImGui::TextDisabled(" Dual spec (active %u)", (unsigned)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 { + if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { + 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::TextDisabled("[%s] (loading…)", kSlotNames[s]); + continue; + } + + ImGui::TextDisabled("%s", kSlotNames[s]); + ImGui::SameLine(90); + auto qColor = InventoryScreen::getQualityColor( + static_cast(info->quality)); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + if (info->itemLevel > 0) + ImGui::Text("Item Level %u", info->itemLevel); + if (info->armor > 0) + ImGui::Text("Armor: %d", info->armor); + ImGui::EndTooltip(); + } + } + } + ImGui::EndChild(); + } + + ImGui::End(); +} + }} // namespace wowee::ui diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 2f63c34a..e5735977 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -101,6 +101,14 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) { auto it = iconCache_.find(displayInfoId); if (it != iconCache_.end()) return it->second; + // Rate-limit GPU uploads per frame to avoid stalling when many items appear at once + // (e.g., opening a full bag, vendor window, or loot from a boss with many drops). + static int iiLoadsThisFrame = 0; + static int iiLastImGuiFrame = -1; + int iiCurFrame = ImGui::GetFrameCount(); + if (iiCurFrame != iiLastImGuiFrame) { iiLoadsThisFrame = 0; iiLastImGuiFrame = iiCurFrame; } + if (iiLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + // Load ItemDisplayInfo.dbc auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc) { @@ -143,6 +151,7 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) { return VK_NULL_HANDLE; } + ++iiLoadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); iconCache_[displayInfoId] = ds; return ds; @@ -721,6 +730,9 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN, false); if (characterDown && !cKeyWasDown) { characterOpen = !characterOpen; + if (characterOpen && gameHandler_) { + gameHandler_->requestPlayedTime(); + } } cKeyWasDown = characterDown; @@ -825,6 +837,33 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { ImGui::EndPopup(); } + // Shift+right-click destroy confirmation popup + if (destroyConfirmOpen_) { + ImVec2 mousePos = ImGui::GetIO().MousePos; + ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always); + ImGui::OpenPopup("##DestroyItem"); + destroyConfirmOpen_ = false; + } + if (ImGui::BeginPopup("##DestroyItem", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Destroy"); + ImGui::TextUnformatted(destroyItemName_.c_str()); + ImGui::Spacing(); + if (ImGui::Button("Yes, Destroy", ImVec2(110, 0))) { + if (gameHandler_) { + gameHandler_->destroyItem(destroyBag_, destroySlot_, destroyCount_); + } + destroyItemName_.clear(); + inventoryDirty = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(70, 0))) { + destroyItemName_.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + // Draw held item at cursor renderHeldItem(); } @@ -1085,6 +1124,18 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (ImGui::BeginTabBar("##CharacterTabs")) { if (ImGui::BeginTabItem("Equipment")) { renderEquipmentPanel(inventory); + ImGui::Spacing(); + ImGui::Separator(); + // Appearance visibility toggles + bool helmVis = gameHandler.isHelmVisible(); + bool cloakVis = gameHandler.isCloakVisible(); + if (ImGui::Checkbox("Show Helm", &helmVis)) { + gameHandler.toggleHelm(); + } + ImGui::SameLine(); + if (ImGui::Checkbox("Show Cloak", &cloakVis)) { + gameHandler.toggleCloak(); + } ImGui::EndTabItem(); } @@ -1094,6 +1145,30 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats); + + // Played time (shown if available, fetched on character screen open) + uint32_t totalSec = gameHandler.getTotalTimePlayed(); + uint32_t levelSec = gameHandler.getLevelTimePlayed(); + if (totalSec > 0 || levelSec > 0) { + ImGui::Separator(); + // Helper lambda to format seconds as "Xd Xh Xm" + auto fmtTime = [](uint32_t sec) -> std::string { + uint32_t d = sec / 86400, h = (sec % 86400) / 3600, m = (sec % 3600) / 60; + char buf[48]; + if (d > 0) snprintf(buf, sizeof(buf), "%ud %uh %um", d, h, m); + else if (h > 0) snprintf(buf, sizeof(buf), "%uh %um", h, m); + else snprintf(buf, sizeof(buf), "%um", m); + return buf; + }; + ImGui::TextDisabled("Time Played"); + ImGui::Columns(2, "##playtime", false); + ImGui::SetColumnWidth(0, 130); + ImGui::Text("Total:"); ImGui::NextColumn(); + ImGui::Text("%s", fmtTime(totalSec).c_str()); ImGui::NextColumn(); + ImGui::Text("This level:"); ImGui::NextColumn(); + ImGui::Text("%s", fmtTime(levelSec).c_str()); ImGui::NextColumn(); + ImGui::Columns(1); + } ImGui::EndTabItem(); } @@ -1171,6 +1246,85 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Achievements")) { + const auto& earned = gameHandler.getEarnedAchievements(); + if (earned.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("No achievements earned yet."); + } else { + static char achieveFilter[128] = {}; + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##achsearch", "Search achievements...", + achieveFilter, sizeof(achieveFilter)); + ImGui::Separator(); + + char filterLower[128]; + for (size_t i = 0; i < sizeof(achieveFilter); ++i) + filterLower[i] = static_cast(tolower(static_cast(achieveFilter[i]))); + + ImGui::BeginChild("##AchList", ImVec2(0, 0), false); + // Sort by ID for stable ordering + std::vector sortedIds(earned.begin(), earned.end()); + std::sort(sortedIds.begin(), sortedIds.end()); + int shown = 0; + for (uint32_t id : sortedIds) { + const std::string& name = gameHandler.getAchievementName(id); + const char* displayName = name.empty() ? nullptr : name.c_str(); + if (displayName == nullptr) continue; // skip unknown achievements + + // Apply filter + if (filterLower[0] != '\0') { + // simple case-insensitive substring match + std::string lower; + lower.reserve(name.size()); + for (char c : name) lower += static_cast(tolower(static_cast(c))); + if (lower.find(filterLower) == std::string::npos) continue; + } + + ImGui::PushID(static_cast(id)); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "[Achievement]"); + ImGui::SameLine(); + ImGui::Text("%s", displayName); + ImGui::PopID(); + ++shown; + } + if (shown == 0 && filterLower[0] != '\0') { + ImGui::TextDisabled("No achievements match the filter."); + } + ImGui::Text("Total: %d", static_cast(earned.size())); + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("PvP")) { + const auto& arenaStats = gameHandler.getArenaTeamStats(); + if (arenaStats.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("Not a member of any Arena team."); + } else { + for (const auto& team : arenaStats) { + ImGui::PushID(static_cast(team.teamId)); + char header[64]; + snprintf(header, sizeof(header), "Team ID %u (Rating: %u)", team.teamId, team.rating); + if (ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Columns(2, "##arenacols", false); + ImGui::Text("Rating:"); ImGui::NextColumn(); + ImGui::Text("%u", team.rating); ImGui::NextColumn(); + ImGui::Text("Rank:"); ImGui::NextColumn(); + ImGui::Text("#%u", team.rank); ImGui::NextColumn(); + ImGui::Text("This week:"); ImGui::NextColumn(); + ImGui::Text("%u / %u (W/G)", team.weekWins, team.weekGames); ImGui::NextColumn(); + ImGui::Text("Season:"); ImGui::NextColumn(); + ImGui::Text("%u / %u (W/G)", team.seasonWins, team.seasonGames); ImGui::NextColumn(); + ImGui::Columns(1); + } + ImGui::PopID(); + } + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } @@ -1211,8 +1365,9 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { { "Exalted", 42000, 42000, ImVec4(1.0f, 0.84f, 0.0f, 1.0f) }, }; + constexpr int kNumTiers = static_cast(sizeof(tiers) / sizeof(tiers[0])); auto getTier = [&](int32_t val) -> const RepTier& { - for (int i = 6; i >= 0; --i) { + for (int i = kNumTiers - 1; i >= 0; --i) { if (val >= tiers[i].floor) return tiers[i]; } return tiers[0]; @@ -1390,6 +1545,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play int32_t serverArmor, const int32_t* serverStats) { // Sum equipment stats for item-query bonus display int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; + // Secondary stat sums from extraStats + int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0; + int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; @@ -1398,6 +1556,20 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play itemSta += slot.item.stamina; itemInt += slot.item.intellect; itemSpi += slot.item.spirit; + for (const auto& es : slot.item.extraStats) { + switch (es.statType) { + case 16: case 17: case 18: case 31: itemHit += es.statValue; break; + case 19: case 20: case 21: case 32: itemCrit += es.statValue; break; + case 28: case 29: case 30: case 36: itemHaste += es.statValue; break; + case 35: itemResil += es.statValue; break; + case 37: itemExpertise += es.statValue; break; + case 38: case 39: itemAP += es.statValue; break; + case 41: case 42: case 45: itemSP += es.statValue; break; + case 43: itemMp5 += es.statValue; break; + case 46: itemHp5 += es.statValue; break; + default: break; + } + } } // Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available. @@ -1456,6 +1628,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play renderStat("Intellect", itemInt); renderStat("Spirit", itemSpi); } + + // Secondary stats from equipped items + bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste || + itemResil || itemExpertise || itemMp5 || itemHp5; + if (hasSecondary) { + ImGui::Spacing(); + ImGui::Separator(); + auto renderSecondary = [&](const char* name, int32_t val) { + if (val > 0) { + ImGui::TextColored(green, "+%d %s", val, name); + } + }; + renderSecondary("Attack Power", itemAP); + renderSecondary("Spell Power", itemSP); + renderSecondary("Hit Rating", itemHit); + renderSecondary("Crit Rating", itemCrit); + renderSecondary("Haste Rating", itemHaste); + renderSecondary("Resilience", itemResil); + renderSecondary("Expertise", itemExpertise); + renderSecondary("Mana per 5 sec", itemMp5); + renderSecondary("Health per 5 sec",itemHp5); + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { @@ -1683,9 +1877,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } + // Shift+right-click: open destroy confirmation for non-quest items + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && + !holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) { + destroyConfirmOpen_ = true; + destroyItemName_ = item.name; + destroyCount_ = static_cast(std::clamp( + std::max(1u, item.stackCount), 1u, 255u)); + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + destroyBag_ = static_cast(19 + bagIndex); + destroySlot_ = static_cast(bagSlotIndex); + } else if (kind == SlotKind::EQUIPMENT) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(equipSlot); + } + } + // Right-click: bank deposit (if bank open), vendor sell (if vendor mode), or auto-equip/use // Note: InvisibleButton only tracks left-click by default, so use IsItemHovered+IsMouseClicked - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) { + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && !ImGui::GetIO().KeyShift && gameHandler_) { LOG_WARNING("Right-click slot: kind=", (int)kind, " backpackIndex=", backpackIndex, " bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex, @@ -1728,6 +1941,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } + // Shift+left-click: insert item link into chat input + if (ImGui::IsItemHovered() && !holdingItem && + ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && + item.itemId != 0 && !item.name.empty()) { + // Build WoW item link: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r + const char* qualHex = "9d9d9d"; + switch (item.quality) { + case game::ItemQuality::COMMON: qualHex = "ffffff"; break; + case game::ItemQuality::UNCOMMON: qualHex = "1eff00"; break; + case game::ItemQuality::RARE: qualHex = "0070dd"; break; + case game::ItemQuality::EPIC: qualHex = "a335ee"; break; + case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break; + default: break; + } + char linkBuf[512]; + snprintf(linkBuf, sizeof(linkBuf), + "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + qualHex, item.itemId, item.name.c_str()); + pendingChatItemLink_ = linkBuf; + } + if (ImGui::IsItemHovered() && !holdingItem) { // Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory; @@ -2070,6 +2305,16 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // Destroy hint (not shown for quest items) + if (item.itemId != 0 && item.bindType != 4) { + ImGui::Spacing(); + if (ImGui::GetIO().KeyShift) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.45f, 0.9f), "Shift+RClick to destroy"); + } else { + ImGui::TextDisabled("Shift+RClick to destroy"); + } + } + ImGui::EndTooltip(); } diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index 212d2af0..5ac79927 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -31,6 +31,7 @@ void KeybindingManager::initializeDefaults() { bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) bindings_[static_cast(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q; + bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) } bool KeybindingManager::isActionPressed(Action action, bool repeat) { @@ -71,6 +72,7 @@ const char* KeybindingManager::getActionName(Action action) { case Action::TOGGLE_NAMEPLATES: return "Nameplates"; case Action::TOGGLE_RAID_FRAMES: return "Raid Frames"; case Action::TOGGLE_QUEST_LOG: return "Quest Log"; + case Action::TOGGLE_ACHIEVEMENTS: return "Achievements"; case Action::ACTION_COUNT: break; } return "Unknown"; @@ -135,6 +137,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { else if (action == "toggle_nameplates") actionIdx = static_cast(Action::TOGGLE_NAMEPLATES); else if (action == "toggle_raid_frames") actionIdx = static_cast(Action::TOGGLE_RAID_FRAMES); else if (action == "toggle_quest_log") actionIdx = static_cast(Action::TOGGLE_QUEST_LOG); + else if (action == "toggle_achievements") actionIdx = static_cast(Action::TOGGLE_ACHIEVEMENTS); if (actionIdx < 0) continue; @@ -226,6 +229,7 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const { {Action::TOGGLE_NAMEPLATES, "toggle_nameplates"}, {Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"}, {Action::TOGGLE_QUEST_LOG, "toggle_quest_log"}, + {Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"}, }; for (const auto& [action, nameStr] : actionMap) { diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index a5dc4945..81f8657d 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -1,4 +1,5 @@ #include "ui/quest_log_screen.hpp" +#include "ui/inventory_screen.hpp" #include "ui/keybinding_manager.hpp" #include "core/application.hpp" #include "core/input.hpp" @@ -206,7 +207,7 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { } } // anonymous namespace -void QuestLogScreen::render(game::GameHandler& gameHandler) { +void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) { // Quests toggle via keybinding (edge-triggered) // Customizable key (default: L) from KeybindingManager bool questsDown = KeybindingManager::getInstance().isActionPressed( @@ -247,6 +248,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { else activeCount++; } + // Search bar + filter buttons on one row + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 210.0f); + ImGui::InputTextWithHint("##qsearch", "Search quests...", questSearchFilter_, sizeof(questSearchFilter_)); + ImGui::SameLine(); + if (ImGui::RadioButton("All", questFilterMode_ == 0)) questFilterMode_ = 0; + ImGui::SameLine(); + if (ImGui::RadioButton("Active", questFilterMode_ == 1)) questFilterMode_ = 1; + ImGui::SameLine(); + if (ImGui::RadioButton("Ready", questFilterMode_ == 2)) questFilterMode_ = 2; + + // Summary counts ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.35f, 1.0f), "Active: %d", activeCount); ImGui::SameLine(); ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.45f, 1.0f), "Ready: %d", completeCount); @@ -269,14 +281,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { for (size_t i = 0; i < quests.size(); i++) { if (quests[i].questId == pendingSelectQuestId_) { selectedIndex = static_cast(i); + // Clear filter so the target quest is visible + questSearchFilter_[0] = '\0'; + questFilterMode_ = 0; break; } } pendingSelectQuestId_ = 0; } + // Build a case-insensitive lowercase copy of the search filter once + char filterLower[64] = {}; + for (size_t fi = 0; fi < sizeof(questSearchFilter_) && questSearchFilter_[fi]; ++fi) + filterLower[fi] = static_cast(std::tolower(static_cast(questSearchFilter_[fi]))); + + int visibleQuestCount = 0; for (size_t i = 0; i < quests.size(); i++) { const auto& q = quests[i]; + + // Apply mode filter + if (questFilterMode_ == 1 && q.complete) continue; + if (questFilterMode_ == 2 && !q.complete) continue; + + // Apply name search filter + if (filterLower[0]) { + std::string titleLower = cleanQuestTitleForUi(q.title, q.questId); + for (char& c : titleLower) c = static_cast(std::tolower(static_cast(c))); + if (titleLower.find(filterLower) == std::string::npos) continue; + } + + visibleQuestCount++; ImGui::PushID(static_cast(i)); bool selected = (selectedIndex == static_cast(i)); @@ -318,8 +352,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { questDetailQueryNoResponse_.erase(q.questId); } } + + // Right-click context menu on quest row + if (ImGui::BeginPopupContextItem("QuestRowCtx")) { + selectedIndex = static_cast(i); // select on right-click too + ImGui::TextDisabled("%s", displayTitle.c_str()); + ImGui::Separator(); + bool tracked = gameHandler.isQuestTracked(q.questId); + if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) { + gameHandler.setQuestTracked(q.questId, !tracked); + } + if (!q.complete) { + ImGui::Separator(); + if (ImGui::MenuItem("Abandon Quest")) { + gameHandler.abandonQuest(q.questId); + gameHandler.setQuestTracked(q.questId, false); + selectedIndex = -1; + } + } + ImGui::EndPopup(); + } + ImGui::PopID(); } + if (visibleQuestCount == 0) { + ImGui::Spacing(); + if (filterLower[0] || questFilterMode_ != 0) + ImGui::TextDisabled("No quests match the filter."); + else + ImGui::TextDisabled("No active quests."); + } ImGui::EndChild(); ImGui::SameLine(); @@ -392,13 +454,98 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { } for (const auto& [itemId, count] : sel.itemCounts) { std::string itemLabel = "Item " + std::to_string(itemId); + uint32_t dispId = 0; if (const auto* info = gameHandler.getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; + dispId = info->displayInfoId; + } else { + gameHandler.ensureItemInfo(itemId); } uint32_t required = 1; auto reqIt = sel.requiredItemCounts.find(itemId); if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second; - ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); + VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14)); + ImGui::SameLine(); + ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required); + } else { + ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); + } + } + } + + // Reward summary + bool hasAnyReward = (sel.rewardMoney != 0); + for (const auto& ri : sel.rewardItems) if (ri.itemId) hasAnyReward = true; + for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) hasAnyReward = true; + if (hasAnyReward) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "Rewards"); + + // Money reward + if (sel.rewardMoney > 0) { + uint32_t rg = static_cast(sel.rewardMoney) / 10000; + uint32_t rs = static_cast(sel.rewardMoney % 10000) / 100; + uint32_t rc = static_cast(sel.rewardMoney % 100); + if (rg > 0) + ImGui::Text("%ug %us %uc", rg, rs, rc); + else if (rs > 0) + ImGui::Text("%us %uc", rs, rc); + else + ImGui::Text("%uc", rc); + } + + // Guaranteed reward items + bool anyFixed = false; + for (const auto& ri : sel.rewardItems) if (ri.itemId) { anyFixed = true; break; } + if (anyFixed) { + ImGui::TextDisabled("You will receive:"); + for (const auto& ri : sel.rewardItems) { + if (!ri.itemId) continue; + std::string name = "Item " + std::to_string(ri.itemId); + uint32_t dispId = 0; + const auto* info = gameHandler.getItemInfo(ri.itemId); + if (info && info->valid) { + if (!info->name.empty()) name = info->name; + dispId = info->displayInfoId; + } + VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16)); + ImGui::SameLine(); + } + if (ri.count > 1) + ImGui::Text("%s x%u", name.c_str(), ri.count); + else + ImGui::Text("%s", name.c_str()); + } + } + + // Choice reward items + bool anyChoice = false; + for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) { anyChoice = true; break; } + if (anyChoice) { + ImGui::TextDisabled("Choose one of:"); + for (const auto& ri : sel.rewardChoiceItems) { + if (!ri.itemId) continue; + std::string name = "Item " + std::to_string(ri.itemId); + uint32_t dispId = 0; + const auto* info = gameHandler.getItemInfo(ri.itemId); + if (info && info->valid) { + if (!info->name.empty()) name = info->name; + dispId = info->displayInfoId; + } + VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16)); + ImGui::SameLine(); + } + if (ri.count > 1) + ImGui::Text("%s x%u", name.c_str(), ri.count); + else + ImGui::Text("%s", name.c_str()); + } } } diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index ef8815f5..e2c81756 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -411,6 +411,14 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; + // Rate-limit GPU uploads to avoid a multi-frame stall when switching tabs. + // Icons not loaded this frame will be retried next frame (progressive load). + static int loadsThisFrame = 0; + static int lastImGuiFrame = -1; + int curFrame = ImGui::GetFrameCount(); + if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; } + if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { spellIconCache[iconId] = VK_NULL_HANDLE; @@ -437,6 +445,7 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa return VK_NULL_HANDLE; } + ++loadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); spellIconCache[iconId] = ds; return ds; @@ -657,9 +666,49 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana // Row selectable ImGui::Selectable("##row", false, - ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight)); + ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_DontClosePopups, + ImVec2(0, rowHeight)); bool rowHovered = ImGui::IsItemHovered(); bool rowClicked = ImGui::IsItemClicked(0); + + // Right-click context menu + if (ImGui::BeginPopupContextItem("##SpellCtx")) { + ImGui::TextDisabled("%s", info->name.c_str()); + if (!info->rank.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", info->rank.c_str()); + } + ImGui::Separator(); + if (!isPassive) { + if (onCooldown) ImGui::BeginDisabled(); + if (ImGui::MenuItem("Cast")) { + uint64_t tgt = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(info->spellId, tgt); + } + if (onCooldown) ImGui::EndDisabled(); + } + if (!isPassive) { + if (ImGui::MenuItem("Add to Action Bar")) { + const auto& bar = gameHandler.getActionBar(); + int firstEmpty = -1; + for (int si = 0; si < game::GameHandler::SLOTS_PER_BAR; ++si) { + if (bar[si].isEmpty()) { firstEmpty = si; break; } + } + if (firstEmpty >= 0) { + gameHandler.setActionBarSlot(firstEmpty, + game::ActionBarSlot::SPELL, info->spellId); + } + } + } + if (ImGui::MenuItem("Copy Spell Link")) { + char linkBuf[256]; + snprintf(linkBuf, sizeof(linkBuf), + "|cffffd000|Hspell:%u|h[%s]|h|r", + info->spellId, info->name.c_str()); + pendingChatSpellLink_ = linkBuf; + } + ImGui::EndPopup(); + } ImVec2 rMin = ImGui::GetItemRectMin(); ImVec2 rMax = ImGui::GetItemRectMax(); auto* dl = ImGui::GetWindowDrawList(); @@ -748,15 +797,25 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana // Interaction if (rowHovered) { - // Start drag on click (not passive) - if (rowClicked && !isPassive) { + // Shift-click to insert spell link into chat + if (rowClicked && ImGui::GetIO().KeyShift && !info->name.empty()) { + // WoW spell link format: |cffffd000|Hspell:|h[Name]|h|r + char linkBuf[256]; + snprintf(linkBuf, sizeof(linkBuf), + "|cffffd000|Hspell:%u|h[%s]|h|r", + info->spellId, info->name.c_str()); + pendingChatSpellLink_ = linkBuf; + } + // Start drag on click (not passive, not shift-click) + else if (rowClicked && !isPassive && !ImGui::GetIO().KeyShift) { draggingSpell_ = true; dragSpellId_ = info->spellId; dragSpellIconTex_ = iconTex; } // Double-click to cast - if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) { + if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown + && !ImGui::GetIO().KeyShift) { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index d1ee6627..5c6bdaf9 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -216,7 +216,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab float availW = ImGui::GetContentRegionAvail().x; float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f); - ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false); + char childId[32]; + snprintf(childId, sizeof(childId), "TalentGrid_%u", tabId); + ImGui::BeginChild(childId, ImVec2(0, 0), false); ImVec2 gridOrigin = ImGui::GetCursorScreenPos(); gridOrigin.x += offsetX; @@ -326,8 +328,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab renderTalent(gameHandler, *talent, pointsInTree); } else { // Empty cell — invisible placeholder - ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(), - ImVec2(iconSize, iconSize)); + char emptyId[32]; + snprintf(emptyId, sizeof(emptyId), "e_%u_%u_%u", tabId, row, col); + ImGui::InvisibleButton(emptyId, ImVec2(iconSize, iconSize)); } } }