diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index ca8c8a50..5c550c81 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, - "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, + "DispelType": 4 }, "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index fdc9e07d..b99159a6 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 5, "IconID": 124, "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215, - "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40 + "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40, + "DispelType": 3 }, "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index a2482e0d..3b06971d 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, - "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33, + "DispelType": 4 }, "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 0d1667a1..94f7df4d 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -2,7 +2,8 @@ "Spell": { "ID": 0, "Attributes": 4, "IconID": 133, "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225, - "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49 + "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49, + "DispelType": 2 }, "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { diff --git a/include/audio/ui_sound_manager.hpp b/include/audio/ui_sound_manager.hpp index 241014ae..6423d460 100644 --- a/include/audio/ui_sound_manager.hpp +++ b/include/audio/ui_sound_manager.hpp @@ -75,6 +75,9 @@ public: void playTargetSelect(); void playTargetDeselect(); + // Chat notifications + void playWhisperReceived(); + private: struct UISample { std::string path; @@ -122,6 +125,7 @@ private: std::vector errorSounds_; std::vector selectTargetSounds_; std::vector deselectTargetSounds_; + std::vector whisperSounds_; // State tracking float volumeScale_ = 1.0f; diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 9f4dfde7..57147902 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -214,6 +214,9 @@ public: void setMaxPower(uint32_t p) { maxPowers[powerType < 7 ? powerType : 0] = p; } void setMaxPowerByType(uint8_t type, uint32_t p) { if (type < 7) maxPowers[type] = p; } + uint32_t getPowerByType(uint8_t type) const { return type < 7 ? powers[type] : 0; } + uint32_t getMaxPowerByType(uint8_t type) const { return type < 7 ? maxPowers[type] : 0; } + uint8_t getPowerType() const { return powerType; } void setPowerType(uint8_t t) { powerType = t; } diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 79460a17..7a11d97b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -339,7 +339,8 @@ public: uint32_t unspentTalents = 0; uint8_t talentGroups = 0; uint8_t activeTalentGroup = 0; - std::array itemEntries{}; // 0=head…18=ranged + std::array itemEntries{}; // 0=head…18=ranged + std::array enchantIds{}; // permanent enchant per slot (0 = none) }; const InspectResult* getInspectResult() const { return inspectResult_.guid ? &inspectResult_ : nullptr; @@ -352,6 +353,19 @@ public: uint32_t getTotalTimePlayed() const { return totalTimePlayed_; } uint32_t getLevelTimePlayed() const { return levelTimePlayed_; } + // Who results (structured, from last SMSG_WHO response) + struct WhoEntry { + std::string name; + std::string guildName; + uint32_t level = 0; + uint32_t classId = 0; + uint32_t raceId = 0; + uint32_t zoneId = 0; + }; + const std::vector& getWhoResults() const { return whoResults_; } + uint32_t getWhoOnlineCount() const { return whoOnlineCount_; } + std::string getWhoAreaName(uint32_t zoneId) const { return getAreaName(zoneId); } + // Social commands void addFriend(const std::string& playerName, const std::string& note = ""); void removeFriend(const std::string& playerName); @@ -379,6 +393,28 @@ public: void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); const std::array& getBgQueues() const { return bgQueues_; } + // BG scoreboard (MSG_PVP_LOG_DATA) + struct BgPlayerScore { + uint64_t guid = 0; + std::string name; + uint8_t team = 0; // 0=Horde, 1=Alliance + uint32_t killingBlows = 0; + uint32_t deaths = 0; + uint32_t honorableKills = 0; + uint32_t bonusHonor = 0; + std::vector> bgStats; // BG-specific fields + }; + struct BgScoreboardData { + std::vector players; + bool hasWinner = false; + uint8_t winner = 0; // 0=Horde, 1=Alliance + bool isArena = false; + }; + void requestPvpLog(); + const BgScoreboardData* getBgScoreboard() const { + return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_; + } + // Network latency (milliseconds, updated each PONG response) uint32_t getLatencyMs() const { return lastLatency; } @@ -401,6 +437,7 @@ public: // Follow/Assist void followTarget(); + void cancelFollow(); // Stop following current target void assistTarget(); // PvP @@ -457,11 +494,16 @@ public: uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; } // Ready check + struct ReadyCheckResult { + std::string name; + bool ready = false; + }; void initiateReadyCheck(); void respondToReadyCheck(bool ready); bool hasPendingReadyCheck() const { return pendingReadyCheck_; } void dismissReadyCheck() { pendingReadyCheck_ = false; } const std::string& getReadyCheckInitiator() const { return readyCheckInitiator_; } + const std::vector& getReadyCheckResults() const { return readyCheckResults_; } // Duel void forfeitDuel(); @@ -516,6 +558,19 @@ public: const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); + // Combat log (persistent rolling history, max MAX_COMBAT_LOG entries) + const std::deque& getCombatLog() const { return combatLog_; } + void clearCombatLog() { combatLog_.clear(); } + + // Area trigger messages (SMSG_AREA_TRIGGER_MESSAGE) — drained by UI each frame + bool hasAreaTriggerMsg() const { return !areaTriggerMsgs_.empty(); } + std::string popAreaTriggerMsg() { + if (areaTriggerMsgs_.empty()) return {}; + std::string msg = areaTriggerMsgs_.front(); + areaTriggerMsgs_.pop_front(); + return msg; + } + // Threat struct ThreatEntry { uint64_t victimGuid = 0; @@ -672,6 +727,11 @@ public: // Auras const std::vector& getPlayerAuras() const { return playerAuras; } const std::vector& getTargetAuras() const { return targetAuras; } + // Per-unit aura cache (populated for party members and any unit we receive updates for) + const std::vector* getUnitAuras(uint64_t guid) const { + auto it = unitAurasCache_.find(guid); + return (it != unitAurasCache_.end()) ? &it->second : nullptr; + } // Completed quests (populated from SMSG_QUERY_QUESTS_COMPLETED_RESPONSE) bool isQuestCompleted(uint32_t questId) const { return completedQuests_.count(questId) > 0; } @@ -743,6 +803,17 @@ public: float getGameTime() const { return gameTime_; } float getTimeSpeed() const { return timeSpeed_; } + // Global Cooldown (GCD) — set when the server sends a spellId=0 cooldown entry + float getGCDRemaining() const { + if (gcdTotal_ <= 0.0f) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - gcdStartedAt_).count() / 1000.0f; + float rem = gcdTotal_ - elapsed; + return rem > 0.0f ? rem : 0.0f; + } + float getGCDTotal() const { return gcdTotal_; } + bool isGCDActive() const { return getGCDRemaining() > 0.0f; } + // Weather state (updated by SMSG_WEATHER) // weatherType: 0=clear, 1=rain, 2=snow, 3=storm/fog uint32_t getWeatherType() const { return weatherType_; } @@ -1035,6 +1106,14 @@ public: const std::string& getDuelChallengerName() const { return duelChallengerName_; } void acceptDuel(); // forfeitDuel() already declared at line ~399 + // Returns remaining duel countdown seconds, or 0 if no active countdown + float getDuelCountdownRemaining() const { + if (duelCountdownMs_ == 0) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - duelCountdownStartedAt_).count(); + float rem = (static_cast(duelCountdownMs_) - static_cast(elapsed)) / 1000.0f; + return rem > 0.0f ? rem : 0.0f; + } // ---- Instance lockouts ---- struct InstanceLockout { @@ -1095,10 +1174,12 @@ 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_; } + uint32_t getLfgBootVotes() const { return lfgBootVotes_; } + uint32_t getLfgBootTotal() const { return lfgBootTotal_; } + uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; } + uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; } + const std::string& getLfgBootTargetName() const { return lfgBootTargetName_; } + const std::string& getLfgBootReason() const { return lfgBootReason_; } // ---- Arena Team Stats ---- struct ArenaTeamStats { @@ -1129,6 +1210,15 @@ public: uint32_t itemId = 0; std::string itemName; uint8_t itemQuality = 0; + uint32_t rollCountdownMs = 60000; // Duration of roll window in ms + std::chrono::steady_clock::time_point rollStartedAt{}; + + struct PlayerRollResult { + std::string playerName; + uint8_t rollNum = 0; + uint8_t rollType = 0; // 0=need,1=greed,2=disenchant,96=pass + }; + std::vector playerRolls; // live roll results from group members }; bool hasPendingLootRoll() const { return pendingLootRollActive_; } const LootRollEntry& getPendingLootRoll() const { return pendingLootRoll_; } @@ -1215,6 +1305,7 @@ public: }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); + void shareQuestWithParty(uint32_t questId); // CMSG_PUSHQUESTTOPARTY bool requestQuestQuery(uint32_t questId, bool force = false); bool isQuestTracked(uint32_t questId) const { return trackedQuestIds_.count(questId) > 0; } void setQuestTracked(uint32_t questId, bool tracked) { @@ -1267,7 +1358,29 @@ public: }; const std::vector& getInitialFactions() const { return initialFactions_; } const std::unordered_map& getFactionStandings() const { return factionStandings_; } + // Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air) + struct TotemSlot { + uint32_t spellId = 0; + uint32_t durationMs = 0; + std::chrono::steady_clock::time_point placedAt{}; + bool active() const { return spellId != 0 && remainingMs() > 0; } + float remainingMs() const { + if (spellId == 0 || durationMs == 0) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - placedAt).count(); + float rem = static_cast(durationMs) - static_cast(elapsed); + return rem > 0.0f ? rem : 0.0f; + } + }; + static constexpr int NUM_TOTEM_SLOTS = 4; + const TotemSlot& getTotemSlot(int slot) const { + static TotemSlot empty; + return (slot >= 0 && slot < NUM_TOTEM_SLOTS) ? activeTotemSlots_[slot] : empty; + } + const std::string& getFactionNamePublic(uint32_t factionId) const; + uint32_t getWatchedFactionId() const { return watchedFactionId_; } + void setWatchedFactionId(uint32_t id) { watchedFactionId_ = id; } uint32_t getLastContactListMask() const { return lastContactListMask_; } uint32_t getLastContactListCount() const { return lastContactListCount_; } bool isServerMovementAllowed() const { return serverMovementAllowed_; } @@ -1297,6 +1410,11 @@ public: 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 WoW PackedTime earn date for an achievement, or 0 if unknown. + uint32_t getAchievementDate(uint32_t id) const { + auto it = achievementDates_.find(id); + return (it != achievementDates_.end()) ? it->second : 0u; + } /// 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); @@ -1330,6 +1448,10 @@ public: using RepChangeCallback = std::function; void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); } + // Quest turn-in completion callback + using QuestCompleteCallback = std::function; + void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -1539,6 +1661,8 @@ public: const std::string& getSpellName(uint32_t spellId) const; const std::string& getSpellRank(uint32_t spellId) const; const std::string& getSkillLineName(uint32_t spellId) const; + /// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other) + uint8_t getSpellDispelType(uint32_t spellId) const; struct TrainerTab { std::string name; @@ -1819,6 +1943,7 @@ private: void handleArenaTeamEvent(network::Packet& packet); void handleArenaTeamStats(network::Packet& packet); void handleArenaError(network::Packet& packet); + void handlePvpLogData(network::Packet& packet); // ---- Bank handlers ---- void handleShowBank(network::Packet& packet); @@ -2065,6 +2190,9 @@ private: float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing std::unordered_set hostileAttackers_; std::vector combatText; + static constexpr size_t MAX_COMBAT_LOG = 500; + std::deque combatLog_; + std::deque areaTriggerMsgs_; // unitGuid → sorted threat list (descending by threat value) std::unordered_map> threatLists_; @@ -2147,6 +2275,7 @@ private: std::array actionBar{}; std::vector playerAuras; std::vector targetAuras; + std::unordered_map> unitAurasCache_; // per-unit aura cache uint64_t petGuid_ = 0; uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots) uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss @@ -2177,6 +2306,9 @@ private: // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) std::vector arenaTeamStats_; + // BG scoreboard (MSG_PVP_LOG_DATA) + BgScoreboardData bgScoreboard_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot @@ -2190,12 +2322,15 @@ private: uint32_t lfgBootTotal_ = 0; // total votes cast uint32_t lfgBootTimeLeft_ = 0; // seconds remaining uint32_t lfgBootNeeded_ = 0; // votes needed to kick + std::string lfgBootTargetName_; // name of player being voted on + std::string lfgBootReason_; // reason given for kick // Ready check state bool pendingReadyCheck_ = false; uint32_t readyCheckReadyCount_ = 0; uint32_t readyCheckNotReadyCount_ = 0; std::string readyCheckInitiator_; + std::vector readyCheckResults_; // per-player status live during check // Faction standings (factionId → absolute standing value) std::unordered_map factionStandings_; @@ -2229,6 +2364,10 @@ private: uint32_t totalTimePlayed_ = 0; uint32_t levelTimePlayed_ = 0; + // Who results (last SMSG_WHO response) + std::vector whoResults_; + uint32_t whoOnlineCount_ = 0; + // Trade state TradeStatus tradeStatus_ = TradeStatus::None; uint64_t tradePeerGuid_= 0; @@ -2238,11 +2377,16 @@ private: uint64_t myTradeGold_ = 0; uint64_t peerTradeGold_ = 0; + // Shaman totem state + TotemSlot activeTotemSlots_[NUM_TOTEM_SLOTS]; + // Duel state bool pendingDuelRequest_ = false; uint64_t duelChallengerGuid_= 0; uint64_t duelFlagGuid_ = 0; std::string duelChallengerName_; + uint32_t duelCountdownMs_ = 0; // 0 = no active countdown + std::chrono::steady_clock::time_point duelCountdownStartedAt_{}; // ---- Guild state ---- std::string guildName_; @@ -2434,7 +2578,7 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; }; + struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; uint8_t dispelType = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; @@ -2444,6 +2588,8 @@ private: void loadAchievementNameCache(); // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) std::unordered_set earnedAchievements_; + // Earn dates: achievementId → WoW PackedTime (from SMSG_ACHIEVEMENT_EARNED / SMSG_ALL_ACHIEVEMENT_DATA) + std::unordered_map achievementDates_; // Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE) std::unordered_map criteriaProgress_; void handleAllAchievementData(network::Packet& packet); @@ -2518,6 +2664,10 @@ private: float timeSpeed_ = 0.0166f; // Time scale (default: 1 game day = 1 real hour) void handleLoginSetTimeSpeed(network::Packet& packet); + // ---- Global Cooldown (GCD) ---- + float gcdTotal_ = 0.0f; + std::chrono::steady_clock::time_point gcdStartedAt_{}; + // ---- Weather state (SMSG_WEATHER) ---- uint32_t weatherType_ = 0; // 0=clear, 1=rain, 2=snow, 3=storm float weatherIntensity_ = 0.0f; // 0.0 to 1.0 @@ -2630,6 +2780,10 @@ private: // ---- Reputation change callback ---- RepChangeCallback repChangeCallback_; + uint32_t watchedFactionId_ = 0; // auto-set to most recently changed faction + + // ---- Quest completion callback ---- + QuestCompleteCallback questCompleteCallback_; }; } // namespace game diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index c4d70380..dc38f813 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -51,7 +52,7 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST + ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER }; Type type; int32_t amount = 0; @@ -63,6 +64,19 @@ struct CombatTextEntry { bool isExpired() const { return age >= LIFETIME; } }; +/** + * Persistent combat log entry (stored in a rolling deque, survives beyond floating-text lifetime) + */ +struct CombatLogEntry { + CombatTextEntry::Type type = CombatTextEntry::MELEE_DAMAGE; + int32_t amount = 0; + uint32_t spellId = 0; + bool isPlayerSource = false; + time_t timestamp = 0; // Wall-clock time (std::time(nullptr)) + std::string sourceName; // Resolved display name of attacker/caster + std::string targetName; // Resolved display name of victim/target +}; + /** * Spell cooldown entry received from server */ diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 709dc502..c2320681 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -46,21 +46,32 @@ private: char chatInputBuffer[512] = ""; char whisperTargetBuffer[256] = ""; bool chatInputActive = false; - int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER + int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER, ..., 10=CHANNEL int lastChatType = 0; // Track chat type changes + int selectedChannelIdx = 0; // Index into joinedChannels_ when selectedChatType==10 bool chatInputMoveCursorToEnd = false; // Chat sent-message history (Up/Down arrow recall) std::vector chatSentHistory_; int chatHistoryIdx_ = -1; // -1 = not browsing history + // Tab-completion state for slash commands + std::string chatTabPrefix_; // prefix captured on first Tab press + std::vector chatTabMatches_; // matching command list + int chatTabMatchIdx_ = -1; // active match index (-1 = inactive) + + // Mention notification: plays a sound when the player's name appears in chat + size_t chatMentionSeenCount_ = 0; // how many messages have been scanned for mentions + // Chat tabs int activeChatTab_ = 0; struct ChatTab { std::string name; - uint32_t typeMask; // bitmask of ChatType values to show + uint64_t typeMask; // bitmask of ChatType values to show (64-bit: types go up to 84) }; std::vector chatTabs_; + std::vector chatTabUnread_; // unread message count per tab (0 = none) + size_t chatTabSeenCount_ = 0; // how many history messages have been processed void initChatTabs(); bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; @@ -75,6 +86,7 @@ private: uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) bool damageFlashEnabled_ = true; + bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text @@ -100,6 +112,29 @@ private: std::vector repToasts_; bool repChangeCallbackSet_ = false; static constexpr float kRepToastLifetime = 3.5f; + + // Quest completion toast: slide-in when a quest is turned in + struct QuestCompleteToastEntry { uint32_t questId = 0; std::string title; float age = 0.0f; }; + std::vector questCompleteToasts_; + bool questCompleteCallbackSet_ = false; + static constexpr float kQuestCompleteToastLifetime = 4.0f; + + // Zone entry toast: brief banner when entering a new zone + struct ZoneToastEntry { std::string zoneName; float age = 0.0f; }; + std::vector zoneToasts_; + + struct AreaTriggerToast { std::string text; float age = 0.0f; }; + std::vector areaTriggerToasts_; + void renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler); + std::string lastKnownZone_; + static constexpr float kZoneToastLifetime = 3.0f; + + // Death screen: elapsed time since the death dialog first appeared + float deathElapsed_ = 0.0f; + bool deathTimerRunning_ = false; + // WoW forces release after ~6 minutes; show countdown until then + static constexpr float kForcedReleaseSec = 360.0f; + void renderZoneToasts(float deltaTime); bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; @@ -255,6 +290,7 @@ private: * Render pet frame (below player frame when player has an active pet) */ void renderPetFrame(game::GameHandler& gameHandler); + void renderTotemFrame(game::GameHandler& gameHandler); /** * Process targeting input (Tab, Escape, click) @@ -275,6 +311,7 @@ private: void renderActionBar(game::GameHandler& gameHandler); void renderBagBar(game::GameHandler& gameHandler); void renderXpBar(game::GameHandler& gameHandler); + void renderRepBar(game::GameHandler& gameHandler); void renderCastBar(game::GameHandler& gameHandler); void renderMirrorTimers(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); @@ -283,8 +320,10 @@ private: void renderBossFrames(game::GameHandler& gameHandler); void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); void renderRepToasts(float deltaTime); + void renderQuestCompleteToasts(float deltaTime); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); + void renderDuelCountdown(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); void renderTradeRequestPopup(game::GameHandler& gameHandler); void renderTradeWindow(game::GameHandler& gameHandler); @@ -364,6 +403,14 @@ private: int bagBarPickedSlot_ = -1; // Visual drag in progress (-1 = none) int bagBarDragSource_ = -1; // Mouse pressed on this slot, waiting for drag or click (-1 = none) + // Who Results window + bool showWhoWindow_ = false; + void renderWhoWindow(game::GameHandler& gameHandler); + + // Combat Log window + bool showCombatLog_ = false; + void renderCombatLog(game::GameHandler& gameHandler); + // Instance Lockouts window bool showInstanceLockouts_ = false; @@ -387,6 +434,10 @@ private: // Threat window bool showThreatWindow_ = false; void renderThreatWindow(game::GameHandler& gameHandler); + + // BG scoreboard window + bool showBgScoreboard_ = false; + void renderBgScoreboard(game::GameHandler& gameHandler); uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) @@ -477,6 +528,9 @@ private: bool showDPSMeter_ = false; float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) bool dpsWasInCombat_ = false; + float dpsEncounterDamage_ = 0.0f; // total player damage this combat + float dpsEncounterHeal_ = 0.0f; // total player healing this combat + size_t dpsLogSeenCount_ = 0; // log entries already scanned public: void triggerDing(uint32_t newLevel); diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 3453e966..31cae856 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -96,7 +96,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); - void renderItemTooltip(const game::ItemQueryResponseData& info); + void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr); private: // Character model preview diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 6cc13270..2bc0f866 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -54,6 +54,16 @@ public: uint32_t getDragSpellId() const { return dragSpellId_; } void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; } + /// Returns the max range in yards for a spell (0 if self-cast, unknown, or melee). + /// Triggers DBC load if needed. Used by the action bar for out-of-range tinting. + uint32_t getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager); + + /// Returns the power cost and type for a spell (cost=0 if unknown/free). + /// powerType: 0=mana, 1=rage, 2=focus, 3=energy, 6=runic power. + /// Triggers DBC load if needed. Used by the action bar for insufficient-power tinting. + void getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager, + uint32_t& outCost, uint32_t& outPowerType); + /// 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_); diff --git a/src/audio/ui_sound_manager.cpp b/src/audio/ui_sound_manager.cpp index f32f0d9b..8ef800f0 100644 --- a/src/audio/ui_sound_manager.cpp +++ b/src/audio/ui_sound_manager.cpp @@ -122,6 +122,14 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { deselectTargetSounds_.resize(1); loadSound("Sound\\Interface\\iDeselectTarget.wav", deselectTargetSounds_[0], assets); + // Whisper notification (falls back to iSelectTarget if the dedicated file is absent) + whisperSounds_.resize(1); + if (!loadSound("Sound\\Interface\\Whisper_TellMale.wav", whisperSounds_[0], assets)) { + if (!loadSound("Sound\\Interface\\Whisper_TellFemale.wav", whisperSounds_[0], assets)) { + whisperSounds_ = selectTargetSounds_; + } + } + LOG_INFO("UISoundManager: Window sounds - Bag: ", (bagOpenLoaded && bagCloseLoaded) ? "YES" : "NO", ", QuestLog: ", (questLogOpenLoaded && questLogCloseLoaded) ? "YES" : "NO", ", CharSheet: ", (charSheetOpenLoaded && charSheetCloseLoaded) ? "YES" : "NO"); @@ -225,5 +233,8 @@ void UiSoundManager::playError() { playSound(errorSounds_); } void UiSoundManager::playTargetSelect() { playSound(selectTargetSounds_); } void UiSoundManager::playTargetDeselect() { playSound(deselectTargetSounds_); } +// Chat notifications +void UiSoundManager::playWhisperReceived() { playSound(whisperSounds_); } + } // namespace audio } // namespace wowee diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b3e57ccc..df1da0df 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2028,7 +2028,7 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t randSuffix =*/ packet.readUInt32(); /*uint32_t randProp =*/ packet.readUInt32(); } - /*uint32_t countdown =*/ packet.readUInt32(); + uint32_t countdown = packet.readUInt32(); /*uint8_t voteMask =*/ packet.readUInt8(); // Trigger the roll popup for local player pendingLootRollActive_ = true; @@ -2038,6 +2038,8 @@ void GameHandler::handlePacket(network::Packet& packet) { auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000; + pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot); break; @@ -3059,6 +3061,11 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t spellId = packet.readUInt32(); LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, " spellId=", spellId, " duration=", duration, "ms"); + if (slot < NUM_TOTEM_SLOTS) { + activeTotemSlots_[slot].spellId = spellId; + activeTotemSlots_[slot].durationMs = duration; + activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now(); + } break; } case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: { @@ -3142,6 +3149,7 @@ void GameHandler::handlePacket(network::Packet& packet) { readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; readyCheckInitiator_.clear(); + readyCheckResults_.clear(); if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t initiatorGuid = packet.readUInt64(); auto entity = entityManager.getEntity(initiatorGuid); @@ -3175,7 +3183,14 @@ void GameHandler::handlePacket(network::Packet& packet) { auto ent = entityManager.getEntity(respGuid); if (ent) rname = std::static_pointer_cast(ent)->getName(); } + // Track per-player result for live popup display if (!rname.empty()) { + bool found = false; + for (auto& r : readyCheckResults_) { + if (r.name == rname) { r.ready = (isReady != 0); found = true; break; } + } + if (!found) readyCheckResults_.push_back({ rname, isReady != 0 }); + char rbuf[128]; std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); addSystemChatMessage(rbuf); @@ -3191,6 +3206,7 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingReadyCheck_ = false; readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; + readyCheckResults_.clear(); break; } case Opcode::SMSG_RAID_INSTANCE_INFO: @@ -3212,9 +3228,16 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DUEL_INBOUNDS: // Re-entered the duel area; no special action needed. break; - case Opcode::SMSG_DUEL_COUNTDOWN: - // Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update. + case Opcode::SMSG_DUEL_COUNTDOWN: { + // uint32 countdown in milliseconds (typically 3000 ms) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t ms = packet.readUInt32(); + duelCountdownMs_ = (ms > 0 && ms <= 30000) ? ms : 3000; + duelCountdownStartedAt_ = std::chrono::steady_clock::now(); + LOG_INFO("SMSG_DUEL_COUNTDOWN: ", duelCountdownMs_, " ms"); + } break; + } case Opcode::SMSG_PARTYKILLLOG: { // uint64 killerGuid + uint64 victimGuid if (packet.getSize() - packet.getReadPos() < 16) break; @@ -3544,6 +3567,7 @@ void GameHandler::handlePacket(network::Packet& packet) { delta > 0 ? "increased" : "decreased", std::abs(delta)); addSystemChatMessage(buf); + watchedFactionId_ = factionId; if (repChangeCallback_) repChangeCallback_(name, delta, standing); } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); @@ -3853,7 +3877,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) { /*uint32_t len =*/ packet.readUInt32(); std::string msg = packet.readString(); - if (!msg.empty()) addSystemChatMessage(msg); + if (!msg.empty()) { + addSystemChatMessage(msg); + areaTriggerMsgs_.push_back(msg); + } } break; } @@ -4381,6 +4408,10 @@ void GameHandler::handlePacket(network::Packet& packet) { } for (auto it = questLog_.begin(); it != questLog_.end(); ++it) { if (it->questId == questId) { + // Fire toast callback before erasing + if (questCompleteCallback_) { + questCompleteCallback_(questId, it->title); + } questLog_.erase(it); LOG_INFO(" Removed quest ", questId, " from quest log"); break; @@ -4957,7 +4988,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaError(packet); break; case Opcode::MSG_PVP_LOG_DATA: - LOG_INFO("Received MSG_PVP_LOG_DATA"); + handlePvpLogData(packet); break; case Opcode::MSG_INSPECT_ARENA_TEAMS: LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); @@ -5176,6 +5207,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::vector* auraList = nullptr; if (auraTargetGuid == playerGuid) auraList = &playerAuras; else if (auraTargetGuid == targetGuid) auraList = &targetAuras; + else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid]; if (auraList && isInit) auraList->clear(); @@ -5559,9 +5591,28 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } + case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { + // Format (all expansions): PackedGuid target + PackedGuid caster + uint32 spellId + ... + if (packet.getSize() - packet.getReadPos() < 3) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t procCasterGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t procSpellId = packet.readUInt32(); + // Show a "PROC!" floating text when the player triggers the proc + if (procCasterGuid == playerGuid && procSpellId > 0) + addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true); + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_SPELLINSTAKILLLOG: case Opcode::SMSG_SPELLLOGEXECUTE: - case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: packet.setReadPos(packet.getSize()); @@ -6658,6 +6709,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { actionBar = {}; playerAuras.clear(); targetAuras.clear(); + unitAurasCache_.clear(); unitCastStates_.clear(); petGuid_ = 0; playerXp_ = 0; @@ -10209,6 +10261,15 @@ void GameHandler::followTarget() { LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")"); } +void GameHandler::cancelFollow() { + if (followTargetGuid_ == 0) { + addSystemChatMessage("You are not following anyone."); + return; + } + followTargetGuid_ = 0; + addSystemChatMessage("You stop following."); +} + void GameHandler::assistTarget() { if (state != WorldState::IN_WORLD) { LOG_WARNING("Cannot assist: not in world"); @@ -10465,6 +10526,7 @@ void GameHandler::handleDuelComplete(network::Packet& packet) { uint8_t started = packet.readUInt8(); // started=1: duel began, started=0: duel was cancelled before starting pendingDuelRequest_ = false; + duelCountdownMs_ = 0; // clear countdown once duel is resolved if (!started) { addSystemChatMessage("The duel was cancelled."); } @@ -11300,6 +11362,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { } // Parse enchantment slot mask + enchant IDs + std::array enchantIds{}; bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft >= 4) { uint32_t slotMask = packet.readUInt32(); @@ -11307,7 +11370,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { if (slotMask & (1u << slot)) { bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft < 2) break; - packet.readUInt16(); // enchantId + enchantIds[slot] = packet.readUInt16(); } } } @@ -11319,6 +11382,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { inspectResult_.unspentTalents = unspentTalents; inspectResult_.talentGroups = talentGroupCount; inspectResult_.activeTalentGroup = activeTalentGroup; + inspectResult_.enchantIds = enchantIds; // Merge any gear we already have from a prior inspect request auto gearIt = inspectedPlayerItemEntries_.find(guid); @@ -12101,6 +12165,21 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; combatText.push_back(entry); + + // Persistent combat log + CombatLogEntry log; + log.type = type; + log.amount = amount; + log.spellId = spellId; + log.isPlayerSource = isPlayerSource; + log.timestamp = std::time(nullptr); + std::string pname(lookupName(playerGuid)); + std::string tname((targetGuid != 0) ? lookupName(targetGuid) : std::string()); + log.sourceName = isPlayerSource ? pname : tname; + log.targetName = isPlayerSource ? tname : pname; + if (combatLog_.size() >= MAX_COMBAT_LOG) + combatLog_.pop_front(); + combatLog_.push_back(std::move(log)); } void GameHandler::updateCombatText(float deltaTime) { @@ -13061,11 +13140,19 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { lfgBootTimeLeft_ = timeLeft; lfgBootNeeded_ = votesNeeded; + // Optional: reason string and target name (null-terminated) follow the fixed fields + if (packet.getSize() - packet.getReadPos() > 0) + lfgBootReason_ = packet.readString(); + if (packet.getSize() - packet.getReadPos() > 0) + lfgBootTargetName_ = packet.readString(); + if (inProgress) { lfgState_ = LfgState::Boot; } else { // Boot vote ended — return to InDungeon state regardless of outcome lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; + lfgBootTargetName_.clear(); + lfgBootReason_.clear(); lfgState_ = LfgState::InDungeon; if (myAnswer) { addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); @@ -13075,7 +13162,8 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { } LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress, - " bootVotes=", bootVotes, "/", totalVotes); + " bootVotes=", bootVotes, "/", totalVotes, + " target=", lfgBootTargetName_, " reason=", lfgBootReason_); } void GameHandler::handleLfgTeleportDenied(network::Packet& packet) { @@ -13380,6 +13468,80 @@ void GameHandler::handleArenaError(network::Packet& packet) { LOG_INFO("Arena error: ", error, " - ", msg); } +void GameHandler::requestPvpLog() { + if (state != WorldState::IN_WORLD || !socket) return; + // MSG_PVP_LOG_DATA is bidirectional: client sends an empty packet to request + network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA)); + socket->send(pkt); + LOG_INFO("Requested PvP log data"); +} + +void GameHandler::handlePvpLogData(network::Packet& packet) { + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < 1) return; + + bgScoreboard_ = BgScoreboardData{}; + bgScoreboard_.isArena = (packet.readUInt8() != 0); + + if (bgScoreboard_.isArena) { + // Skip arena-specific header (two teams × (rating change uint32 + name string + 5×uint32)) + // Rather than hardcoding arena parse we skip gracefully up to playerCount + // Each arena team block: uint32 + string + uint32*5 — variable length due to string. + // Skip by scanning for the uint32 playerCount heuristically; simply consume rest. + packet.setReadPos(packet.getSize()); + return; + } + + if (remaining() < 4) return; + uint32_t playerCount = packet.readUInt32(); + bgScoreboard_.players.reserve(playerCount); + + for (uint32_t i = 0; i < playerCount && remaining() >= 13; ++i) { + BgPlayerScore ps; + ps.guid = packet.readUInt64(); + ps.team = packet.readUInt8(); + ps.killingBlows = packet.readUInt32(); + ps.honorableKills = packet.readUInt32(); + ps.deaths = packet.readUInt32(); + ps.bonusHonor = packet.readUInt32(); + + // Resolve player name from entity manager + { + auto ent = entityManager.getEntity(ps.guid); + if (ent && (ent->getType() == game::ObjectType::PLAYER || + ent->getType() == game::ObjectType::UNIT)) { + auto u = std::static_pointer_cast(ent); + if (!u->getName().empty()) ps.name = u->getName(); + } + } + + // BG-specific stat blocks: uint32 count + N × (string fieldName + uint32 value) + if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; } + uint32_t statCount = packet.readUInt32(); + for (uint32_t s = 0; s < statCount && remaining() >= 5; ++s) { + std::string fieldName; + while (remaining() > 0) { + char c = static_cast(packet.readUInt8()); + if (c == '\0') break; + fieldName += c; + } + uint32_t val = (remaining() >= 4) ? packet.readUInt32() : 0; + ps.bgStats.emplace_back(std::move(fieldName), val); + } + + bgScoreboard_.players.push_back(std::move(ps)); + } + + if (remaining() >= 1) { + bgScoreboard_.hasWinner = (packet.readUInt8() != 0); + if (bgScoreboard_.hasWinner && remaining() >= 1) + bgScoreboard_.winner = packet.readUInt8(); + } + + LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); +} + void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); @@ -14099,6 +14261,10 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { : CastSpellPacket::build(spellId, target, ++castCount); socket->send(packet); LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec); + + // Optimistically start GCD immediately on cast — server will confirm or override + gcdTotal_ = 1.5f; + gcdStartedAt_ = std::chrono::steady_clock::now(); } void GameHandler::cancelCast() { @@ -14457,6 +14623,14 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) { uint32_t cooldownMs = packet.readUInt32(); float seconds = cooldownMs / 1000.0f; + + // spellId=0 is the Global Cooldown marker (server sends it for GCD triggers) + if (spellId == 0 && cooldownMs > 0 && cooldownMs <= 2000) { + gcdTotal_ = seconds; + gcdStartedAt_ = std::chrono::steady_clock::now(); + continue; + } + spellCooldowns[spellId] = seconds; for (auto& slot : actionBar) { bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) @@ -14497,6 +14671,10 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { } else if (data.guid == targetGuid) { auraList = &targetAuras; } + // Also maintain a per-unit cache for any unit (party members, etc.) + if (data.guid != 0 && data.guid != playerGuid && data.guid != targetGuid) { + auraList = &unitAurasCache_[data.guid]; + } if (auraList) { if (isAll) { @@ -14915,20 +15093,34 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { if (updateFlags & 0x0200) { // AURAS if (remaining() >= 8) { uint64_t auraMask = packet.readUInt64(); + // Collect aura updates for this member and store in unitAurasCache_ + // so party frame debuff dots can use them. + std::vector newAuras; for (int i = 0; i < 64; ++i) { if (auraMask & (uint64_t(1) << i)) { + AuraSlot a; + a.level = static_cast(i); // use slot index if (isWotLK) { // WotLK: uint32 spellId + uint8 auraFlags if (remaining() < 5) break; - packet.readUInt32(); - packet.readUInt8(); + a.spellId = packet.readUInt32(); + a.flags = packet.readUInt8(); } else { - // Classic/TBC: uint16 spellId only + // Classic/TBC: uint16 spellId only; negative auras not indicated here if (remaining() < 2) break; - packet.readUInt16(); + a.spellId = packet.readUInt16(); + // Infer negative/positive from dispel type: non-zero dispel → debuff + uint8_t dt = getSpellDispelType(a.spellId); + if (dt > 0) a.flags = 0x80; // mark as debuff } + if (a.spellId != 0) newAuras.push_back(a); } } + // Populate unitAurasCache_ for this member (merge: keep existing per-GUID data + // only if we already have a richer source; otherwise replace with stats data) + if (memberGuid != 0 && memberGuid != playerGuid && memberGuid != targetGuid) { + unitAurasCache_[memberGuid] = std::move(newAuras); + } } } if (updateFlags & 0x0400) { // PET_GUID @@ -16016,6 +16208,28 @@ void GameHandler::abandonQuest(uint32_t questId) { gossipPois_.end()); } +void GameHandler::shareQuestWithParty(uint32_t questId) { + if (state != WorldState::IN_WORLD || !socket) { + addSystemChatMessage("Cannot share quest: not in world."); + return; + } + if (!isInGroup()) { + addSystemChatMessage("You must be in a group to share a quest."); + return; + } + network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY)); + pkt.writeUInt32(questId); + socket->send(pkt); + // Local feedback: find quest title + for (const auto& q : questLog_) { + if (q.questId == questId && !q.title.empty()) { + addSystemChatMessage("Sharing quest: " + q.title); + return; + } + } + addSystemChatMessage("Quest shared."); +} + void GameHandler::handleQuestRequestItems(network::Packet& packet) { QuestRequestItemsData data; if (!QuestRequestItemsParser::parse(packet, data)) { @@ -16932,6 +17146,14 @@ void GameHandler::loadSpellNameCache() { if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; } } + // DispelType field (0=none,1=magic,2=curse,3=disease,4=poison,5=stealth,…) + uint32_t dispelField = 0xFFFFFFFF; + bool hasDispelField = false; + if (spellL) { + uint32_t f = spellL->field("DispelType"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } + } + uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); @@ -16939,7 +17161,7 @@ void GameHandler::loadSpellNameCache() { std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136); std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153); if (!name.empty()) { - SpellNameEntry entry{std::move(name), std::move(rank), 0}; + SpellNameEntry entry{std::move(name), std::move(rank), 0, 0}; if (hasSchoolMask) { entry.schoolMask = dbc->getUInt32(i, schoolMaskField); } else if (hasSchoolEnum) { @@ -16948,6 +17170,9 @@ void GameHandler::loadSpellNameCache() { uint32_t e = dbc->getUInt32(i, schoolEnumField); entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0; } + if (hasDispelField) { + entry.dispelType = static_cast(dbc->getUInt32(i, dispelField)); + } spellNameCache_[id] = std::move(entry); } } @@ -17141,6 +17366,12 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING; } +uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.dispelType : 0; +} + const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { auto slIt = spellToSkillLine_.find(spellId); if (slIt == spellToSkillLine_.end()) return EMPTY_STRING; @@ -18233,13 +18464,15 @@ void GameHandler::handleWho(network::Packet& packet) { LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online"); + // Store structured results for the who-results window + whoResults_.clear(); + whoOnlineCount_ = onlineCount; + if (displayCount == 0) { addSystemChatMessage("No players found."); return; } - addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:"); - for (uint32_t i = 0; i < displayCount; ++i) { if (packet.getReadPos() >= packet.getSize()) break; std::string playerName = packet.readString(); @@ -18254,17 +18487,16 @@ void GameHandler::handleWho(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) zoneId = packet.readUInt32(); - std::string msg = " " + playerName; - if (!guildName.empty()) - msg += " <" + guildName + ">"; - msg += " - Level " + std::to_string(level); - if (zoneId != 0) { - std::string zoneName = getAreaName(zoneId); - if (!zoneName.empty()) - msg += " [" + zoneName + "]"; - } + // Store structured entry + WhoEntry entry; + entry.name = playerName; + entry.guildName = guildName; + entry.level = level; + entry.classId = classId; + entry.raceId = raceId; + entry.zoneId = zoneId; + whoResults_.push_back(std::move(entry)); - addSystemChatMessage(msg); LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId, " Zone:", zoneId); } @@ -19921,12 +20153,15 @@ void GameHandler::handleLootRoll(network::Packet& packet) { pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + pendingLootRoll_.playerRolls.clear(); // Ensure item info is in cache; query if not queryItemInfo(itemId, 0); // Look up item name from cache auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + pendingLootRoll_.rollCountdownMs = 60000; + pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_ROLL: need/greed prompt for item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot); return; @@ -19943,6 +20178,28 @@ void GameHandler::handleLootRoll(network::Packet& packet) { } if (rollerName.empty()) rollerName = "Someone"; + // Track in the live roll list while our popup is open for the same item + if (pendingLootRollActive_ && + pendingLootRoll_.objectGuid == objectGuid && + pendingLootRoll_.slot == slot) { + bool found = false; + for (auto& r : pendingLootRoll_.playerRolls) { + if (r.playerName == rollerName) { + r.rollNum = rollNum; + r.rollType = rollType; + found = true; + break; + } + } + if (!found) { + LootRollEntry::PlayerRollResult prr; + prr.playerName = rollerName; + prr.rollNum = rollNum; + prr.rollType = rollType; + pendingLootRoll_.playerRolls.push_back(std::move(prr)); + } + } + auto* info = getItemInfo(itemId); std::string iName = info ? info->name : std::to_string(itemId); @@ -19997,10 +20254,8 @@ void GameHandler::handleLootRollWon(network::Packet& packet) { winnerName.c_str(), iName.c_str(), rollName, static_cast(rollNum)); addSystemChatMessage(buf); - // Clear pending roll if it was ours - if (pendingLootRollActive_ && winnerGuid == playerGuid) { - pendingLootRollActive_ = false; - } + // Dismiss roll popup — roll contest is over regardless of who won + pendingLootRollActive_ = false; LOG_INFO("SMSG_LOOT_ROLL_WON: winner=", winnerName, " item=", itemId, " roll=", rollName, "(", rollNum, ")"); } @@ -20057,7 +20312,7 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { uint64_t guid = packet.readUInt64(); uint32_t achievementId = packet.readUInt32(); - /*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed + uint32_t earnDate = packet.readUInt32(); // WoW PackedTime bitfield loadAchievementNameCache(); auto nameIt = achievementNameCache_.find(achievementId); @@ -20076,6 +20331,7 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { addSystemChatMessage(buf); earnedAchievements_.insert(achievementId); + achievementDates_[achievementId] = earnDate; if (achievementEarnedCallback_) { achievementEarnedCallback_(achievementId, achName); } @@ -20116,14 +20372,16 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { void GameHandler::handleAllAchievementData(network::Packet& packet) { loadAchievementNameCache(); earnedAchievements_.clear(); + achievementDates_.clear(); // Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF) while (packet.getSize() - packet.getReadPos() >= 4) { uint32_t id = packet.readUInt32(); if (id == 0xFFFFFFFF) break; if (packet.getSize() - packet.getReadPos() < 4) break; - /*uint32_t date =*/ packet.readUInt32(); + uint32_t date = packet.readUInt32(); earnedAchievements_.insert(id); + achievementDates_[id] = date; } // Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 9c30a3b5..701c5148 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -12,6 +12,7 @@ #include "core/logger.hpp" #include #include +#include #include #include @@ -1016,6 +1017,40 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } + // Hover coordinate display — show WoW coordinates under cursor + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + auto& io = ImGui::GetIO(); + ImVec2 mp = io.MousePos; + if (mp.x >= imgMin.x && mp.x <= imgMin.x + displayW && + mp.y >= imgMin.y && mp.y <= imgMin.y + displayH) { + float mu = (mp.x - imgMin.x) / displayW; + float mv = (mp.y - imgMin.y) / displayH; + + const auto& zone = zones[currentIdx]; + float left = zone.locLeft, right = zone.locRight; + float top = zone.locTop, bottom = zone.locBottom; + if (zone.areaID == 0) { + float l, r, t, b; + getContinentProjectionBounds(currentIdx, l, r, t, b); + left = l; right = r; top = t; bottom = b; + // Undo the kVOffset applied during renderPosToMapUV for continent + constexpr float kVOffset = -0.15f; + mv -= kVOffset; + } + + float hWowX = left - mu * (left - right); + float hWowY = top - mv * (top - bottom); + + char coordBuf[32]; + snprintf(coordBuf, sizeof(coordBuf), "%.0f, %.0f", hWowX, hWowY); + ImVec2 coordSz = ImGui::CalcTextSize(coordBuf); + float cx = imgMin.x + displayW - coordSz.x - 8.0f; + float cy = imgMin.y + displayH - coordSz.y - 8.0f; + drawList->AddText(ImVec2(cx + 1.0f, cy + 1.0f), IM_COL32(0, 0, 0, 180), coordBuf); + drawList->AddText(ImVec2(cx, cy), IM_COL32(220, 210, 150, 230), coordBuf); + } + } + // Continent view: clickable zone overlays if (viewLevel == ViewLevel::CONTINENT && continentIdx >= 0) { const auto& cont = zones[continentIdx]; @@ -1080,6 +1115,23 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1), IM_COL32(255, 255, 255, 30), 0.0f, 0, 1.0f); } + + // Zone name label — only if the rect is large enough to fit it + if (!z.areaName.empty()) { + ImVec2 textSz = ImGui::CalcTextSize(z.areaName.c_str()); + float rectW = sx1 - sx0; + float rectH = sy1 - sy0; + if (rectW > textSz.x + 4.0f && rectH > textSz.y + 2.0f) { + float tx = (sx0 + sx1) * 0.5f - textSz.x * 0.5f; + float ty = (sy0 + sy1) * 0.5f - textSz.y * 0.5f; + ImU32 labelCol = explored + ? IM_COL32(255, 230, 150, 210) + : IM_COL32(160, 160, 160, 80); + drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, 130), z.areaName.c_str()); + drawList->AddText(ImVec2(tx, ty), labelCol, z.areaName.c_str()); + } + } } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2ba78329..33e78f25 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -31,6 +31,7 @@ #include "pipeline/dbc_layout.hpp" #include "game/expansion_profile.hpp" +#include "game/character.hpp" #include "core/logger.hpp" #include #include @@ -71,6 +72,67 @@ namespace { return s; } + // Render gold/silver/copper amounts in WoW-canonical colors on the current ImGui line. + // Skips zero-value denominations (except copper, which is always shown when gold=silver=0). + void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); + } + + // Return the canonical Blizzard class color as ImVec4. + // classId is byte 1 of UNIT_FIELD_BYTES_0 (or CharacterData::classId). + // Returns a neutral light-gray for unknown / class 0. + ImVec4 classColorVec4(uint8_t classId) { + switch (classId) { + case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E + case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA + case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473 + case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569 + case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF + case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B + case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE + case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0 + case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9 + case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A + default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f); // unknown + } + } + + // ImU32 variant with alpha in [0,255]. + ImU32 classColorU32(uint8_t classId, int alpha = 255) { + ImVec4 c = classColorVec4(classId); + return IM_COL32(static_cast(c.x * 255), static_cast(c.y * 255), + static_cast(c.z * 255), alpha); + } + + // Extract class id from a unit's UNIT_FIELD_BYTES_0 update field. + // Returns 0 if the entity pointer is null or field is unset. + uint8_t entityClassId(const wowee::game::Entity* entity) { + if (!entity) return 0; + using UF = wowee::game::UF; + uint32_t bytes0 = entity->getField(wowee::game::fieldIndex(UF::UNIT_FIELD_BYTES_0)); + return static_cast((bytes0 >> 8) & 0xFF); + } + + // Return the English class name for a class ID (1-11), or "Unknown". + const char* classNameStr(uint8_t classId) { + static const char* kNames[] = { + "Unknown","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid" + }; + return (classId < 12) ? kNames[classId] : "Unknown"; + } + bool isPortBotTarget(const std::string& target) { std::string t = toLower(trim(target)); return t == "portbot" || t == "gmbot" || t == "telebot"; @@ -143,32 +205,43 @@ GameScreen::GameScreen() { void GameScreen::initChatTabs() { chatTabs_.clear(); // General tab: shows everything - chatTabs_.push_back({"General", 0xFFFFFFFF}); + chatTabs_.push_back({"General", ~0ULL}); // 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::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))}); + chatTabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | + (1ULL << static_cast(game::ChatType::LOOT)) | + (1ULL << static_cast(game::ChatType::SKILL)) | + (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | + (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | + (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | + (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | + (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); // Whispers tab - chatTabs_.push_back({"Whispers", (1u << static_cast(game::ChatType::WHISPER)) | - (1u << static_cast(game::ChatType::WHISPER_INFORM))}); + chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | + (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); + // Guild tab: guild and officer chat + chatTabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | + (1ULL << static_cast(game::ChatType::OFFICER)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); // Trade/LFG tab: channel messages - chatTabs_.push_back({"Trade/LFG", (1u << static_cast(game::ChatType::CHANNEL))}); + chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); + // Reset unread counts to match new tab list + chatTabUnread_.assign(chatTabs_.size(), 0); + chatTabSeenCount_ = 0; } bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; const auto& tab = chatTabs_[tabIndex]; - if (tab.typeMask == 0xFFFFFFFF) return true; // General tab shows all + if (tab.typeMask == ~0ULL) return true; // General tab shows all - uint32_t typeBit = 1u << static_cast(msg.type); + uint64_t typeBit = 1ULL << static_cast(msg.type); - // For Trade/LFG tab, also filter by channel name - if (tabIndex == 3 && msg.type == game::ChatType::CHANNEL) { + // For Trade/LFG tab (now index 4), also filter by channel name + if (tabIndex == 4 && msg.type == game::ChatType::CHANNEL) { const std::string& ch = msg.channelName; if (ch.find("Trade") == std::string::npos && ch.find("General") == std::string::npos && @@ -245,6 +318,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { repChangeCallbackSet_ = true; } + // Set up quest completion toast callback (once) + if (!questCompleteCallbackSet_) { + gameHandler.setQuestCompleteCallback([this](uint32_t id, const std::string& title) { + questCompleteToasts_.push_back({id, title, 0.0f}); + if (questCompleteToasts_.size() > 3) questCompleteToasts_.erase(questCompleteToasts_.begin()); + }); + questCompleteCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -410,6 +492,20 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Apply auto-loot setting to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); + // Zone entry detection — fire a toast when the renderer's zone name changes + if (auto* rend = core::Application::getInstance().getRenderer()) { + const std::string& curZone = rend->getCurrentZoneName(); + if (!curZone.empty() && curZone != lastKnownZone_) { + if (!lastKnownZone_.empty()) { + // Genuine zone change (not first entry) + zoneToasts_.push_back({curZone, 0.0f}); + if (zoneToasts_.size() > 3) + zoneToasts_.erase(zoneToasts_.begin()); + } + lastKnownZone_ = curZone; + } + } + // Sync chat auto-join settings to GameHandler gameHandler.chatAutoJoin.general = chatAutoJoinGeneral_; gameHandler.chatAutoJoin.trade = chatAutoJoinTrade_; @@ -428,6 +524,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPetFrame(gameHandler); } + // Totem frame (Shaman only, when any totem is active) + if (gameHandler.getPlayerClass() == 7) { + renderTotemFrame(gameHandler); + } + // Target frame (only when we have a target) if (gameHandler.hasTarget()) { renderTargetFrame(gameHandler); @@ -455,6 +556,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderActionBar(gameHandler); renderBagBar(gameHandler); renderXpBar(gameHandler); + renderRepBar(gameHandler); renderCastBar(gameHandler); renderMirrorTimers(gameHandler); renderQuestObjectiveTracker(gameHandler); @@ -465,12 +567,16 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDPSMeter(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); + renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); + renderZoneToasts(ImGui::GetIO().DeltaTime); + renderAreaTriggerToasts(ImGui::GetIO().DeltaTime, gameHandler); if (showRaidFrames_) { renderPartyFrames(gameHandler); } renderBossFrames(gameHandler); renderGroupInvitePopup(gameHandler); renderDuelRequestPopup(gameHandler); + renderDuelCountdown(gameHandler); renderLootRollPopup(gameHandler); renderTradeRequestPopup(gameHandler); renderTradeWindow(gameHandler); @@ -499,10 +605,13 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAuctionHouseWindow(gameHandler); renderDungeonFinderWindow(gameHandler); renderInstanceLockouts(gameHandler); + renderWhoWindow(gameHandler); + renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderThreatWindow(gameHandler); + renderBgScoreboard(gameHandler); renderObjectiveTracker(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { @@ -746,6 +855,45 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Persistent low-health vignette — pulsing red edges when HP < 20% + { + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + bool isDead = gameHandler.isPlayerDead(); + float hpPct = 1.0f; + if (!isDead && playerEntity && + (playerEntity->getType() == game::ObjectType::PLAYER || + playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) + hpPct = static_cast(unit->getHealth()) / static_cast(unit->getMaxHealth()); + } + + // Only show when alive and below 20% HP; intensity increases as HP drops + if (lowHealthVignetteEnabled_ && !isDead && hpPct < 0.20f && hpPct > 0.0f) { + // Base intensity from HP deficit (0 at 20%, 1 at 0%); pulse at ~1.5 Hz + float danger = (0.20f - hpPct) / 0.20f; + float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 9.4f); + int alpha = static_cast(danger * pulse * 90.0f); // max ~90 alpha, subtle + if (alpha > 0) { + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const float thickness = std::min(W, H) * 0.15f; + const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); + const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + edgeCol, edgeCol, fadeCol, fadeCol); + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + fadeCol, fadeCol, edgeCol, edgeCol); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + edgeCol, fadeCol, fadeCol, edgeCol); + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + fadeCol, edgeCol, edgeCol, fadeCol); + } + } + } + // Level-up golden burst overlay if (levelUpFlashAlpha_ > 0.0f) { levelUpFlashAlpha_ -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second @@ -933,6 +1081,7 @@ void GameScreen::renderEntityList(game::GameHandler& gameHandler) { void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { auto* window = core::Application::getInstance().getWindow(); + auto* assetMgr = core::Application::getInstance().getAssetManager(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; float chatW = std::min(500.0f, screenW * 0.4f); @@ -962,11 +1111,43 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { chatWindowPos_ = ImGui::GetWindowPos(); } + // Update unread counts: scan any new messages since last frame + { + const auto& history = gameHandler.getChatHistory(); + // Ensure unread array is sized correctly (guards against late init) + if (chatTabUnread_.size() != chatTabs_.size()) + chatTabUnread_.assign(chatTabs_.size(), 0); + // If history shrank (e.g. cleared), reset + if (chatTabSeenCount_ > history.size()) chatTabSeenCount_ = 0; + for (size_t mi = chatTabSeenCount_; mi < history.size(); ++mi) { + const auto& msg = history[mi]; + // For each non-General (non-0) tab that isn't currently active, check visibility + for (int ti = 1; ti < static_cast(chatTabs_.size()); ++ti) { + if (ti == activeChatTab_) continue; + if (shouldShowMessage(msg, ti)) { + chatTabUnread_[ti]++; + } + } + } + chatTabSeenCount_ = history.size(); + } + // Chat tabs if (ImGui::BeginTabBar("ChatTabs")) { for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { - if (ImGui::BeginTabItem(chatTabs_[i].name.c_str())) { - activeChatTab_ = i; + // Build label with unread count suffix for non-General tabs + std::string tabLabel = chatTabs_[i].name; + if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { + tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; + } + // Use ImGuiTabItemFlags_NoPushId so label changes don't break tab identity + if (ImGui::BeginTabItem(tabLabel.c_str())) { + if (activeChatTab_ != i) { + activeChatTab_ = i; + // Clear unread count when tab becomes active + if (i < static_cast(chatTabUnread_.size())) + chatTabUnread_[i] = 0; + } ImGui::EndTabItem(); } } @@ -1146,7 +1327,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { uint32_t g = info->sellPrice / 10000; uint32_t s = (info->sellPrice / 100) % 100; uint32_t c = info->sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { @@ -1189,10 +1371,13 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Find next special element: URL or WoW link size_t urlStart = text.find("https://", pos); - // Find next WoW item link: |cXXXXXXXX|Hitem:ENTRY:...|h[Name]|h|r + // Find next WoW link (may be colored with |c prefix or bare |H) size_t linkStart = text.find("|c", pos); - // Also handle bare |Hitem: without color prefix - size_t bareLinkStart = text.find("|Hitem:", pos); + // Also handle bare |H links without color prefix + size_t bareItem = text.find("|Hitem:", pos); + size_t bareSpell = text.find("|Hspell:", pos); + size_t bareQuest = text.find("|Hquest:", pos); + size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); // Determine which comes first size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart}); @@ -1225,18 +1410,30 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (nextSpecial == linkStart && text.size() > linkStart + 10) { // Parse |cAARRGGBB color linkColor = parseWowColor(text, linkStart); - hStart = text.find("|Hitem:", linkStart + 10); + // Find the nearest |H link of any supported type + size_t hItem = text.find("|Hitem:", linkStart + 10); + size_t hSpell = text.find("|Hspell:", linkStart + 10); + size_t hQuest = text.find("|Hquest:", linkStart + 10); + size_t hAch = text.find("|Hachievement:", linkStart + 10); + hStart = std::min({hItem, hSpell, hQuest, hAch}); } else if (nextSpecial == bareLinkStart) { hStart = bareLinkStart; } if (hStart != std::string::npos) { - // Parse item entry: |Hitem:ENTRY:... - size_t entryStart = hStart + 7; // skip "|Hitem:" + // Determine link type + const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); + const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); + const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); + // Default: item link + + // Parse the first numeric ID after |Htype: + size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); + size_t entryStart = hStart + idOffset; size_t entryEnd = text.find(':', entryStart); - uint32_t itemEntry = 0; + uint32_t linkId = 0; if (entryEnd != std::string::npos) { - itemEntry = static_cast(strtoul( + linkId = static_cast(strtoul( text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10)); } @@ -1245,53 +1442,122 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { size_t nameTagEnd = (nameTagStart != std::string::npos) ? text.find("]|h", nameTagStart + 3) : std::string::npos; - std::string itemName = "Unknown Item"; + std::string linkName = isSpellLink ? "Unknown Spell" + : isQuestLink ? "Unknown Quest" + : isAchievLink ? "Unknown Achievement" + : "Unknown Item"; if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { - itemName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); + linkName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); } // Find end of entire link sequence (|r or after ]|h) - size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + 7; + size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + idOffset; size_t resetPos = text.find("|r", linkEnd); if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { linkEnd = resetPos + 2; } - // Ensure item info is cached (trigger query if needed) - if (itemEntry > 0) { - gameHandler.ensureItemInfo(itemEntry); - } + if (!isSpellLink && !isQuestLink && !isAchievLink) { + // --- Item link --- + uint32_t itemEntry = linkId; + if (itemEntry > 0) { + gameHandler.ensureItemInfo(itemEntry); + } - // Show small icon before item link if available - if (itemEntry > 0) { - const auto* chatInfo = gameHandler.getItemInfo(itemEntry); - if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) { - VkDescriptorSet chatIcon = inventoryScreen.getItemIcon(chatInfo->displayInfoId); - if (chatIcon) { - ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - renderItemLinkTooltip(itemEntry); + // 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); } - ImGui::SameLine(0, 2); } } - } - // Render bracketed item name in quality color - std::string display = "[" + itemName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, linkColor); - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); + // Render bracketed item name in quality color + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - if (itemEntry > 0) { - renderItemLinkTooltip(itemEntry); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (itemEntry > 0) { + renderItemLinkTooltip(itemEntry); + } + } + } else if (isSpellLink) { + // --- Spell link: |Hspell:SPELLID:RANK|h[Name]|h --- + // Small icon (use spell icon cache if available) + VkDescriptorSet spellIcon = (linkId > 0) ? getSpellIcon(linkId, assetMgr) : VK_NULL_HANDLE; + if (spellIcon) { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + ImGui::SameLine(0, 2); + } + + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (linkId > 0) { + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + } + } else if (isQuestLink) { + // --- Quest link: |Hquest:QUESTID:QUESTLEVEL|h[Name]|h --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.84f, 0.0f, 1.0f)); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%s", linkName.c_str()); + // Parse quest level (second field after questId) + if (entryEnd != std::string::npos) { + size_t lvlEnd = text.find(':', entryEnd + 1); + if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); + if (lvlEnd != std::string::npos) { + uint32_t qLvl = static_cast(strtoul( + text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); + if (qLvl > 0) ImGui::TextDisabled("Level %u Quest", qLvl); + } + } + ImGui::TextDisabled("Click quest log to view details"); + ImGui::EndTooltip(); + } + // Click: open quest log and select this quest if we have it + if (ImGui::IsItemClicked() && linkId > 0) { + questLogScreen.openAndSelectQuest(linkId); + } + } else { + // --- Achievement link --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Achievement: %s", linkName.c_str()); } } - // Shift-click: insert item link into chat input + // Shift-click: insert entire link back into chat input if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { std::string linkText = text.substr(nextSpecial, linkEnd - nextSpecial); size_t curLen = strlen(chatInputBuffer); @@ -1377,6 +1643,39 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } }; + // Determine local player name for mention detection (case-insensitive) + std::string selfNameLower; + { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && !ch->name.empty()) { + selfNameLower = ch->name; + for (auto& c : selfNameLower) c = static_cast(std::tolower(static_cast(c))); + } + } + + // Scan NEW messages (beyond chatMentionSeenCount_) for mentions and play notification sound + if (!selfNameLower.empty() && chatHistory.size() > chatMentionSeenCount_) { + for (size_t mi = chatMentionSeenCount_; mi < chatHistory.size(); ++mi) { + const auto& mMsg = chatHistory[mi]; + // Skip outgoing whispers, system, and monster messages + if (mMsg.type == game::ChatType::WHISPER_INFORM || + mMsg.type == game::ChatType::SYSTEM) continue; + // Case-insensitive search in message body + std::string bodyLower = mMsg.message; + for (auto& c : bodyLower) c = static_cast(std::tolower(static_cast(c))); + if (bodyLower.find(selfNameLower) != std::string::npos) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } + break; // play at most once per scan pass + } + } + chatMentionSeenCount_ = chatHistory.size(); + } else if (chatHistory.size() <= chatMentionSeenCount_) { + chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared + } + int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; @@ -1460,10 +1759,30 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } } + // Detect mention: does this message contain the local player's name? + bool isMention = false; + if (!selfNameLower.empty() && + msg.type != game::ChatType::WHISPER_INFORM && + msg.type != game::ChatType::SYSTEM) { + std::string msgLower = fullMsg; + for (auto& c : msgLower) c = static_cast(std::tolower(static_cast(c))); + isMention = (msgLower.find(selfNameLower) != std::string::npos); + } + // Render message in a group so we can attach a right-click context menu ImGui::PushID(chatMsgIdx++); + if (isMention) { + // Golden highlight strip behind the text + ImVec2 groupMin = ImGui::GetCursorScreenPos(); + float availW = ImGui::GetContentRegionAvail().x; + float lineH = ImGui::GetTextLineHeightWithSpacing(); + ImGui::GetWindowDrawList()->AddRectFilled( + groupMin, + ImVec2(groupMin.x + availW, groupMin.y + lineH), + IM_COL32(255, 200, 50, 45)); // soft golden tint + } ImGui::BeginGroup(); - renderTextWithLinks(fullMsg, color); + renderTextWithLinks(fullMsg, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : color); ImGui::EndGroup(); // Right-click context menu (only for player messages with a sender) @@ -1544,8 +1863,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::Text("Type:"); ImGui::SameLine(); ImGui::SetNextItemWidth(100); - const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE" }; - ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 10); + const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE", "CHANNEL" }; + ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 11); // Auto-fill whisper target when switching to WHISPER mode if (selectedChatType == 4 && lastChatType != 4) { @@ -1572,6 +1891,27 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::InputText("##WhisperTarget", whisperTargetBuffer, sizeof(whisperTargetBuffer)); } + // Show channel picker if CHANNEL is selected + if (selectedChatType == 10) { + const auto& channels = gameHandler.getJoinedChannels(); + if (channels.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(no channels joined)"); + } else { + ImGui::SameLine(); + if (selectedChannelIdx >= static_cast(channels.size())) selectedChannelIdx = 0; + ImGui::SetNextItemWidth(140); + if (ImGui::BeginCombo("##ChannelPicker", channels[selectedChannelIdx].c_str())) { + for (int ci = 0; ci < static_cast(channels.size()); ++ci) { + bool selected = (ci == selectedChannelIdx); + if (ImGui::Selectable(channels[ci].c_str(), selected)) selectedChannelIdx = ci; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + } + ImGui::SameLine(); ImGui::Text("Message:"); ImGui::SameLine(); @@ -1602,7 +1942,16 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { else if (cmd == "bg" || cmd == "battleground") detected = 7; else if (cmd == "rw" || cmd == "raidwarning") detected = 8; else if (cmd == "i" || cmd == "instance") detected = 9; - if (detected >= 0 && selectedChatType != detected) { + else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. + if (detected >= 0 && (selectedChatType != detected || detected == 10)) { + // For channel shortcuts, also update selectedChannelIdx + if (detected == 10) { + int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. + const auto& chans = gameHandler.getJoinedChannels(); + if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { + selectedChannelIdx = chanIdx; + } + } selectedChatType = detected; // Strip the prefix, keep only the message part std::string remaining = buf.substr(sp + 1); @@ -1640,7 +1989,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { case 6: inputColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); break; // OFFICER - green case 7: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // BG - orange case 8: inputColor = ImVec4(1.0f, 0.3f, 0.0f, 1.0f); break; // RAID WARNING - red-orange - case 9: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // INSTANCE - blue + case 9: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // INSTANCE - blue + case 10: inputColor = ImVec4(0.3f, 0.9f, 0.9f, 1.0f); break; // CHANNEL - cyan default: inputColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // SAY - white } ImGui::PushStyleColor(ImGuiCol_Text, inputColor); @@ -1658,8 +2008,70 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { self->chatInputMoveCursorToEnd = false; } + // Tab: slash-command autocomplete + if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { + if (data->BufTextLen > 0 && data->Buf[0] == '/') { + // Split buffer into command word and trailing args + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + // Normalize to lowercase for matching + std::string lowerWord = word; + for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); + + static const std::vector kCmds = { + "/afk", "/away", "/cast", "/chathelp", "/clear", + "/dance", "/do", "/dnd", "/e", "/emote", + "/cl", "/combatlog", "/equip", "/follow", "/g", "/guild", "/guildinfo", + "/gmticket", "/grouploot", "/i", "/instance", + "/invite", "/j", "/join", "/kick", + "/l", "/leave", "/local", "/me", + "/p", "/party", "/r", "/raid", + "/raidwarning", "/random", "/reply", "/roll", + "/s", "/say", "/setloot", "/shout", + "/stopattack", "/stopfollow", "/t", "/time", + "/trade", "/uninvite", "/use", "/w", "/whisper", + "/who", "/wts", "/wtb", "/y", "/yell", "/zone" + }; + + // New session if prefix changed + if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) { + self->chatTabPrefix_ = lowerWord; + self->chatTabMatches_.clear(); + for (const auto& cmd : kCmds) { + if (cmd.size() >= lowerWord.size() && + cmd.compare(0, lowerWord.size(), lowerWord) == 0) + self->chatTabMatches_.push_back(cmd); + } + self->chatTabMatchIdx_ = 0; + } else { + // Cycle forward through matches + ++self->chatTabMatchIdx_; + if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) + self->chatTabMatchIdx_ = 0; + } + + if (!self->chatTabMatches_.empty()) { + std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + // Append trailing space when match is unambiguous + if (self->chatTabMatches_.size() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } + return 0; + } + // Up/Down arrow: cycle through sent message history if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + // Any history navigation resets autocomplete + self->chatTabMatchIdx_ = -1; + self->chatTabMatches_.clear(); + const int histSize = static_cast(self->chatSentHistory_.size()); if (histSize == 0) return 0; @@ -1690,7 +2102,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways | - ImGuiInputTextFlags_CallbackHistory; + ImGuiInputTextFlags_CallbackHistory | + ImGuiInputTextFlags_CallbackCompletion; if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. @@ -1744,11 +2157,23 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } - // Toggle nameplates (customizable keybinding, default V) + // Toggle character screen (C) and inventory/bags (I) + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { + inventoryScreen.toggleCharacter(); + } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { inventoryScreen.toggle(); } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { + if (inventoryScreen.isSeparateBags()) { + inventoryScreen.openAllBags(); + } else { + inventoryScreen.toggle(); + } + } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { showNameplates_ = !showNameplates_; } @@ -2121,8 +2546,13 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { playerHp = playerMaxHp; } - // 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)); + // Derive class color via shared helper + ImVec4 classColor = activeChar + ? classColorVec4(static_cast(activeChar->characterClass)) + : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + + // Name in class color — clickable for self-target, right-click for menu + ImGui::PushStyleColor(ImGuiCol_Text, classColor); if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) { gameHandler.setTarget(gameHandler.getPlayerGuid()); } @@ -2168,6 +2598,12 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); } + if (inCombatConfirmed && !isDead) { + float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * combatPulse, 0.2f * combatPulse, 1.0f), "[Combat]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); + } // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); @@ -2213,7 +2649,16 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { float mpPct = static_cast(power) / static_cast(maxPower); ImVec4 powerColor; switch (powerType) { - case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue) + case 0: { + // Mana: pulse desaturated blue when critically low (< 20%) + if (mpPct < 0.2f) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + powerColor = ImVec4(0.1f, 0.1f, 0.8f * pulse, 1.0f); + } else { + powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); + } + break; + } case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red) case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange) case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow) @@ -2310,6 +2755,85 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } } } + + // Shaman totem bar (class 7) — 4 slots: Earth, Fire, Water, Air + if (gameHandler.getPlayerClass() == 7) { + static const ImVec4 kTotemColors[] = { + ImVec4(0.80f, 0.55f, 0.25f, 1.0f), // Earth — brown + ImVec4(1.00f, 0.35f, 0.10f, 1.0f), // Fire — orange-red + ImVec4(0.20f, 0.55f, 0.90f, 1.0f), // Water — blue + ImVec4(0.70f, 0.90f, 1.00f, 1.0f), // Air — pale sky + }; + static const char* kTotemNames[] = { "Earth", "Fire", "Water", "Air" }; + + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + float spacing = 3.0f; + float slotW = (totalW - spacing * 3.0f) / 4.0f; + float slotH = 14.0f; + ImDrawList* tdl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; i++) { + const auto& ts = gameHandler.getTotemSlot(i); + float x0 = cursor.x + i * (slotW + spacing); + float y0 = cursor.y; + float x1 = x0 + slotW; + float y1 = y0 + slotH; + + // Background + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), IM_COL32(20, 20, 20, 200), 2.0f); + + if (ts.active()) { + float rem = ts.remainingMs(); + float frac = rem / static_cast(ts.durationMs); + float fillX = x0 + (x1 - x0) * frac; + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), + ImGui::ColorConvertFloat4ToU32(kTotemColors[i]), 2.0f); + // Remaining seconds label + char secBuf[8]; + snprintf(secBuf, sizeof(secBuf), "%.0f", rem / 1000.0f); + ImVec2 tsz = ImGui::CalcTextSize(secBuf); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx + 1, ly + 1), IM_COL32(0, 0, 0, 180), secBuf); + tdl->AddText(ImVec2(lx, ly), IM_COL32(255, 255, 255, 230), secBuf); + } else { + // Inactive — show element letter + const char* letter = kTotemNames[i]; + char single[2] = { letter[0], '\0' }; + ImVec2 tsz = ImGui::CalcTextSize(single); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx, ly), IM_COL32(80, 80, 80, 200), single); + } + + // Border + ImU32 borderCol = ts.active() + ? ImGui::ColorConvertFloat4ToU32(kTotemColors[i]) + : IM_COL32(60, 60, 60, 160); + tdl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); + + // Tooltip on hover + ImGui::SetCursorScreenPos(ImVec2(x0, y0)); + ImGui::InvisibleButton(("##totem" + std::to_string(i)).c_str(), ImVec2(slotW, slotH)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + if (ts.active()) { + const std::string& spellNm = gameHandler.getSpellName(ts.spellId); + ImGui::TextColored(ImVec4(kTotemColors[i].x, kTotemColors[i].y, + kTotemColors[i].z, 1.0f), + "%s Totem", kTotemNames[i]); + if (!spellNm.empty()) ImGui::Text("%s", spellNm.c_str()); + ImGui::Text("%.1fs remaining", ts.remainingMs() / 1000.0f); + } else { + ImGui::TextDisabled("%s Totem (empty)", kTotemNames[i]); + } + ImGui::EndTooltip(); + } + } + ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 2.0f)); + } } ImGui::End(); @@ -2412,10 +2936,94 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - // Dismiss button (compact, right-aligned) - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); - if (ImGui::SmallButton("Dismiss")) { - gameHandler.dismissPet(); + // Happiness bar — hunter pets store happiness as power type 4 + { + uint32_t happiness = petUnit->getPowerByType(4); + uint32_t maxHappiness = petUnit->getMaxPowerByType(4); + if (maxHappiness > 0 && happiness > 0) { + float hapPct = static_cast(happiness) / static_cast(maxHappiness); + // Tier: < 33% = Unhappy (red), < 67% = Content (yellow), >= 67% = Happy (green) + ImVec4 hapColor = hapPct >= 0.667f ? ImVec4(0.2f, 0.85f, 0.2f, 1.0f) + : hapPct >= 0.333f ? ImVec4(0.9f, 0.75f, 0.1f, 1.0f) + : ImVec4(0.85f, 0.2f, 0.2f, 1.0f); + const char* hapLabel = hapPct >= 0.667f ? "Happy" : hapPct >= 0.333f ? "Content" : "Unhappy"; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hapColor); + ImGui::ProgressBar(hapPct, ImVec2(-1, 8), hapLabel); + ImGui::PopStyleColor(); + } + } + + // Pet cast bar + if (auto* pcs = gameHandler.getUnitCastState(petGuid)) { + float castPct = (pcs->timeTotal > 0.0f) + ? (pcs->timeTotal - pcs->timeRemaining) / pcs->timeTotal : 0.0f; + // Orange color to distinguish from health/power bars + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.85f, 0.55f, 0.1f, 1.0f)); + char petCastLabel[48]; + const std::string& spellNm = gameHandler.getSpellName(pcs->spellId); + if (!spellNm.empty()) + snprintf(petCastLabel, sizeof(petCastLabel), "%s (%.1fs)", spellNm.c_str(), pcs->timeRemaining); + else + snprintf(petCastLabel, sizeof(petCastLabel), "Casting... (%.1fs)", pcs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), petCastLabel); + ImGui::PopStyleColor(); + } + + // Stance row: Passive / Defensive / Aggressive — with Dismiss right-aligned + { + static const char* kReactLabels[] = { "Psv", "Def", "Agg" }; + static const char* kReactTooltips[] = { "Passive", "Defensive", "Aggressive" }; + static const ImVec4 kReactColors[] = { + ImVec4(0.4f, 0.6f, 1.0f, 1.0f), // passive — blue + ImVec4(0.3f, 0.85f, 0.3f, 1.0f), // defensive — green + ImVec4(1.0f, 0.35f, 0.35f, 1.0f),// aggressive — red + }; + static const ImVec4 kReactDimColors[] = { + ImVec4(0.15f, 0.2f, 0.4f, 0.8f), + ImVec4(0.1f, 0.3f, 0.1f, 0.8f), + ImVec4(0.4f, 0.1f, 0.1f, 0.8f), + }; + uint8_t curReact = gameHandler.getPetReact(); // 0=passive,1=defensive,2=aggressive + + // Find each react-type slot in the action bar by known built-in IDs: + // 1=Passive, 4=Defensive, 6=Aggressive (WoW wire protocol) + static const uint32_t kReactActionIds[] = { 1u, 4u, 6u }; + uint32_t reactSlotVals[3] = { 0, 0, 0 }; + const int slotTotal = game::GameHandler::PET_ACTION_BAR_SLOTS; + for (int i = 0; i < slotTotal; ++i) { + uint32_t sv = gameHandler.getPetActionSlot(i); + uint32_t aid = sv & 0x00FFFFFFu; + for (int r = 0; r < 3; ++r) { + if (aid == kReactActionIds[r]) { reactSlotVals[r] = sv; break; } + } + } + + for (int r = 0; r < 3; ++r) { + if (r > 0) ImGui::SameLine(0.0f, 3.0f); + bool active = (curReact == static_cast(r)); + ImVec4 btnCol = active ? kReactColors[r] : kReactDimColors[r]; + ImGui::PushID(r + 1000); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kReactColors[r]); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, kReactColors[r]); + if (ImGui::Button(kReactLabels[r], ImVec2(34.0f, 16.0f))) { + // Use server-provided slot value if available; fall back to raw ID + uint32_t action = (reactSlotVals[r] != 0) + ? reactSlotVals[r] + : kReactActionIds[r]; + gameHandler.sendPetAction(action, 0); + } + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", kReactTooltips[r]); + ImGui::PopID(); + } + + // Dismiss button right-aligned on the same row + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 58.0f); + if (ImGui::SmallButton("Dismiss")) { + gameHandler.dismissPet(); + } } // Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS @@ -2445,9 +3053,12 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { // Try to show spell icon; fall back to abbreviated text label. VkDescriptorSet iconTex = VK_NULL_HANDLE; const char* builtinLabel = nullptr; - if (actionId == 2) builtinLabel = "Fol"; + if (actionId == 1) builtinLabel = "Psv"; + else if (actionId == 2) builtinLabel = "Fol"; else if (actionId == 3) builtinLabel = "Sty"; + else if (actionId == 4) builtinLabel = "Def"; else if (actionId == 5) builtinLabel = "Atk"; + else if (actionId == 6) builtinLabel = "Agg"; else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); // Tint green when autocast is on. @@ -2485,11 +3096,17 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { // Tooltip: show spell name or built-in command name. if (ImGui::IsItemHovered()) { - const char* tip = builtinLabel - ? (actionId == 5 ? "Attack" : actionId == 2 ? "Follow" : "Stay") - : nullptr; + const char* tip = nullptr; + if (builtinLabel) { + if (actionId == 1) tip = "Passive"; + else if (actionId == 2) tip = "Follow"; + else if (actionId == 3) tip = "Stay"; + else if (actionId == 4) tip = "Defensive"; + else if (actionId == 5) tip = "Attack"; + else if (actionId == 6) tip = "Aggressive"; + } std::string spellNm; - if (!tip && actionId > 5) { + if (!tip && actionId > 6) { spellNm = gameHandler.getSpellName(actionId); if (!spellNm.empty()) tip = spellNm.c_str(); } @@ -2507,6 +3124,87 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Totem Frame (Shaman — below pet frame / player frame) +// ============================================================ + +void GameScreen::renderTotemFrame(game::GameHandler& gameHandler) { + // Only show if at least one totem is active + bool anyActive = false; + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + if (gameHandler.getTotemSlot(i).active()) { anyActive = true; break; } + } + if (!anyActive) return; + + static const struct { const char* name; ImU32 color; } kTotemInfo[4] = { + { "Earth", IM_COL32(139, 90, 43, 255) }, // brown + { "Fire", IM_COL32(220, 80, 30, 255) }, // red-orange + { "Water", IM_COL32( 30,120, 220, 255) }, // blue + { "Air", IM_COL32(180,220, 255, 255) }, // light blue + }; + + // Position: below pet frame / player frame, left side + // Pet frame is at ~y=200 if active, player frame is at y=20; totem frame near y=300 + // We anchor relative to screen left edge like pet frame + ImGui::SetNextWindowPos(ImVec2(8.0f, 300.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(130.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoTitleBar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.08f, 0.06f, 0.88f)); + + if (ImGui::Begin("##TotemFrame", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.3f, 1.0f), "Totems"); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + const auto& slot = gameHandler.getTotemSlot(i); + if (!slot.active()) continue; + + ImGui::PushID(i); + + // Colored element dot + ImVec2 dotPos = ImGui::GetCursorScreenPos(); + dotPos.x += 4.0f; dotPos.y += 6.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotPos.x + 4.0f, dotPos.y + 4.0f), 4.0f, kTotemInfo[i].color); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + + // Totem name or spell name + const std::string& spellName = gameHandler.getSpellName(slot.spellId); + const char* displayName = spellName.empty() ? kTotemInfo[i].name : spellName.c_str(); + ImGui::Text("%s", displayName); + + // Duration countdown bar + float remMs = slot.remainingMs(); + float totMs = static_cast(slot.durationMs); + float frac = (totMs > 0.0f) ? std::min(remMs / totMs, 1.0f) : 0.0f; + float remSec = remMs / 1000.0f; + + // Color bar with totem element tint + ImVec4 barCol( + static_cast((kTotemInfo[i].color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f, + 0.9f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barCol); + char timeBuf[16]; + snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remSec); + ImGui::ProgressBar(frac, ImVec2(-1, 8), timeBuf); + ImGui::PopStyleColor(); + + ImGui::PopID(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { auto target = gameHandler.getTarget(); if (!target) return; @@ -2594,7 +3292,12 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { // Entity name and type — Selectable so we can attach a right-click context menu std::string name = getEntityName(target); + // Player targets: use class color instead of the generic green ImVec4 nameColor = hostileColor; + if (target->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(target.get()); + if (cid != 0) nameColor = classColorVec4(cid); + } ImGui::SameLine(0.0f, 0.0f); ImGui::PushStyleColor(ImGuiCol_Text, nameColor); @@ -2679,6 +3382,12 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { levelColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + if (confirmedCombatWithTarget) { + float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * cPulse, 0.2f * cPulse, 1.0f), "[Attacking]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Engaged in combat with this target"); + } // Health bar uint32_t hp = unit->getHealth(); @@ -2729,13 +3438,32 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float castLeft = gameHandler.getTargetCastTimeRemaining(); uint32_t tspell = gameHandler.getTargetCastSpellId(); const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Pulse bright orange when cast is > 80% complete — interrupt window closing + ImVec4 castBarColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + castBarColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + castBarColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor); char castLabel[72]; if (!castName.empty()) snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); else snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft); - ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + { + auto* tcastAsset = core::Application::getInstance().getAssetManager(); + VkDescriptorSet tIcon = (tspell != 0 && tcastAsset) + ? getSpellIcon(tspell, tcastAsset) : VK_NULL_HANDLE; + if (tIcon) { + ImGui::Image((ImTextureID)(uintptr_t)tIcon, ImVec2(14, 14)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } + } ImGui::PopStyleColor(); } @@ -2769,8 +3497,31 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::Separator(); + // Build sorted index list: debuffs before buffs, shorter duration first + uint64_t tNowSort = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector sortedIdx; + sortedIdx.reserve(targetAuras.size()); + for (size_t i = 0; i < targetAuras.size(); ++i) + if (!targetAuras[i].isEmpty()) sortedIdx.push_back(i); + std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = targetAuras[a]; const auto& ab = targetAuras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; // debuffs first + int32_t ra = aa.getRemainingMs(tNowSort); + int32_t rb = ab.getRemainingMs(tNowSort); + // Permanent (-1) goes last; shorter remaining goes first + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + int shown = 0; - for (size_t i = 0; i < targetAuras.size() && shown < 16; ++i) { + for (size_t si = 0; si < sortedIdx.size() && shown < 16; ++si) { + size_t i = sortedIdx[si]; const auto& aura = targetAuras[i]; if (aura.isEmpty()) continue; @@ -2779,7 +3530,20 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PushID(static_cast(10000 + i)); bool isBuff = (aura.flags & 0x80) == 0; - ImVec4 auraBorderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); + ImVec4 auraBorderColor; + if (isBuff) { + auraBorderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + // Debuff: color by dispel type, matching player buff bar convention + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: auraBorderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: auraBorderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: auraBorderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: auraBorderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: auraBorderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } VkDescriptorSet iconTex = VK_NULL_HANDLE; if (assetMgr) { @@ -2823,10 +3587,24 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(timeStr); float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; float cy = iconMax.y - textSize.y - 1.0f; + // Color by urgency (matches player buff bar) + ImU32 tTimerColor; + if (tRemainMs < 10000) { + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + tTimerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (tRemainMs < 30000) { + tTimerColor = IM_COL32(255, 165, 0, 255); + } else { + tTimerColor = IM_COL32(255, 255, 255, 255); + } ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 200), timeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 255, 255), timeStr); + tTimerColor, timeStr); } // Stack / charge count — upper-left corner @@ -2900,8 +3678,14 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { std::string totName = getEntityName(totEntity); + // Class color for players; gray for NPCs + ImVec4 totNameColor = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + if (totEntity->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(totEntity.get()); + if (cid != 0) totNameColor = classColorVec4(cid); + } // Selectable so we can attach a right-click context menu - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.8f, 0.8f, 0.8f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, totNameColor); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); @@ -2971,7 +3755,9 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { // Determine color based on relation (same logic as target frame) ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f); if (focus->getType() == game::ObjectType::PLAYER) { - focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + // Use class color for player focus targets + uint8_t cid = entityClassId(focus.get()); + focusColor = (cid != 0) ? classColorVec4(cid) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); } else if (focus->getType() == game::ObjectType::UNIT) { auto u = std::static_pointer_cast(focus); if (u->getHealth() == 0 && u->getMaxHealth() > 0) { @@ -3099,13 +3885,32 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { float rem = focusCast->timeRemaining; float prog = std::clamp(1.0f - rem / total, 0.f, 1.f); const std::string& spName = gameHandler.getSpellName(focusCast->spellId); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Pulse orange when > 80% complete — interrupt window closing + ImVec4 focusCastColor; + if (prog > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + focusCastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + focusCastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, focusCastColor); char castBuf[64]; if (!spName.empty()) snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); else snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem); - ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + { + auto* fcAsset = core::Application::getInstance().getAssetManager(); + VkDescriptorSet fcIcon = (focusCast->spellId != 0 && fcAsset) + ? getSpellIcon(focusCast->spellId, fcAsset) : VK_NULL_HANDLE; + if (fcIcon) { + ImGui::Image((ImTextureID)(uintptr_t)fcIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } else { + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } + } ImGui::PopStyleColor(); } } @@ -3187,6 +3992,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /score command — BG scoreboard + if (cmdLower == "score") { + gameHandler.requestPvpLog(); + showBgScoreboard_ = true; + chatInputBuffer[0] = '\0'; + return; + } + // /time command if (cmdLower == "time") { gameHandler.queryServerTime(); @@ -3194,6 +4007,20 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /zone command — print current zone name + if (cmdLower == "zone") { + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + // /played command if (cmdLower == "played") { gameHandler.requestPlayedTime(); @@ -3208,6 +4035,39 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /chathelp command — list chat-channel slash commands + if (cmdLower == "chathelp") { + static const char* kChatHelp[] = { + "--- Chat Channel Commands ---", + "/s [msg] Say to nearby players", + "/y [msg] Yell to a wider area", + "/w [msg] Whisper to player", + "/r [msg] Reply to last whisper", + "/p [msg] Party chat", + "/g [msg] Guild chat", + "/o [msg] Guild officer chat", + "/raid [msg] Raid chat", + "/rw [msg] Raid warning", + "/bg [msg] Battleground chat", + "/1 [msg] General channel", + "/2 [msg] Trade channel (also /wts /wtb)", + "/ [msg] Channel by number", + "/join Join a channel", + "/leave Leave a channel", + "/afk [msg] Set AFK status", + "/dnd [msg] Set Do Not Disturb", + }; + for (const char* line : kChatHelp) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // /help command — list available slash commands if (cmdLower == "help" || cmdLower == "?") { static const char* kHelpLines[] = { @@ -3219,13 +4079,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { " /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", + "Combat: /startattack /stopattack /stopcasting /cast /duel /pvp", + " /forfeit /follow /stopfollow /assist", + "Items: /use /equip ", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", - "Misc: /played /time /afk [msg] /dnd [msg] /inspect", + "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", - " /unstuck /logout /ticket /help", + " /score /unstuck /logout /ticket /help", }; for (const char* line : kHelpLines) { game::MessageChatData helpMsg; @@ -3274,6 +4135,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } gameHandler.queryWho(query); + showWhoWindow_ = true; + chatInputBuffer[0] = '\0'; + return; + } + + // /combatlog command + if (cmdLower == "combatlog" || cmdLower == "cl") { + showCombatLog_ = !showCombatLog_; chatInputBuffer[0] = '\0'; return; } @@ -3468,6 +4337,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /stopfollow command + if (cmdLower == "stopfollow") { + gameHandler.cancelFollow(); + chatInputBuffer[0] = '\0'; + return; + } + // /assist command if (cmdLower == "assist") { gameHandler.assistTarget(); @@ -3836,6 +4712,177 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "cast" && spacePos != std::string::npos) { + std::string spellArg = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + + // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" + int requestedRank = -1; // -1 = highest rank + std::string spellName = spellArg; + { + auto rankPos = spellArg.find('('); + if (rankPos != std::string::npos) { + std::string rankStr = spellArg.substr(rankPos + 1); + // Strip closing paren and whitespace + auto closePos = rankStr.find(')'); + if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); + for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); + // Expect "rank N" + if (rankStr.rfind("rank ", 0) == 0) { + try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} + } + spellName = spellArg.substr(0, rankPos); + while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); + } + } + + std::string spellNameLower = spellName; + for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); + + // Search known spells for a name match; pick highest rank (or specific rank) + uint32_t bestSpellId = 0; + int bestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sName = gameHandler.getSpellName(sid); + if (sName.empty()) continue; + std::string sNameLower = sName; + for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); + if (sNameLower != spellNameLower) continue; + + // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) + int sRank = 0; + const std::string& rankStr = gameHandler.getSpellRank(sid); + if (!rankStr.empty()) { + std::string rLow = rankStr; + for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); + if (rLow.rfind("rank ", 0) == 0) { + try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} + } + } + + if (requestedRank >= 0) { + if (sRank == requestedRank) { bestSpellId = sid; break; } + } else { + if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } + } + } + + if (bestSpellId) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(bestSpellId, targetGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = requestedRank >= 0 + ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." + : "Unknown spell: '" + spellName + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /use — use an item from backpack/bags by name + if (cmdLower == "use" && spacePos != std::string::npos) { + std::string useArg = command.substr(spacePos + 1); + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + std::string useArgLower = useArg; + for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + useArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /equip — auto-equip an item from backpack/bags by name + if (cmdLower == "equip" && spacePos != std::string::npos) { + std::string equipArg = command.substr(spacePos + 1); + while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); + while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); + std::string equipArgLower = equipArg; + for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + equipArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // Targeting commands if (cmdLower == "cleartarget") { gameHandler.clearTarget(); @@ -4066,6 +5113,31 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } chatInputBuffer[0] = '\0'; return; + } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { + // /wts and /wtb — send to Trade channel + // Prefix with [WTS] / [WTB] and route to the Trade channel + const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; + const std::string body = command.substr(spacePos + 1); + // Find the Trade channel among joined channels (case-insensitive prefix match) + std::string tradeChan; + for (const auto& ch : gameHandler.getJoinedChannels()) { + std::string chLow = ch; + for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); + if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } + } + if (tradeChan.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "You are not in the Trade channel."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer[0] = '\0'; + return; + } + message = tag + body; + type = game::ChatType::CHANNEL; + target = tradeChan; + isChannelCommand = true; } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { // /1 msg, /2 msg — channel shortcuts int channelIdx = cmdLower[0] - '0'; @@ -4172,6 +5244,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { case 7: type = game::ChatType::BATTLEGROUND; break; case 8: type = game::ChatType::RAID_WARNING; break; case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx]; + } else { type = game::ChatType::SAY; } + break; + } default: type = game::ChatType::SAY; break; } } @@ -4188,6 +5268,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { case 7: type = game::ChatType::BATTLEGROUND; break; case 8: type = game::ChatType::RAID_WARNING; break; case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx]; + } else { type = game::ChatType::SAY; } + break; + } default: type = game::ChatType::SAY; break; } } @@ -4777,6 +5865,48 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { const auto& slot = bar[absSlot]; bool onCooldown = !slot.isReady(); + const bool onGCD = gameHandler.isGCDActive() && !onCooldown && !slot.isEmpty(); + + // Out-of-range check: red tint when a targeted spell cannot reach the current target. + // Only applies to SPELL slots with a known max range (>5 yd) and an active target. + bool outOfRange = false; + if (!slot.isEmpty() && slot.type == game::ActionBarSlot::SPELL && slot.id != 0 + && !onCooldown && gameHandler.hasTarget()) { + uint32_t maxRange = spellbookScreen.getSpellMaxRange(slot.id, assetMgr); + if (maxRange > 5) { // >5 yd = not melee/self + auto& em = gameHandler.getEntityManager(); + auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); + auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); + if (playerEnt && targetEnt) { + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist > static_cast(maxRange)) + outOfRange = true; + } + } + } + + // Insufficient-power check: orange tint when player doesn't have enough power to cast. + // Only applies to SPELL slots with a known power cost and when not already on cooldown. + bool insufficientPower = false; + if (!slot.isEmpty() && slot.type == game::ActionBarSlot::SPELL && slot.id != 0 + && !onCooldown) { + uint32_t spellCost = 0, spellPowerType = 0; + spellbookScreen.getSpellPowerInfo(slot.id, assetMgr, spellCost, spellPowerType); + if (spellCost > 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt && (playerEnt->getType() == game::ObjectType::PLAYER || + playerEnt->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEnt); + if (unit->getPowerType() == static_cast(spellPowerType)) { + if (unit->getPower() < spellCost) + insufficientPower = true; + } + } + } + } auto getSpellName = [&](uint32_t spellId) -> std::string { std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); @@ -4824,20 +5954,31 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); } + // Item-missing check: grey out item slots whose item is not in the player's inventory. + const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 + && barItemDef == nullptr && !onCooldown); + bool clicked = false; if (iconTex) { ImVec4 tintColor(1, 1, 1, 1); ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); - if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } + else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } + else if (insufficientPower) { tintColor = ImVec4(0.6f, 0.5f, 0.9f, 0.85f); } + else if (itemMissing) { tintColor = ImVec4(0.35f, 0.35f, 0.35f, 0.7f); } clicked = ImGui::ImageButton("##icon", (ImTextureID)(uintptr_t)iconTex, ImVec2(slotSize, slotSize), ImVec2(0, 0), ImVec2(1, 1), bgColor, tintColor); } else { - if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); - else if (slot.isEmpty())ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); + if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); + else if (insufficientPower)ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.15f, 0.4f, 0.9f)); + else if (itemMissing) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.12f, 0.12f, 0.7f)); + else if (slot.isEmpty()) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); char label[32]; if (slot.type == game::ActionBarSlot::SPELL) { @@ -4945,6 +6086,12 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); } } + if (outOfRange) { + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Out of range"); + } + if (insufficientPower) { + ImGui::TextColored(ImVec4(0.75f, 0.55f, 1.0f, 1.0f), "Not enough power"); + } if (onCooldown) { float cd = slot.cooldownRemaining; if (cd >= 60.0f) @@ -5013,6 +6160,99 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); } + // GCD overlay — subtle dark fan sweep (thinner/lighter than regular cooldown) + if (onGCD) { + ImVec2 btnMin = ImGui::GetItemRectMin(); + ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + auto* dl = ImGui::GetWindowDrawList(); + float gcdRem = gameHandler.getGCDRemaining(); + float gcdTotal = gameHandler.getGCDTotal(); + if (gcdTotal > 0.0f) { + float elapsed = gcdTotal - gcdRem; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / gcdTotal)); + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 24; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.4f; + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); + } + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 110)); + } + } + } + + // Item stack count overlay — bottom-right corner of icon + if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + // Count total of this item across all inventory slots + auto& inv = gameHandler.getInventory(); + int totalCount = 0; + for (int bi = 0; bi < inv.getBackpackSize(); bi++) { + const auto& bs = inv.getBackpackSlot(bi); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) { + for (int si = 0; si < inv.getBagSize(bag); si++) { + const auto& bs = inv.getBagSlot(bag, si); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + } + if (totalCount > 0) { + char countStr[8]; + snprintf(countStr, sizeof(countStr), "%d", totalCount); + ImVec2 btnMax = ImGui::GetItemRectMax(); + ImVec2 tsz = ImGui::CalcTextSize(countStr); + float cx2 = btnMax.x - tsz.x - 2.0f; + float cy2 = btnMax.y - tsz.y - 1.0f; + auto* cdl = ImGui::GetWindowDrawList(); + cdl->AddText(ImVec2(cx2 + 1.0f, cy2 + 1.0f), IM_COL32(0, 0, 0, 200), countStr); + cdl->AddText(ImVec2(cx2, cy2), + totalCount <= 1 ? IM_COL32(220, 100, 100, 255) : IM_COL32(255, 255, 255, 255), + countStr); + } + } + + // Ready glow: animate a gold border for ~1.5s when a cooldown just expires + { + static std::unordered_map slotGlowTimers; // absSlot -> remaining glow seconds + static std::unordered_map slotWasOnCooldown; // absSlot -> last frame state + + float dt = ImGui::GetIO().DeltaTime; + bool wasOnCd = slotWasOnCooldown.count(absSlot) ? slotWasOnCooldown[absSlot] : false; + + // Trigger glow when transitioning from on-cooldown to ready (and slot isn't empty) + if (wasOnCd && !onCooldown && !slot.isEmpty()) { + slotGlowTimers[absSlot] = 1.5f; + } + slotWasOnCooldown[absSlot] = onCooldown; + + auto git = slotGlowTimers.find(absSlot); + if (git != slotGlowTimers.end() && git->second > 0.0f) { + git->second -= dt; + float t = git->second / 1.5f; // 1.0 → 0.0 over lifetime + // Pulse: bright when fresh, fading out + float pulse = std::sin(t * IM_PI * 4.0f) * 0.5f + 0.5f; // 4 pulses + uint8_t alpha = static_cast(200 * t * (0.5f + 0.5f * pulse)); + if (alpha > 0) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* gdl = ImGui::GetWindowDrawList(); + // Gold glow border (2px inset, 3px thick) + gdl->AddRect(ImVec2(bMin.x - 2, bMin.y - 2), + ImVec2(bMax.x + 2, bMax.y + 2), + IM_COL32(255, 215, 0, alpha), 3.0f, 0, 3.0f); + } + if (git->second <= 0.0f) slotGlowTimers.erase(git); + } + } + // Key label below ImGui::TextDisabled("%s", keyLabel); @@ -5542,6 +6782,26 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay); ImGui::Dummy(barSize); + + // Tooltip with XP-to-level and rested details + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + uint32_t xpToLevel = (currentXp < nextLevelXp) ? (nextLevelXp - currentXp) : 0; + ImGui::TextColored(ImVec4(0.9f, 0.85f, 1.0f, 1.0f), "Experience"); + ImGui::Separator(); + ImGui::Text("Current: %u / %u XP", currentXp, nextLevelXp); + ImGui::Text("To next level: %u XP", xpToLevel); + if (restedXp > 0) { + float restedLevels = static_cast(restedXp) / static_cast(nextLevelXp); + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.78f, 0.60f, 1.0f, 1.0f), + "Rested: +%u XP (%.1f%% of a level)", restedXp, restedLevels * 100.0f); + if (isResting) + ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), + "Resting — accumulating bonus XP"); + } + ImGui::EndTooltip(); + } } ImGui::End(); @@ -5549,6 +6809,126 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ============================================================ +// Reputation Bar +// ============================================================ + +void GameScreen::renderRepBar(game::GameHandler& gameHandler) { + uint32_t factionId = gameHandler.getWatchedFactionId(); + if (factionId == 0) return; + + const auto& standings = gameHandler.getFactionStandings(); + auto it = standings.find(factionId); + if (it == standings.end()) return; + + int32_t standing = it->second; + + // WoW reputation rank thresholds + struct RepRank { const char* name; int32_t min; int32_t max; ImU32 color; }; + static const RepRank kRanks[] = { + { "Hated", -42000, -6001, IM_COL32(180, 40, 40, 255) }, + { "Hostile", -6000, -3001, IM_COL32(180, 40, 40, 255) }, + { "Unfriendly", -3000, -1, IM_COL32(220, 100, 50, 255) }, + { "Neutral", 0, 2999, IM_COL32(200, 200, 60, 255) }, + { "Friendly", 3000, 8999, IM_COL32( 60, 180, 60, 255) }, + { "Honored", 9000, 20999, IM_COL32( 60, 160, 220, 255) }, + { "Revered", 21000, 41999, IM_COL32(140, 80, 220, 255) }, + { "Exalted", 42000, 42999, IM_COL32(255, 200, 50, 255) }, + }; + constexpr int kNumRanks = static_cast(sizeof(kRanks) / sizeof(kRanks[0])); + + int rankIdx = kNumRanks - 1; // default to Exalted + for (int i = 0; i < kNumRanks; ++i) { + if (standing <= kRanks[i].max) { rankIdx = i; break; } + } + const RepRank& rank = kRanks[rankIdx]; + + float fraction = 1.0f; + if (rankIdx < kNumRanks - 1) { + float range = static_cast(rank.max - rank.min + 1); + fraction = static_cast(standing - rank.min) / range; + fraction = std::max(0.0f, std::min(1.0f, fraction)); + } + + const std::string& factionName = gameHandler.getFactionNamePublic(factionId); + + // Position directly above the XP bar + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + float slotSize = 48.0f * pendingActionBarScale; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH_ab = slotSize + 24.0f; + float xpBarH = 20.0f; + float repBarH = 12.0f; + float xpBarW = barW; + float xpBarX = (screenW - xpBarW) / 2.0f; + + float bar1TopY = screenH - barH_ab; + float xpBarY; + if (pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH_ab - 2.0f + pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } + float repBarY = xpBarY - repBarH - 2.0f; + + ImGui::SetNextWindowPos(ImVec2(xpBarX, repBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(xpBarW, repBarH + 4.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); + + if (ImGui::Begin("##RepBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, repBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* dl = ImGui::GetWindowDrawList(); + + dl->AddRectFilled(barMin, barMax, IM_COL32(15, 15, 20, 220), 2.0f); + dl->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); + + float fillW = barSize.x * fraction; + if (fillW > 0.0f) + dl->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), rank.color, 2.0f); + + // Label: "FactionName - Rank" + char label[96]; + snprintf(label, sizeof(label), "%s - %s", factionName.c_str(), rank.name); + ImVec2 textSize = ImGui::CalcTextSize(label); + float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; + float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; + dl->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), label); + + // Tooltip with exact values on hover + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + float cr = ((rank.color ) & 0xFF) / 255.0f; + float cg = ((rank.color >> 8) & 0xFF) / 255.0f; + float cb = ((rank.color >> 16) & 0xFF) / 255.0f; + ImGui::TextColored(ImVec4(cr, cg, cb, 1.0f), "%s", rank.name); + int32_t rankMin = rank.min; + int32_t rankMax = (rankIdx < kNumRanks - 1) ? rank.max : 42000; + ImGui::Text("%s: %d / %d", factionName.c_str(), standing - rankMin, rankMax - rankMin + 1); + ImGui::EndTooltip(); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); +} + // ============================================================ // Cast Bar (Phase 3) // ============================================================ @@ -5556,10 +6936,16 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { void GameScreen::renderCastBar(game::GameHandler& gameHandler) { if (!gameHandler.isCasting()) return; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); + VkDescriptorSet iconTex = (currentSpellId != 0 && assetMgr) + ? getSpellIcon(currentSpellId, assetMgr) : VK_NULL_HANDLE; + float barW = 300.0f; float barX = (screenW - barW) / 2.0f; float barY = screenH - 120.0f; @@ -5587,7 +6973,6 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); char overlay[64]; - uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); if (currentSpellId == 0) { snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining()); } else { @@ -5598,7 +6983,15 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { else snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); } - ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + + if (iconTex) { + // Spell icon to the left of the progress bar + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(20, 20)); + ImGui::SameLine(0, 4); + ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + } else { + ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); + } ImGui::PopStyleColor(); } ImGui::End(); @@ -5744,6 +7137,11 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { gameHandler.setQuestTracked(q.questId, true); } } + if (gameHandler.isInGroup() && !q.complete) { + if (ImGui::MenuItem("Share Quest")) { + gameHandler.shareQuestWithParty(q.questId); + } + } if (!q.complete) { ImGui::Separator(); if (ImGui::MenuItem("Abandon Quest")) { @@ -5759,28 +7157,33 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { if (q.complete) { ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), " (Complete)"); } else { - // Kill counts + // Kill counts — green when complete, gray when in progress for (const auto& [entry, progress] : q.killCounts) { + bool objDone = (progress.first >= progress.second && progress.second > 0); + ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); std::string name = gameHandler.getCachedCreatureName(entry); if (name.empty()) { - // May be a game object objective; fall back to GO name cache. const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); if (goInfo && !goInfo->name.empty()) name = goInfo->name; } if (!name.empty()) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " %s: %u/%u", name.c_str(), progress.first, progress.second); } else { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " %u/%u", progress.first, progress.second); } } - // Item counts + // Item counts — green when complete, gray when in progress for (const auto& [itemId, count] : q.itemCounts) { uint32_t required = 1; auto reqIt = q.requiredItemCounts.find(itemId); if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; + bool objDone = (count >= required); + ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); const auto* info = gameHandler.getItemInfo(itemId); const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; @@ -5790,13 +7193,13 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { 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), + ImGui::TextColored(objColor, "%s: %u/%u", itemName ? itemName : "Item", count, required); } else if (itemName) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " %s: %u/%u", itemName, count, required); } else { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " Item: %u/%u", count, required); } } @@ -5835,6 +7238,7 @@ void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) { // Walk only the new messages (deque — iterate from back by skipping old ones) size_t toScan = newCount - raidWarnChatSeenCount_; size_t startIdx = newCount > toScan ? newCount - toScan : 0; + auto* renderer = core::Application::getInstance().getRenderer(); for (size_t i = startIdx; i < newCount; ++i) { const auto& msg = chatHistory[i]; if (msg.type == game::ChatType::RAID_WARNING || @@ -5848,6 +7252,11 @@ void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) { if (raidWarnEntries_.size() > 3) raidWarnEntries_.erase(raidWarnEntries_.begin()); } + // Whisper audio notification + if (msg.type == game::ChatType::WHISPER && renderer) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } } raidWarnChatSeenCount_ = newCount; } @@ -6031,6 +7440,15 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Resisted"); color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist break; + case game::CombatTextEntry::PROC_TRIGGER: { + const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!procName.empty()) + snprintf(text, sizeof(text), "%s!", procName.c_str()); + else + snprintf(text, sizeof(text), "PROC!"); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc + break; + } default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -6042,8 +7460,29 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { float baseX = outgoing ? outgoingX : incomingX; float xOffset = baseX + (idx % 3 - 1) * 60.0f; ++idx; + + // Crits render at 1.35× normal font size for visual impact + bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || + entry.type == game::CombatTextEntry::CRIT_HEAL); + ImFont* font = ImGui::GetFont(); + float baseFontSize = ImGui::GetFontSize(); + float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; + + // Advance cursor so layout accounting is correct, then read screen pos ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); - ImGui::TextColored(color, "%s", text); + ImVec2 screenPos = ImGui::GetCursorScreenPos(); + + // Drop shadow for readability over complex backgrounds + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); + ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), + shadowCol, text); + dl->AddText(font, renderFontSize, screenPos, textCol, text); + + // Reserve space so ImGui doesn't clip the window prematurely + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + ImGui::Dummy(ts); } } ImGui::End(); @@ -6061,11 +7500,37 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { // Track combat duration for accurate DPS denominator in short fights bool inCombat = gameHandler.isInCombat(); + if (inCombat && !dpsWasInCombat_) { + // Just entered combat — reset encounter accumulators + dpsEncounterDamage_ = 0.0f; + dpsEncounterHeal_ = 0.0f; + dpsLogSeenCount_ = gameHandler.getCombatLog().size(); + dpsCombatAge_ = 0.0f; + } if (inCombat) { dpsCombatAge_ += dt; + // Scan any new log entries since last frame + const auto& log = gameHandler.getCombatLog(); + while (dpsLogSeenCount_ < log.size()) { + const auto& e = log[dpsLogSeenCount_++]; + if (!e.isPlayerSource) continue; + switch (e.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + case game::CombatTextEntry::CRIT_DAMAGE: + case game::CombatTextEntry::PERIODIC_DAMAGE: + dpsEncounterDamage_ += static_cast(e.amount); + break; + case game::CombatTextEntry::HEAL: + case game::CombatTextEntry::CRIT_HEAL: + case game::CombatTextEntry::PERIODIC_HEAL: + dpsEncounterHeal_ += static_cast(e.amount); + break; + default: break; + } + } } else if (dpsWasInCombat_) { - // Just left combat — let meter show last reading for LIFETIME then reset - dpsCombatAge_ = 0.0f; + // Just left combat — keep encounter totals but stop accumulating } dpsWasInCombat_ = inCombat; @@ -6089,8 +7554,9 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { } } - // Only show if there's something to report - if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat) return; + // Only show if there's something to report (rolling window or lingering encounter data) + if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat && + dpsEncounterDamage_ < 1.0f && dpsEncounterHeal_ < 1.0f) return; // DPS window = min(combat age, combat-text lifetime) to avoid under-counting // at the start of a fight and over-counting when entries expire. @@ -6116,8 +7582,22 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { float screenW = appWin ? static_cast(appWin->getWidth()) : 1280.0f; float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f; + // Show encounter row when fight has been going long enough (> 3s) + bool showEnc = (dpsCombatAge_ > 3.0f || (!inCombat && dpsEncounterDamage_ > 0.0f)); + float encDPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterDamage_ / dpsCombatAge_ : 0.0f; + float encHPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterHeal_ / dpsCombatAge_ : 0.0f; + + char encDpsBuf[16], encHpsBuf[16]; + fmtNum(encDPS, encDpsBuf, sizeof(encDpsBuf)); + fmtNum(encHPS, encHpsBuf, sizeof(encHpsBuf)); + constexpr float WIN_W = 90.0f; - constexpr float WIN_H = 36.0f; + // Extra rows for encounter DPS/HPS if active + int extraRows = 0; + if (showEnc && encDPS > 0.5f) ++extraRows; + if (showEnc && encHPS > 0.5f) ++extraRows; + float WIN_H = 18.0f + extraRows * 14.0f; + if (dps > 0.5f || hps > 0.5f) WIN_H = std::max(WIN_H, 36.0f); float wx = screenW * 0.5f + 160.0f; // right of cast bar float wy = screenH - 130.0f; // above action bar area @@ -6144,6 +7624,17 @@ void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { ImGui::SameLine(0, 2); ImGui::TextDisabled("hps"); } + // Encounter totals (full-fight average, shown when fight > 3s) + if (showEnc && encDPS > 0.5f) { + ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 0.80f), "%s", encDpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); + } + if (showEnc && encHPS > 0.5f) { + ImGui::TextColored(ImVec4(0.50f, 1.0f, 0.50f, 0.80f), "%s", encHpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); + } } ImGui::End(); @@ -6291,6 +7782,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Cast bar below health bar when unit is casting float castBarBaseY = sy + barH + 2.0f; + float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots { const auto* cs = gameHandler.getUnitCastState(guid); if (cs && cs->casting && cs->timeTotal > 0.0f) { @@ -6308,9 +7800,15 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { 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 + // Cast bar background + fill (pulse orange when >80% = interrupt window closing) + ImU32 cbBg = IM_COL32(40, 30, 60, A(180)); + ImU32 cbFill; + if (castPct > 0.8f && unit->isHostile()) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + cbFill = IM_COL32(static_cast(255 * pulse), static_cast(130 * pulse), 0, A(220)); + } else { + 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), @@ -6327,6 +7825,37 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f; drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf); drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf); + nameplateBottom = castBarBaseY + cbH + 2.0f; + } + } + + // Debuff dot indicators: small colored squares below the nameplate showing + // player-applied auras on the current hostile target. + // Colors: Magic=blue, Curse=purple, Disease=yellow, Poison=green, Other=grey + if (isTarget && unit->isHostile() && !isCorpse) { + const auto& auras = gameHandler.getTargetAuras(); + const uint64_t pguid = gameHandler.getPlayerGuid(); + const float dotSize = 6.0f * nameplateScale_; + const float dotGap = 2.0f; + float dotX = barX; + for (const auto& aura : auras) { + if (aura.isEmpty() || aura.casterGuid != pguid) continue; + uint8_t dispelType = gameHandler.getSpellDispelType(aura.spellId); + ImU32 dotCol; + switch (dispelType) { + case 1: dotCol = IM_COL32( 64, 128, 255, A(210)); break; // Magic - blue + case 2: dotCol = IM_COL32(160, 32, 240, A(210)); break; // Curse - purple + case 3: dotCol = IM_COL32(180, 140, 40, A(210)); break; // Disease - yellow-brown + case 4: dotCol = IM_COL32( 50, 200, 50, A(210)); break; // Poison - green + default: dotCol = IM_COL32(170, 170, 170, A(170)); break; // Other - grey + } + drawList->AddRectFilled(ImVec2(dotX, nameplateBottom), + ImVec2(dotX + dotSize, nameplateBottom + dotSize), dotCol, 1.0f); + drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f), + ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), + IM_COL32(0, 0, 0, A(150)), 1.0f); + dotX += dotSize + dotGap; + if (dotX + dotSize > barX + barW) break; } } @@ -6360,12 +7889,19 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(labelBuf); float nameX = sx - textSize.x * 0.5f; float nameY = sy - barH - 12.0f; - // Name color: other player=cyan, hostile=red, non-hostile=yellow (WoW convention) - ImU32 nameColor = isPlayer - ? IM_COL32( 80, 200, 255, A(230)) // cyan — other players - : unit->isHostile() + // Name color: players get WoW class colors; NPCs use hostility (red/yellow) + ImU32 nameColor; + if (isPlayer) { + // Class color with cyan fallback for unknown class + uint8_t cid = entityClassId(unit); + ImVec4 cc = (cid != 0) ? classColorVec4(cid) : ImVec4(0.31f, 0.78f, 1.0f, 1.0f); + nameColor = IM_COL32(static_cast(cc.x*255), static_cast(cc.y*255), + static_cast(cc.z*255), A(230)); + } else { + nameColor = unit->isHostile() ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC + } drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); @@ -6447,6 +7983,16 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(ctxName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(nameplateCtxGuid_); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(nameplateCtxGuid_); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(nameplateCtxGuid_); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); if (ImGui::MenuItem("Add Friend")) gameHandler.addFriend(ctxName); if (ImGui::MenuItem("Ignore")) @@ -6468,6 +8014,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (!gameHandler.isInGroup()) return; + auto* assetMgr = core::Application::getInstance().getAssetManager(); const auto& partyData = gameHandler.getPartyData(); const bool isRaid = (partyData.groupType == 1); float frameY = 120.0f; @@ -6543,13 +8090,35 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { bool isDead = (m.onlineStatus & 0x0020) != 0; bool isGhost = (m.onlineStatus & 0x0010) != 0; - // Name text (truncated); leader name is gold + // Out-of-range check (40 yard threshold) + bool isOOR = false; + if (m.hasPartyStats && isOnline && !isDead && !isGhost && m.zoneId != 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt) { + float dx = playerEnt->getX() - static_cast(m.posX); + float dy = playerEnt->getY() - static_cast(m.posY); + isOOR = (dx * dx + dy * dy) > (40.0f * 40.0f); + } + } + // Dim cell overlay when out of range + if (isOOR) + draw->AddRectFilled(cellMin, cellMax, IM_COL32(0, 0, 0, 80), 3.0f); + + // Name text (truncated) — class color when alive+online, gray when dead/offline char truncName[16]; snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); bool isMemberLeader = (m.guid == partyData.leaderGuid); - ImU32 nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : - (!isOnline || isDead || isGhost) - ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); + ImU32 nameCol; + if (!isOnline || isDead || isGhost) { + nameCol = IM_COL32(140, 140, 140, 200); // gray for dead/offline + } else { + // Default: gold for leader, light gray for others + nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : IM_COL32(220, 220, 220, 255); + // Override with WoW class color if entity is loaded + auto mEnt = gameHandler.getEntityManager().getEntity(m.guid); + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) nameCol = classColorU32(cid); + } draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); // Leader crown star in top-right of cell @@ -6575,13 +8144,17 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f); ImVec2 barFill(barBg.x, barBg.y); ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); - ImU32 hpCol = pct > 0.5f ? IM_COL32(60, 180, 60, 255) : - pct > 0.2f ? IM_COL32(200, 180, 50, 255) : - IM_COL32(200, 60, 60, 255); + ImU32 hpCol = isOOR ? IM_COL32(100, 100, 100, 160) : + pct > 0.5f ? IM_COL32(60, 180, 60, 255) : + pct > 0.2f ? IM_COL32(200, 180, 50, 255) : + IM_COL32(200, 60, 60, 255); draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); - // HP percentage text centered on bar + // HP percentage or OOR text centered on bar char hpPct[8]; - snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(pct * 100.0f + 0.5f)); + if (isOOR) + snprintf(hpPct, sizeof(hpPct), "OOR"); + else + snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(pct * 100.0f + 0.5f)); ImVec2 ts = ImGui::CalcTextSize(hpPct); float tx = (barBg.x + barBgEnd.x - ts.x) * 0.5f; float ty = barBg.y + (BAR_H - ts.y) * 0.5f; @@ -6711,12 +8284,21 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { else if (isDead || isGhost) label += " (dead)"; } - // Clickable name to target; leader name is gold - if (isLeader) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + // Clickable name to target — use WoW class colors when entity is loaded, + // fall back to gold for leader / light gray for others + ImVec4 nameColor = isLeader + ? ImVec4(1.0f, 0.85f, 0.0f, 1.0f) + : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + { + auto memberEntity = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(memberEntity.get()); + if (cid != 0) nameColor = classColorVec4(cid); + } + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } - if (isLeader) ImGui::PopStyleColor(); + ImGui::PopStyleColor(); // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set if (member.roles != 0) { @@ -6739,24 +8321,68 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { maxHp = unit->getMaxHealth(); } } - if (maxHp > 0) { + // Check dead/ghost state for health bar rendering + bool memberDead = false; + bool memberOffline = false; + if (member.hasPartyStats) { + bool isOnline2 = (member.onlineStatus & 0x0001) != 0; + bool isDead2 = (member.onlineStatus & 0x0020) != 0; + bool isGhost2 = (member.onlineStatus & 0x0010) != 0; + memberDead = isDead2 || isGhost2; + memberOffline = !isOnline2; + } + + // Out-of-range check: compare player position to member's reported position + // Range threshold: 40 yards (standard heal/spell range) + bool memberOutOfRange = false; + if (member.hasPartyStats && !memberOffline && !memberDead && + member.zoneId != 0) { + // Same map: use 2D Euclidean distance in WoW coordinates (yards) + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEntity) { + float dx = playerEntity->getX() - static_cast(member.posX); + float dy = playerEntity->getY() - static_cast(member.posY); + float distSq = dx * dx + dy * dy; + memberOutOfRange = (distSq > 40.0f * 40.0f); + } + } + + if (memberDead) { + // Gray "Dead" bar for fallen party members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Dead"); + ImGui::PopStyleColor(2); + } else if (memberOffline) { + // Dim bar for offline members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.25f, 0.25f, 0.25f, 0.6f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.1f, 0.1f, 0.1f, 0.6f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Offline"); + ImGui::PopStyleColor(2); + } else if (maxHp > 0) { float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - 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)); + // Out-of-range: desaturate health bar to gray + ImVec4 hpBarColor = memberOutOfRange + ? ImVec4(0.45f, 0.45f, 0.45f, 0.7f) + : (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::PushStyleColor(ImGuiCol_PlotHistogram, hpBarColor); char hpText[32]; - if (maxHp >= 10000) + if (memberOutOfRange) { + snprintf(hpText, sizeof(hpText), "OOR"); + } else if (maxHp >= 10000) { snprintf(hpText, sizeof(hpText), "%dk/%dk", (int)hp / 1000, (int)maxHp / 1000); - else + } else { snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + } ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); ImGui::PopStyleColor(); } - // Power bar (mana/rage/energy) from party stats - if (member.hasPartyStats && member.maxPower > 0) { + // Power bar (mana/rage/energy) from party stats — hidden for dead/offline/OOR + if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) { float powerPct = static_cast(member.curPower) / static_cast(member.maxPower); ImVec4 powerColor; switch (member.powerType) { @@ -6774,6 +8400,57 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Dispellable debuff indicators — small colored dots for party member debuffs + // Only show magic/curse/disease/poison (types 1-4); skip non-dispellable + if (!memberDead && !memberOffline) { + const std::vector* unitAuras = nullptr; + if (member.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (member.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(member.guid); + + if (unitAuras) { + bool anyDebuff = false; + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // only debuffs + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0) continue; // skip non-dispellable + anyDebuff = true; + break; + } + if (anyDebuff) { + // Render one dot per unique dispel type present + bool shown[5] = {}; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 1.0f)); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dotCol; + switch (dt) { + case 1: dotCol = ImVec4(0.25f, 0.50f, 1.00f, 1.0f); break; // Magic: blue + case 2: dotCol = ImVec4(0.70f, 0.15f, 0.90f, 1.0f); break; // Curse: purple + case 3: dotCol = ImVec4(0.65f, 0.45f, 0.10f, 1.0f); break; // Disease: brown + case 4: dotCol = ImVec4(0.10f, 0.75f, 0.10f, 1.0f); break; // Poison: green + default: break; + } + ImGui::PushStyleColor(ImGuiCol_Button, dotCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); + ImGui::Button("##d", ImVec2(8.0f, 8.0f)); + ImGui::PopStyleColor(2); + ImGui::SameLine(); + } + ImGui::NewLine(); + ImGui::PopStyleVar(); + } + } + } + // Party member cast bar — shows when the party member is casting if (auto* cs = gameHandler.getUnitCastState(member.guid)) { float castPct = (cs->timeTotal > 0.0f) @@ -6785,7 +8462,17 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining); else snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + { + VkDescriptorSet pIcon = (cs->spellId != 0 && assetMgr) + ? getSpellIcon(cs->spellId, assetMgr) : VK_NULL_HANDLE; + if (pIcon) { + ImGui::Image((ImTextureID)(uintptr_t)pIcon, ImVec2(10, 10)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } + } ImGui::PopStyleColor(); } @@ -7008,11 +8695,199 @@ void GameScreen::renderRepToasts(float deltaTime) { } } +void GameScreen::renderQuestCompleteToasts(float deltaTime) { + for (auto& e : questCompleteToasts_) e.age += deltaTime; + questCompleteToasts_.erase( + std::remove_if(questCompleteToasts_.begin(), questCompleteToasts_.end(), + [](const QuestCompleteToastEntry& e) { return e.age >= kQuestCompleteToastLifetime; }), + questCompleteToasts_.end()); + + if (questCompleteToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + const float toastW = 260.0f; + const float toastH = 40.0f; + const float padY = 4.0f; + const float baseY = screenH - 220.0f; // above rep toasts + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + for (int i = 0; i < static_cast(questCompleteToasts_.size()); ++i) { + const auto& e = questCompleteToasts_[i]; + constexpr float kSlideDur = 0.3f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kQuestCompleteToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + float xFull = screenW - 14.0f - toastW; + float xStart = screenW + 10.0f; + float toastX = xStart + (xFull - xStart) * slide; + float toastY = baseY - i * (toastH + padY); + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + // Background + gold border (quest completion) + draw->AddRectFilled(tl, br, IM_COL32(20, 18, 8, (int)(alpha * 210)), 5.0f); + draw->AddRect(tl, br, IM_COL32(220, 180, 30, (int)(alpha * 230)), 5.0f, 0, 1.5f); + + // Scroll icon placeholder (gold diamond) + float iconCx = tl.x + 18.0f; + float iconCy = tl.y + toastH * 0.5f; + draw->AddCircleFilled(ImVec2(iconCx, iconCy), 7.0f, IM_COL32(210, 170, 20, (int)(alpha * 230))); + draw->AddCircle (ImVec2(iconCx, iconCy), 7.0f, IM_COL32(255, 220, 50, (int)(alpha * 200))); + + // "Quest Complete" header in gold + const char* header = "Quest Complete"; + draw->AddText(font, fontSize * 0.78f, + ImVec2(tl.x + 34.0f, tl.y + 4.0f), + IM_COL32(240, 200, 40, (int)(alpha * 240)), header); + + // Quest title in off-white + const char* titleStr = e.title.empty() ? "Unknown Quest" : e.title.c_str(); + draw->AddText(font, fontSize * 0.82f, + ImVec2(tl.x + 34.0f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(220, 215, 195, (int)(alpha * 220)), titleStr); + } +} + +// ============================================================ +// Zone Entry Toast +// ============================================================ + +void GameScreen::renderZoneToasts(float deltaTime) { + for (auto& e : zoneToasts_) e.age += deltaTime; + zoneToasts_.erase( + std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), + [](const ZoneToastEntry& e) { return e.age >= kZoneToastLifetime; }), + zoneToasts_.end()); + + if (zoneToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + + for (int i = 0; i < static_cast(zoneToasts_.size()); ++i) { + const auto& e = zoneToasts_[i]; + constexpr float kSlideDur = 0.35f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kZoneToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + // Measure text to size the toast + ImVec2 nameSz = font->CalcTextSizeA(14.0f, FLT_MAX, 0.0f, e.zoneName.c_str()); + const char* header = "Entering:"; + ImVec2 hdrSz = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, header); + + float toastW = std::max(nameSz.x, hdrSz.x) + 28.0f; + float toastH = 42.0f; + + // Center the toast horizontally, appear just below the zone name area (top-center) + float toastX = (screenW - toastW) * 0.5f; + float toastY = 56.0f + i * (toastH + 4.0f); + // Slide down from above + float offY = (1.0f - slide) * (-toastH - 10.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(10, 10, 16, (int)(alpha * 200)), 6.0f); + draw->AddRect(tl, br, IM_COL32(160, 140, 80, (int)(alpha * 220)), 6.0f, 0, 1.2f); + + float cx = tl.x + toastW * 0.5f; + draw->AddText(font, 11.0f, + ImVec2(cx - hdrSz.x * 0.5f, tl.y + 5.0f), + IM_COL32(180, 170, 120, (int)(alpha * 200)), header); + draw->AddText(font, 14.0f, + ImVec2(cx - nameSz.x * 0.5f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(255, 230, 140, (int)(alpha * 240)), e.zoneName.c_str()); + } +} + +// ─── Area Trigger Message Toasts ───────────────────────────────────────────── +void GameScreen::renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler) { + // Drain any pending messages from GameHandler + while (gameHandler.hasAreaTriggerMsg()) { + AreaTriggerToast t; + t.text = gameHandler.popAreaTriggerMsg(); + t.age = 0.0f; + areaTriggerToasts_.push_back(std::move(t)); + if (areaTriggerToasts_.size() > 4) + areaTriggerToasts_.erase(areaTriggerToasts_.begin()); + } + + // Age and prune + constexpr float kLifetime = 4.5f; + for (auto& t : areaTriggerToasts_) t.age += deltaTime; + areaTriggerToasts_.erase( + std::remove_if(areaTriggerToasts_.begin(), areaTriggerToasts_.end(), + [](const AreaTriggerToast& t) { return t.age >= kLifetime; }), + areaTriggerToasts_.end()); + if (areaTriggerToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + constexpr float kSlideDur = 0.35f; + + for (int i = 0; i < static_cast(areaTriggerToasts_.size()); ++i) { + const auto& t = areaTriggerToasts_[i]; + + float slideIn = std::min(t.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kLifetime - t.age), kSlideDur) / kSlideDur; + float alpha = std::clamp(std::min(slideIn, slideOut), 0.0f, 1.0f); + + // Measure text + ImVec2 txtSz = font->CalcTextSizeA(13.0f, FLT_MAX, 0.0f, t.text.c_str()); + float toastW = txtSz.x + 30.0f; + float toastH = 30.0f; + + // Center horizontally, place below zone text (center of lower-third) + float toastX = (screenW - toastW) * 0.5f; + float toastY = screenH * 0.62f + i * (toastH + 3.0f); + // Slide up from below + float offY = (1.0f - std::min(slideIn, slideOut)) * (toastH + 12.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(8, 12, 22, (int)(alpha * 190)), 5.0f); + draw->AddRect(tl, br, IM_COL32(100, 160, 220, (int)(alpha * 200)), 5.0f, 0, 1.0f); + + float cx = tl.x + toastW * 0.5f; + // Shadow + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f + 1, tl.y + (toastH - txtSz.y) * 0.5f + 1), + IM_COL32(0, 0, 0, (int)(alpha * 180)), t.text.c_str()); + // Text in light blue + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f, tl.y + (toastH - txtSz.y) * 0.5f), + IM_COL32(180, 220, 255, (int)(alpha * 240)), t.text.c_str()); + } +} + // ============================================================ // Boss Encounter Frames // ============================================================ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { + auto* assetMgr = core::Application::getInstance().getAssetManager(); + // Collect active boss unit slots struct BossSlot { uint32_t slot; uint64_t guid; }; std::vector active; @@ -7078,14 +8953,32 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { uint32_t bspell = cs->spellId; const std::string& bcastName = (bspell != 0) ? gameHandler.getSpellName(bspell) : ""; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Pulse bright orange when > 80% complete — interrupt window closing + ImVec4 bcastColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + bcastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + bcastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor); char bcastLabel[72]; if (!bcastName.empty()) snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)", bcastName.c_str(), cs->timeRemaining); else snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + { + VkDescriptorSet bIcon = (bspell != 0 && assetMgr) + ? getSpellIcon(bspell, assetMgr) : VK_NULL_HANDLE; + if (bIcon) { + ImGui::Image((ImTextureID)(uintptr_t)bIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } + } ImGui::PopStyleColor(); } @@ -7151,6 +9044,47 @@ void GameScreen::renderDuelRequestPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderDuelCountdown(game::GameHandler& gameHandler) { + float remaining = gameHandler.getDuelCountdownRemaining(); + if (remaining <= 0.0f) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + auto* dl = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + // Show integer countdown or "Fight!" when under 0.5s + char buf[32]; + if (remaining > 0.5f) { + snprintf(buf, sizeof(buf), "%d", static_cast(std::ceil(remaining))); + } else { + snprintf(buf, sizeof(buf), "Fight!"); + } + + // Large font by scaling — use 4x font size for dramatic effect + float scale = 4.0f; + float scaledSize = fontSize * scale; + ImVec2 textSz = font->CalcTextSizeA(scaledSize, FLT_MAX, 0.0f, buf); + float tx = (screenW - textSz.x) * 0.5f; + float ty = screenH * 0.35f - textSz.y * 0.5f; + + // Pulsing alpha: fades in and out per second + float pulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 6.28f); + uint8_t alpha = static_cast(255 * pulse); + + // Color: golden countdown, red "Fight!" + ImU32 color = (remaining > 0.5f) + ? IM_COL32(255, 200, 50, alpha) + : IM_COL32(255, 60, 60, alpha); + + // Drop shadow + dl->AddText(font, scaledSize, ImVec2(tx + 2.0f, ty + 2.0f), IM_COL32(0, 0, 0, alpha / 2), buf); + dl->AddText(font, scaledSize, ImVec2(tx, ty), color, buf); +} + void GameScreen::renderItemTextWindow(game::GameHandler& gameHandler) { if (!gameHandler.isItemTextOpen()) return; @@ -7457,6 +9391,34 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { uint8_t q = roll.itemQuality; ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; + // Countdown bar + { + auto now = std::chrono::steady_clock::now(); + float elapsedMs = static_cast( + std::chrono::duration_cast(now - roll.rollStartedAt).count()); + float totalMs = static_cast(roll.rollCountdownMs > 0 ? roll.rollCountdownMs : 60000); + float fraction = 1.0f - std::min(elapsedMs / totalMs, 1.0f); + float remainSec = (totalMs - elapsedMs) / 1000.0f; + if (remainSec < 0.0f) remainSec = 0.0f; + + // Color: green → yellow → red + ImVec4 barColor; + if (fraction > 0.5f) + barColor = ImVec4(0.2f + (1.0f - fraction) * 1.4f, 0.85f, 0.2f, 1.0f); + else if (fraction > 0.2f) + barColor = ImVec4(1.0f, fraction * 1.7f, 0.1f, 1.0f); + else { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 6.0f); + barColor = ImVec4(pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); + } + + char timeBuf[16]; + std::snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remainSec); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + ImGui::ProgressBar(fraction, ImVec2(-1, 12), timeBuf); + ImGui::PopStyleColor(); + } + ImGui::Text("An item is up for rolls:"); // Show item icon if available @@ -7498,6 +9460,48 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { if (ImGui::Button("Pass", ImVec2(70, 30))) { gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); } + + // Live roll results from group members + if (!roll.playerRolls.empty()) { + ImGui::Separator(); + ImGui::TextDisabled("Rolls so far:"); + // Roll-type label + color + static const char* kRollLabels[] = {"Need", "Greed", "Disenchant", "Pass"}; + static const ImVec4 kRollColors[] = { + ImVec4(0.2f, 0.9f, 0.2f, 1.0f), // Need — green + ImVec4(0.3f, 0.6f, 1.0f, 1.0f), // Greed — blue + ImVec4(0.7f, 0.3f, 0.9f, 1.0f), // Disenchant — purple + ImVec4(0.5f, 0.5f, 0.5f, 1.0f), // Pass — gray + }; + auto rollTypeIndex = [](uint8_t t) -> int { + if (t == 0) return 0; + if (t == 1) return 1; + if (t == 2) return 2; + return 3; // pass (96 or unknown) + }; + + if (ImGui::BeginTable("##lootrolls", 3, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Roll", ImGuiTableColumnFlags_WidthFixed, 32.0f); + for (const auto& r : roll.playerRolls) { + int ri = rollTypeIndex(r.rollType); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.playerName.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(kRollColors[ri], "%s", kRollLabels[ri]); + ImGui::TableSetColumnIndex(2); + if (r.rollType != 96) { + ImGui::TextColored(kRollColors[ri], "%d", static_cast(r.rollNum)); + } else { + ImGui::TextDisabled("—"); + } + } + ImGui::EndTable(); + } + } } ImGui::End(); } @@ -7556,6 +9560,29 @@ void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) { gameHandler.respondToReadyCheck(false); gameHandler.dismissReadyCheck(); } + + // Live player responses + const auto& results = gameHandler.getReadyCheckResults(); + if (!results.empty()) { + ImGui::Separator(); + if (ImGui::BeginTable("##rcresults", 2, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 72.0f); + for (const auto& r : results) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.name.c_str()); + ImGui::TableSetColumnIndex(1); + if (r.ready) { + ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "Ready"); + } else { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "Not Ready"); + } + } + ImGui::EndTable(); + } + } } ImGui::End(); } @@ -7736,7 +9763,8 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { uint32_t gold = cost / 10000; uint32_t silver = (cost % 10000) / 100; uint32_t copper = cost % 100; - ImGui::Text("Cost: %ug %us %uc", gold, silver, copper); + ImGui::TextDisabled("Cost:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); ImGui::Spacing(); ImGui::Text("Guild Name:"); ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_)); @@ -7819,19 +9847,14 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { return a.name < b.name; }); - static const char* classNames[] = { - "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", - "Priest", "Death Knight", "Shaman", "Mage", "Warlock", - "", "Druid" - }; - for (const auto& m : sortedMembers) { ImGui::TableNextRow(); ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + ImVec4 nameColor = m.online ? classColorVec4(m.classId) : textColor; ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.name.c_str()); + ImGui::TextColored(nameColor, "%s", m.name.c_str()); // Right-click context menu if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { @@ -7851,8 +9874,9 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::TextColored(textColor, "%u", m.level); ImGui::TableNextColumn(); - const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; - ImGui::TextColored(textColor, "%s", className); + const char* className = classNameStr(m.classId); + ImVec4 classCol = m.online ? classColorVec4(m.classId) : textColor; + ImGui::TextColored(classCol, "%s", className); ImGui::TableNextColumn(); // Zone name lookup @@ -8191,17 +10215,37 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndTooltip(); } - // Level and status + // Level, class, and status if (c.isOnline()) { - ImGui::SameLine(160.0f); + ImGui::SameLine(150.0f); const char* statusLabel = (c.status == 2) ? " (AFK)" : (c.status == 3) ? " (DND)" : ""; - if (c.level > 0) { + // Class color for the level/class display + ImVec4 friendClassCol = classColorVec4(static_cast(c.classId)); + const char* friendClassName = classNameStr(static_cast(c.classId)); + if (c.level > 0 && c.classId > 0) { + ImGui::TextColored(friendClassCol, "Lv%u %s%s", c.level, friendClassName, statusLabel); + } else if (c.level > 0) { ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); } else if (*statusLabel) { ImGui::TextDisabled("%s", statusLabel + 1); } + + // Tooltip: zone info + if (ImGui::IsItemHovered() && c.areaId != 0) { + ImGui::BeginTooltip(); + if (zoneManager) { + const auto* zi = zoneManager->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) + ImGui::Text("Zone: %s", zi->name.c_str()); + else + ImGui::TextDisabled("Area ID: %u", c.areaId); + } else { + ImGui::TextDisabled("Area ID: %u", c.areaId); + } + ImGui::EndTooltip(); + } } ImGui::PopID(); @@ -8306,6 +10350,10 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f)); + // State for "Set Note" inline editing + static int noteEditContactIdx = -1; + static char noteEditBuf[128] = {}; + bool open = showSocialFrame_; char socialTitle[32]; snprintf(socialTitle, sizeof(socialTitle), "Social (%d online)##SocialFrame", onlineCount); @@ -8313,6 +10361,11 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { + // Get zone manager for area name lookups + game::ZoneManager* socialZoneMgr = nullptr; + if (auto* rend = core::Application::getInstance().getRenderer()) + socialZoneMgr = rend->getZoneManager(); + if (ImGui::BeginTabBar("##SocialTabs")) { // ---- Friends tab ---- if (ImGui::BeginTabItem("Friends")) { @@ -8344,13 +10397,36 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); ImVec4 nameCol = c.isOnline() - ? ImVec4(0.9f, 0.9f, 0.9f, 1.0f) + ? classColorVec4(static_cast(c.classId)) : 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); + // Show level and class name in class color + ImGui::TextColored(classColorVec4(static_cast(c.classId)), + "Lv%u %s", c.level, classNameStr(static_cast(c.classId))); + } + + // Tooltip: zone info and note + if (ImGui::IsItemHovered() || (c.isOnline() && ImGui::IsItemHovered())) { + if (c.isOnline() && (c.areaId != 0 || !c.note.empty())) { + ImGui::BeginTooltip(); + if (c.areaId != 0) { + const char* zoneName = nullptr; + if (socialZoneMgr) { + const auto* zi = socialZoneMgr->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) zoneName = zi->name.c_str(); + } + if (zoneName) + ImGui::Text("Zone: %s", zoneName); + else + ImGui::Text("Area ID: %u", c.areaId); + } + if (!c.note.empty()) + ImGui::TextDisabled("Note: %s", c.note.c_str()); + ImGui::EndTooltip(); + } } // Right-click context menu @@ -8367,6 +10443,14 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(c.name); + if (c.guid != 0 && ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(c.guid); + } + if (ImGui::MenuItem("Set Note")) { + noteEditContactIdx = static_cast(ci); + strncpy(noteEditBuf, c.note.c_str(), sizeof(noteEditBuf) - 1); + noteEditBuf[sizeof(noteEditBuf) - 1] = '\0'; + ImGui::OpenPopup("##SetFriendNote"); } if (ImGui::MenuItem("Remove Friend")) gameHandler.removeFriend(c.name); @@ -8387,6 +10471,31 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { } ImGui::EndChild(); + + // "Set Note" modal popup + if (ImGui::BeginPopup("##SetFriendNote")) { + const std::string& noteName = (noteEditContactIdx >= 0 && + noteEditContactIdx < static_cast(contacts.size())) + ? contacts[noteEditContactIdx].name : ""; + ImGui::TextDisabled("Note for %s:", noteName.c_str()); + ImGui::SetNextItemWidth(180.0f); + bool confirm = ImGui::InputText("##noteinput", noteEditBuf, sizeof(noteEditBuf), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (confirm || ImGui::Button("OK")) { + if (!noteName.empty()) + gameHandler.setFriendNote(noteName, noteEditBuf); + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + ImGui::Separator(); // Add friend @@ -8523,12 +10632,33 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); if (ImGui::Begin("##BuffBar", nullptr, flags)) { - // Separate buffs and debuffs; show buffs first, then debuffs with a visual gap + // Pre-sort auras: buffs first, then debuffs; within each group, shorter remaining first + uint64_t buffNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector buffSortedIdx; + buffSortedIdx.reserve(auras.size()); + for (size_t i = 0; i < auras.size(); ++i) + if (!auras[i].isEmpty()) buffSortedIdx.push_back(i); + std::sort(buffSortedIdx.begin(), buffSortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = auras[a]; const auto& ab = auras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff < bDebuff; // buffs (0) first + int32_t ra = aa.getRemainingMs(buffNowMs); + int32_t rb = ab.getRemainingMs(buffNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + // Render one pass for buffs, one for debuffs for (int pass = 0; pass < 2; ++pass) { bool wantBuff = (pass == 0); int shown = 0; - for (size_t i = 0; i < auras.size() && shown < 40; ++i) { + for (size_t si = 0; si < buffSortedIdx.size() && shown < 40; ++si) { + size_t i = buffSortedIdx[si]; const auto& aura = auras[i]; if (aura.isEmpty()) continue; @@ -8539,7 +10669,22 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PushID(static_cast(i) + (pass * 256)); - ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); + // Determine border color: buffs = green; debuffs use WoW dispel-type colors + ImVec4 borderColor; + if (isBuff) { + borderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); // green + } else { + // Debuff: color by dispel type (0=none/red, 1=magic/blue, 2=curse/purple, + // 3=disease/brown, 4=poison/green, other=dark-red) + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: borderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: borderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: borderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: borderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } // Try to get spell icon VkDescriptorSet iconTex = VK_NULL_HANDLE; @@ -8584,11 +10729,26 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(timeStr); float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; float cy = iconMax.y - textSize.y - 2.0f; + // Choose timer color based on urgency + ImU32 timerColor; + if (remainMs < 10000) { + // < 10s: pulse red + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + timerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (remainMs < 30000) { + timerColor = IM_COL32(255, 165, 0, 255); // orange + } else { + timerColor = IM_COL32(255, 255, 255, 255); // white + } // Drop shadow for readability over any icon colour ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 200), timeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 255, 255), timeStr); + timerColor, timeStr); } // Stack / charge count overlay — upper-left corner of the icon @@ -8672,10 +10832,11 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { const auto& loot = gameHandler.getCurrentLoot(); - // Gold + // Gold (auto-looted on open; shown for feedback) if (loot.gold > 0) { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%ug %us %uc", - loot.getGold(), loot.getSilver(), loot.getCopper()); + ImGui::TextDisabled("Gold:"); + ImGui::SameLine(0, 4); + renderCoinsText(loot.getGold(), loot.getSilver(), loot.getCopper()); ImGui::Separator(); } @@ -9081,9 +11242,8 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { uint32_t gold = quest.rewardMoney / 10000; uint32_t silver = (quest.rewardMoney % 10000) / 100; uint32_t copper = quest.rewardMoney % 100; - if (gold > 0) ImGui::Text(" %ug %us %uc", gold, silver, copper); - else if (silver > 0) ImGui::Text(" %us %uc", silver, copper); - else ImGui::Text(" %uc", copper); + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); } } @@ -9197,7 +11357,8 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { uint32_t g = quest.requiredMoney / 10000; uint32_t s = (quest.requiredMoney % 10000) / 100; uint32_t c = quest.requiredMoney % 100; - ImGui::Text("Required money: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Required money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } // Complete / Cancel buttons @@ -9375,9 +11536,8 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { uint32_t g = quest.rewardMoney / 10000; uint32_t s = (quest.rewardMoney % 10000) / 100; uint32_t c = quest.rewardMoney % 100; - if (g > 0) ImGui::Text(" %ug %us %uc", g, s, c); - else if (s > 0) ImGui::Text(" %us %uc", s, c); - else ImGui::Text(" %uc", c); + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } } @@ -9437,7 +11597,8 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); if (vendor.canRepair) { ImGui::SameLine(); @@ -9538,9 +11699,11 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { 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(); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } ImGui::TableSetColumnIndex(3); if (!canAfford) ImGui::BeginDisabled(); char bbLabel[32]; @@ -9622,7 +11785,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImVec4 qc = InventoryScreen::getQualityColor(static_cast(info->quality)); ImGui::TextColored(qc, "%s", info->name.c_str()); if (ImGui::IsItemHovered()) { - inventoryScreen.renderItemTooltip(*info); + inventoryScreen.renderItemTooltip(*info, &gameHandler.getInventory()); } // Shift-click: insert item link into chat if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { @@ -9647,9 +11810,11 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { uint32_t s = (item.buyPrice / 100) % 100; uint32_t c = item.buyPrice % 100; bool canAfford = money >= item.buyPrice; - 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(); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } } ImGui::TableSetColumnIndex(3); @@ -9727,7 +11892,8 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); // Filter controls static bool showUnavailable = false; @@ -9882,8 +12048,11 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t s = (spell->spellCost / 100) % 100; uint32_t c = spell->spellCost % 100; bool canAfford = money >= spell->spellCost; - ImVec4 costColor = canAfford ? color : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); - ImGui::TextColored(costColor, "%ug %us %uc", g, s, c); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } } else { ImGui::TextColored(color, "Free"); } @@ -10163,13 +12332,7 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { } ImGui::TableSetColumnIndex(1); - if (gold > 0) { - ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper); - } else if (silver > 0) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), "%us %uc", silver, copper); - } else { - ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.2f, 1.0f), "%uc", copper); - } + renderCoinsText(gold, silver, copper); ImGui::TableSetColumnIndex(2); if (ImGui::SmallButton("Fly")) { @@ -10210,7 +12373,18 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { - if (!gameHandler.showDeathDialog()) return; + if (!gameHandler.showDeathDialog()) { + deathTimerRunning_ = false; + deathElapsed_ = 0.0f; + return; + } + float dt = ImGui::GetIO().DeltaTime; + if (!deathTimerRunning_) { + deathElapsed_ = 0.0f; + deathTimerRunning_ = true; + } else { + deathElapsed_ += dt; + } auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; @@ -10228,7 +12402,7 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { // "Release Spirit" dialog centered on screen float dlgW = 280.0f; - float dlgH = 100.0f; + float dlgH = 130.0f; ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); @@ -10247,6 +12421,18 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::SetCursorPosX((dlgW - textW) / 2); ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "%s", deathText); + // Respawn timer: show how long until forced release + float timeLeft = kForcedReleaseSec - deathElapsed_; + if (timeLeft > 0.0f) { + int mins = static_cast(timeLeft) / 60; + int secs = static_cast(timeLeft) % 60; + char timerBuf[48]; + snprintf(timerBuf, sizeof(timerBuf), "Release in %d:%02d", mins, secs); + float tw = ImGui::CalcTextSize(timerBuf).x; + ImGui::SetCursorPosX((dlgW - tw) / 2); + ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "%s", timerBuf); + } + ImGui::Spacing(); ImGui::Spacing(); @@ -10906,6 +13092,12 @@ void GameScreen::renderSettingsWindow() { ImGui::SameLine(); ImGui::TextDisabled("(red vignette on taking damage)"); + if (ImGui::Checkbox("Low Health Vignette", &lowHealthVignetteEnabled_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(pulsing red edges below 20%% HP)"); + ImGui::EndChild(); ImGui::EndTabItem(); } @@ -11784,6 +13976,43 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Lootable corpse dots: small yellow-green diamonds on dead, lootable units. + // Shown whenever NPC dots are enabled (or always, since they're always useful). + { + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit) continue; + // Must be dead (health == 0) and marked lootable + if (unit->getHealth() != 0) continue; + if (!(unit->getDynamicFlags() & UNIT_DYNFLAG_LOOTABLE)) continue; + + glm::vec3 npcRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(npcRender, sx, sy)) continue; + + // Draw a small diamond (rotated square) in light yellow-green + const float dr = 3.5f; + ImVec2 top (sx, sy - dr); + ImVec2 right(sx + dr, sy ); + ImVec2 bot (sx, sy + dr); + ImVec2 left (sx - dr, sy ); + drawList->AddQuadFilled(top, right, bot, left, IM_COL32(180, 230, 80, 230)); + drawList->AddQuad (top, right, bot, left, IM_COL32(60, 80, 20, 200), 1.0f); + + // Tooltip on hover + if (ImGui::IsMouseHoveringRect(ImVec2(sx - dr, sy - dr), ImVec2(sx + dr, sy + dr))) { + const std::string& nm = unit->getName(); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.3f, 1.0f), "%s", + nm.empty() ? "Lootable corpse" : nm.c_str()); + ImGui::EndTooltip(); + } + } + } + for (const auto& [guid, status] : statuses) { ImU32 dotColor; const char* marker = nullptr; @@ -11822,6 +14051,68 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { IM_COL32(0, 0, 0, 255), marker); } + // Quest kill objective markers — highlight live NPCs matching active quest kill objectives + { + // Build map of NPC entry → (quest title, current, required) for tooltips + struct KillInfo { std::string questTitle; uint32_t current = 0; uint32_t required = 0; }; + std::unordered_map killInfoMap; + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& quest : gameHandler.getQuestLog()) { + if (quest.complete) continue; + if (!trackedIds.empty() && !trackedIds.count(quest.questId)) continue; + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId <= 0 || obj.required == 0) continue; + uint32_t npcEntry = static_cast(obj.npcOrGoId); + auto it = quest.killCounts.find(npcEntry); + uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0; + if (current < obj.required) { + killInfoMap[npcEntry] = { quest.title, current, obj.required }; + } + } + } + + if (!killInfoMap.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit || unit->getHealth() == 0) continue; + auto infoIt = killInfoMap.find(unit->getEntry()); + if (infoIt == killInfoMap.end()) continue; + + glm::vec3 unitRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(unitRender, sx, sy)) continue; + + // Gold circle with a dark "x" mark — indicates a quest kill target + drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, IM_COL32(255, 185, 0, 240)); + drawList->AddCircle(ImVec2(sx, sy), 5.5f, IM_COL32(0, 0, 0, 180), 12, 1.0f); + drawList->AddLine(ImVec2(sx - 2.5f, sy - 2.5f), ImVec2(sx + 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + drawList->AddLine(ImVec2(sx + 2.5f, sy - 2.5f), ImVec2(sx - 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const auto& ki = infoIt->second; + const std::string& npcName = unit->getName(); + if (!npcName.empty()) { + ImGui::SetTooltip("%s\n%s: %u/%u", + npcName.c_str(), + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } else { + ImGui::SetTooltip("%s: %u/%u", + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } + } + } + } + } + // Gossip POI markers (quest / NPC navigation targets) for (const auto& poi : gameHandler.getGossipPois()) { // Convert WoW canonical coords to render coords for minimap projection @@ -11885,9 +14176,16 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(memberRender, sx, sy)) continue; - ImU32 dotColor = (member.guid == leaderGuid) - ? IM_COL32(255, 210, 0, 235) - : IM_COL32(100, 180, 255, 235); + ImU32 dotColor; + { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + dotColor = (cid != 0) + ? classColorU32(cid, 235) + : (member.guid == leaderGuid) + ? IM_COL32(255, 210, 0, 235) + : IM_COL32(100, 180, 255, 235); + } drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor); drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f); @@ -12005,18 +14303,111 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } - // Hover tooltip: show player's WoW coordinates (canonical X=North, Y=West) + // Persistent coordinate display below the minimap + { + glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender); + char coordBuf[32]; + std::snprintf(coordBuf, sizeof(coordBuf), "%.1f, %.1f", playerCanon.x, playerCanon.y); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, coordBuf); + + float tx = centerX - textSz.x * 0.5f; + float ty = centerY + mapRadius + 3.0f; + + // Semi-transparent dark background pill + float pad = 3.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + textSz.x + pad, ty + textSz.y + pad), + IM_COL32(0, 0, 0, 140), 4.0f); + // Coordinate text in warm yellow + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); + } + + // Zone name display — drawn inside the top edge of the minimap circle + { + auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; + uint32_t zoneId = gameHandler.getWorldStateZoneId(); + const game::ZoneInfo* zi = (zmRenderer && zoneId != 0) ? zmRenderer->getZoneInfo(zoneId) : nullptr; + if (zi && !zi->name.empty()) { + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, zi->name.c_str()); + float tx = centerX - ts.x * 0.5f; + float ty = centerY - mapRadius + 4.0f; // just inside top edge of the circle + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + IM_COL32(0, 0, 0, 160), 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, 180), zi->name.c_str()); + drawList->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 230, 150, 220), zi->name.c_str()); + } + } + + // Hover tooltip and right-click context menu { 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); + bool overMinimap = (mdx * mdx + mdy * mdy <= mapRadius * mapRadius); + + if (overMinimap) { 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"); + // Compute the world coordinate under the mouse cursor + // Inverse of projectToMinimap: pixel offset → world offset in render space → canonical + float rxW = mdx / mapRadius * viewRadius; + float ryW = mdy / mapRadius * viewRadius; + // Un-rotate: [dx, dy] = R^-1 * [rxW, ryW] + // where R applied: rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + float hoverDx = -cosB * rxW + sinB * ryW; + float hoverDy = -sinB * rxW - cosB * ryW; + glm::vec3 hoverRender(playerRender.x + hoverDx, playerRender.y + hoverDy, playerRender.z); + glm::vec3 hoverCanon = core::coords::renderToCanonical(hoverRender); + ImGui::TextColored(ImVec4(0.9f, 0.85f, 0.5f, 1.0f), "%.1f, %.1f", hoverCanon.x, hoverCanon.y); + ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "Ctrl+click to ping"); ImGui::EndTooltip(); + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup("##minimapContextMenu"); + } + } + + if (ImGui::BeginPopup("##minimapContextMenu")) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Minimap"); + ImGui::Separator(); + + // Zoom controls + if (ImGui::MenuItem("Zoom In")) { + minimap->zoomIn(); + } + if (ImGui::MenuItem("Zoom Out")) { + minimap->zoomOut(); + } + + ImGui::Separator(); + + // Toggle options with checkmarks + bool rotWithCam = minimap->isRotateWithCamera(); + if (ImGui::MenuItem("Rotate with Camera", nullptr, rotWithCam)) { + minimap->setRotateWithCamera(!rotWithCam); + } + + bool squareShape = minimap->isSquareShape(); + if (ImGui::MenuItem("Square Shape", nullptr, squareShape)) { + minimap->setSquareShape(!squareShape); + } + + bool npcDots = minimapNpcDots_; + if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) { + minimapNpcDots_ = !minimapNpcDots_; + } + + ImGui::EndPopup(); } } @@ -12064,12 +14455,40 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { auto* fgDl = ImGui::GetForegroundDrawList(); float zoneTextY = centerY - mapRadius - 16.0f; ImFont* font = ImGui::GetFont(); - ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + + // Weather icon appended to zone name when active + uint32_t wType = gameHandler.getWeatherType(); + float wIntensity = gameHandler.getWeatherIntensity(); + const char* weatherIcon = nullptr; + ImU32 weatherColor = IM_COL32(255, 255, 255, 200); + if (wType == 1 && wIntensity > 0.05f) { // Rain + weatherIcon = " \xe2\x9b\x86"; // U+26C6 ⛆ + weatherColor = IM_COL32(140, 180, 240, 220); + } else if (wType == 2 && wIntensity > 0.05f) { // Snow + weatherIcon = " \xe2\x9d\x84"; // U+2744 ❄ + weatherColor = IM_COL32(210, 230, 255, 220); + } else if (wType == 3 && wIntensity > 0.05f) { // Storm/Fog + weatherIcon = " \xe2\x98\x81"; // U+2601 ☁ + weatherColor = IM_COL32(160, 160, 190, 220); + } + + std::string displayName = zoneName; + // Build combined string if weather active + std::string fullLabel = weatherIcon ? (zoneName + weatherIcon) : zoneName; + ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, fullLabel.c_str()); float tzx = centerX - tsz.x * 0.5f; + + // Shadow pass fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), IM_COL32(0, 0, 0, 180), zoneName.c_str()); + // Zone name in gold fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), IM_COL32(255, 220, 120, 230), zoneName.c_str()); + // Weather symbol in its own color appended after + if (weatherIcon) { + ImVec2 nameSz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + fgDl->AddText(font, 12.0f, ImVec2(tzx + nameSz.x, zoneTextY), weatherColor, weatherIcon); + } } } @@ -12206,6 +14625,24 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { nextIndicatorY += kIndicatorH; } + // Unspent talent points indicator + { + uint8_t unspent = gameHandler.getUnspentTalentPoints(); + if (unspent > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TalentIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.5f); + char talentBuf[40]; + snprintf(talentBuf, sizeof(talentBuf), "! %u Talent Point%s Available", + static_cast(unspent), unspent == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f * pulse, pulse), "%s", talentBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + // BG queue status indicator (when in queue but not yet invited) for (const auto& slot : gameHandler.getBgQueues()) { if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only @@ -12508,7 +14945,9 @@ void GameScreen::renderChatBubbles(game::GameHandler& gameHandler) { glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); float screenX = (ndc.x * 0.5f + 0.5f) * screenW; - float screenY = (1.0f - (ndc.y * 0.5f + 0.5f)) * screenH; // Flip Y + // Camera bakes the Vulkan Y-flip into the projection matrix: + // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. + float screenY = (ndc.y * 0.5f + 0.5f) * screenH; // Skip if off-screen if (screenX < -200.0f || screenX > screenW + 200.0f || @@ -12579,6 +15018,7 @@ void GameScreen::saveSettings() { out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n"; out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n"; out << "damage_flash=" << (damageFlashEnabled_ ? 1 : 0) << "\n"; + out << "low_health_vignette=" << (lowHealthVignetteEnabled_ ? 1 : 0) << "\n"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -12700,6 +15140,8 @@ void GameScreen::loadSettings() { pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } else if (key == "damage_flash") { damageFlashEnabled_ = (std::stoi(val) != 0); + } else if (key == "low_health_vignette") { + lowHealthVignetteEnabled_ = (std::stoi(val) != 0); } // Audio else if (key == "sound_muted") { @@ -12801,7 +15243,8 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); ImGui::SameLine(ImGui::GetWindowWidth() - 100); if (ImGui::Button("Compose")) { mailRecipientBuffer_[0] = '\0'; @@ -12856,6 +15299,21 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), " [A]"); } + // Expiry warning if within 3 days + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + if (secsLeft < 3.0f * 86400.0f && secsLeft > 0.0f) { + ImGui::SameLine(); + int daysLeft = static_cast(secsLeft / 86400.0f); + if (daysLeft == 0) { + ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), " [expires today!]"); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), + " [expires in %dd]", daysLeft); + } + } + } ImGui::PopID(); } @@ -12876,6 +15334,35 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { if (mail.messageType == 2) { ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.2f, 1.0f), "[Auction House]"); } + + // Show expiry date in the detail panel + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + // Format absolute expiry as a date using struct tm + time_t expT = static_cast(mail.expirationTime); + struct tm* tmExp = std::localtime(&expT); + if (tmExp) { + static const char* kMon[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + const char* mname = kMon[tmExp->tm_mon]; + int daysLeft = static_cast(secsLeft / 86400.0f); + if (secsLeft <= 0.0f) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + "Expired: %s %d, %d", mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } else if (secsLeft < 3.0f * 86400.0f) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Expires: %s %d, %d (%d day%s!)", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year, + daysLeft, daysLeft == 1 ? "" : "s"); + } else { + ImGui::TextDisabled("Expires: %s %d, %d", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } + } + } ImGui::Separator(); // Body text @@ -12889,7 +15376,8 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { uint32_t g = mail.money / 10000; uint32_t s = (mail.money / 100) % 100; uint32_t c = mail.money % 100; - ImGui::Text("Money: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); ImGui::SameLine(); if (ImGui::SmallButton("Take Money")) { gameHandler.mailTakeMoney(mail.messageId); @@ -13385,9 +15873,8 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { uint32_t gold = static_cast(data.money / 10000); uint32_t silver = static_cast((data.money / 100) % 100); uint32_t copper = static_cast(data.money % 100); - ImGui::Text("Guild Bank Money: "); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper); + ImGui::TextDisabled("Guild Bank Money:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); // Tab bar if (!data.tabs.empty()) { @@ -13770,13 +16257,13 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(3); { uint32_t bid = auction.currentBid > 0 ? auction.currentBid : auction.startBid; - ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100); + renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(4); if (auction.buyoutPrice > 0) { - ImGui::Text("%ug%us%uc", auction.buyoutPrice / 10000, - (auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100); + renderCoinsText(auction.buyoutPrice / 10000, + (auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100); } else { ImGui::TextDisabled("--"); } @@ -13950,10 +16437,10 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); - ImGui::Text("%ug%us%uc", a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100); + renderCoinsText(a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100); ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) - ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); + renderCoinsText(a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); else ImGui::TextDisabled("--"); ImGui::TableSetColumnIndex(4); @@ -14026,11 +16513,11 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(2); { uint32_t bid = a.currentBid > 0 ? a.currentBid : a.startBid; - ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100); + renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) - ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); + renderCoinsText(a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); else ImGui::TextDisabled("--"); ImGui::TableSetColumnIndex(4); @@ -14340,6 +16827,18 @@ 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:"); + const std::string& bootTarget = gameHandler.getLfgBootTargetName(); + const std::string& bootReason = gameHandler.getLfgBootReason(); + if (!bootTarget.empty()) { + ImGui::Text("Player: "); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", bootTarget.c_str()); + } + if (!bootReason.empty()) { + ImGui::Text("Reason: "); + ImGui::SameLine(); + ImGui::TextWrapped("%s", bootReason.c_str()); + } uint32_t bootVotes = gameHandler.getLfgBootVotes(); uint32_t bootTotal = gameHandler.getLfgBootTotal(); uint32_t bootNeeded = gameHandler.getLfgBootNeeded(); @@ -14696,6 +17195,321 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ─── Who Results Window ─────────────────────────────────────────────────────── +void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { + if (!showWhoWindow_) return; + + const auto& results = gameHandler.getWhoResults(); + + ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(200, 180), ImGuiCond_FirstUseEver); + + char title[64]; + uint32_t onlineCount = gameHandler.getWhoOnlineCount(); + if (onlineCount > 0) + snprintf(title, sizeof(title), "Players Online: %u###WhoWindow", onlineCount); + else + snprintf(title, sizeof(title), "Who###WhoWindow"); + + if (!ImGui::Begin(title, &showWhoWindow_)) { + ImGui::End(); + return; + } + + // Search bar with Send button + static char whoSearchBuf[64] = {}; + bool doSearch = false; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 60.0f); + if (ImGui::InputTextWithHint("##whosearch", "Search players...", whoSearchBuf, sizeof(whoSearchBuf), + ImGuiInputTextFlags_EnterReturnsTrue)) + doSearch = true; + ImGui::SameLine(); + if (ImGui::Button("Search", ImVec2(-1, 0))) + doSearch = true; + if (doSearch) { + gameHandler.queryWho(std::string(whoSearchBuf)); + } + ImGui::Separator(); + + if (results.empty()) { + ImGui::TextDisabled("No results. Type a filter above or use /who [filter]."); + ImGui::End(); + return; + } + + // Table: Name | Guild | Level | Class | Zone + if (ImGui::BeginTable("##WhoTable", 5, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, + ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.22f); + ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 0.28f); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < results.size(); ++i) { + const auto& e = results[i]; + ImGui::TableNextRow(); + ImGui::PushID(static_cast(i)); + + // Name (class-colored if class is known) + ImGui::TableSetColumnIndex(0); + uint8_t cid = static_cast(e.classId); + ImVec4 nameCol = classColorVec4(cid); + ImGui::TextColored(nameCol, "%s", e.name.c_str()); + + // Right-click context menu on the name + if (ImGui::BeginPopupContextItem("##WhoCtx")) { + ImGui::TextDisabled("%s", e.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, e.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(e.name); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(e.name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(e.name); + ImGui::EndPopup(); + } + + // Guild + ImGui::TableSetColumnIndex(1); + if (!e.guildName.empty()) + ImGui::TextDisabled("<%s>", e.guildName.c_str()); + + // Level + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", e.level); + + // Class + ImGui::TableSetColumnIndex(3); + const char* className = game::getClassName(static_cast(e.classId)); + ImGui::TextColored(nameCol, "%s", className); + + // Zone + ImGui::TableSetColumnIndex(4); + if (e.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(e.zoneId); + ImGui::TextUnformatted(zoneName.empty() ? "Unknown" : zoneName.c_str()); + } + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + + ImGui::End(); +} + +// ─── Combat Log Window ──────────────────────────────────────────────────────── +void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { + if (!showCombatLog_) return; + + const auto& log = gameHandler.getCombatLog(); + + ImGui::SetNextWindowSize(ImVec2(520, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(160, 200), ImGuiCond_FirstUseEver); + + char title[64]; + snprintf(title, sizeof(title), "Combat Log (%zu)###CombatLog", log.size()); + if (!ImGui::Begin(title, &showCombatLog_)) { + ImGui::End(); + return; + } + + // Filter toggles + static bool filterDamage = true; + static bool filterHeal = true; + static bool filterMisc = true; + static bool autoScroll = true; + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 2)); + ImGui::Checkbox("Damage", &filterDamage); ImGui::SameLine(); + ImGui::Checkbox("Healing", &filterHeal); ImGui::SameLine(); + ImGui::Checkbox("Misc", &filterMisc); ImGui::SameLine(); + ImGui::Checkbox("Auto-scroll", &autoScroll); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 40.0f); + if (ImGui::SmallButton("Clear")) + gameHandler.clearCombatLog(); + ImGui::PopStyleVar(); + ImGui::Separator(); + + // Helper: categorize entry + auto isDamageType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || + t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || + t == T::ENVIRONMENTAL; + }; + auto isHealType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::HEAL || t == T::CRIT_HEAL || t == T::PERIODIC_HEAL; + }; + + // Two-column table: Time | Event description + ImGuiTableFlags tableFlags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingFixedFit; + float availH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##CombatLogTable", 2, tableFlags, ImVec2(0.0f, availH))) { + ImGui::TableSetupScrollFreeze(0, 0); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("Event", ImGuiTableColumnFlags_WidthStretch); + + for (const auto& e : log) { + // Apply filters + bool isDmg = isDamageType(e.type); + bool isHeal = isHealType(e.type); + bool isMisc = !isDmg && !isHeal; + if (isDmg && !filterDamage) continue; + if (isHeal && !filterHeal) continue; + if (isMisc && !filterMisc) continue; + + // Format timestamp as HH:MM:SS + char timeBuf[10]; + { + struct tm* tm_info = std::localtime(&e.timestamp); + if (tm_info) + snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", + tm_info->tm_hour, tm_info->tm_min, tm_info->tm_sec); + else + snprintf(timeBuf, sizeof(timeBuf), "--:--:--"); + } + + // Build event description and choose color + char desc[256]; + ImVec4 color; + using T = game::CombatTextEntry; + const char* src = e.sourceName.empty() ? (e.isPlayerSource ? "You" : "?") : e.sourceName.c_str(); + const char* tgt = e.targetName.empty() ? "?" : e.targetName.c_str(); + const std::string& spellName = (e.spellId != 0) ? gameHandler.getSpellName(e.spellId) : std::string(); + const char* spell = spellName.empty() ? nullptr : spellName.c_str(); + + switch (e.type) { + case T::MELEE_DAMAGE: + snprintf(desc, sizeof(desc), "%s hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + break; + case T::CRIT_DAMAGE: + snprintf(desc, sizeof(desc), "%s crits %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 1.0f, 0.0f, 1.0f) : ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + break; + case T::SPELL_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s hits %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's spell hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + break; + case T::PERIODIC_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s ticks %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's DoT ticks %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.9f, 0.7f, 0.3f, 1.0f) : ImVec4(0.9f, 0.3f, 0.3f, 1.0f); + break; + case T::HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s heals %s for %d (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s heals %s for %d", src, tgt, e.amount); + color = ImVec4(0.4f, 1.0f, 0.4f, 1.0f); + break; + case T::CRIT_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s critically heals %s for %d! (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s critically heals %s for %d!", src, tgt, e.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + break; + case T::PERIODIC_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s heals %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's HoT heals %s for %d", src, tgt, e.amount); + color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); + break; + case T::MISS: + snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::DODGE: + snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::PARRY: + snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::BLOCK: + snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); + color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); + break; + case T::IMMUNE: + snprintf(desc, sizeof(desc), "%s is immune", tgt); + color = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + break; + case T::ABSORB: + snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); + break; + case T::RESIST: + snprintf(desc, sizeof(desc), "%d resisted", e.amount); + color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); + break; + case T::ENVIRONMENTAL: + snprintf(desc, sizeof(desc), "Environmental damage: %d", e.amount); + color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); + break; + case T::ENERGIZE: + if (spell) + snprintf(desc, sizeof(desc), "%s gains %d power (%s)", tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s gains %d power", tgt, e.amount); + color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); + break; + case T::XP_GAIN: + snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); + color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); + break; + case T::PROC_TRIGGER: + if (spell) + snprintf(desc, sizeof(desc), "%s procs!", spell); + else + snprintf(desc, sizeof(desc), "Proc triggered"); + color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); + break; + default: + snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); + color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + break; + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("%s", timeBuf); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(color, "%s", desc); + } + + // Auto-scroll to bottom + if (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) + ImGui::SetScrollHereY(1.0f); + + ImGui::EndTable(); + } + + ImGui::End(); +} + // ─── Achievement Window ─────────────────────────────────────────────────────── void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { if (!showAchievementWindow_) return; @@ -14745,7 +17559,22 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { ImGui::TextUnformatted(display.c_str()); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); - ImGui::Text("ID: %u", id); + ImGui::Text("Achievement ID: %u", id); + uint32_t packed = gameHandler.getAchievementDate(id); + if (packed != 0) { + // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] + int minute = (packed >> 3) & 0x3F; + int hour = (packed >> 9) & 0x1F; + int day = (packed >> 17) & 0x1F; + int month = (packed >> 21) & 0x0F; + int year = ((packed >> 25) & 0x7F) + 2000; + static const char* kMonths[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + const char* mname = (month >= 1 && month <= 12) ? kMonths[month - 1] : "?"; + ImGui::Text("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); + } ImGui::EndTooltip(); } ImGui::PopID(); @@ -14853,10 +17682,38 @@ void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { uint32_t maxThreat = list->front().threat; + // Pre-scan to find the player's rank and threat percentage + uint64_t playerGuid = gameHandler.getPlayerGuid(); + int playerRank = 0; + float playerPct = 0.0f; + { + int scan = 0; + for (const auto& e : *list) { + ++scan; + if (e.victimGuid == playerGuid) { + playerRank = scan; + playerPct = (maxThreat > 0) ? static_cast(e.threat) / static_cast(maxThreat) : 0.0f; + break; + } + if (scan >= 10) break; + } + } + + // Status bar: aggro alert or rank summary + if (playerRank == 1) { + // Player has aggro — persistent red warning + ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.25f, 1.0f), "!! YOU HAVE AGGRO !!"); + } else if (playerRank > 1 && playerPct >= 0.8f) { + // Close to pulling — pulsing warning + float pulse = 0.55f + 0.45f * sinf(static_cast(ImGui::GetTime()) * 5.0f); + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.1f, pulse), "! PULLING AGGRO (%.0f%%) !", playerPct * 100.0f); + } else if (playerRank > 0) { + ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "You: #%d %.0f%% threat", playerRank, playerPct * 100.0f); + } + ImGui::TextDisabled("%-19s Threat", "Player"); ImGui::Separator(); - uint64_t playerGuid = gameHandler.getPlayerGuid(); int rank = 0; for (const auto& entry : *list) { ++rank; @@ -14902,6 +17759,139 @@ void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { ImGui::End(); } +// ─── BG Scoreboard ──────────────────────────────────────────────────────────── +void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { + if (!showBgScoreboard_) return; + + const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard(); + + ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); + + const char* title = "Battleground Score###BgScore"; + if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!data) { + ImGui::TextDisabled("No score data yet."); + ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground."); + ImGui::End(); + return; + } + + // Winner banner + if (data->hasWinner) { + const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; + ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f) + : ImVec4(1.0f, 0.35f, 0.35f, 1.0f); + float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); + ImGui::TextColored(winnerColor, "%s", winnerStr); + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Victory!"); + ImGui::Separator(); + } + + // Refresh button + if (ImGui::SmallButton("Refresh")) { + gameHandler.requestPvpLog(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu players", data->players.size()); + + // Score table + constexpr ImGuiTableFlags kTableFlags = + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV | + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable; + + // Build dynamic column count based on what BG-specific stats are present + int numBgCols = 0; + std::vector bgColNames; + for (const auto& ps : data->players) { + for (const auto& [fieldName, val] : ps.bgStats) { + // Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps") + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + bool found = false; + for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } } + if (!found) bgColNames.push_back(shortName); + } + } + numBgCols = static_cast(bgColNames.size()); + + // Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific + int totalCols = 6 + numBgCols; + float tableH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f); + for (const auto& col : bgColNames) + ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableHeadersRow(); + + // Sort: Alliance first, then Horde; within each team by KB desc + std::vector sorted; + sorted.reserve(data->players.size()); + for (const auto& ps : data->players) sorted.push_back(&ps); + std::stable_sort(sorted.begin(), sorted.end(), + [](const game::GameHandler::BgPlayerScore* a, + const game::GameHandler::BgPlayerScore* b) { + if (a->team != b->team) return a->team > b->team; // Alliance(1) first + return a->killingBlows > b->killingBlows; + }); + + uint64_t playerGuid = gameHandler.getPlayerGuid(); + for (const auto* ps : sorted) { + ImGui::TableNextRow(); + + // Team + ImGui::TableNextColumn(); + if (ps->team == 1) + ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "Alliance"); + else + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Horde"); + + // Name (highlight player's own row) + ImGui::TableNextColumn(); + bool isSelf = (ps->guid == playerGuid); + if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str(); + ImGui::TextUnformatted(nameStr); + if (isSelf) ImGui::PopStyleColor(); + + ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor); + + for (const auto& col : bgColNames) { + ImGui::TableNextColumn(); + uint32_t val = 0; + for (const auto& [fieldName, fval] : ps->bgStats) { + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + if (shortName == col) { val = fval; break; } + } + if (val > 0) ImGui::Text("%u", val); + else ImGui::TextDisabled("-"); + } + } + ImGui::EndTable(); + } + + ImGui::End(); +} + // ─── Quest Objective Tracker ────────────────────────────────────────────────── void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { if (gameHandler.getState() != game::WorldState::IN_WORLD) return; @@ -15032,10 +18022,19 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { 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(); + // Player name — class-colored if entity is loaded, else gold + { + auto ent = gameHandler.getEntityManager().getEntity(result->guid); + uint8_t cid = entityClassId(ent.get()); + ImVec4 nameColor = (cid != 0) ? classColorVec4(cid) : ImVec4(1.0f, 0.82f, 0.0f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::Text("%s", result->playerName.c_str()); + ImGui::PopStyleColor(); + if (cid != 0) { + ImGui::SameLine(); + ImGui::TextColored(classColorVec4(cid), "(%s)", classNameStr(cid)); + } + } ImGui::SameLine(); ImGui::TextDisabled(" %u talent pts", result->totalTalents); if (result->unspentTalents > 0) { @@ -15059,7 +18058,29 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::TextDisabled("Equipment data not yet available."); ImGui::TextDisabled("(Gear loads after the player is inspected in-range)"); } else { + // Average item level (only slots that have loaded info and are not shirt/tabard) + // Shirt=slot3, Tabard=slot18 — excluded from gear score by WoW convention + uint32_t iLevelSum = 0; + int iLevelCount = 0; + for (int s = 0; s < 19; ++s) { + if (s == 3 || s == 18) continue; // shirt, tabard + uint32_t entry = result->itemEntries[s]; + if (entry == 0) continue; + const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); + if (info && info->valid && info->itemLevel > 0) { + iLevelSum += info->itemLevel; + ++iLevelCount; + } + } + if (iLevelCount > 0) { + float avgIlvl = static_cast(iLevelSum) / static_cast(iLevelCount); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Avg iLvl: %.1f", avgIlvl); + ImGui::SameLine(); + ImGui::TextDisabled("(%d/%d slots loaded)", iLevelCount, + [&]{ int c=0; for(int s=0;s<19;++s){if(s==3||s==18)continue;if(result->itemEntries[s])++c;} return c; }()); + } if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { + constexpr float kIconSz = 28.0f; for (int s = 0; s < 19; ++s) { uint32_t entry = result->itemEntries[s]; if (entry == 0) continue; @@ -15067,24 +18088,56 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); if (!info) { gameHandler.ensureItemInfo(entry); + ImGui::PushID(s); ImGui::TextDisabled("[%s] (loading…)", kSlotNames[s]); + ImGui::PopID(); continue; } - ImGui::TextDisabled("%s", kSlotNames[s]); - ImGui::SameLine(90); + ImGui::PushID(s); 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(); + uint16_t enchantId = result->enchantIds[s]; + + // Item icon + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(kIconSz, kIconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(1,1,1,1), qColor); + } else { + ImGui::GetWindowDrawList()->AddRectFilled( + ImGui::GetCursorScreenPos(), + ImVec2(ImGui::GetCursorScreenPos().x + kIconSz, + ImGui::GetCursorScreenPos().y + kIconSz), + IM_COL32(40, 40, 50, 200)); + ImGui::Dummy(ImVec2(kIconSz, kIconSz)); } + bool hovered = ImGui::IsItemHovered(); + + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (kIconSz - ImGui::GetTextLineHeight()) * 0.5f); + ImGui::BeginGroup(); + ImGui::TextDisabled("%s", kSlotNames[s]); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Enchant indicator on the same row as the name + if (enchantId != 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); // UTF-8 ✦ + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); + } + ImGui::EndGroup(); + hovered = hovered || ImGui::IsItemHovered(); + + if (hovered && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered) { + ImGui::SetTooltip("%s", info->name.c_str()); + } + + ImGui::PopID(); + ImGui::Spacing(); } } ImGui::EndChild(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index e5735977..e2075f09 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -72,6 +72,21 @@ const game::ItemSlot* findComparableEquipped(const game::Inventory& inventory, u default: return nullptr; } } + +void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); +} } // namespace InventoryScreen::~InventoryScreen() { @@ -2197,7 +2212,8 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I uint32_t g = item.sellPrice / 10000; uint32_t s = (item.sellPrice / 100) % 100; uint32_t c = item.sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } // Shift-hover comparison with currently equipped equivalent. @@ -2321,7 +2337,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I // --------------------------------------------------------------------------- // Tooltip overload for ItemQueryResponseData (used by loot window, etc.) // --------------------------------------------------------------------------- -void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) { +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory) { ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(static_cast(info.quality)); @@ -2477,7 +2493,50 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) uint32_t g = info.sellPrice / 10000; uint32_t s = (info.sellPrice / 100) % 100; uint32_t c = info.sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); + } + + // Shift-hover: compare with currently equipped item + if (inventory && ImGui::GetIO().KeyShift && info.inventoryType > 0) { + if (const game::ItemSlot* eq = findComparableEquipped(*inventory, static_cast(info.inventoryType))) { + ImGui::Separator(); + ImGui::TextDisabled("Equipped:"); + VkDescriptorSet eqIcon = getItemIcon(eq->item.displayInfoId); + if (eqIcon) { ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); ImGui::SameLine(); } + ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + + auto showDiff = [](const char* label, float nv, float ev) { + if (nv == 0.0f && ev == 0.0f) return; + float diff = nv - ev; + char buf[96]; + if (diff > 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, nv, diff); ImGui::TextColored(ImVec4(0.0f,1.0f,0.0f,1.0f), "%s", buf); } + else if (diff < 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, nv, -diff); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "%s", buf); } + else { std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, nv); ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", buf); } + }; + + float ilvlDiff = static_cast(info.itemLevel) - static_cast(eq->item.itemLevel); + if (info.itemLevel > 0 || eq->item.itemLevel > 0) { + char ilvlBuf[64]; + if (ilvlDiff > 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", info.itemLevel, ilvlDiff); + else if (ilvlDiff < 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", info.itemLevel, -ilvlDiff); + else std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", info.itemLevel); + ImVec4 ic = ilvlDiff > 0 ? ImVec4(0,1,0,1) : ilvlDiff < 0 ? ImVec4(1,0.3f,0.3f,1) : ImVec4(0.7f,0.7f,0.7f,1); + ImGui::TextColored(ic, "%s", ilvlBuf); + } + + showDiff("Armor", static_cast(info.armor), static_cast(eq->item.armor)); + showDiff("Str", static_cast(info.strength), static_cast(eq->item.strength)); + showDiff("Agi", static_cast(info.agility), static_cast(eq->item.agility)); + showDiff("Sta", static_cast(info.stamina), static_cast(eq->item.stamina)); + showDiff("Int", static_cast(info.intellect), static_cast(eq->item.intellect)); + showDiff("Spi", static_cast(info.spirit), static_cast(eq->item.spirit)); + + // Hint text + ImGui::TextDisabled("Hold Shift to compare"); + } + } else if (info.inventoryType > 0) { + ImGui::TextDisabled("Hold Shift to compare"); } ImGui::EndTooltip(); diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 81f8657d..c343baa5 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -205,6 +205,21 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { if (s.size() > 72) s = s.substr(0, 72) + "..."; return s; } + +void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); +} } // anonymous namespace void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) { @@ -362,6 +377,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) { gameHandler.setQuestTracked(q.questId, !tracked); } + if (gameHandler.isInGroup() && !q.complete) { + if (ImGui::MenuItem("Share Quest")) { + gameHandler.shareQuestWithParty(q.questId); + } + } if (!q.complete) { ImGui::Separator(); if (ImGui::MenuItem("Abandon Quest")) { @@ -488,12 +508,7 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv 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); + renderCoinsText(rg, rs, rc); } // Guaranteed reward items @@ -549,12 +564,19 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv } } - // Track / Abandon buttons + // Track / Share / Abandon buttons ImGui::Separator(); bool isTracked = gameHandler.isQuestTracked(sel.questId); if (ImGui::Button(isTracked ? "Untrack" : "Track", ImVec2(100.0f, 0.0f))) { gameHandler.setQuestTracked(sel.questId, !isTracked); } + if (gameHandler.isInGroup() && !sel.complete) { + ImGui::SameLine(); + if (ImGui::Button("Share", ImVec2(80.0f, 0.0f))) { + gameHandler.shareQuestWithParty(sel.questId); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Share this quest with your party"); + } if (!sel.complete) { ImGui::SameLine(); if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) { diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index e2c81756..8c78ab7d 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -203,6 +203,29 @@ std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetMa return {}; } +uint32_t SpellbookScreen::getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager) { + if (!dbcLoadAttempted) { + loadSpellDBC(assetManager); + } + auto it = spellData.find(spellId); + if (it != spellData.end()) return it->second.rangeIndex; + return 0; +} + +void SpellbookScreen::getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager, + uint32_t& outCost, uint32_t& outPowerType) { + outCost = 0; + outPowerType = 0; + if (!dbcLoadAttempted) { + loadSpellDBC(assetManager); + } + auto it = spellData.find(spellId); + if (it != spellData.end()) { + outCost = it->second.manaCost; + outPowerType = it->second.powerType; + } +} + void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { if (iconDbLoaded) return; iconDbLoaded = true;