diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index b99159a6..26ac235e 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -31,6 +31,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, + "CharTitles": { "ID": 0, "Title": 2, "TitleBit": 20 }, "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, @@ -97,5 +98,18 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, "Name": 8 + }, + "ItemSet": { + "ID": 0, "Name": 1, + "Item0": 18, "Item1": 19, "Item2": 20, "Item3": 21, "Item4": 22, + "Item5": 23, "Item6": 24, "Item7": 25, "Item8": 26, "Item9": 27, + "Spell0": 28, "Spell1": 29, "Spell2": 30, "Spell3": 31, "Spell4": 32, + "Spell5": 33, "Spell6": 34, "Spell7": 35, "Spell8": 36, "Spell9": 37, + "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, + "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, + "Threshold8": 46, "Threshold9": 47 } } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index 3b06971d..c5a3948e 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -95,5 +95,18 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, "Name": 8 + }, + "ItemSet": { + "ID": 0, "Name": 1, + "Item0": 10, "Item1": 11, "Item2": 12, "Item3": 13, "Item4": 14, + "Item5": 15, "Item6": 16, "Item7": 17, "Item8": 18, "Item9": 19, + "Spell0": 20, "Spell1": 21, "Spell2": 22, "Spell3": 23, "Spell4": 24, + "Spell5": 25, "Spell6": 26, "Spell7": 27, "Spell8": 28, "Spell9": 29, + "Threshold0": 30, "Threshold1": 31, "Threshold2": 32, "Threshold3": 33, + "Threshold4": 34, "Threshold5": 35, "Threshold6": 36, "Threshold7": 37, + "Threshold8": 38, "Threshold9": 39 } } diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 94f7df4d..de137ad8 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -31,7 +31,9 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "Achievement": { "ID": 0, "Title": 4, "Description": 21 }, + "CharTitles": { "ID": 0, "Title": 2, "TitleBit": 36 }, + "Achievement": { "ID": 0, "Title": 4, "Description": 21, "Points": 39 }, + "AchievementCriteria": { "ID": 0, "AchievementID": 1, "Quantity": 4, "Description": 9 }, "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, @@ -98,5 +100,18 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, "Name": 8 + }, + "ItemSet": { + "ID": 0, "Name": 1, + "Item0": 18, "Item1": 19, "Item2": 20, "Item3": 21, "Item4": 22, + "Item5": 23, "Item6": 24, "Item7": 25, "Item8": 26, "Item9": 27, + "Spell0": 28, "Spell1": 29, "Spell2": 30, "Spell3": 31, "Spell4": 32, + "Spell5": 33, "Spell6": 34, "Spell7": 35, "Spell8": 36, "Spell9": 37, + "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, + "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, + "Threshold8": 46, "Threshold9": 47 } } diff --git a/assets/shaders/fxaa.frag.glsl b/assets/shaders/fxaa.frag.glsl new file mode 100644 index 00000000..df35aaa0 --- /dev/null +++ b/assets/shaders/fxaa.frag.glsl @@ -0,0 +1,132 @@ +#version 450 + +// FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass. +// Reads the resolved scene color and outputs a smoothed result. +// Push constant: rcpFrame = vec2(1/width, 1/height). + +layout(set = 0, binding = 0) uniform sampler2D uScene; + +layout(location = 0) in vec2 TexCoord; +layout(location = 0) out vec4 outColor; + +layout(push_constant) uniform PC { + vec2 rcpFrame; +} pc; + +// Quality tuning +#define FXAA_EDGE_THRESHOLD (1.0/8.0) // minimum edge contrast to process +#define FXAA_EDGE_THRESHOLD_MIN (1.0/24.0) // ignore very dark regions +#define FXAA_SEARCH_STEPS 12 +#define FXAA_SEARCH_THRESHOLD (1.0/4.0) +#define FXAA_SUBPIX 0.75 +#define FXAA_SUBPIX_TRIM (1.0/4.0) +#define FXAA_SUBPIX_TRIM_SCALE (1.0/(1.0 - FXAA_SUBPIX_TRIM)) +#define FXAA_SUBPIX_CAP (3.0/4.0) + +float luma(vec3 c) { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + +void main() { + vec2 uv = TexCoord; + vec2 rcp = pc.rcpFrame; + + // --- Centre and cardinal neighbours --- + vec3 rgbM = texture(uScene, uv).rgb; + vec3 rgbN = texture(uScene, uv + vec2( 0.0, -1.0) * rcp).rgb; + vec3 rgbS = texture(uScene, uv + vec2( 0.0, 1.0) * rcp).rgb; + vec3 rgbE = texture(uScene, uv + vec2( 1.0, 0.0) * rcp).rgb; + vec3 rgbW = texture(uScene, uv + vec2(-1.0, 0.0) * rcp).rgb; + + float lumaN = luma(rgbN); + float lumaS = luma(rgbS); + float lumaE = luma(rgbE); + float lumaW = luma(rgbW); + float lumaM = luma(rgbM); + + float lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaE, lumaW))); + float lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaE, lumaW))); + float range = lumaMax - lumaMin; + + // Early exit on smooth regions + if (range < max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD)) { + outColor = vec4(rgbM, 1.0); + return; + } + + // --- Diagonal neighbours --- + vec3 rgbNW = texture(uScene, uv + vec2(-1.0, -1.0) * rcp).rgb; + vec3 rgbNE = texture(uScene, uv + vec2( 1.0, -1.0) * rcp).rgb; + vec3 rgbSW = texture(uScene, uv + vec2(-1.0, 1.0) * rcp).rgb; + vec3 rgbSE = texture(uScene, uv + vec2( 1.0, 1.0) * rcp).rgb; + + float lumaNW = luma(rgbNW); + float lumaNE = luma(rgbNE); + float lumaSW = luma(rgbSW); + float lumaSE = luma(rgbSE); + + // --- Sub-pixel blend factor --- + float lumaL = (lumaN + lumaS + lumaE + lumaW) * 0.25; + float rangeL = abs(lumaL - lumaM); + float blendL = max(0.0, (rangeL / range) - FXAA_SUBPIX_TRIM) * FXAA_SUBPIX_TRIM_SCALE; + blendL = min(FXAA_SUBPIX_CAP, blendL) * FXAA_SUBPIX; + + // --- Edge orientation (horizontal vs. vertical) --- + float edgeHorz = + abs(-2.0*lumaW + lumaNW + lumaSW) + + 2.0*abs(-2.0*lumaM + lumaN + lumaS) + + abs(-2.0*lumaE + lumaNE + lumaSE); + float edgeVert = + abs(-2.0*lumaS + lumaSW + lumaSE) + + 2.0*abs(-2.0*lumaM + lumaW + lumaE) + + abs(-2.0*lumaN + lumaNW + lumaNE); + + bool horzSpan = (edgeHorz >= edgeVert); + float lengthSign = horzSpan ? rcp.y : rcp.x; + + float luma1 = horzSpan ? lumaN : lumaW; + float luma2 = horzSpan ? lumaS : lumaE; + float grad1 = abs(luma1 - lumaM); + float grad2 = abs(luma2 - lumaM); + lengthSign = (grad1 >= grad2) ? -lengthSign : lengthSign; + + // --- Edge search --- + vec2 posB = uv; + vec2 offNP = horzSpan ? vec2(rcp.x, 0.0) : vec2(0.0, rcp.y); + if (!horzSpan) posB.x += lengthSign * 0.5; + if ( horzSpan) posB.y += lengthSign * 0.5; + + float lumaMLSS = lumaM - (luma1 + luma2) * 0.5; + float gradientScaled = max(grad1, grad2) * 0.25; + + vec2 posN = posB - offNP; + vec2 posP = posB + offNP; + bool done1 = false, done2 = false; + float lumaEnd1 = 0.0, lumaEnd2 = 0.0; + + for (int i = 0; i < FXAA_SEARCH_STEPS; ++i) { + if (!done1) lumaEnd1 = luma(texture(uScene, posN).rgb) - lumaMLSS; + if (!done2) lumaEnd2 = luma(texture(uScene, posP).rgb) - lumaMLSS; + done1 = done1 || (abs(lumaEnd1) >= gradientScaled * FXAA_SEARCH_THRESHOLD); + done2 = done2 || (abs(lumaEnd2) >= gradientScaled * FXAA_SEARCH_THRESHOLD); + if (done1 && done2) break; + if (!done1) posN -= offNP; + if (!done2) posP += offNP; + } + + float dstN = horzSpan ? (uv.x - posN.x) : (uv.y - posN.y); + float dstP = horzSpan ? (posP.x - uv.x) : (posP.y - uv.y); + bool dirN = (dstN < dstP); + float lumaEndFinal = dirN ? lumaEnd1 : lumaEnd2; + + float spanLength = dstN + dstP; + float pixelOffset = (dirN ? dstN : dstP) / spanLength; + bool goodSpan = ((lumaEndFinal < 0.0) != (lumaMLSS < 0.0)); + float pixelOffsetFinal = max(goodSpan ? pixelOffset : 0.0, blendL); + + vec2 finalUV = uv; + if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign; + if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign; + + outColor = vec4(texture(uScene, finalUV).rgb, 1.0); +} diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7a11d97b..1f6a029b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -295,6 +295,13 @@ public: // Server-authoritative armor (UNIT_FIELD_RESISTANCES[0]) int32_t getArmorRating() const { return playerArmorRating_; } + // Server-authoritative elemental resistances (UNIT_FIELD_RESISTANCES[1-6]). + // school: 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane. Returns 0 if not received. + int32_t getResistance(int school) const { + if (school < 1 || school > 6) return 0; + return playerResistances_[school - 1]; + } + // Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI). // Returns -1 if the server hasn't sent the value yet. int32_t getPlayerStat(int idx) const { @@ -541,6 +548,17 @@ public: } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; + // Returns the creature subname/title (e.g. ""), empty if not cached + std::string getCachedCreatureSubName(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.subName : ""; + } + // Returns the creature rank (0=Normal,1=Elite,2=RareElite,3=Boss,4=Rare) + // or -1 if not cached yet + int getCreatureRank(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? static_cast(it->second.rank) : -1; + } // ---- Phase 2: Combat ---- void startAutoAttack(uint64_t targetGuid); @@ -555,6 +573,9 @@ public: } uint64_t getAutoAttackTargetGuid() const { return autoAttackTarget; } bool isAggressiveTowardPlayer(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } + // Timestamp (ms since epoch) of the most recent player melee auto-attack. + // Zero if no swing has occurred this session. + uint64_t getLastMeleeSwingMs() const { return lastMeleeSwingMs_; } const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); @@ -591,6 +612,7 @@ public: void cancelCast(); void cancelAura(uint32_t spellId); void dismissPet(); + void renamePet(const std::string& newName); bool hasPet() const { return petGuid_ != 0; } uint64_t getPetGuid() const { return petGuid_; } @@ -616,6 +638,24 @@ public: void sendPetAction(uint32_t action, uint64_t targetGuid = 0); const std::unordered_set& getKnownSpells() const { return knownSpells; } + // ---- Pet Stable ---- + struct StabledPet { + uint32_t petNumber = 0; // server-side pet number (used for unstable/swap) + uint32_t entry = 0; // creature entry ID + uint32_t level = 0; + std::string name; + uint32_t displayId = 0; + bool isActive = false; // true = currently summoned/active slot + }; + bool isStableWindowOpen() const { return stableWindowOpen_; } + void closeStableWindow() { stableWindowOpen_ = false; } + uint64_t getStableMasterGuid() const { return stableMasterGuid_; } + uint8_t getStableSlots() const { return stableNumSlots_; } + const std::vector& getStabledPets() const { return stabledPets_; } + void requestStabledPetList(); // CMSG MSG_LIST_STABLED_PETS + void stablePet(uint8_t slot); // CMSG_STABLE_PET (store active pet in slot) + void unstablePet(uint32_t petNumber); // CMSG_UNSTABLE_PET (retrieve to active) + // Player proficiency bitmasks (from SMSG_SET_PROFICIENCY) // itemClass 2 = Weapon (subClassMask bits: 0=Axe1H,1=Axe2H,2=Bow,3=Gun,4=Mace1H,5=Mace2H,6=Polearm,7=Sword1H,8=Sword2H,10=Staff,13=Fist,14=Misc,15=Dagger,16=Thrown,17=Crossbow,18=Wand,19=Fishing) // itemClass 4 = Armor (subClassMask bits: 1=Cloth,2=Leather,3=Mail,4=Plate,6=Shield) @@ -688,6 +728,14 @@ public: static std::unordered_map empty; return spec < 2 ? learnedTalents_[spec] : empty; } + + // Glyphs (WotLK): up to 6 glyph slots per spec (3 major + 3 minor) + static constexpr uint8_t MAX_GLYPH_SLOTS = 6; + const std::array& getGlyphs() const { return learnedGlyphs_[activeTalentSpec_]; } + const std::array& getGlyphs(uint8_t spec) const { + static std::array empty{}; + return spec < 2 ? learnedGlyphs_[spec] : empty; + } uint8_t getTalentRank(uint32_t talentId) const { auto it = learnedTalents_[activeTalentSpec_].find(talentId); return (it != learnedTalents_[activeTalentSpec_].end()) ? it->second : 0; @@ -838,6 +886,11 @@ public: using KnockBackCallback = std::function; void setKnockBackCallback(KnockBackCallback cb) { knockBackCallback_ = std::move(cb); } + // Camera shake callback: called when server sends SMSG_CAMERA_SHAKE. + // Parameters: magnitude (world units), frequency (Hz), duration (seconds). + using CameraShakeCallback = std::function; + void setCameraShakeCallback(CameraShakeCallback cb) { cameraShakeCallback_ = std::move(cb); } + // Unstuck callback (resets player Z to floor height) using UnstuckCallback = std::function; void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } @@ -965,6 +1018,7 @@ public: // Cooldowns float getSpellCooldown(uint32_t spellId) const; + const std::unordered_map& getSpellCooldowns() const { return spellCooldowns; } // Player GUID uint64_t getPlayerGuid() const { return playerGuid; } @@ -1203,6 +1257,11 @@ public: void setAutoLoot(bool enabled) { autoLoot_ = enabled; } bool isAutoLoot() const { return autoLoot_; } + // Master loot candidates (from SMSG_LOOT_MASTER_LIST) + const std::vector& getMasterLootCandidates() const { return masterLootCandidates_; } + bool hasMasterLootCandidates() const { return !masterLootCandidates_.empty(); } + void lootMasterGive(uint8_t lootSlot, uint64_t targetGuid); + // Group loot roll struct LootRollEntry { uint64_t objectGuid = 0; @@ -1225,6 +1284,16 @@ public: void sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType); // rollType: 0=need, 1=greed, 2=disenchant, 96=pass + // Equipment Sets (WotLK): saved gear loadouts + struct EquipmentSetInfo { + uint64_t setGuid = 0; + uint32_t setId = 0; + std::string name; + std::string iconName; + }; + const std::vector& getEquipmentSets() const { return equipmentSetInfo_; } + void useEquipmentSet(uint32_t setId); + // NPC Gossip void interactWithNpc(uint64_t guid); void interactWithGameObject(uint64_t guid); @@ -1401,6 +1470,32 @@ public: using LevelUpCallback = std::function; void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback_ = std::move(cb); } + // Stat deltas from the last SMSG_LEVELUP_INFO (valid until next level-up) + struct LevelUpDeltas { + uint32_t hp = 0; + uint32_t mana = 0; + uint32_t str = 0, agi = 0, sta = 0, intel = 0, spi = 0; + }; + const LevelUpDeltas& getLastLevelUpDeltas() const { return lastLevelUpDeltas_; } + + // Temporary weapon enchant timers (from SMSG_ITEM_ENCHANT_TIME_UPDATE) + // Slot: 0=main-hand, 1=off-hand, 2=ranged. Value: expire time (steady_clock ms). + struct TempEnchantTimer { + uint32_t slot = 0; + uint64_t expireMs = 0; // std::chrono::steady_clock ms timestamp when it expires + }; + const std::vector& getTempEnchantTimers() const { return tempEnchantTimers_; } + // Returns remaining ms for a given slot, or 0 if absent/expired. + uint32_t getTempEnchantRemainingMs(uint32_t slot) const; + static constexpr const char* kTempEnchantSlotNames[] = { "Main Hand", "Off Hand", "Ranged" }; + + // ---- Readable text (books / scrolls / notes) ---- + // Populated by handlePageTextQueryResponse(); multi-page items chain via nextPageId. + struct BookPage { uint32_t pageId = 0; std::string text; }; + const std::vector& getBookPages() const { return bookPages_; } + bool hasBookOpen() const { return !bookPages_.empty(); } + void clearBook() { bookPages_.clear(); } + // Other player level-up callback — fires when another player gains a level using OtherPlayerLevelUpCallback = std::function; void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } @@ -1409,6 +1504,17 @@ public: using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + + // Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received + using AreaDiscoveryCallback = std::function; + void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); } + + // Quest objective progress callback — fires on SMSG_QUESTUPDATE_ADD_KILL / ADD_ITEM + // questTitle: name of the quest; objectiveName: creature/item name; current/required counts + using QuestProgressCallback = std::function; + void setQuestProgressCallback(QuestProgressCallback cb) { questProgressCallback_ = std::move(cb); } 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 { @@ -1422,6 +1528,18 @@ public: static const std::string kEmpty; return kEmpty; } + /// Returns the description of an achievement by ID, or empty string if unknown. + const std::string& getAchievementDescription(uint32_t id) const { + auto it = achievementDescCache_.find(id); + if (it != achievementDescCache_.end()) return it->second; + static const std::string kEmpty; + return kEmpty; + } + /// Returns the point value of an achievement by ID, or 0 if unknown. + uint32_t getAchievementPoints(uint32_t id) const { + auto it = achievementPointsCache_.find(id); + return (it != achievementPointsCache_.end()) ? it->second : 0u; + } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -1448,6 +1566,14 @@ public: using RepChangeCallback = std::function; void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); } + // PvP honor credit callback (honorable kill or BG reward) + using PvpHonorCallback = std::function; + void setPvpHonorCallback(PvpHonorCallback cb) { pvpHonorCallback_ = std::move(cb); } + + // Item looted / received callback (SMSG_ITEM_PUSH_RESULT when showInChat is set) + using ItemLootCallback = std::function; + void setItemLootCallback(ItemLootCallback cb) { itemLootCallback_ = std::move(cb); } + // Quest turn-in completion callback using QuestCompleteCallback = std::function; void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); } @@ -1544,6 +1670,11 @@ public: return it != taxiNpcHasRoutes_.end() && it->second; } + // Vehicle (WotLK) + bool isInVehicle() const { return vehicleId_ != 0; } + uint32_t getVehicleId() const { return vehicleId_; } + void sendRequestVehicleExit(); + // Vendor void openVendor(uint64_t npcGuid); void closeVendor(); @@ -1660,6 +1791,8 @@ public: void closeTrainer(); const std::string& getSpellName(uint32_t spellId) const; const std::string& getSpellRank(uint32_t spellId) const; + /// Returns the tooltip/description text from Spell.dbc (empty if unknown or has no text). + const std::string& getSpellDescription(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; @@ -2199,6 +2332,7 @@ private: // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; KnockBackCallback knockBackCallback_; + CameraShakeCallback cameraShakeCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; @@ -2251,6 +2385,7 @@ private: uint8_t activeTalentSpec_ = 0; // Currently active spec (0 or 1) uint8_t unspentTalentPoints_[2] = {0, 0}; // Unspent points per spec std::unordered_map learnedTalents_[2]; // Learned talents per spec + std::array, 2> learnedGlyphs_{}; // Glyphs per spec std::unordered_map talentCache_; // talentId -> entry std::unordered_map talentTabCache_; // tabId -> entry bool talentDbcLoaded_ = false; @@ -2283,6 +2418,13 @@ private: std::vector petSpellList_; // known pet spells std::unordered_set petAutocastSpells_; // spells with autocast on + // ---- Pet Stable ---- + bool stableWindowOpen_ = false; + uint64_t stableMasterGuid_ = 0; + uint8_t stableNumSlots_ = 0; + std::vector stabledPets_; + void handleListStabledPets(network::Packet& packet); + // ---- Battleground queue state ---- std::array bgQueues_{}; @@ -2409,6 +2551,7 @@ private: bool lootWindowOpen = false; bool autoLoot_ = false; LootResponseData currentLoot; + std::vector masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST // Group loot roll state bool pendingLootRollActive_ = false; @@ -2436,6 +2579,7 @@ private: std::unordered_map recentLootMoneyAnnounceCooldowns_; uint64_t playerMoneyCopper_ = 0; int32_t playerArmorRating_ = 0; + int32_t playerResistances_[6] = {}; // [0]=Holy,[1]=Fire,[2]=Nature,[3]=Frost,[4]=Shadow,[5]=Arcane // Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet int32_t playerStats_[5] = {-1, -1, -1, -1, -1}; // Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating @@ -2483,6 +2627,9 @@ private: return it != factionHostileMap_.end() ? it->second : true; // default hostile if unknown } + // Vehicle (WotLK): non-zero when player is seated in a vehicle + uint32_t vehicleId_ = 0; + // Taxi / Flight Paths std::unordered_map taxiNpcHasRoutes_; // guid -> has new/available routes std::unordered_map taxiNodes_; @@ -2578,12 +2725,20 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; uint8_t dispelType = 0; }; + struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; - // Achievement name cache (lazy-loaded from Achievement.dbc on first earned event) + // Title cache: maps titleBit → title string (lazy-loaded from CharTitles.dbc) + // The strings use "%s" as a player-name placeholder (e.g. "Commander %s", "%s the Explorer"). + std::unordered_map titleNameCache_; + bool titleNameCacheLoaded_ = false; + void loadTitleNameCache(); + + // Achievement caches (lazy-loaded from Achievement.dbc on first earned event) std::unordered_map achievementNameCache_; + std::unordered_map achievementDescCache_; + std::unordered_map achievementPointsCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) @@ -2702,6 +2857,7 @@ private: StandStateCallback standStateCallback_; GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; + uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing SpellCastAnimCallback spellCastAnimCallback_; UnitAnimHintCallback unitAnimHintCallback_; UnitMoveFlagsCallback unitMoveFlagsCallback_; @@ -2711,8 +2867,13 @@ private: NpcVendorCallback npcVendorCallback_; ChargeCallback chargeCallback_; LevelUpCallback levelUpCallback_; + LevelUpDeltas lastLevelUpDeltas_; + std::vector tempEnchantTimers_; + std::vector bookPages_; // pages collected for the current readable item OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; + AreaDiscoveryCallback areaDiscoveryCallback_; + QuestProgressCallback questProgressCallback_; MountCallback mountCallback_; TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; @@ -2766,6 +2927,7 @@ private: std::array itemGuids{}; }; std::vector equipmentSets_; + std::vector equipmentSetInfo_; // public-facing copy // ---- Forced faction reactions (SMSG_SET_FORCED_REACTIONS) ---- std::unordered_map forcedReactions_; // factionId -> reaction tier @@ -2782,6 +2944,12 @@ private: RepChangeCallback repChangeCallback_; uint32_t watchedFactionId_ = 0; // auto-set to most recently changed faction + // ---- PvP honor credit callback ---- + PvpHonorCallback pvpHonorCallback_; + + // ---- Item loot callback ---- + ItemLootCallback itemLootCallback_; + // ---- Quest completion callback ---- QuestCompleteCallback questCompleteCallback_; }; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 6e5721fd..71be1501 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1594,6 +1594,10 @@ struct ItemQueryResponseData { struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; std::vector extraStats; uint32_t startQuestId = 0; // Non-zero: item begins a quest + // Gem socket slots (WotLK/TBC): 0=no socket; color mask: 1=Meta,2=Red,4=Yellow,8=Blue + std::array socketColor{}; + uint32_t socketBonus = 0; // enchantmentId of socket bonus; 0=none + uint32_t itemSetId = 0; // ItemSet.dbc entry; 0=not part of a set bool valid = false; }; @@ -2695,5 +2699,33 @@ public: static bool parse(network::Packet& packet, AuctionCommandResult& data); }; +/** Pet Stable packet builders */ +class ListStabledPetsPacket { +public: + /** MSG_LIST_STABLED_PETS (CMSG): request list from stable master */ + static network::Packet build(uint64_t stableMasterGuid); +}; + +class StablePetPacket { +public: + /** CMSG_STABLE_PET: store active pet in the given stable slot (1-based) */ + static network::Packet build(uint64_t stableMasterGuid, uint8_t slot); +}; + +class UnstablePetPacket { +public: + /** CMSG_UNSTABLE_PET: retrieve a stabled pet by its server-side petNumber */ + static network::Packet build(uint64_t stableMasterGuid, uint32_t petNumber); +}; + +class PetRenamePacket { +public: + /** CMSG_PET_RENAME: rename the player's active pet. + * petGuid: the pet's object GUID (from GameHandler::getPetGuid()) + * name: new name (max 12 chars; server validates and may reject) + * isDeclined: 0 for non-Cyrillic locales (no declined name forms) */ + static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0); +}; + } // namespace game } // namespace wowee diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 7401ffdd..fbddd523 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -129,6 +129,12 @@ public: // vspeed: raw packet vspeed field (server sends negative for upward launch) void applyKnockBack(float vcos, float vsin, float hspeed, float vspeed); + // Trigger a camera shake effect (e.g. from SMSG_CAMERA_SHAKE). + // magnitude: peak positional offset in world units + // frequency: oscillation frequency in Hz + // duration: shake duration in seconds + void triggerShake(float magnitude, float frequency, float duration); + // For first-person player hiding void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) { characterRenderer = cr; @@ -369,6 +375,12 @@ private: glm::vec2 knockbackHorizVel_ = glm::vec2(0.0f); // render-space horizontal velocity (units/s) // Horizontal velocity decays via WoW-like drag so the player doesn't slide forever. static constexpr float KNOCKBACK_HORIZ_DRAG = 4.5f; // exponential decay rate (1/s) + + // Camera shake state (SMSG_CAMERA_SHAKE) + float shakeElapsed_ = 0.0f; + float shakeDuration_ = 0.0f; + float shakeMagnitude_ = 0.0f; + float shakeFrequency_ = 0.0f; }; } // namespace rendering diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 93bbed03..07d8091f 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -271,6 +271,10 @@ public: float getShadowDistance() const { return shadowDistance_; } void setMsaaSamples(VkSampleCountFlagBits samples); + // FXAA post-process anti-aliasing (combinable with MSAA) + void setFXAAEnabled(bool enabled); + bool isFXAAEnabled() const { return fxaa_.enabled; } + // FSR (FidelityFX Super Resolution) upscaling void setFSREnabled(bool enabled); bool isFSREnabled() const { return fsr_.enabled; } @@ -398,6 +402,31 @@ private: void destroyFSRResources(); void renderFSRUpscale(); + // FXAA post-process state + struct FXAAState { + bool enabled = false; + bool needsRecreate = false; + + // Off-screen scene target (same resolution as swapchain — no scaling) + AllocatedImage sceneColor{}; // 1x resolved color target + AllocatedImage sceneDepth{}; // Depth (matches MSAA sample count) + AllocatedImage sceneMsaaColor{}; // MSAA color target (when MSAA > 1x) + AllocatedImage sceneDepthResolve{}; // Depth resolve (MSAA + depth resolve) + VkFramebuffer sceneFramebuffer = VK_NULL_HANDLE; + VkSampler sceneSampler = VK_NULL_HANDLE; + + // FXAA fullscreen pipeline + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + VkDescriptorSetLayout descSetLayout = VK_NULL_HANDLE; + VkDescriptorPool descPool = VK_NULL_HANDLE; + VkDescriptorSet descSet = VK_NULL_HANDLE; + }; + FXAAState fxaa_; + bool initFXAAResources(); + void destroyFXAAResources(); + void renderFXAAPass(); + // FSR 2.2 temporal upscaling state struct FSR2State { bool enabled = false; diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index 47956b42..77a98ec0 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,13 @@ class VkContext; class VkTexture; class VkRenderTarget; +/// Party member dot passed in from the UI layer for world map overlay. +struct WorldMapPartyDot { + glm::vec3 renderPos; ///< Position in render-space coordinates + uint32_t color; ///< RGBA packed color (IM_COL32 format) + std::string name; ///< Member name (shown as tooltip on hover) +}; + struct WorldMapZone { uint32_t wmaID = 0; uint32_t areaID = 0; // 0 = continent level @@ -47,6 +55,7 @@ public: void setMapName(const std::string& name); void setServerExplorationMask(const std::vector& masks, bool hasData); + void setPartyDots(std::vector dots) { partyDots_ = std::move(dots); } bool isOpen() const { return open; } void close() { open = false; } @@ -113,6 +122,9 @@ private: // Texture storage (owns all VkTexture objects for zone tiles) std::vector> zoneTextures; + // Party member dots (set each frame from the UI layer) + std::vector partyDots_; + // Exploration / fog of war std::vector serverExplorationMask; bool hasServerExplorationMask = false; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index c2320681..56e133cd 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -157,6 +157,8 @@ private: bool chatWindowLocked = true; ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); bool chatWindowPosInit_ = false; + ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default + bool questTrackerPosInit_ = false; bool showEscapeMenu = false; bool showEscapeSettingsNotice = false; bool showSettingsWindow = false; @@ -204,6 +206,7 @@ private: float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x + bool pendingFXAA = false; // FXAA post-process (combinable with MSAA) bool pendingNormalMapping = true; // on by default float pendingNormalMapStrength = 0.8f; // 0.0-2.0 bool pendingPOM = true; // on by default @@ -238,6 +241,7 @@ private: bool minimapSettingsApplied_ = false; bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer + bool fxaaSettingsApplied_ = false; // True once saved FXAA setting applied to renderer bool waterRefractionApplied_ = false; bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied @@ -314,6 +318,7 @@ private: void renderRepBar(game::GameHandler& gameHandler); void renderCastBar(game::GameHandler& gameHandler); void renderMirrorTimers(game::GameHandler& gameHandler); + void renderCooldownTracker(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); void renderRaidWarningOverlay(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); @@ -339,6 +344,7 @@ private: void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); void renderTrainerWindow(game::GameHandler& gameHandler); + void renderStableWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); @@ -368,6 +374,7 @@ private: void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); void renderDPSMeter(game::GameHandler& gameHandler); + void renderDurabilityWarning(game::GameHandler& gameHandler); /** * Inventory screen @@ -427,10 +434,19 @@ private: char gmTicketBuf_[2048] = {}; void renderGmTicketWindow(game::GameHandler& gameHandler); + // Pet rename modal (triggered from pet frame context menu) + bool petRenameOpen_ = false; + char petRenameBuf_[16] = {}; + // Inspect window bool showInspectWindow_ = false; void renderInspectWindow(game::GameHandler& gameHandler); + // Readable text window (books / scrolls / notes) + bool showBookWindow_ = false; + int bookCurrentPage_ = 0; + void renderBookWindow(game::GameHandler& gameHandler); + // Threat window bool showThreatWindow_ = false; void renderThreatWindow(game::GameHandler& gameHandler); @@ -505,9 +521,12 @@ private: bool leftClickWasPress_ = false; // Level-up ding animation - static constexpr float DING_DURATION = 3.0f; + static constexpr float DING_DURATION = 4.0f; float dingTimer_ = 0.0f; uint32_t dingLevel_ = 0; + uint32_t dingHpDelta_ = 0; + uint32_t dingManaDelta_ = 0; + uint32_t dingStats_[5] = {}; // str/agi/sta/int/spi deltas void renderDingEffect(); // Achievement toast banner @@ -517,6 +536,80 @@ private: std::string achievementToastName_; void renderAchievementToast(); + // Area discovery toast ("Discovered! +XP XP") + static constexpr float DISCOVERY_TOAST_DURATION = 4.0f; + float discoveryToastTimer_ = 0.0f; + std::string discoveryToastName_; + uint32_t discoveryToastXP_ = 0; + bool areaDiscoveryCallbackSet_ = false; + void renderDiscoveryToast(); + + // Whisper toast — brief overlay at screen top when a whisper arrives while chat is not focused + struct WhisperToastEntry { + std::string sender; + std::string preview; // first ~60 chars of message + float age = 0.0f; + }; + static constexpr float WHISPER_TOAST_DURATION = 5.0f; + std::vector whisperToasts_; + size_t whisperSeenCount_ = 0; // how many chat entries have been scanned for whispers + void renderWhisperToasts(); + + // Quest objective progress toast ("Quest: X/Y") + struct QuestProgressToastEntry { + std::string questTitle; + std::string objectiveName; + uint32_t current = 0; + uint32_t required = 0; + float age = 0.0f; + }; + static constexpr float QUEST_TOAST_DURATION = 4.0f; + std::vector questToasts_; + bool questProgressCallbackSet_ = false; + void renderQuestProgressToasts(); + + // Nearby player level-up toast (" is now level X!") + struct PlayerLevelUpToastEntry { + uint64_t guid = 0; + std::string playerName; // resolved lazily at render time + uint32_t newLevel = 0; + float age = 0.0f; + }; + static constexpr float PLAYER_LEVELUP_TOAST_DURATION = 4.0f; + std::vector playerLevelUpToasts_; + bool otherPlayerLevelUpCallbackSet_ = false; + void renderPlayerLevelUpToasts(game::GameHandler& gameHandler); + + // PvP honor credit toast ("+N Honor" shown when an honorable kill is credited) + struct PvpHonorToastEntry { + uint32_t honor = 0; + uint32_t victimRank = 0; // 0 = unranked / not available + float age = 0.0f; + }; + static constexpr float PVP_HONOR_TOAST_DURATION = 3.5f; + std::vector pvpHonorToasts_; + bool pvpHonorCallbackSet_ = false; + void renderPvpHonorToasts(); + + // Item loot toast — quality-coloured popup when an item is received + struct ItemLootToastEntry { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t quality = 1; // 0=grey,1=white,2=green,3=blue,4=purple,5=orange + std::string name; + float age = 0.0f; + }; + static constexpr float ITEM_LOOT_TOAST_DURATION = 3.0f; + std::vector itemLootToasts_; + bool itemLootCallbackSet_ = false; + void renderItemLootToasts(); + + // Resurrection flash: brief "You have been resurrected!" overlay on ghost→alive transition + float resurrectFlashTimer_ = 0.0f; + static constexpr float kResurrectFlashDuration = 3.0f; + bool ghostStateCallbackSet_ = false; + void renderResurrectFlash(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; @@ -524,6 +617,9 @@ private: std::string lastKnownZoneName_; void renderZoneText(); + // Cooldown tracker + bool showCooldownTracker_ = false; + // DPS / HPS meter bool showDPSMeter_ = false; float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) @@ -533,7 +629,9 @@ private: size_t dpsLogSeenCount_ = 0; // log entries already scanned public: - void triggerDing(uint32_t newLevel); + void triggerDing(uint32_t newLevel, uint32_t hpDelta = 0, uint32_t manaDelta = 0, + uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0, + uint32_t intel = 0, uint32_t spi = 0); void triggerAchievementToast(uint32_t achievementId, std::string name = {}); }; diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 31cae856..7a40f43b 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -149,7 +149,7 @@ private: void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0, - const int32_t* serverStats = nullptr); + const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr); void renderReputationPanel(game::GameHandler& gameHandler); void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 18bbe152..72eafc2a 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -28,6 +28,8 @@ private: void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); + void loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager); + void renderGlyphs(game::GameHandler& gameHandler); VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); bool open = false; @@ -36,11 +38,16 @@ private: // DBC caches bool spellDbcLoaded = false; bool iconDbcLoaded = false; + bool glyphDbcLoaded = false; std::unordered_map spellIconIds; // spellId -> iconId std::unordered_map spellIconPaths; // iconId -> path std::unordered_map spellIconCache; // iconId -> texture std::unordered_map spellTooltips; // spellId -> description std::unordered_map bgTextureCache_; // tabId -> bg texture + + // GlyphProperties.dbc cache: glyphId -> { spellId, isMajor } + struct GlyphInfo { uint32_t spellId = 0; bool isMajor = false; }; + std::unordered_map glyphProperties_; // glyphId -> info }; } // namespace ui diff --git a/src/core/application.cpp b/src/core/application.cpp index 9ad75cc6..396c260f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -646,6 +646,11 @@ void Application::setState(AppState newState) { renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); } }); + gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) { + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->triggerShake(magnitude, frequency, duration); + } + }); } // Load quest marker models loadQuestMarkerModels(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index df1da0df..f3d3eb2c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1701,12 +1701,17 @@ void GameHandler::handlePacket(network::Packet& packet) { queryItemInfo(itemId, 0); if (showInChat) { std::string itemName = "item #" + std::to_string(itemId); + uint32_t quality = 1; // white default if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemName = info->name; + quality = info->quality; } std::string msg = "Received: " + itemName; if (count > 1) msg += " x" + std::to_string(count); addSystemChatMessage(msg); + if (itemLootCallback_) { + itemLootCallback_(itemId, count, quality, itemName); + } } LOG_INFO("Item push: itemId=", itemId, " count=", count, " showInChat=", static_cast(showInChat)); @@ -1767,6 +1772,8 @@ void GameHandler::handlePacket(network::Packet& packet) { } addSystemChatMessage(msg); // XP is updated via PLAYER_XP update fields from the server. + if (areaDiscoveryCallback_) + areaDiscoveryCallback_(areaName, xpGained); } } break; @@ -1787,8 +1794,23 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_PET_ACTION_FEEDBACK: { - // uint8 action + uint8 flags - packet.setReadPos(packet.getSize()); // Consume; no UI for pet feedback yet. + // uint8 msg: 1=dead, 2=nothing_to_attack, 3=cant_attack_target, + // 4=target_too_far, 5=no_path, 6=cant_attack_immune + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t msg = packet.readUInt8(); + static const char* kPetFeedback[] = { + nullptr, + "Your pet is dead.", + "Your pet has nothing to attack.", + "Your pet cannot attack that target.", + "That target is too far away.", + "Your pet cannot find a path to the target.", + "Your pet cannot attack an immune target.", + }; + if (msg > 0 && msg < 7 && kPetFeedback[msg]) { + addSystemChatMessage(kPetFeedback[msg]); + } + packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_NAME_QUERY_RESPONSE: { @@ -1880,6 +1902,9 @@ void GameHandler::handlePacket(network::Packet& packet) { std::dec, " rank=", rank); std::string msg = "You gain " + std::to_string(honor) + " honor points."; addSystemChatMessage(msg); + if (pvpHonorCallback_) { + pvpHonorCallback_(honor, victimGuid, rank); + } } break; } @@ -2045,6 +2070,11 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } + // ---- Pet stable list ---- + case Opcode::MSG_LIST_STABLED_PETS: + if (state == WorldState::IN_WORLD) handleListStabledPets(packet); + break; + // ---- Pet stable result ---- case Opcode::SMSG_STABLE_RESULT: { // uint8 result @@ -2061,6 +2091,11 @@ void GameHandler::handlePacket(network::Packet& packet) { } if (msg) addSystemChatMessage(msg); LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); + // Refresh the stable list after a result to reflect the new state + if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) { + auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_); + socket->send(refreshPkt); + } break; } @@ -2070,12 +2105,40 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t titleBit = packet.readUInt32(); uint32_t isLost = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - isLost ? "Title removed (ID %u)." : "Title earned (ID %u)!", - titleBit); - addSystemChatMessage(buf); - LOG_INFO("SMSG_TITLE_EARNED: id=", titleBit, " lost=", isLost); + loadTitleNameCache(); + + // Format the title string using the player's own name + std::string titleStr; + auto tit = titleNameCache_.find(titleBit); + if (tit != titleNameCache_.end() && !tit->second.empty()) { + // Title strings contain "%s" as a player-name placeholder. + // Replace it with the local player's name if known. + auto nameIt = playerNameCache.find(playerGuid); + const std::string& pName = (nameIt != playerNameCache.end()) + ? nameIt->second : "you"; + const std::string& fmt = tit->second; + size_t pos = fmt.find("%s"); + if (pos != std::string::npos) { + titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2); + } else { + titleStr = fmt; + } + } + + std::string msg; + if (!titleStr.empty()) { + msg = isLost ? ("Title removed: " + titleStr + ".") + : ("Title earned: " + titleStr + "!"); + } else { + char buf[64]; + std::snprintf(buf, sizeof(buf), + isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!", + titleBit); + msg = buf; + } + addSystemChatMessage(msg); + LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, + " title='", titleStr, "'"); break; } @@ -2697,6 +2760,25 @@ void GameHandler::handlePacket(network::Packet& packet) { handleMoveKnockBack(packet); break; + case Opcode::SMSG_CAMERA_SHAKE: { + // uint32 shakeID (CameraShakes.dbc), uint32 shakeType + // We don't parse CameraShakes.dbc; apply a hardcoded moderate shake. + if (packet.getSize() - packet.getReadPos() >= 8) { + uint32_t shakeId = packet.readUInt32(); + uint32_t shakeType = packet.readUInt32(); + (void)shakeType; + // Map shakeId ranges to approximate magnitudes: + // IDs < 50: minor environmental (0.04), others: larger boss effects (0.08) + float magnitude = (shakeId < 50) ? 0.04f : 0.08f; + if (cameraShakeCallback_) { + cameraShakeCallback_(magnitude, 18.0f, 0.5f); + } + LOG_DEBUG("SMSG_CAMERA_SHAKE: id=", shakeId, " type=", shakeType, + " magnitude=", magnitude); + } + break; + } + case Opcode::SMSG_CLIENT_CONTROL_UPDATE: { // Minimal parse: PackedGuid + uint8 allowMovement. if (packet.getSize() - packet.getReadPos() < 2) { @@ -3332,10 +3414,19 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LOOT_ROLL_WON: handleLootRollWon(packet); break; - case Opcode::SMSG_LOOT_MASTER_LIST: - // Master looter list — no UI yet; consume to avoid unhandled warning. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_LOOT_MASTER_LIST: { + // uint8 count + count * uint64 guid — eligible recipients for master looter + masterLootCandidates_.clear(); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t mlCount = packet.readUInt8(); + masterLootCandidates_.reserve(mlCount); + for (uint8_t i = 0; i < mlCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 8) break; + masterLootCandidates_.push_back(packet.readUInt64()); + } + LOG_INFO("SMSG_LOOT_MASTER_LIST: ", (int)masterLootCandidates_.size(), " candidates"); break; + } case Opcode::SMSG_GOSSIP_MESSAGE: handleGossipMessage(packet); break; @@ -3813,10 +3904,21 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LEVELUP_INFO: case Opcode::SMSG_LEVELUP_INFO_ALT: { // Server-authoritative level-up event. - // First field is always the new level in Classic/TBC/WotLK-era layouts. + // WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t newLevel = packet.readUInt32(); if (newLevel > 0) { + // Parse stat deltas (WotLK layout has 7 more uint32s) + lastLevelUpDeltas_ = {}; + if (packet.getSize() - packet.getReadPos() >= 28) { + lastLevelUpDeltas_.hp = packet.readUInt32(); + lastLevelUpDeltas_.mana = packet.readUInt32(); + lastLevelUpDeltas_.str = packet.readUInt32(); + lastLevelUpDeltas_.agi = packet.readUInt32(); + lastLevelUpDeltas_.sta = packet.readUInt32(); + lastLevelUpDeltas_.intel = packet.readUInt32(); + lastLevelUpDeltas_.spi = packet.readUInt32(); + } uint32_t oldLevel = serverPlayerLevel_; serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel); for (auto& ch : characters) { @@ -3830,7 +3932,6 @@ void GameHandler::handlePacket(network::Packet& packet) { } } } - // Remaining payload (hp/mana/stat deltas) is optional for our client. packet.setReadPos(packet.getSize()); break; } @@ -4232,6 +4333,11 @@ void GameHandler::handlePacket(network::Packet& packet) { weatherIntensity_ = wIntensity; const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear"; LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity); + // Storm transition: trigger a low-frequency thunder rumble shake + if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) { + float mag = 0.03f + wIntensity * 0.04f; // 0.03–0.07 units + cameraShakeCallback_(mag, 6.0f, 0.6f); + } } break; } @@ -4482,6 +4588,10 @@ void GameHandler::handlePacket(network::Packet& packet) { progressMsg += std::to_string(count) + "/" + std::to_string(reqCount); addSystemChatMessage(progressMsg); + if (questProgressCallback_) { + questProgressCallback_(quest.title, creatureName, count, reqCount); + } + LOG_INFO("Updated kill count for quest ", questId, ": ", count, "/", reqCount); break; @@ -4536,6 +4646,26 @@ void GameHandler::handlePacket(network::Packet& packet) { updatedAny = true; } addSystemChatMessage("Quest item: " + itemLabel + " (" + std::to_string(count) + ")"); + + if (questProgressCallback_ && updatedAny) { + // Find the quest that tracks this item to get title and required count + for (const auto& quest : questLog_) { + if (quest.complete) continue; + if (quest.itemCounts.count(itemId) == 0) continue; + uint32_t required = 0; + auto rIt = quest.requiredItemCounts.find(itemId); + if (rIt != quest.requiredItemCounts.end()) required = rIt->second; + if (required == 0) { + for (const auto& obj : quest.itemObjectives) { + if (obj.itemId == itemId) { required = obj.required; break; } + } + } + if (required == 0) required = count; + questProgressCallback_(quest.title, itemLabel, count, required); + break; + } + } + LOG_INFO("Quest item update: itemId=", itemId, " count=", count, " trackedQuestsUpdated=", updatedAny); } @@ -5177,10 +5307,19 @@ void GameHandler::handlePacket(network::Packet& packet) { // GM ticket status (new/updated); no ticket UI yet packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_PLAYER_VEHICLE_DATA: - // Vehicle data update for player in vehicle; no vehicle UI yet - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_PLAYER_VEHICLE_DATA: { + // PackedGuid (player guid) + uint32 vehicleId + // vehicleId == 0 means the player left the vehicle + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); // player guid (unused) + } + if (packet.getSize() - packet.getReadPos() >= 4) { + vehicleId_ = packet.readUInt32(); + } else { + vehicleId_ = 0; + } break; + } case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE: packet.setReadPos(packet.getSize()); break; @@ -5636,9 +5775,56 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Misc consume ---- + case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: { + // Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid + // slot: 0=main-hand, 1=off-hand, 2=ranged + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t enchSlot = packet.readUInt32(); + uint32_t durationSec = packet.readUInt32(); + /*uint64_t playerGuid =*/ packet.readUInt64(); + + // Clamp to known slots (0-2) + if (enchSlot > 2) { break; } + + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + if (durationSec == 0) { + // Enchant expired / removed — erase the slot entry + tempEnchantTimers_.erase( + std::remove_if(tempEnchantTimers_.begin(), tempEnchantTimers_.end(), + [enchSlot](const TempEnchantTimer& t) { return t.slot == enchSlot; }), + tempEnchantTimers_.end()); + } else { + uint64_t expireMs = nowMs + static_cast(durationSec) * 1000u; + bool found = false; + for (auto& t : tempEnchantTimers_) { + if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; } + } + if (!found) tempEnchantTimers_.push_back({enchSlot, expireMs}); + + // Warn at important thresholds + if (durationSec <= 60 && durationSec > 55) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 1 minute!", slotName); + addSystemChatMessage(buf); + } else if (durationSec <= 300 && durationSec > 295) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 5 minutes.", slotName); + addSystemChatMessage(buf); + } + } + LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s"); + break; + } case Opcode::SMSG_COMPLAIN_RESULT: case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: - case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: // Consume — not yet processed packet.setReadPos(packet.getSize()); @@ -5989,7 +6175,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Read item results ---- case Opcode::SMSG_READ_ITEM_OK: - addSystemChatMessage("You read the item."); + bookPages_.clear(); // fresh book for this item read packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_READ_ITEM_FAILED: @@ -6150,10 +6336,58 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; - // ---- Inspect (full character inspection) ---- - case Opcode::SMSG_INSPECT: - packet.setReadPos(packet.getSize()); + // ---- Inspect (Classic 1.12 gear inspection) ---- + case Opcode::SMSG_INSPECT: { + // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) + // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to + // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0) { packet.setReadPos(packet.getSize()); break; } + + constexpr int kGearSlots = 19; + size_t needed = kGearSlots * sizeof(uint32_t); + if (packet.getSize() - packet.getReadPos() < needed) { + packet.setReadPos(packet.getSize()); break; + } + + std::array items{}; + for (int s = 0; s < kGearSlots; ++s) + items[s] = packet.readUInt32(); + + // Resolve player name + auto ent = entityManager.getEntity(guid); + std::string playerName = "Target"; + if (ent) { + auto pl = std::dynamic_pointer_cast(ent); + if (pl && !pl->getName().empty()) playerName = pl->getName(); + } + + // Populate inspect result immediately (no talent data in Classic SMSG_INSPECT) + inspectResult_.guid = guid; + inspectResult_.playerName = playerName; + inspectResult_.totalTalents = 0; + inspectResult_.unspentTalents = 0; + inspectResult_.talentGroups = 0; + inspectResult_.activeTalentGroup = 0; + inspectResult_.itemEntries = items; + inspectResult_.enchantIds = {}; + + // Also cache for future talent-inspect cross-reference + inspectedPlayerItemEntries_[guid] = items; + + // Trigger item queries for non-empty slots + for (int s = 0; s < kGearSlots; ++s) { + if (items[s] != 0) queryItemInfo(items[s], 0); + } + + LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", + std::count_if(items.begin(), items.end(), + [](uint32_t e) { return e != 0; }), "/19 slots"); break; + } // ---- Multiple aggregated packets/moves ---- case Opcode::SMSG_MULTIPLE_MOVES: @@ -6209,6 +6443,9 @@ void GameHandler::handlePacket(network::Packet& packet) { handleQuestPoiQueryResponse(packet); break; case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: + vehicleId_ = 0; // Vehicle ride cancelled; clear UI + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: packet.setReadPos(packet.getSize()); @@ -6703,6 +6940,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { onlineEquipDirty_ = false; playerMoneyCopper_ = 0; playerArmorRating_ = 0; + std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); @@ -6712,6 +6950,10 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { unitAurasCache_.clear(); unitCastStates_.clear(); petGuid_ = 0; + stableWindowOpen_ = false; + stableMasterGuid_ = 0; + stableNumSlots_ = 0; + stabledPets_.clear(); playerXp_ = 0; playerNextLevelXp_ = 0; serverPlayerLevel_ = 1; @@ -6833,6 +7075,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { taxiStartGrace_ = 0.0f; currentMountDisplayId_ = 0; taxiMountDisplayId_ = 0; + vehicleId_ = 0; if (mountCallback_) { mountCallback_(0); } @@ -6846,6 +7089,8 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { talentsInitialized_ = false; learnedTalents_[0].clear(); learnedTalents_[1].clear(); + learnedGlyphs_[0].fill(0); + learnedGlyphs_[1].fill(0); unspentTalentPoints_[0] = 0; unspentTalentPoints_[1] = 0; activeTalentSpec_ = 0; @@ -7856,6 +8101,22 @@ void GameHandler::sendPing() { socket->send(packet); } +void GameHandler::sendRequestVehicleExit() { + if (state != WorldState::IN_WORLD || vehicleId_ == 0) return; + // CMSG_REQUEST_VEHICLE_EXIT has no payload — opcode only + network::Packet pkt(wireOpcode(Opcode::CMSG_REQUEST_VEHICLE_EXIT)); + socket->send(pkt); + vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) +} + +void GameHandler::useEquipmentSet(uint32_t setId) { + if (state != WorldState::IN_WORLD) return; + // CMSG_EQUIPMENT_SET_USE: uint32 setId + network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE)); + pkt.writeUInt32(setId); + socket->send(pkt); +} + void GameHandler::sendMinimapPing(float wowX, float wowY) { if (state != WorldState::IN_WORLD) return; @@ -8167,6 +8428,7 @@ void GameHandler::forceClearTaxiAndMovementState() { taxiMountActive_ = false; taxiMountDisplayId_ = 0; currentMountDisplayId_ = 0; + vehicleId_ = 0; resurrectPending_ = false; resurrectRequestPending_ = false; playerDead_ = false; @@ -8807,6 +9069,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerArmorRating_ = static_cast(val); LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, @@ -9147,6 +9412,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, @@ -11140,6 +11408,7 @@ void GameHandler::handleGameObjectPageText(network::Packet& packet) { else if (info.type == 10) pageId = info.data[7]; if (pageId != 0 && socket && state == WorldState::IN_WORLD) { + bookPages_.clear(); // start a fresh book for this interaction auto req = PageTextQueryPacket::build(pageId, guid); socket->send(req); return; @@ -11154,19 +11423,31 @@ void GameHandler::handlePageTextQueryResponse(network::Packet& packet) { PageTextQueryResponseData data; if (!PageTextQueryResponseParser::parse(packet, data)) return; - if (!data.text.empty()) { - std::istringstream iss(data.text); - std::string line; - bool wrote = false; - while (std::getline(iss, line)) { - if (line.empty()) continue; - addSystemChatMessage(line); - wrote = true; + if (!data.isValid()) return; + + // Append page if not already collected + bool alreadyHave = false; + for (const auto& bp : bookPages_) { + if (bp.pageId == data.pageId) { alreadyHave = true; break; } + } + if (!alreadyHave) { + bookPages_.push_back({data.pageId, data.text}); + } + + // Follow the chain: if there's a next page we haven't fetched yet, request it + if (data.nextPageId != 0) { + bool nextHave = false; + for (const auto& bp : bookPages_) { + if (bp.pageId == data.nextPageId) { nextHave = true; break; } } - if (!wrote) { - addSystemChatMessage(data.text); + if (!nextHave && socket && state == WorldState::IN_WORLD) { + auto req = PageTextQueryPacket::build(data.nextPageId, playerGuid); + socket->send(req); } } + LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, + " nextPage=", data.nextPageId, + " totalPages=", bookPages_.size()); } // ============================================================ @@ -11278,10 +11559,12 @@ void GameHandler::handleInspectResults(network::Packet& packet) { learnedTalents_[g][talentId] = rank; } if (packet.getSize() - packet.getReadPos() < 1) break; + learnedGlyphs_[g].fill(0); uint8_t glyphCount = packet.readUInt8(); for (uint8_t gl = 0; gl < glyphCount; ++gl) { if (packet.getSize() - packet.getReadPos() < 2) break; - packet.readUInt16(); // glyphId (skip) + uint16_t glyphId = packet.readUInt16(); + if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; } } @@ -14045,8 +14328,11 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { bool isPlayerTarget = (data.targetGuid == playerGuid); if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat - if (isPlayerAttacker && meleeSwingCallback_) { - meleeSwingCallback_(); + if (isPlayerAttacker) { + lastMeleeSwingMs_ = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + if (meleeSwingCallback_) meleeSwingCallback_(); } if (!isPlayerAttacker && npcSwingCallback_) { npcSwingCallback_(data.attackerGuid); @@ -14289,6 +14575,19 @@ void GameHandler::cancelAura(uint32_t spellId) { socket->send(packet); } +uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const { + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + for (const auto& t : tempEnchantTimers_) { + if (t.slot == slot) { + return (t.expireMs > nowMs) + ? static_cast(t.expireMs - nowMs) : 0u; + } + } + return 0u; +} + void GameHandler::handlePetSpells(network::Packet& packet) { const size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 8) { @@ -14364,6 +14663,86 @@ void GameHandler::dismissPet() { socket->send(packet); } +void GameHandler::renamePet(const std::string& newName) { + if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return; + if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars + auto packet = PetRenamePacket::build(petGuid_, newName, 0); + socket->send(packet); + LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, petGuid_, std::dec, " name='", newName, "'"); +} + +void GameHandler::requestStabledPetList() { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; + auto pkt = ListStabledPetsPacket::build(stableMasterGuid_); + socket->send(pkt); + LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, stableMasterGuid_, std::dec); +} + +void GameHandler::stablePet(uint8_t slot) { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; + if (petGuid_ == 0) { + addSystemChatMessage("You do not have an active pet to stable."); + return; + } + auto pkt = StablePetPacket::build(stableMasterGuid_, slot); + socket->send(pkt); + LOG_INFO("Sent CMSG_STABLE_PET: slot=", static_cast(slot)); +} + +void GameHandler::unstablePet(uint32_t petNumber) { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0 || petNumber == 0) return; + auto pkt = UnstablePetPacket::build(stableMasterGuid_, petNumber); + socket->send(pkt); + LOG_INFO("Sent CMSG_UNSTABLE_PET: petNumber=", petNumber); +} + +void GameHandler::handleListStabledPets(network::Packet& packet) { + // SMSG MSG_LIST_STABLED_PETS: + // uint64 stableMasterGuid + // uint8 petCount + // uint8 numSlots + // per pet: + // uint32 petNumber + // uint32 entry + // uint32 level + // string name (null-terminated) + // uint32 displayId + // uint8 isActive (1 = active/summoned, 0 = stabled) + constexpr size_t kMinHeader = 8 + 1 + 1; + if (packet.getSize() - packet.getReadPos() < kMinHeader) { + LOG_WARNING("MSG_LIST_STABLED_PETS: packet too short (", packet.getSize(), ")"); + return; + } + stableMasterGuid_ = packet.readUInt64(); + uint8_t petCount = packet.readUInt8(); + stableNumSlots_ = packet.readUInt8(); + + stabledPets_.clear(); + stabledPets_.reserve(petCount); + + for (uint8_t i = 0; i < petCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 4 + 4 + 4) break; + StabledPet pet; + pet.petNumber = packet.readUInt32(); + pet.entry = packet.readUInt32(); + pet.level = packet.readUInt32(); + pet.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 4 + 1) break; + pet.displayId = packet.readUInt32(); + pet.isActive = (packet.readUInt8() != 0); + stabledPets_.push_back(std::move(pet)); + } + + stableWindowOpen_ = true; + LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, stableMasterGuid_, std::dec, + " petCount=", (int)petCount, " numSlots=", (int)stableNumSlots_); + for (const auto& p : stabledPets_) { + LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry, + " level=", p.level, " name='", p.name, "' displayId=", p.displayId, + " active=", p.isActive); + } +} + void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) { if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; actionBar[slot].type = type; @@ -15500,6 +15879,7 @@ void GameHandler::lootItem(uint8_t slotIndex) { void GameHandler::closeLoot() { if (!lootWindowOpen) return; lootWindowOpen = false; + masterLootCandidates_.clear(); if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) { clearTarget(); } @@ -15510,6 +15890,16 @@ void GameHandler::closeLoot() { currentLoot = LootResponseData{}; } +void GameHandler::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_LOOT_MASTER_GIVE: uint64 lootGuid + uint8 slotIndex + uint64 targetGuid + network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_MASTER_GIVE)); + pkt.writeUInt64(currentLoot.lootGuid); + pkt.writeUInt8(lootSlot); + pkt.writeUInt64(targetGuid); + socket->send(pkt); +} + void GameHandler::interactWithNpc(uint64_t guid) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GossipHelloPacket::build(guid); @@ -15689,6 +16079,18 @@ void GameHandler::selectGossipOption(uint32_t optionId) { socket->send(bindPkt); LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec); } + + // Stable master detection: GOSSIP_OPTION_STABLE or text keywords + if (text == "GOSSIP_OPTION_STABLE" || + textLower.find("stable") != std::string::npos || + textLower.find("my pet") != std::string::npos) { + stableMasterGuid_ = currentGossip.npcGuid; + stableWindowOpen_ = false; // will open when list arrives + auto listPkt = ListStabledPetsPacket::build(currentGossip.npcGuid); + socket->send(listPkt); + LOG_INFO("Sent MSG_LIST_STABLED_PETS (gossip) to npc=0x", + std::hex, currentGossip.npcGuid, std::dec); + } break; } } @@ -17154,6 +17556,13 @@ void GameHandler::loadSpellNameCache() { if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } } + // Tooltip/description field + uint32_t tooltipField = 0xFFFFFFFF; + if (spellL) { + uint32_t f = spellL->field("Tooltip"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) tooltipField = f; + } + uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); @@ -17161,7 +17570,10 @@ 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, 0}; + SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0}; + if (tooltipField != 0xFFFFFFFF) { + entry.description = dbc->getString(i, tooltipField); + } if (hasSchoolMask) { entry.schoolMask = dbc->getUInt32(i, schoolMaskField); } else if (hasSchoolEnum) { @@ -17366,6 +17778,12 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING; } +const std::string& GameHandler::getSpellDescription(uint32_t spellId) const { + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.description : EMPTY_STRING; +} + uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { const_cast(this)->loadSpellNameCache(); auto it = spellNameCache_.find(spellId); @@ -20282,6 +20700,33 @@ void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollT // PackedTime date — uint32 bitfield (seconds since epoch) // uint32 realmFirst — how many on realm also got it (0 = realm first) // --------------------------------------------------------------------------- +void GameHandler::loadTitleNameCache() { + if (titleNameCacheLoaded_) return; + titleNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("CharTitles.dbc"); + if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 5) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("CharTitles") : nullptr; + + uint32_t titleField = layout ? layout->field("Title") : 2; + uint32_t bitField = layout ? layout->field("TitleBit") : 36; + if (titleField == 0xFFFFFFFF) titleField = 2; + if (bitField == 0xFFFFFFFF) bitField = static_cast(dbc->getFieldCount() - 1); + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t bit = dbc->getUInt32(i, bitField); + if (bit == 0) continue; + std::string name = dbc->getString(i, titleField); + if (!name.empty()) titleNameCache_[bit] = std::move(name); + } + LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC"); +} + void GameHandler::loadAchievementNameCache() { if (achievementNameCacheLoaded_) return; achievementNameCacheLoaded_ = true; @@ -20296,12 +20741,23 @@ void GameHandler::loadAchievementNameCache() { ? pipeline::getActiveDBCLayout()->getLayout("Achievement") : nullptr; uint32_t titleField = achL ? achL->field("Title") : 4; if (titleField == 0xFFFFFFFF) titleField = 4; + uint32_t descField = achL ? achL->field("Description") : 0xFFFFFFFF; + uint32_t ptsField = achL ? achL->field("Points") : 0xFFFFFFFF; + uint32_t fieldCount = dbc->getFieldCount(); for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t id = dbc->getUInt32(i, 0); if (id == 0) continue; std::string title = dbc->getString(i, titleField); if (!title.empty()) achievementNameCache_[id] = std::move(title); + if (descField != 0xFFFFFFFF && descField < fieldCount) { + std::string desc = dbc->getString(i, descField); + if (!desc.empty()) achievementDescCache_[id] = std::move(desc); + } + if (ptsField != 0xFFFFFFFF && ptsField < fieldCount) { + uint32_t pts = dbc->getUInt32(i, ptsField); + if (pts > 0) achievementPointsCache_[id] = pts; + } } LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc"); } @@ -20539,6 +20995,17 @@ void GameHandler::handleEquipmentSetList(network::Packet& packet) { } equipmentSets_.push_back(std::move(es)); } + // Populate public-facing info + equipmentSetInfo_.clear(); + equipmentSetInfo_.reserve(equipmentSets_.size()); + for (const auto& es : equipmentSets_) { + EquipmentSetInfo info; + info.setGuid = es.setGuid; + info.setId = es.setId; + info.name = es.name; + info.iconName = es.iconName; + equipmentSetInfo_.push_back(std::move(info)); + } LOG_INFO("SMSG_EQUIPMENT_SET_LIST: ", equipmentSets_.size(), " equipment sets received"); } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5a9a77ec..98ddd9d3 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2930,6 +2930,29 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa data.startQuestId = packet.readUInt32(); // StartQuest } + // WotLK 3.3.5a: additional fields after StartQuest (read up to socket data) + // LockID(4), Material(4), Sheath(4), RandomProperty(4), RandomSuffix(4), + // Block(4), ItemSet(4), MaxDurability(4), Area(4), Map(4), BagFamily(4), + // TotemCategory(4) = 48 bytes before sockets + constexpr size_t kPreSocketSkip = 48; + if (packet.getReadPos() + kPreSocketSkip + 28 <= packet.getSize()) { + // LockID(0), Material(1), Sheath(2), RandomProperty(3), RandomSuffix(4), Block(5) + for (size_t i = 0; i < 6; ++i) packet.readUInt32(); + data.itemSetId = packet.readUInt32(); // ItemSet(6) + // MaxDurability(7), Area(8), Map(9), BagFamily(10), TotemCategory(11) + for (size_t i = 0; i < 5; ++i) packet.readUInt32(); + // 3 socket slots: socketColor (4 bytes each) + data.socketColor[0] = packet.readUInt32(); + data.socketColor[1] = packet.readUInt32(); + data.socketColor[2] = packet.readUInt32(); + // 3 socket content (gem enchantment IDs — skip, not currently displayed) + packet.readUInt32(); + packet.readUInt32(); + packet.readUInt32(); + // socketBonus (enchantmentId) + data.socketBonus = packet.readUInt32(); + } + data.valid = !data.name.empty(); return true; } @@ -5374,5 +5397,37 @@ bool AuctionCommandResultParser::parse(network::Packet& packet, AuctionCommandRe return true; } +// ============================================================ +// Pet Stable System +// ============================================================ + +network::Packet ListStabledPetsPacket::build(uint64_t stableMasterGuid) { + network::Packet p(wireOpcode(Opcode::MSG_LIST_STABLED_PETS)); + p.writeUInt64(stableMasterGuid); + return p; +} + +network::Packet StablePetPacket::build(uint64_t stableMasterGuid, uint8_t slot) { + network::Packet p(wireOpcode(Opcode::CMSG_STABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt8(slot); + return p; +} + +network::Packet UnstablePetPacket::build(uint64_t stableMasterGuid, uint32_t petNumber) { + network::Packet p(wireOpcode(Opcode::CMSG_UNSTABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt32(petNumber); + return p; +} + +network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name, uint8_t isDeclined) { + network::Packet p(wireOpcode(Opcode::CMSG_PET_RENAME)); + p.writeUInt64(petGuid); + p.writeString(name); // null-terminated + p.writeUInt8(isDeclined); + return p; +} + } // namespace game } // namespace wowee diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 50872d46..a34f05f1 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -140,6 +140,17 @@ std::optional CameraController::getCachedFloorHeight(float x, float y, fl return result; } +void CameraController::triggerShake(float magnitude, float frequency, float duration) { + // Allow stronger shake to override weaker; don't allow zero magnitude. + if (magnitude <= 0.0f || duration <= 0.0f) return; + if (magnitude > shakeMagnitude_ || shakeElapsed_ >= shakeDuration_) { + shakeMagnitude_ = magnitude; + shakeFrequency_ = frequency; + shakeDuration_ = duration; + shakeElapsed_ = 0.0f; + } +} + void CameraController::update(float deltaTime) { if (!enabled || !camera) { return; @@ -1859,6 +1870,23 @@ void CameraController::update(float deltaTime) { wasFalling = !grounded && verticalVelocity <= 0.0f; // R key is now handled above with chat safeguard (WantTextInput check) + + // Camera shake (SMSG_CAMERA_SHAKE): apply sinusoidal offset to final camera position. + if (shakeElapsed_ < shakeDuration_) { + shakeElapsed_ += deltaTime; + float t = shakeElapsed_ / shakeDuration_; + // Envelope: fade out over the last 30% of shake duration + float envelope = (t < 0.7f) ? 1.0f : (1.0f - (t - 0.7f) / 0.3f); + float theta = shakeElapsed_ * shakeFrequency_ * 2.0f * 3.14159265f; + glm::vec3 offset( + shakeMagnitude_ * envelope * std::sin(theta), + shakeMagnitude_ * envelope * std::cos(theta * 1.3f), + shakeMagnitude_ * envelope * std::sin(theta * 0.7f) * 0.5f + ); + if (camera) { + camera->setPosition(camera->getPosition() + offset); + } + } } void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index c5ef43b2..96659828 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -3905,7 +3905,14 @@ void M2Renderer::cleanupUnusedModels() { } } - // Delete GPU resources and remove from map + // Delete GPU resources and remove from map. + // Wait for the GPU to finish all in-flight frames before destroying any + // buffers — the previous frame's command buffer may still be referencing + // vertex/index buffers that are about to be freed. Without this wait, + // the GPU reads freed memory, which can cause VK_ERROR_DEVICE_LOST. + if (!toRemove.empty() && vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + } for (uint32_t id : toRemove) { auto it = models.find(id); if (it != models.end()) { diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index 09430dce..1351e715 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -219,6 +219,13 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Text(" Upscale Dispatches: %zu", renderer->getAmdFsr3UpscaleDispatchCount()); ImGui::Text(" FG Fallbacks: %zu", renderer->getAmdFsr3FallbackCount()); } + if (renderer->isFXAAEnabled()) { + if (renderer->isFSR2Enabled()) { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.8f, 1.0f), "FXAA: ON (FSR3+FXAA combined)"); + } else { + ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.6f, 1.0f), "FXAA: ON"); + } + } ImGui::Spacing(); } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 7618a345..a7cbb3e7 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -858,6 +858,7 @@ void Renderer::shutdown() { destroyFSRResources(); destroyFSR2Resources(); + destroyFXAAResources(); destroyPerFrameResources(); zoneManager.reset(); @@ -960,8 +961,9 @@ void Renderer::applyMsaaChange() { VkDevice device = vkCtx->getDevice(); if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } - if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() + if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() if (fsr2_.sceneFramebuffer) destroyFSR2Resources(); + if (fxaa_.sceneFramebuffer) destroyFXAAResources(); // Will be lazily recreated in beginFrame() // Reinitialize ImGui Vulkan backend with new MSAA sample count ImGui_ImplVulkan_Shutdown(); @@ -1017,6 +1019,21 @@ void Renderer::beginFrame() { } } + // FXAA resource management — FXAA can coexist with FSR1 and FSR3. + // When both FXAA and FSR3 are enabled, FXAA runs as a post-FSR3 pass. + // When both FXAA and FSR1 are enabled, FXAA takes priority (native res render). + if (fxaa_.needsRecreate && fxaa_.sceneFramebuffer) { + destroyFXAAResources(); + fxaa_.needsRecreate = false; + if (!fxaa_.enabled) LOG_INFO("FXAA: disabled"); + } + if (fxaa_.enabled && !fxaa_.sceneFramebuffer) { + if (!initFXAAResources()) { + LOG_ERROR("FXAA: initialization failed, disabling"); + fxaa_.enabled = false; + } + } + // Handle swapchain recreation if needed if (vkCtx->isSwapchainDirty()) { vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); @@ -1033,6 +1050,12 @@ void Renderer::beginFrame() { destroyFSR2Resources(); initFSR2Resources(); } + // Recreate FXAA resources for new swapchain dimensions + // FXAA can coexist with FSR1 and FSR3 simultaneously. + if (fxaa_.enabled) { + destroyFXAAResources(); + initFXAAResources(); + } } // Acquire swapchain image and begin command buffer @@ -1119,6 +1142,11 @@ void Renderer::beginFrame() { if (fsr2_.enabled && fsr2_.sceneFramebuffer) { rpInfo.framebuffer = fsr2_.sceneFramebuffer; renderExtent = { fsr2_.internalWidth, fsr2_.internalHeight }; + } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { + // FXAA takes priority over FSR1: renders at native res with AA post-process. + // When both FSR1 and FXAA are enabled, FXAA wins (native res, no downscale). + rpInfo.framebuffer = fxaa_.sceneFramebuffer; + renderExtent = vkCtx->getSwapchainExtent(); // native resolution — no downscaling } else if (fsr_.enabled && fsr_.sceneFramebuffer) { rpInfo.framebuffer = fsr_.sceneFramebuffer; renderExtent = { fsr_.internalWidth, fsr_.internalHeight }; @@ -1208,6 +1236,35 @@ void Renderer::endFrame() { VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); } + // FSR3+FXAA combined: re-point FXAA's descriptor to the FSR3 temporal output + // so renderFXAAPass() applies spatial AA on the temporally-stabilized frame. + // This must happen outside the render pass (descriptor updates are CPU-side). + if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler) { + VkImageView fsr3OutputView = VK_NULL_HANDLE; + if (fsr2_.useAmdBackend) { + if (fsr2_.amdFsr3FramegenRuntimeActive && fsr2_.framegenOutput.image) + fsr3OutputView = fsr2_.framegenOutput.imageView; + else if (fsr2_.history[fsr2_.currentHistory].image) + fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView; + } else if (fsr2_.history[fsr2_.currentHistory].image) { + fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView; + } + if (fsr3OutputView) { + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = fsr3OutputView; + imgInfo.sampler = fxaa_.sceneSampler; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fxaa_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr); + } + } + // Begin swapchain render pass at full resolution for sharpening + ImGui VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; @@ -1237,8 +1294,33 @@ void Renderer::endFrame() { sc.extent = ext; vkCmdSetScissor(currentCmd, 0, 1, &sc); - // Draw RCAS sharpening from accumulated history buffer - renderFSR2Sharpen(); + // When FXAA is also enabled: apply FXAA on the FSR3 temporal output instead + // of RCAS sharpening. FXAA descriptor is temporarily pointed to the FSR3 + // history buffer (which is already in SHADER_READ_ONLY_OPTIMAL). This gives + // FSR3 temporal stability + FXAA spatial edge smoothing ("ultra quality native"). + if (fxaa_.enabled && fxaa_.pipeline && fxaa_.descSet) { + renderFXAAPass(); + } else { + // Draw RCAS sharpening from accumulated history buffer + renderFSR2Sharpen(); + } + + // Restore FXAA descriptor to its normal scene color source so standalone + // FXAA frames are not affected by the FSR3 history pointer set above. + if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler && fxaa_.sceneColor.imageView) { + VkDescriptorImageInfo restoreInfo{}; + restoreInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + restoreInfo.imageView = fxaa_.sceneColor.imageView; + restoreInfo.sampler = fxaa_.sceneSampler; + VkWriteDescriptorSet restoreWrite{}; + restoreWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + restoreWrite.dstSet = fxaa_.descSet; + restoreWrite.dstBinding = 0; + restoreWrite.descriptorCount = 1; + restoreWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + restoreWrite.pImageInfo = &restoreInfo; + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &restoreWrite, 0, nullptr); + } // Maintain frame bookkeeping fsr2_.prevViewProjection = camera->getViewProjectionMatrix(); @@ -1249,43 +1331,33 @@ void Renderer::endFrame() { } fsr2_.frameIndex = (fsr2_.frameIndex + 1) % 256; // Wrap to keep Halton values well-distributed - } else if (fsr_.enabled && fsr_.sceneFramebuffer) { + } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { // End the off-screen scene render pass vkCmdEndRenderPass(currentCmd); - // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY - // The render pass finalLayout puts the resolve/color attachment in PRESENT_SRC_KHR - transitionImageLayout(currentCmd, fsr_.sceneColor.image, + // Transition resolved scene color: PRESENT_SRC_KHR → SHADER_READ_ONLY + transitionImageLayout(currentCmd, fxaa_.sceneColor.image, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); - // Begin swapchain render pass at full resolution + // Begin swapchain render pass (1x — no MSAA on the output pass) VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; rpInfo.renderPass = vkCtx->getImGuiRenderPass(); rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; rpInfo.renderArea.offset = {0, 0}; rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); - - // Clear values must match the render pass attachment count - bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); - VkClearValue clearValues[4]{}; - clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; - clearValues[1].depthStencil = {1.0f, 0}; - clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; - clearValues[3].depthStencil = {1.0f, 0}; - if (msaaOn) { - bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); - rpInfo.clearValueCount = depthRes ? 4 : 3; - } else { - rpInfo.clearValueCount = 2; - } - rpInfo.pClearValues = clearValues; + // The swapchain render pass always has 2 attachments when MSAA is off; + // FXAA output goes to the non-MSAA swapchain directly. + VkClearValue fxaaClear[2]{}; + fxaaClear[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fxaaClear[1].depthStencil = {1.0f, 0}; + rpInfo.clearValueCount = 2; + rpInfo.pClearValues = fxaaClear; vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); - // Set full-resolution viewport and scissor VkExtent2D ext = vkCtx->getSwapchainExtent(); VkViewport vp{}; vp.width = static_cast(ext.width); @@ -1296,12 +1368,60 @@ void Renderer::endFrame() { sc.extent = ext; vkCmdSetScissor(currentCmd, 0, 1, &sc); - // Draw FSR upscale fullscreen quad + // Draw FXAA pass + renderFXAAPass(); + + } else if (fsr_.enabled && fsr_.sceneFramebuffer) { + // FSR1 upscale path — only runs when FXAA is not active. + // When both FSR1 and FXAA are enabled, FXAA took priority above. + vkCmdEndRenderPass(currentCmd); + + // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY + transitionImageLayout(currentCmd, fsr_.sceneColor.image, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + + // Begin swapchain render pass at full resolution + VkRenderPassBeginInfo fsrRpInfo{}; + fsrRpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + fsrRpInfo.renderPass = vkCtx->getImGuiRenderPass(); + fsrRpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + fsrRpInfo.renderArea.offset = {0, 0}; + fsrRpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + + bool fsrMsaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); + VkClearValue fsrClearValues[4]{}; + fsrClearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fsrClearValues[1].depthStencil = {1.0f, 0}; + fsrClearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fsrClearValues[3].depthStencil = {1.0f, 0}; + if (fsrMsaaOn) { + bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + fsrRpInfo.clearValueCount = depthRes ? 4 : 3; + } else { + fsrRpInfo.clearValueCount = 2; + } + fsrRpInfo.pClearValues = fsrClearValues; + + vkCmdBeginRenderPass(currentCmd, &fsrRpInfo, VK_SUBPASS_CONTENTS_INLINE); + + VkExtent2D fsrExt = vkCtx->getSwapchainExtent(); + VkViewport fsrVp{}; + fsrVp.width = static_cast(fsrExt.width); + fsrVp.height = static_cast(fsrExt.height); + fsrVp.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &fsrVp); + VkRect2D fsrSc{}; + fsrSc.extent = fsrExt; + vkCmdSetScissor(currentCmd, 0, 1, &fsrSc); + renderFSRUpscale(); } // ImGui rendering — must respect subpass contents mode - if (!fsr_.enabled && !fsr2_.enabled && parallelRecordingEnabled_) { + // Parallel recording only applies when no post-process pass is active. + if (!fsr_.enabled && !fsr2_.enabled && !fxaa_.enabled && parallelRecordingEnabled_) { // Scene pass was begun with VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS, // so ImGui must be recorded into a secondary command buffer. VkCommandBuffer imguiCmd = beginSecondary(SEC_IMGUI); @@ -4698,6 +4818,247 @@ void Renderer::setAmdFsr3FramegenEnabled(bool enabled) { // ========================= End FSR 2.2 ========================= +// ========================= FXAA Post-Process ========================= + +bool Renderer::initFXAAResources() { + if (!vkCtx) return false; + + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + VkExtent2D ext = vkCtx->getSwapchainExtent(); + VkSampleCountFlagBits msaa = vkCtx->getMsaaSamples(); + bool useMsaa = (msaa > VK_SAMPLE_COUNT_1_BIT); + bool useDepthResolve = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + + LOG_INFO("FXAA: initializing at ", ext.width, "x", ext.height, + " (MSAA=", static_cast(msaa), "x)"); + + VkFormat colorFmt = vkCtx->getSwapchainFormat(); + VkFormat depthFmt = vkCtx->getDepthFormat(); + + // sceneColor: 1x resolved color target — FXAA reads from here + fxaa_.sceneColor = createImage(device, alloc, ext.width, ext.height, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + if (!fxaa_.sceneColor.image) { + LOG_ERROR("FXAA: failed to create scene color image"); + return false; + } + + // sceneDepth: depth buffer at current MSAA sample count + fxaa_.sceneDepth = createImage(device, alloc, ext.width, ext.height, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, msaa); + if (!fxaa_.sceneDepth.image) { + LOG_ERROR("FXAA: failed to create scene depth image"); + destroyFXAAResources(); + return false; + } + + if (useMsaa) { + fxaa_.sceneMsaaColor = createImage(device, alloc, ext.width, ext.height, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, msaa); + if (!fxaa_.sceneMsaaColor.image) { + LOG_ERROR("FXAA: failed to create MSAA color image"); + destroyFXAAResources(); + return false; + } + if (useDepthResolve) { + fxaa_.sceneDepthResolve = createImage(device, alloc, ext.width, ext.height, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT); + if (!fxaa_.sceneDepthResolve.image) { + LOG_ERROR("FXAA: failed to create depth resolve image"); + destroyFXAAResources(); + return false; + } + } + } + + // Framebuffer — same attachment layout as main render pass + VkImageView fbAttachments[4]{}; + uint32_t fbCount; + if (useMsaa) { + fbAttachments[0] = fxaa_.sceneMsaaColor.imageView; + fbAttachments[1] = fxaa_.sceneDepth.imageView; + fbAttachments[2] = fxaa_.sceneColor.imageView; // resolve target + fbCount = 3; + if (useDepthResolve) { + fbAttachments[3] = fxaa_.sceneDepthResolve.imageView; + fbCount = 4; + } + } else { + fbAttachments[0] = fxaa_.sceneColor.imageView; + fbAttachments[1] = fxaa_.sceneDepth.imageView; + fbCount = 2; + } + + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = vkCtx->getImGuiRenderPass(); + fbInfo.attachmentCount = fbCount; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = ext.width; + fbInfo.height = ext.height; + fbInfo.layers = 1; + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &fxaa_.sceneFramebuffer) != VK_SUCCESS) { + LOG_ERROR("FXAA: failed to create scene framebuffer"); + destroyFXAAResources(); + return false; + } + + // Sampler + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = VK_FILTER_LINEAR; + samplerInfo.magFilter = VK_FILTER_LINEAR; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + if (vkCreateSampler(device, &samplerInfo, nullptr, &fxaa_.sceneSampler) != VK_SUCCESS) { + LOG_ERROR("FXAA: failed to create sampler"); + destroyFXAAResources(); + return false; + } + + // Descriptor set layout: binding 0 = combined image sampler + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = 1; + layoutInfo.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &fxaa_.descSetLayout); + + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = 1; + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = 1; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + vkCreateDescriptorPool(device, &poolInfo, nullptr, &fxaa_.descPool); + + VkDescriptorSetAllocateInfo dsAllocInfo{}; + dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + dsAllocInfo.descriptorPool = fxaa_.descPool; + dsAllocInfo.descriptorSetCount = 1; + dsAllocInfo.pSetLayouts = &fxaa_.descSetLayout; + vkAllocateDescriptorSets(device, &dsAllocInfo, &fxaa_.descSet); + + // Bind the resolved 1x sceneColor + VkDescriptorImageInfo imgInfo{}; + imgInfo.sampler = fxaa_.sceneSampler; + imgInfo.imageView = fxaa_.sceneColor.imageView; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fxaa_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + + // Pipeline layout — push constant holds vec2 rcpFrame + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pc.offset = 0; + pc.size = 8; // vec2 + VkPipelineLayoutCreateInfo plCI{}; + plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plCI.setLayoutCount = 1; + plCI.pSetLayouts = &fxaa_.descSetLayout; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + vkCreatePipelineLayout(device, &plCI, nullptr, &fxaa_.pipelineLayout); + + // FXAA pipeline — fullscreen triangle into the swapchain render pass + // Uses VK_SAMPLE_COUNT_1_BIT: it always runs after MSAA resolve. + VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || + !fragMod.loadFromFile(device, "assets/shaders/fxaa.frag.spv")) { + LOG_ERROR("FXAA: failed to load shaders"); + destroyFXAAResources(); + return false; + } + + fxaa_.pipeline = PipelineBuilder() + .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(VK_SAMPLE_COUNT_1_BIT) // swapchain pass is always 1x + .setLayout(fxaa_.pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertMod.destroy(); + fragMod.destroy(); + + if (!fxaa_.pipeline) { + LOG_ERROR("FXAA: failed to create pipeline"); + destroyFXAAResources(); + return false; + } + + LOG_INFO("FXAA: initialized successfully"); + return true; +} + +void Renderer::destroyFXAAResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + vkDeviceWaitIdle(device); + + if (fxaa_.pipeline) { vkDestroyPipeline(device, fxaa_.pipeline, nullptr); fxaa_.pipeline = VK_NULL_HANDLE; } + if (fxaa_.pipelineLayout) { vkDestroyPipelineLayout(device, fxaa_.pipelineLayout, nullptr); fxaa_.pipelineLayout = VK_NULL_HANDLE; } + if (fxaa_.descPool) { vkDestroyDescriptorPool(device, fxaa_.descPool, nullptr); fxaa_.descPool = VK_NULL_HANDLE; fxaa_.descSet = VK_NULL_HANDLE; } + if (fxaa_.descSetLayout) { vkDestroyDescriptorSetLayout(device, fxaa_.descSetLayout, nullptr); fxaa_.descSetLayout = VK_NULL_HANDLE; } + if (fxaa_.sceneFramebuffer) { vkDestroyFramebuffer(device, fxaa_.sceneFramebuffer, nullptr); fxaa_.sceneFramebuffer = VK_NULL_HANDLE; } + if (fxaa_.sceneSampler) { vkDestroySampler(device, fxaa_.sceneSampler, nullptr); fxaa_.sceneSampler = VK_NULL_HANDLE; } + destroyImage(device, alloc, fxaa_.sceneDepthResolve); + destroyImage(device, alloc, fxaa_.sceneMsaaColor); + destroyImage(device, alloc, fxaa_.sceneDepth); + destroyImage(device, alloc, fxaa_.sceneColor); +} + +void Renderer::renderFXAAPass() { + if (!fxaa_.pipeline || currentCmd == VK_NULL_HANDLE) return; + VkExtent2D ext = vkCtx->getSwapchainExtent(); + + vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipeline); + vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr); + + // Push rcpFrame = vec2(1/width, 1/height) + float rcpFrame[2] = { + 1.0f / static_cast(ext.width), + 1.0f / static_cast(ext.height) + }; + vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 8, rcpFrame); + + vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle +} + +void Renderer::setFXAAEnabled(bool enabled) { + if (fxaa_.enabled == enabled) return; + fxaa_.enabled = enabled; + if (!enabled) { + fxaa_.needsRecreate = true; // defer destruction to next beginFrame() + } +} + +// ========================= End FXAA ========================= + void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { (void)world; diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index c2a81301..bc9aa362 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -835,7 +835,12 @@ void WMORenderer::cleanupUnusedModels() { } } - // Delete GPU resources and remove from map + // Delete GPU resources and remove from map. + // Ensure all in-flight frames are complete before freeing vertex/index buffers — + // the GPU may still be reading them from the previous frame's command buffer. + if (!toRemove.empty() && vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + } for (uint32_t id : toRemove) { unloadModel(id); } diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 701c5148..138d39db 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -1017,6 +1017,33 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } + // Party member dots + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + ImFont* font = ImGui::GetFont(); + for (const auto& dot : partyDots_) { + glm::vec2 uv = renderPosToMapUV(dot.renderPos, currentIdx); + if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; + float px = imgMin.x + uv.x * displayW; + float py = imgMin.y + uv.y * displayH; + drawList->AddCircleFilled(ImVec2(px, py), 5.0f, dot.color); + drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(0, 0, 0, 200), 0, 1.5f); + // Name tooltip on hover + if (!dot.name.empty()) { + ImVec2 mp = ImGui::GetMousePos(); + float dx = mp.x - px, dy = mp.y - py; + if (dx * dx + dy * dy <= 49.0f) { // radius 7 px hit area + ImGui::SetTooltip("%s", dot.name.c_str()); + } + // Draw name label above the dot + ImVec2 nameSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, dot.name.c_str()); + float tx = px - nameSz.x * 0.5f; + float ty = py - nameSz.y - 7.0f; + drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), dot.name.c_str()); + drawList->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 220), dot.name.c_str()); + } + } + } + // Hover coordinate display — show WoW coordinates under cursor if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { auto& io = ImGui::GetIO(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 33e78f25..6d86c1a1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -284,10 +284,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Set up level-up callback (once) if (!levelUpCallbackSet_) { - gameHandler.setLevelUpCallback([this](uint32_t newLevel) { + gameHandler.setLevelUpCallback([this, &gameHandler](uint32_t newLevel) { levelUpFlashAlpha_ = 1.0f; levelUpDisplayLevel_ = newLevel; - triggerDing(newLevel); + const auto& d = gameHandler.getLastLevelUpDeltas(); + triggerDing(newLevel, d.hp, d.mana, d.str, d.agi, d.sta, d.intel, d.spi); }); levelUpCallbackSet_ = true; } @@ -300,6 +301,95 @@ void GameScreen::render(game::GameHandler& gameHandler) { achievementCallbackSet_ = true; } + // Set up area discovery toast callback (once) + if (!areaDiscoveryCallbackSet_) { + gameHandler.setAreaDiscoveryCallback([this](const std::string& areaName, uint32_t xpGained) { + discoveryToastName_ = areaName.empty() ? "New Area" : areaName; + discoveryToastXP_ = xpGained; + discoveryToastTimer_ = DISCOVERY_TOAST_DURATION; + }); + areaDiscoveryCallbackSet_ = true; + } + + // Set up quest objective progress toast callback (once) + if (!questProgressCallbackSet_) { + gameHandler.setQuestProgressCallback([this](const std::string& questTitle, + const std::string& objectiveName, + uint32_t current, uint32_t required) { + // Coalesce: if the same objective already has a toast, just update counts + for (auto& t : questToasts_) { + if (t.questTitle == questTitle && t.objectiveName == objectiveName) { + t.current = current; + t.required = required; + t.age = 0.0f; // restart lifetime + return; + } + } + if (questToasts_.size() >= 4) questToasts_.erase(questToasts_.begin()); + questToasts_.push_back({questTitle, objectiveName, current, required, 0.0f}); + }); + questProgressCallbackSet_ = true; + } + + // Set up other-player level-up toast callback (once) + if (!otherPlayerLevelUpCallbackSet_) { + gameHandler.setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { + // Coalesce: update existing toast for same player + for (auto& t : playerLevelUpToasts_) { + if (t.guid == guid) { + t.newLevel = newLevel; + t.age = 0.0f; + return; + } + } + if (playerLevelUpToasts_.size() >= 3) + playerLevelUpToasts_.erase(playerLevelUpToasts_.begin()); + playerLevelUpToasts_.push_back({guid, "", newLevel, 0.0f}); + }); + otherPlayerLevelUpCallbackSet_ = true; + } + + // Set up PvP honor credit toast callback (once) + if (!pvpHonorCallbackSet_) { + gameHandler.setPvpHonorCallback([this](uint32_t honor, uint64_t /*victimGuid*/, uint32_t rank) { + if (honor == 0) return; + pvpHonorToasts_.push_back({honor, rank, 0.0f}); + if (pvpHonorToasts_.size() > 4) + pvpHonorToasts_.erase(pvpHonorToasts_.begin()); + }); + pvpHonorCallbackSet_ = true; + } + + // Set up item loot toast callback (once) + if (!itemLootCallbackSet_) { + gameHandler.setItemLootCallback([this](uint32_t itemId, uint32_t count, + uint32_t quality, const std::string& name) { + // Coalesce: if same item already in queue, bump count and reset age + for (auto& t : itemLootToasts_) { + if (t.itemId == itemId) { + t.count += count; + t.age = 0.0f; + return; + } + } + if (itemLootToasts_.size() >= 5) + itemLootToasts_.erase(itemLootToasts_.begin()); + itemLootToasts_.push_back({itemId, count, quality, name, 0.0f}); + }); + itemLootCallbackSet_ = true; + } + + // Set up ghost-state callback to flash "You have been resurrected!" on revival (once) + if (!ghostStateCallbackSet_) { + gameHandler.setGhostStateCallback([this](bool isGhost) { + if (!isGhost) { + // Transitioning ghost→alive: trigger the resurrection flash + resurrectFlashTimer_ = kResurrectFlashDuration; + } + }); + ghostStateCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -423,6 +513,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { msaaSettingsApplied_ = true; } + // Apply saved FXAA setting once when renderer is available + if (!fxaaSettingsApplied_) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + renderer->setFXAAEnabled(pendingFXAA); + fxaaSettingsApplied_ = true; + } + } + // Apply saved water refraction setting once when renderer is available if (!waterRefractionApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -559,12 +658,14 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderRepBar(gameHandler); renderCastBar(gameHandler); renderMirrorTimers(gameHandler); + renderCooldownTracker(gameHandler); renderQuestObjectiveTracker(gameHandler); renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); renderRaidWarningOverlay(gameHandler); renderCombatText(gameHandler); renderDPSMeter(gameHandler); + renderDurabilityWarning(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); @@ -597,6 +698,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); renderTrainerWindow(gameHandler); + renderStableWindow(gameHandler); renderTaxiWindow(gameHandler); renderMailWindow(gameHandler); renderMailComposeWindow(gameHandler); @@ -610,9 +712,9 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); + renderBookWindow(gameHandler); renderThreatWindow(gameHandler); renderBgScoreboard(gameHandler); - renderObjectiveTracker(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now if (showMinimap_) { renderMinimapMarkers(gameHandler); @@ -626,6 +728,13 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderSettingsWindow(); renderDingEffect(); renderAchievementToast(); + renderDiscoveryToast(); + renderWhisperToasts(); + renderQuestProgressToasts(); + renderPlayerLevelUpToasts(gameHandler); + renderPvpHonorToasts(); + renderItemLootToasts(); + renderResurrectFlash(); renderZoneText(); // World map (M key toggle handled inside) @@ -1676,6 +1785,32 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared } + // Scan NEW messages for incoming whispers and push a toast notification + { + size_t histSize = chatHistory.size(); + if (histSize < whisperSeenCount_) whisperSeenCount_ = histSize; // cleared + for (size_t wi = whisperSeenCount_; wi < histSize; ++wi) { + const auto& wMsg = chatHistory[wi]; + if (wMsg.type == game::ChatType::WHISPER || + wMsg.type == game::ChatType::RAID_BOSS_WHISPER) { + WhisperToastEntry toast; + toast.sender = wMsg.senderName; + if (toast.sender.empty() && wMsg.senderGuid != 0) + toast.sender = gameHandler.lookupName(wMsg.senderGuid); + if (toast.sender.empty()) toast.sender = "Unknown"; + // Truncate preview to 60 chars + toast.preview = wMsg.message.size() > 60 + ? wMsg.message.substr(0, 57) + "..." + : wMsg.message; + toast.age = 0.0f; + // Keep at most 3 stacked toasts + if (whisperToasts_.size() >= 3) whisperToasts_.erase(whisperToasts_.begin()); + whisperToasts_.push_back(std::move(toast)); + } + } + whisperSeenCount_ = histSize; + } + int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; @@ -2589,6 +2724,13 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD"); } + // Group leader crown on self frame when you lead the party/raid + if (gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are the group leader"); + } if (gameHandler.isAfk()) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); @@ -2835,6 +2977,47 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 2.0f)); } } + + // Melee swing timer — shown when player is auto-attacking + if (gameHandler.isAutoAttacking()) { + const uint64_t lastSwingMs = gameHandler.getLastMeleeSwingMs(); + if (lastSwingMs > 0) { + // Determine weapon speed from the equipped main-hand weapon + uint32_t weaponDelayMs = 2000; // Default: 2.0s unarmed + const auto& mainSlot = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND); + if (!mainSlot.empty() && mainSlot.item.itemId != 0) { + const auto* info = gameHandler.getItemInfo(mainSlot.item.itemId); + if (info && info->delayMs > 0) { + weaponDelayMs = info->delayMs; + } + } + + // Compute elapsed since last swing + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + uint64_t elapsedMs = (nowMs >= lastSwingMs) ? (nowMs - lastSwingMs) : 0; + + // Clamp to weapon delay (cap at 1.0 so the bar fills but doesn't exceed) + float pct = std::min(static_cast(elapsedMs) / static_cast(weaponDelayMs), 1.0f); + + // Light silver-orange color indicating auto-attack readiness + ImVec4 swingColor = (pct >= 0.95f) + ? ImVec4(1.0f, 0.75f, 0.15f, 1.0f) // gold when ready to swing + : ImVec4(0.65f, 0.55f, 0.40f, 1.0f); // muted brown-orange while filling + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, swingColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.12f, 0.08f, 0.8f)); + char swingLabel[24]; + float remainSec = std::max(0.0f, (weaponDelayMs - static_cast(elapsedMs)) / 1000.0f); + if (pct >= 0.98f) + snprintf(swingLabel, sizeof(swingLabel), "Swing!"); + else + snprintf(swingLabel, sizeof(swingLabel), "%.1fs", remainSec); + ImGui::ProgressBar(pct, ImVec2(-1.0f, 8.0f), swingLabel); + ImGui::PopStyleColor(2); + } + } + ImGui::End(); ImGui::PopStyleColor(2); @@ -2888,11 +3071,41 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (ImGui::MenuItem("Target Pet")) { gameHandler.setTarget(petGuid); } + if (ImGui::MenuItem("Rename Pet")) { + ImGui::CloseCurrentPopup(); + petRenameOpen_ = true; + petRenameBuf_[0] = '\0'; + } if (ImGui::MenuItem("Dismiss Pet")) { gameHandler.dismissPet(); } ImGui::EndPopup(); } + // Pet rename modal (opened via context menu) + if (petRenameOpen_) { + ImGui::OpenPopup("Rename Pet###PetRename"); + petRenameOpen_ = false; + } + if (ImGui::BeginPopupModal("Rename Pet###PetRename", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Enter new pet name (max 12 characters):"); + ImGui::SetNextItemWidth(180.0f); + bool submitted = ImGui::InputText("##PetRenameInput", petRenameBuf_, sizeof(petRenameBuf_), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (ImGui::Button("OK") || submitted) { + std::string newName(petRenameBuf_); + if (!newName.empty() && newName.size() <= 12) { + gameHandler.renamePet(newName); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); if (petLevel > 0) { ImGui::SameLine(); @@ -3094,23 +3307,30 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { gameHandler.sendPetAction(slotVal, targetGuid); } - // Tooltip: show spell name or built-in command name. + // Tooltip: rich spell info for pet spells, simple label for built-in commands if (ImGui::IsItemHovered()) { - const char* tip = nullptr; if (builtinLabel) { + const char* tip = nullptr; if (actionId == 1) tip = "Passive"; else if (actionId == 2) tip = "Follow"; else if (actionId == 3) tip = "Stay"; else if (actionId == 4) tip = "Defensive"; else if (actionId == 5) tip = "Attack"; else if (actionId == 6) tip = "Aggressive"; + if (tip) ImGui::SetTooltip("%s", tip); + } else if (actionId > 6) { + auto* spellAsset = core::Application::getInstance().getAssetManager(); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(actionId, gameHandler, spellAsset); + if (!richOk) { + std::string nm = gameHandler.getSpellName(actionId); + if (nm.empty()) nm = "Spell #" + std::to_string(actionId); + ImGui::Text("%s", nm.c_str()); + } + if (autocastOn) + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Autocast: On"); + ImGui::EndTooltip(); } - std::string spellNm; - if (!tip && actionId > 6) { - spellNm = gameHandler.getSpellName(actionId); - if (!spellNm.empty()) tip = spellNm.c_str(); - } - if (tip) ImGui::SetTooltip("%s", tip); } ImGui::PopID(); @@ -3308,6 +3528,47 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Group leader crown — golden ♛ when the targeted player is the party/raid leader + if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + + // Quest giver indicator — "!" for available quests, "?" for completable quests + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(target->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a quest available"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a low-level quest available"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest ready to turn in"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest incomplete"); + } + } + + // Creature subtitle (e.g. "", "Captain of the Guard") + if (target->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(target); + const std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", sub.c_str()); + } + } + // Right-click context menu on the target name if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); @@ -3382,6 +3643,27 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { levelColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + // Classification badge: Elite / Rare Elite / Boss / Rare + if (target->getType() == game::ObjectType::UNIT) { + int rank = gameHandler.getCreatureRank(unit->getEntry()); + if (rank == 1) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "[Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Elite — requires a group"); + } else if (rank == 2) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.8f, 0.4f, 1.0f, 1.0f), "[Rare Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare Elite — uncommon spawn, group recommended"); + } else if (rank == 3) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "[Boss]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Boss — raid / dungeon boss"); + } else if (rank == 4) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.0f, 1.0f), "[Rare]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); + } + } if (confirmedCombatWithTarget) { float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); @@ -3724,6 +4006,154 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::ProgressBar(pct, ImVec2(-1, 10), ""); ImGui::PopStyleColor(); } + + // ToT cast bar — orange-yellow, pulses when near completion + if (auto* totCs = gameHandler.getUnitCastState(totGuid)) { + float totCastPct = (totCs->timeTotal > 0.0f) + ? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f; + ImVec4 tcColor; + if (totCastPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + tcColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + tcColor = ImVec4(0.8f, 0.5f, 0.1f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); + char tcLabel[48]; + const std::string& tcName = gameHandler.getSpellName(totCs->spellId); + if (!tcName.empty()) + snprintf(tcLabel, sizeof(tcLabel), "%s (%.1fs)", tcName.c_str(), totCs->timeRemaining); + else + snprintf(tcLabel, sizeof(tcLabel), "Casting... (%.1fs)", totCs->timeRemaining); + ImGui::ProgressBar(totCastPct, ImVec2(-1, 8), tcLabel); + ImGui::PopStyleColor(); + } + + // ToT aura row — compact icons, debuffs first + { + const std::vector* totAuras = nullptr; + if (totGuid == gameHandler.getPlayerGuid()) + totAuras = &gameHandler.getPlayerAuras(); + else if (totGuid == gameHandler.getTargetGuid()) + totAuras = &gameHandler.getTargetAuras(); + else + totAuras = gameHandler.getUnitAuras(totGuid); + + if (totAuras) { + int totActive = 0; + for (const auto& a : *totAuras) if (!a.isEmpty()) totActive++; + if (totActive > 0) { + auto* totAsset = core::Application::getInstance().getAssetManager(); + constexpr float TA_ICON = 16.0f; + constexpr int TA_PER_ROW = 8; + + ImGui::Separator(); + + uint64_t taNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + std::vector taIdx; + taIdx.reserve(totAuras->size()); + for (size_t i = 0; i < totAuras->size(); ++i) + if (!(*totAuras)[i].isEmpty()) taIdx.push_back(i); + std::sort(taIdx.begin(), taIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*totAuras)[a].flags & 0x80) != 0; + bool bD = ((*totAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; + int32_t ra = (*totAuras)[a].getRemainingMs(taNowMs); + int32_t rb = (*totAuras)[b].getRemainingMs(taNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int taShown = 0; + for (size_t si = 0; si < taIdx.size() && taShown < 16; ++si) { + const auto& aura = (*totAuras)[taIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (taShown > 0 && taShown % TA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(taIdx[si]) + 5000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet taIcon = (totAsset) + ? getSpellIcon(aura.spellId, totAsset) : VK_NULL_HANDLE; + if (taIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##taura", + (ImTextureID)(uintptr_t)taIcon, + ImVec2(TA_ICON - 2, TA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); + ImGui::Button(lab, ImVec2(TA_ICON, TA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t taRemain = aura.getRemainingMs(taNowMs); + if (taRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (taRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, totAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, totAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (taRemain > 0) { + int s = taRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + taShown++; + } + ImGui::PopStyleVar(); + } + } + } } } ImGui::End(); @@ -3790,6 +4220,27 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImGui::TextDisabled("[Focus]"); ImGui::SameLine(); + // Raid mark icon (star, circle, diamond, …) preceding the name + { + static constexpr struct { const char* sym; ImU32 col; } kFocusMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 204, 0, 255) }, // 0 Star (yellow) + { "\xe2\x97\x8f", IM_COL32(255, 103, 0, 255) }, // 1 Circle (orange) + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple) + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green) + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue) + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal) + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red) + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white) + }; + uint8_t fmark = gameHandler.getEntityRaidMark(focus->getGuid()); + if (fmark < game::GameHandler::kRaidMarkCount) { + ImGui::GetWindowDrawList()->AddText( + ImGui::GetCursorScreenPos(), + kFocusMarks[fmark].col, kFocusMarks[fmark].sym); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); + } + } + std::string focusName = getEntityName(focus); ImGui::PushStyleColor(ImGuiCol_Text, focusColor); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); @@ -3799,6 +4250,51 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Group leader crown — golden ♛ when the focused player is the party/raid leader + if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + + // Quest giver indicator and classification badge for NPC focus targets + if (focus->getType() == game::ObjectType::UNIT) { + auto focusUnit = std::static_pointer_cast(focus); + + // Quest indicator: ! / ? + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(focus->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); + } + } + + // Classification badge + int fRank = gameHandler.getCreatureRank(focusUnit->getEntry()); + if (fRank == 1) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.8f,0.2f,1.0f), "[Elite]"); } + else if (fRank == 2) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.8f,0.4f,1.0f,1.0f), "[Rare Elite]"); } + else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "[Boss]"); } + else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } + + // Creature subtitle + const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); + if (!fSub.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); + } + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); const uint64_t fGuid = focus->getGuid(); @@ -3915,6 +4411,151 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { } } + // Focus auras — buffs first, then debuffs, up to 8 icons wide + { + const std::vector* focusAuras = + (focus->getGuid() == gameHandler.getTargetGuid()) + ? &gameHandler.getTargetAuras() + : gameHandler.getUnitAuras(focus->getGuid()); + + if (focusAuras) { + int activeCount = 0; + for (const auto& a : *focusAuras) if (!a.isEmpty()) activeCount++; + if (activeCount > 0) { + auto* focusAsset = core::Application::getInstance().getAssetManager(); + constexpr float FA_ICON = 20.0f; + constexpr int FA_PER_ROW = 10; + + ImGui::Separator(); + + uint64_t faNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: debuffs first (so hostile-caster info is prominent), then buffs + std::vector faIdx; + faIdx.reserve(focusAuras->size()); + for (size_t i = 0; i < focusAuras->size(); ++i) + if (!(*focusAuras)[i].isEmpty()) faIdx.push_back(i); + std::sort(faIdx.begin(), faIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*focusAuras)[a].flags & 0x80) != 0; + bool bD = ((*focusAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; // debuffs first + int32_t ra = (*focusAuras)[a].getRemainingMs(faNowMs); + int32_t rb = (*focusAuras)[b].getRemainingMs(faNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int faShown = 0; + for (size_t si = 0; si < faIdx.size() && faShown < 20; ++si) { + const auto& aura = (*focusAuras)[faIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (faShown > 0 && faShown % FA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(faIdx[si]) + 3000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet faIcon = (focusAsset) + ? getSpellIcon(aura.spellId, focusAsset) : VK_NULL_HANDLE; + if (faIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##faura", + (ImTextureID)(uintptr_t)faIcon, + ImVec2(FA_ICON - 2, FA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId); + ImGui::Button(lab, ImVec2(FA_ICON, FA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t faRemain = aura.getRemainingMs(faNowMs); + if (faRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (faRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y - 1.0f; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Stack / charge count — upper-left corner (parity with target frame) + if (aura.charges > 1) { + ImVec2 faMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 3, faMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 2, faMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, focusAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, focusAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (faRemain > 0) { + int s = faRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + faShown++; + } + ImGui::PopStyleVar(); + } + } + } + + // Distance to focus target + { + const auto& mv = gameHandler.getMovementInfo(); + float fdx = focus->getX() - mv.x; + float fdy = focus->getY() - mv.y; + float fdz = focus->getZ() - mv.z; + float fdist = std::sqrt(fdx * fdx + fdy * fdy + fdz * fdz); + ImGui::TextDisabled("%.1f yd", fdist); + } + // Clicking the focus frame targets it if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { gameHandler.setTarget(focus->getGuid()); @@ -5703,6 +6344,31 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { gameHandler.getPlayerExploredZoneMasks(), gameHandler.hasPlayerExploredZoneMasks()); + // Party member dots on world map + { + std::vector dots; + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + for (const auto& member : partyData.members) { + if (!member.isOnline || !member.hasPartyStats) continue; + if (member.posX == 0 && member.posY == 0) continue; + // posY → canonical X (north), posX → canonical Y (west) + float wowX = static_cast(member.posY); + float wowY = static_cast(member.posX); + glm::vec3 rpos = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f)); + auto ent = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(ent.get()); + ImU32 col = (cid != 0) + ? classColorU32(cid, 230) + : (member.guid == partyData.leaderGuid + ? IM_COL32(255, 210, 0, 230) + : IM_COL32(100, 180, 255, 230)); + dots.push_back({ rpos, col, member.name }); + } + } + wm->setPartyDots(std::move(dots)); + } + glm::vec3 playerPos = renderer->getCharacterPosition(); auto* window = app.getWindow(); int screenW = window ? window->getWidth() : 1280; @@ -6103,12 +6769,17 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { ImGui::BeginTooltip(); - if (barItemDef && !barItemDef->name.empty()) + // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) + const auto* itemQueryInfo = gameHandler.getItemInfo(slot.id); + if (itemQueryInfo && itemQueryInfo->valid) { + inventoryScreen.renderItemTooltip(*itemQueryInfo); + } else if (barItemDef && !barItemDef->name.empty()) { ImGui::Text("%s", barItemDef->name.c_str()); - else if (!itemNameFromQuery.empty()) + } else if (!itemNameFromQuery.empty()) { ImGui::Text("%s", itemNameFromQuery.c_str()); - else + } else { ImGui::Text("Item #%u", slot.id); + } if (onCooldown) { float cd = slot.cooldownRemaining; if (cd >= 60.0f) @@ -6189,6 +6860,20 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } + // Auto-attack active glow — pulsing golden border when slot 6603 (Attack) is toggled on + if (slot.type == game::ActionBarSlot::SPELL && slot.id == 6603 + && gameHandler.isAutoAttacking()) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 5.0f); + ImU32 glowCol = IM_COL32( + static_cast(255), + static_cast(200 * pulse), + static_cast(0), + static_cast(200 * pulse)); + ImGui::GetWindowDrawList()->AddRect(bMin, bMax, glowCol, 2.0f, 0, 2.5f); + } + // Item stack count overlay — bottom-right corner of icon if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { // Count total of this item across all inventory slots @@ -6357,6 +7042,38 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(4); } + // Vehicle exit button (WotLK): floating button above action bar when player is in a vehicle + if (gameHandler.isInVehicle()) { + const float btnW = 120.0f; + const float btnH = 32.0f; + const float btnX = (screenW - btnW) / 2.0f; + const float btnY = barY - btnH - 6.0f; + + ImGui::SetNextWindowPos(ImVec2(btnX, btnY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW, btnH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGuiWindowFlags vFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##VehicleExit", nullptr, vFlags)) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.4f, 0.0f, 0.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + if (ImGui::Button("Leave Vehicle", ImVec2(btnW - 8.0f, btnH - 8.0f))) { + gameHandler.sendRequestVehicleExit(); + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); + } + // Handle action bar drag: render icon at cursor and detect drop outside if (actionBarDragSlot_ >= 0) { ImVec2 mousePos = ImGui::GetMousePos(); @@ -7052,6 +7769,98 @@ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { } } +// ============================================================ +// Cooldown Tracker — floating panel showing all active spell CDs +// ============================================================ + +void GameScreen::renderCooldownTracker(game::GameHandler& gameHandler) { + if (!showCooldownTracker_) return; + + const auto& cooldowns = gameHandler.getSpellCooldowns(); + if (cooldowns.empty()) return; + + // Collect spells with remaining cooldown > 0.5s (skip GCD noise) + struct CDEntry { uint32_t spellId; float remaining; }; + std::vector active; + active.reserve(16); + for (const auto& [sid, rem] : cooldowns) { + if (rem > 0.5f) active.push_back({sid, rem}); + } + if (active.empty()) return; + + // Sort: longest remaining first + std::sort(active.begin(), active.end(), [](const CDEntry& a, const CDEntry& b) { + return a.remaining > b.remaining; + }); + + auto* assetMgr = core::Application::getInstance().getAssetManager(); + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + constexpr float TRACKER_W = 200.0f; + constexpr int MAX_SHOWN = 12; + float posX = screenW - TRACKER_W - 10.0f; + float posY = screenH - 220.0f; // above the action bar area + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##CooldownTracker", nullptr, flags)) { + ImGui::TextDisabled("Cooldowns"); + ImGui::Separator(); + + int shown = 0; + for (const auto& cd : active) { + if (shown >= MAX_SHOWN) break; + + const std::string& name = gameHandler.getSpellName(cd.spellId); + if (name.empty()) continue; // skip unnamed spells (internal/passive) + + // Small icon if available + VkDescriptorSet icon = assetMgr ? getSpellIcon(cd.spellId, assetMgr) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(14, 14)); + ImGui::SameLine(0, 3); + } + + // Name (truncated) + remaining time + char timeStr[16]; + if (cd.remaining >= 60.0f) + snprintf(timeStr, sizeof(timeStr), "%dm%ds", (int)cd.remaining / 60, (int)cd.remaining % 60); + else + snprintf(timeStr, sizeof(timeStr), "%.0fs", cd.remaining); + + // Color: red > 30s, orange > 10s, yellow > 5s, green otherwise + ImVec4 cdColor = cd.remaining > 30.0f ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) : + cd.remaining > 10.0f ? ImVec4(1.0f, 0.6f, 0.2f, 1.0f) : + cd.remaining > 5.0f ? ImVec4(1.0f, 1.0f, 0.3f, 1.0f) : + ImVec4(0.5f, 1.0f, 0.5f, 1.0f); + + // Truncate name to fit + std::string displayName = name; + if (displayName.size() > 16) displayName = displayName.substr(0, 15) + "\xe2\x80\xa6"; // ellipsis + + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", displayName.c_str()); + ImGui::SameLine(TRACKER_W - 48.0f); + ImGui::TextColored(cdColor, "%s", timeStr); + + ++shown; + } + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + // ============================================================ // Quest Objective Tracker (right-side HUD) // ============================================================ @@ -7088,14 +7897,19 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } if (toShow.empty()) return; - float x = screenW - TRACKER_W - RIGHT_MARGIN; - float y = 320.0f; // below minimap (210) + buff bar space (up to 3 rows ≈ 114px) + float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f; - ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + // Default position: top-right, below minimap + buff bar space + if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) { + questTrackerPos_ = ImVec2(screenW - TRACKER_W - RIGHT_MARGIN, 320.0f); + questTrackerPosInit_ = true; + } + + ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); @@ -7192,12 +8006,27 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; if (iconTex) { ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12)); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } ImGui::SameLine(0, 3); ImGui::TextColored(objColor, "%s: %u/%u", itemName ? itemName : "Item", count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } else if (itemName) { ImGui::TextColored(objColor, " %s: %u/%u", itemName, count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } else { ImGui::TextColored(objColor, " Item: %u/%u", count, required); @@ -7219,6 +8048,16 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::Spacing(); } } + + // Capture position after drag + ImVec2 newPos = ImGui::GetWindowPos(); + if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || + std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { + newPos.x = std::clamp(newPos.x, 0.0f, screenW - TRACKER_W); + newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); + questTrackerPos_ = newPos; + saveSettings(); + } } ImGui::End(); @@ -7744,6 +8583,23 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } else if (unit->isHostile()) { barColor = IM_COL32(220, 60, 60, A(200)); bgColor = IM_COL32(100, 25, 25, A(160)); + } else if (isPlayer) { + // Player nameplates: use class color for easy identification + uint8_t cid = entityClassId(unit); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + barColor = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), A(210)); + bgColor = IM_COL32( + static_cast(cv.x * 80), + static_cast(cv.y * 80), + static_cast(cv.z * 80), A(160)); + } else { + barColor = IM_COL32(60, 200, 80, A(200)); + bgColor = IM_COL32(25, 100, 35, A(160)); + } } else { barColor = IM_COL32(60, 200, 80, A(200)); bgColor = IM_COL32(25, 100, 35, A(160)); @@ -7854,6 +8710,18 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { 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); + + // Spell name tooltip on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x >= dotX && mouse.x < dotX + dotSize && + mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { + const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); + if (!dotSpellName.empty()) + ImGui::SetTooltip("%s", dotSpellName.c_str()); + } + } + dotX += dotSize + dotGap; if (dotX + dotSize > barX + barW) break; } @@ -7905,6 +8773,15 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + // Group leader crown to the right of the name on player nameplates + if (isPlayer && gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == guid) { + float crownX = nameX + textSize.x + 3.0f; + const char* crownSym = "\xe2\x99\x9b"; // ♛ + drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym); + drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym); + } + // Raid mark (if any) to the left of the name { static const struct { const char* sym; ImU32 col; } kNPMarks[] = { @@ -7925,11 +8802,35 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } // Quest kill objective indicator: small yellow sword icon to the right of the name + float questIconX = nameX + textSize.x + 4.0f; if (!isPlayer && questKillEntries.count(unit->getEntry())) { const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) - float objX = nameX + textSize.x + 4.0f; - drawList->AddText(ImVec2(objX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); - drawList->AddText(ImVec2(objX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); + drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f; + } + + // Quest giver indicator: "!" for available quests, "?" for completable/incomplete + if (!isPlayer) { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(guid); + const char* qSym = nullptr; + ImU32 qCol = IM_COL32(255, 210, 0, A(255)); + if (qgs == QGS::AVAILABLE) { + qSym = "!"; + } else if (qgs == QGS::AVAILABLE_LOW) { + qSym = "!"; + qCol = IM_COL32(160, 160, 160, A(220)); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + qSym = "?"; + } else if (qgs == QGS::INCOMPLETE) { + qSym = "?"; + qCol = IM_COL32(160, 160, 160, A(220)); + } + if (qSym) { + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym); + drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym); + } } } @@ -8125,6 +9026,29 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (isMemberLeader) draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*"); + // Raid mark symbol — small, just to the left of the leader crown + { + static const struct { const char* sym; ImU32 col; } kCellMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, + }; + uint8_t rmk = gameHandler.getEntityRaidMark(m.guid); + if (rmk < game::GameHandler::kRaidMarkCount) { + ImFont* rmFont = ImGui::GetFont(); + ImVec2 rmsz = rmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kCellMarks[rmk].sym); + float rmX = cellMax.x - 10.0f - 2.0f - rmsz.x; + draw->AddText(rmFont, 9.0f, + ImVec2(rmX, cellMin.y + 2.0f), + kCellMarks[rmk].col, kCellMarks[rmk].sym); + } + } + // LFG role badge in bottom-right corner of cell if (m.roles & 0x02) draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T"); @@ -8182,6 +9106,60 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f); } + // Dispellable debuff dots at the bottom of the raid cell + // Mirrors party frame debuff indicators for healers in 25/40-man raids + if (!isDead && !isGhost) { + const std::vector* unitAuras = nullptr; + if (m.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (m.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(m.guid); + + if (unitAuras) { + bool shown[5] = {}; + float dotX = cellMin.x + 4.0f; + const float dotY = cellMax.y - 5.0f; + const float DOT_R = 3.5f; + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // debuffs only + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dc; + switch (dt) { + case 1: dc = ImVec4(0.25f, 0.50f, 1.00f, 0.90f); break; // Magic: blue + case 2: dc = ImVec4(0.70f, 0.15f, 0.90f, 0.90f); break; // Curse: purple + case 3: dc = ImVec4(0.65f, 0.45f, 0.10f, 0.90f); break; // Disease: brown + case 4: dc = ImVec4(0.10f, 0.75f, 0.10f, 0.90f); break; // Poison: green + default: continue; + } + ImU32 dotColU = ImGui::ColorConvertFloat4ToU32(dc); + draw->AddCircleFilled(ImVec2(dotX, dotY), DOT_R, dotColU); + draw->AddCircle(ImVec2(dotX, dotY), DOT_R + 0.5f, IM_COL32(0, 0, 0, 160), 8, 1.0f); + + float mdx = mouse.x - dotX, mdy = mouse.y - dotY; + if (mdx * mdx + mdy * mdy < (DOT_R + 4.0f) * (DOT_R + 4.0f)) { + static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; + ImGui::BeginTooltip(); + ImGui::TextColored(dc, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } + dotX += 9.0f; + } + } + } + // Clickable invisible region over the whole cell ImGui::SetCursorScreenPos(cellMin); ImGui::PushID(static_cast(m.guid)); @@ -8298,6 +9276,12 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } + // Zone tooltip on name hover + if (ImGui::IsItemHovered() && member.hasPartyStats && member.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(member.zoneId); + if (!zoneName.empty()) + ImGui::SetTooltip("%s", zoneName.c_str()); + } ImGui::PopStyleColor(); // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set @@ -8308,6 +9292,27 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } } + // Raid mark symbol — shown on same line as name when this party member has a mark + { + static const struct { const char* sym; ImU32 col; } kPartyMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImGui::SameLine(); + ImGui::TextColored( + ImGui::ColorConvertU32ToFloat4(kPartyMarks[pmk].col), + "%s", kPartyMarks[pmk].sym); + } + } + // Health bar: prefer party stats, fall back to entity uint32_t hp = 0, maxHp = 0; if (member.hasPartyStats && member.maxHealth > 0) { @@ -8443,6 +9448,20 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); ImGui::Button("##d", ImVec2(8.0f, 8.0f)); ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) { + static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; + // Find spell name(s) of this dispel type + ImGui::BeginTooltip(); + ImGui::TextColored(dotCol, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } ImGui::SameLine(); } ImGui::NewLine(); @@ -8552,6 +9571,72 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Durability Warning (equipment damage indicator) +// ============================================================ + +void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { + if (gameHandler.getPlayerGuid() == 0) return; + + const auto& inv = gameHandler.getInventory(); + + // Scan all equipment slots (skip bag slots which have no durability) + float minDurPct = 1.0f; + bool hasBroken = false; + + for (int i = static_cast(game::EquipSlot::HEAD); + i < static_cast(game::EquipSlot::BAG1); ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) { + hasBroken = true; + } + float pct = static_cast(slot.item.curDurability) / + static_cast(slot.item.maxDurability); + if (pct < minDurPct) minDurPct = pct; + } + + // Only show warning below 20% + if (minDurPct >= 0.2f && !hasBroken) return; + + ImGuiIO& io = ImGui::GetIO(); + const float screenW = io.DisplaySize.x; + const float screenH = io.DisplaySize.y; + + // Position: just above the XP bar / action bar area (bottom-center) + const float warningW = 220.0f; + const float warningH = 26.0f; + const float posX = (screenW - warningW) * 0.5f; + const float posY = screenH - 140.0f; // above action bar + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0)); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + if (ImGui::Begin("##durability_warn", nullptr, flags)) { + if (hasBroken) { + ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f), + "\xef\x94\x9b Gear broken! Visit a repair NPC"); + } else { + int pctInt = static_cast(minDurPct * 100.0f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), + "\xef\x94\x9b Low durability: %d%%", pctInt); + } + if (ImGui::IsWindowHovered()) + ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC."); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + // ============================================================ // UI Error Frame (WoW-style center-bottom error overlay) // ============================================================ @@ -8915,17 +10000,22 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { for (const auto& bs : active) { ImGui::PushID(static_cast(bs.guid)); - // Try to resolve name and health from entity manager + // Try to resolve name, health, and power from entity manager std::string name = "Boss"; uint32_t hp = 0, maxHp = 0; + uint8_t bossPowerType = 0; + uint32_t bossPower = 0, bossMaxPower = 0; auto entity = gameHandler.getEntityManager().getEntity(bs.guid); if (entity && (entity->getType() == game::ObjectType::UNIT || entity->getType() == game::ObjectType::PLAYER)) { auto unit = std::static_pointer_cast(entity); const auto& n = unit->getName(); if (!n.empty()) name = n; - hp = unit->getHealth(); - maxHp = unit->getMaxHealth(); + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + bossPowerType = unit->getPowerType(); + bossPower = unit->getPower(); + bossMaxPower = unit->getMaxPower(); } // Clickable name to target @@ -8946,6 +10036,25 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Boss power bar — shown when boss has a non-zero power pool + // Energy bosses (type 3) are particularly important: full energy signals ability use + if (bossMaxPower > 0 && bossPower > 0) { + float bpPct = static_cast(bossPower) / static_cast(bossMaxPower); + ImVec4 bpColor; + switch (bossPowerType) { + case 0: bpColor = ImVec4(0.2f, 0.3f, 0.9f, 1.0f); break; // Mana: blue + case 1: bpColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage: red + case 2: bpColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus: orange + case 3: bpColor = ImVec4(0.9f, 0.9f, 0.1f, 1.0f); break; // Energy: yellow + default: bpColor = ImVec4(0.4f, 0.8f, 0.4f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bpColor); + char bpLabel[24]; + std::snprintf(bpLabel, sizeof(bpLabel), "%u", bossPower); + ImGui::ProgressBar(bpPct, ImVec2(-1, 6), bpLabel); + ImGui::PopStyleColor(); + } + // Boss cast bar — shown when the boss is casting (critical for interrupt) if (auto* cs = gameHandler.getUnitCastState(bs.guid)) { float castPct = (cs->timeTotal > 0.0f) @@ -8982,6 +10091,152 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Boss aura row: debuffs first (player DoTs), then boss buffs + { + const std::vector* bossAuras = nullptr; + if (bs.guid == gameHandler.getTargetGuid()) + bossAuras = &gameHandler.getTargetAuras(); + else + bossAuras = gameHandler.getUnitAuras(bs.guid); + + if (bossAuras) { + int bossActive = 0; + for (const auto& a : *bossAuras) if (!a.isEmpty()) bossActive++; + if (bossActive > 0) { + constexpr float BA_ICON = 16.0f; + constexpr int BA_PER_ROW = 10; + + uint64_t baNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: player-applied debuffs first (most relevant), then others + const uint64_t pguid = gameHandler.getPlayerGuid(); + std::vector baIdx; + baIdx.reserve(bossAuras->size()); + for (size_t i = 0; i < bossAuras->size(); ++i) + if (!(*bossAuras)[i].isEmpty()) baIdx.push_back(i); + std::sort(baIdx.begin(), baIdx.end(), [&](size_t a, size_t b) { + const auto& aa = (*bossAuras)[a]; + const auto& ab = (*bossAuras)[b]; + bool aPlayerDot = (aa.flags & 0x80) != 0 && aa.casterGuid == pguid; + bool bPlayerDot = (ab.flags & 0x80) != 0 && ab.casterGuid == pguid; + if (aPlayerDot != bPlayerDot) return aPlayerDot > bPlayerDot; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; + int32_t ra = aa.getRemainingMs(baNowMs); + int32_t rb = ab.getRemainingMs(baNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int baShown = 0; + for (size_t si = 0; si < baIdx.size() && baShown < 20; ++si) { + const auto& aura = (*bossAuras)[baIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + bool isPlayerCast = (aura.casterGuid == pguid); + + if (baShown > 0 && baShown % BA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(baIdx[si]) + 7000); + + ImVec4 borderCol; + if (isBuff) { + // Boss buffs: gold for important enrage/shield types + borderCol = ImVec4(0.8f, 0.6f, 0.1f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = isPlayerCast + ? ImVec4(0.90f, 0.30f, 0.10f, 0.9f) // player DoT: orange-red + : ImVec4(0.60f, 0.20f, 0.20f, 0.9f); // other debuff: dark red + break; + } + } + + VkDescriptorSet baIcon = assetMgr + ? getSpellIcon(aura.spellId, assetMgr) : VK_NULL_HANDLE; + if (baIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##baura", + (ImTextureID)(uintptr_t)baIcon, + ImVec2(BA_ICON - 2, BA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); + ImGui::Button(lab, ImVec2(BA_ICON, BA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t baRemain = aura.getRemainingMs(baNowMs); + if (baRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (baRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Stack / charge count — upper-left corner (parity with target/focus frames) + if (aura.charges > 1) { + ImVec2 baMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 2, baMin.y + 2), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 1, baMin.y + 1), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (isPlayerCast && !isBuff) + ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "Your DoT"); + if (baRemain > 0) { + int s = baRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + baShown++; + } + ImGui::PopStyleVar(); + } + } + } + ImGui::PopID(); ImGui::Spacing(); } @@ -10587,6 +11842,52 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // ---- Arena tab (WotLK: shows per-team rating/record) ---- + const auto& arenaStats = gameHandler.getArenaTeamStats(); + if (!arenaStats.empty()) { + if (ImGui::BeginTabItem("Arena")) { + ImGui::BeginChild("##ArenaList", ImVec2(200, 200), false); + + for (size_t ai = 0; ai < arenaStats.size(); ++ai) { + const auto& ts = arenaStats[ai]; + ImGui::PushID(static_cast(ai)); + + // Team header with rating + char teamLabel[48]; + snprintf(teamLabel, sizeof(teamLabel), "Team #%u", ts.teamId); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel); + + ImGui::Indent(8.0f); + // Rating and rank + ImGui::Text("Rating: %u", ts.rating); + if (ts.rank > 0) { + ImGui::SameLine(0, 6); + ImGui::TextDisabled("(Rank #%u)", ts.rank); + } + + // Weekly record + uint32_t weekLosses = ts.weekGames > ts.weekWins + ? ts.weekGames - ts.weekWins : 0; + ImGui::Text("Week: %u W / %u L", ts.weekWins, weekLosses); + + // Season record + uint32_t seasLosses = ts.seasonGames > ts.seasonWins + ? ts.seasonGames - ts.seasonWins : 0; + ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); + + ImGui::Unindent(8.0f); + + if (ai + 1 < arenaStats.size()) + ImGui::Separator(); + + ImGui::PopID(); + } + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); } } @@ -10808,6 +12109,60 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } ImGui::PopStyleColor(2); } + + // Temporary weapon enchant timers (Shaman imbues, Rogue poisons, whetstones, etc.) + { + const auto& timers = gameHandler.getTempEnchantTimers(); + if (!timers.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + static const ImVec4 kEnchantSlotColors[] = { + ImVec4(0.9f, 0.6f, 0.1f, 1.0f), // main-hand: gold + ImVec4(0.5f, 0.8f, 0.9f, 1.0f), // off-hand: teal + ImVec4(0.7f, 0.5f, 0.9f, 1.0f), // ranged: purple + }; + uint64_t enchNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + for (const auto& t : timers) { + if (t.slot > 2) continue; + uint64_t remMs = (t.expireMs > enchNowMs) ? (t.expireMs - enchNowMs) : 0; + if (remMs == 0) continue; + + ImVec4 col = kEnchantSlotColors[t.slot]; + // Flash red when < 60s remaining + if (remMs < 60000) { + float pulse = 0.6f + 0.4f * std::sin( + static_cast(ImGui::GetTime()) * 4.0f); + col = ImVec4(pulse, 0.2f, 0.1f, 1.0f); + } + + // Format remaining time + uint32_t secs = static_cast((remMs + 999) / 1000); + char timeStr[16]; + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh%02dm", secs / 3600, (secs % 3600) / 60); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%ds", secs); + + ImGui::PushID(static_cast(t.slot) + 5000); + ImGui::PushStyleColor(ImGuiCol_Button, col); + char label[40]; + snprintf(label, sizeof(label), "~%s %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], timeStr); + ImGui::Button(label, ImVec2(-1, 16)); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Temporary weapon enchant: %s\nRemaining: %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], + timeStr); + ImGui::PopStyleColor(); + ImGui::PopID(); + } + } + } } ImGui::End(); @@ -10936,7 +12291,43 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Process deferred loot pickup (after loop to avoid iterator invalidation) if (lootSlotClicked >= 0) { - gameHandler.lootItem(static_cast(lootSlotClicked)); + if (gameHandler.hasMasterLootCandidates()) { + // Master looter: open popup to choose recipient + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", lootSlotClicked); + ImGui::OpenPopup(popupId); + } else { + gameHandler.lootItem(static_cast(lootSlotClicked)); + } + } + + // Master loot "Give to" popups + if (gameHandler.hasMasterLootCandidates()) { + for (const auto& item : loot.items) { + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", item.slotIndex); + if (ImGui::BeginPopup(popupId)) { + ImGui::TextDisabled("Give to:"); + ImGui::Separator(); + const auto& candidates = gameHandler.getMasterLootCandidates(); + for (uint64_t candidateGuid : candidates) { + auto entity = gameHandler.getEntityManager().getEntity(candidateGuid); + auto* unit = entity ? dynamic_cast(entity.get()) : nullptr; + const char* cName = unit ? unit->getName().c_str() : nullptr; + char nameBuf[64]; + if (!cName || cName[0] == '\0') { + snprintf(nameBuf, sizeof(nameBuf), "Player 0x%llx", + static_cast(candidateGuid)); + cName = nameBuf; + } + if (ImGui::MenuItem(cName)) { + gameHandler.lootMasterGive(item.slotIndex, candidateGuid); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } } if (loot.items.empty() && loot.gold == 0) { @@ -12012,10 +13403,18 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); if (!name.empty()) { - ImGui::Text("%s", name.c_str()); - if (!rank.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", rank.c_str()); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", name.c_str()); + if (!rank.empty()) ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "%s", rank.c_str()); } - ImGui::Text("Status: %s", statusLabel); + const std::string& spDesc = gameHandler.getSpellDescription(spell->spellId); + if (!spDesc.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextWrapped("%s", spDesc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + ImGui::TextDisabled("Status: %s", statusLabel); if (spell->reqLevel > 0) { ImVec4 lvlColor = levelMet ? ImVec4(0.7f, 0.7f, 0.7f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); ImGui::TextColored(lvlColor, "Required Level: %u", spell->reqLevel); @@ -12266,6 +13665,123 @@ void GameScreen::renderEscapeMenu() { ImGui::End(); } +// ============================================================ +// Pet Stable Window +// ============================================================ + +void GameScreen::renderStableWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isStableWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), + ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); + + bool open = true; + if (!ImGui::Begin("Pet Stable", &open, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::End(); + if (!open) { + // User closed the window; clear stable state + gameHandler.closeStableWindow(); + } + return; + } + + const auto& pets = gameHandler.getStabledPets(); + uint8_t numSlots = gameHandler.getStableSlots(); + + ImGui::TextDisabled("Stable slots: %u", static_cast(numSlots)); + ImGui::Separator(); + + // Active pets section + bool hasActivePets = false; + for (const auto& p : pets) { + if (p.isActive) { hasActivePets = true; break; } + } + + if (hasActivePets) { + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Active / Summoned"); + for (const auto& p : pets) { + if (!p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber) * -1 - 1); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u)", displayName.c_str(), p.level); + ImGui::SameLine(); + ImGui::TextDisabled("[Active]"); + + // Offer to stable the active pet if there are free slots + uint8_t usedSlots = 0; + for (const auto& sp : pets) { if (!sp.isActive) ++usedSlots; } + if (usedSlots < numSlots) { + ImGui::SameLine(); + if (ImGui::SmallButton("Store in stable")) { + // Slot 1 is first stable slot; server handles free slot assignment. + gameHandler.stablePet(1); + } + } + ImGui::PopID(); + } + ImGui::Separator(); + } + + // Stabled pets section + ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f), "Stabled Pets"); + + bool hasStabledPets = false; + for (const auto& p : pets) { + if (!p.isActive) { hasStabledPets = true; break; } + } + + if (!hasStabledPets) { + ImGui::TextDisabled(" (No pets in stable)"); + } else { + for (const auto& p : pets) { + if (p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber)); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u, Entry %u)", + displayName.c_str(), p.level, p.entry); + ImGui::SameLine(); + if (ImGui::SmallButton("Retrieve")) { + gameHandler.unstablePet(p.petNumber); + } + ImGui::PopID(); + } + } + + // Empty slots + uint8_t usedStableSlots = 0; + for (const auto& p : pets) { if (!p.isActive) ++usedStableSlots; } + if (usedStableSlots < numSlots) { + ImGui::TextDisabled(" %u empty slot(s) available", + static_cast(numSlots - usedStableSlots)); + } + + ImGui::Separator(); + if (ImGui::Button("Refresh")) { + gameHandler.requestStabledPetList(); + } + ImGui::SameLine(); + if (ImGui::Button("Close")) { + gameHandler.closeStableWindow(); + } + + ImGui::End(); + if (!open) { + gameHandler.closeStableWindow(); + } +} + // ============================================================ // Taxi Window // ============================================================ @@ -12789,6 +14305,20 @@ void GameScreen::renderSettingsWindow() { updateGraphicsPresetFromCurrentSettings(); saveSettings(); } + // FXAA — post-process, combinable with MSAA or FSR3 + { + if (ImGui::Checkbox("FXAA (post-process)", &pendingFXAA)) { + if (renderer) renderer->setFXAAEnabled(pendingFXAA); + updateGraphicsPresetFromCurrentSettings(); + saveSettings(); + } + if (ImGui::IsItemHovered()) { + if (fsr2Active) + ImGui::SetTooltip("FXAA applies spatial anti-aliasing after FSR3 upscaling.\nFSR3 + FXAA is the recommended ultra-quality combination."); + else + ImGui::SetTooltip("FXAA smooths jagged edges as a post-process pass.\nCan be combined with MSAA for extra quality."); + } + } } // FSR Upscaling { @@ -13082,6 +14612,12 @@ void GameScreen::renderSettingsWindow() { ImGui::SameLine(); ImGui::TextDisabled("(damage/healing per second above action bar)"); + if (ImGui::Checkbox("Show Cooldown Tracker", &showCooldownTracker_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(active spell cooldowns near action bar)"); + ImGui::Spacing(); ImGui::SeparatorText("Screen Effects"); ImGui::Spacing(); @@ -13721,6 +15257,7 @@ void GameScreen::applyGraphicsPreset(GraphicsPreset preset) { pendingShadows = true; pendingShadowDistance = 500.0f; pendingAntiAliasing = 3; // 8x MSAA + pendingFXAA = true; // FXAA on top of MSAA for maximum smoothness pendingNormalMapping = true; pendingNormalMapStrength = 1.2f; pendingPOM = true; @@ -13730,6 +15267,7 @@ void GameScreen::applyGraphicsPreset(GraphicsPreset preset) { renderer->setShadowsEnabled(true); renderer->setShadowDistance(500.0f); renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT); + renderer->setFXAAEnabled(true); if (auto* wr = renderer->getWMORenderer()) { wr->setNormalMappingEnabled(true); wr->setNormalMapStrength(1.2f); @@ -13775,7 +15313,7 @@ void GameScreen::updateGraphicsPresetFromCurrentSettings() { pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110; case GraphicsPreset::ULTRA: return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 && - pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140; + pendingFXAA && pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140; default: return false; } @@ -13959,8 +15497,36 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220)); } + // Build sets of entries that are incomplete objectives for tracked quests. + // minimapQuestEntries: NPC creature entries (npcOrGoId > 0) + // minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value) + std::unordered_set minimapQuestEntries; + std::unordered_set minimapQuestGoEntries; + { + const auto& ql = gameHandler.getQuestLog(); + const auto& tq = gameHandler.getTrackedQuestIds(); + for (const auto& q : ql) { + if (q.complete || q.questId == 0) continue; + if (!tq.empty() && !tq.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.required == 0) continue; + if (obj.npcOrGoId > 0) { + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestEntries.insert(static_cast(obj.npcOrGoId)); + } else if (obj.npcOrGoId < 0) { + uint32_t goEntry = static_cast(-obj.npcOrGoId); + auto it = q.killCounts.find(goEntry); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestGoEntries.insert(goEntry); + } + } + } + } + // Optional base nearby NPC dots (independent of quest status packets). if (minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::UNIT) continue; @@ -13971,8 +15537,47 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(npcRender, sx, sy)) continue; - ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); - drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); + bool isQuestTarget = minimapQuestEntries.count(unit->getEntry()) != 0; + if (isQuestTarget) { + // Quest kill objective: larger gold dot with dark outline + drawList->AddCircleFilled(ImVec2(sx, sy), 3.5f, IM_COL32(255, 210, 30, 240)); + drawList->AddCircle(ImVec2(sx, sy), 3.5f, IM_COL32(80, 50, 0, 180), 0, 1.0f); + // Tooltip on hover showing unit name + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const std::string& nm = unit->getName(); + if (!nm.empty()) ImGui::SetTooltip("%s (quest)", nm.c_str()); + } + } else { + ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); + drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); + } + } + } + + // Nearby other-player dots — shown when NPC dots are enabled. + // Party members are already drawn as squares above; other players get a small circle. + if (minimapNpcDots_) { + const uint64_t selfGuid = gameHandler.getPlayerGuid(); + const auto& partyData = gameHandler.getPartyData(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + if (entity->getGuid() == selfGuid) continue; // skip self (already drawn as arrow) + + // Skip party members (already drawn as squares above) + bool isPartyMember = false; + for (const auto& m : partyData.members) { + if (m.guid == guid) { isPartyMember = true; break; } + } + if (isPartyMember) continue; + + glm::vec3 pRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(pRender, sx, sy)) continue; + + // Blue dot for other nearby players + drawList->AddCircleFilled(ImVec2(sx, sy), 2.0f, IM_COL32(80, 160, 255, 220)); } } @@ -14013,6 +15618,108 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Interactable game object dots (chests, resource nodes) when NPC dots are enabled. + // Shown as small orange triangles to distinguish from unit dots and loot corpses. + if (minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue; + + // Only show objects that are likely interactive (chests/nodes: type 3; + // also show type 0=Door when open, but filter by dynamic-flag ACTIVATED). + // For simplicity, show all game objects that have a non-empty cached name. + auto go = std::static_pointer_cast(entity); + if (!go) continue; + + // Only show if we have name data (avoids cluttering with unknown objects) + const auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry()); + if (!goInfo || !goInfo->isValid()) continue; + // Skip transport objects (boats/zeppelins): type 15 = MO_TRANSPORT, 11 = TRANSPORT + if (goInfo->type == 11 || goInfo->type == 15) continue; + + glm::vec3 goRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(goRender, sx, sy)) continue; + + // Triangle size and color: bright cyan for quest objectives, amber for others + bool isQuestGO = minimapQuestGoEntries.count(go->getEntry()) != 0; + const float ts = isQuestGO ? 4.5f : 3.5f; + ImVec2 goTip (sx, sy - ts); + ImVec2 goLeft (sx - ts, sy + ts * 0.6f); + ImVec2 goRight(sx + ts, sy + ts * 0.6f); + if (isQuestGO) { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(50, 230, 255, 240)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(0, 60, 80, 200), 1.5f); + } else { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f); + } + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + if (isQuestGO) + ImGui::SetTooltip("%s (quest)", goInfo->name.c_str()); + else + ImGui::SetTooltip("%s", goInfo->name.c_str()); + } + } + } + + // Party member dots on minimap — small colored squares with name tooltip on hover + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& member : partyData.members) { + if (!member.hasPartyStats) continue; + bool isOnline = (member.onlineStatus & 0x0001) != 0; + bool isDead = (member.onlineStatus & 0x0020) != 0; + bool isGhost = (member.onlineStatus & 0x0010) != 0; + if (!isOnline) continue; + if (member.posX == 0 && member.posY == 0) continue; + + // Party stat positions: posY = canonical X (north), posX = canonical Y (west) + glm::vec3 memberRender = core::coords::canonicalToRender( + glm::vec3(static_cast(member.posY), + static_cast(member.posX), 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(memberRender, sx, sy)) continue; + + // Determine dot color: class color > leader gold > light blue + ImU32 dotCol; + if (isDead || isGhost) { + dotCol = IM_COL32(140, 140, 140, 200); // gray for dead + } else { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + dotCol = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), 230); + } else if (member.guid == partyData.leaderGuid) { + dotCol = IM_COL32(255, 210, 0, 230); // gold for leader + } else { + dotCol = IM_COL32(100, 180, 255, 230); // blue for others + } + } + + // Draw a small square (WoW-style party member dot) + const float hs = 3.5f; + drawList->AddRectFilled(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), dotCol, 1.0f); + drawList->AddRect(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), + IM_COL32(0, 0, 0, 180), 1.0f, 0, 1.0f); + + // Name tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f && !member.name.empty()) { + ImGui::SetTooltip("%s", member.name.c_str()); + } + } + } + for (const auto& [guid, status] : statuses) { ImU32 dotColor; const char* marker = nullptr; @@ -14049,6 +15756,25 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddText(font, 11.0f, ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f), IM_COL32(0, 0, 0, 255), marker); + + // Show NPC name and quest status on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + std::string npcName; + if (entity->getType() == game::ObjectType::UNIT) { + auto npcUnit = std::static_pointer_cast(entity); + npcName = npcUnit->getName(); + } + if (!npcName.empty()) { + bool hasQuest = (status == game::QuestGiverStatus::AVAILABLE || + status == game::QuestGiverStatus::AVAILABLE_LOW); + ImGui::SetTooltip("%s\n%s", npcName.c_str(), + hasQuest ? "Has a quest for you" : "Quest ready to turn in"); + } + } + } } // Quest kill objective markers — highlight live NPCs matching active quest kill objectives @@ -14189,10 +15915,41 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor); drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f); + // Raid mark: tiny symbol drawn above the dot + { + static const struct { const char* sym; ImU32 col; } kMMMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImFont* mmFont = ImGui::GetFont(); + ImVec2 msz = mmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kMMMarks[pmk].sym); + drawList->AddText(mmFont, 9.0f, + ImVec2(sx - msz.x * 0.5f, sy - 4.0f - msz.y), + kMMMarks[pmk].col, kMMMarks[pmk].sym); + } + } + ImVec2 cursorPos = ImGui::GetMousePos(); float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { - ImGui::SetTooltip("%s", member.name.c_str()); + uint8_t pmk2 = gameHandler.getEntityRaidMark(member.guid); + if (pmk2 < game::GameHandler::kRaidMarkCount) { + static const char* kMarkNames[] = { + "Star", "Circle", "Diamond", "Triangle", + "Moon", "Square", "Cross", "Skull" + }; + ImGui::SetTooltip("%s {%s}", member.name.c_str(), kMarkNames[pmk2]); + } else { + ImGui::SetTooltip("%s", member.name.c_str()); + } } } } @@ -14674,6 +16431,31 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { break; // Show at most one queue slot indicator } + // LFG queue indicator — shown when Dungeon Finder queue is active (Queued or RoleCheck) + { + using LfgState = game::GameHandler::LfgState; + LfgState lfgSt = gameHandler.getLfgState(); + if (lfgSt == LfgState::Queued || lfgSt == LfgState::RoleCheck) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##LfgQueueIndicator", nullptr, indicatorFlags)) { + if (lfgSt == LfgState::RoleCheck) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, pulse), "LFG: Role Check..."); + } else { + uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); + int qMin = static_cast(qMs / 60000); + int qSec = static_cast((qMs % 60000) / 1000); + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.2f); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, pulse), + "LFG: %d:%02d", qMin, qSec); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + // Latency indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { @@ -15007,6 +16789,7 @@ void GameScreen::saveSettings() { out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n"; out << "show_dps_meter=" << (showDPSMeter_ ? 1 : 0) << "\n"; + out << "show_cooldown_tracker=" << (showCooldownTracker_ ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; out << "action_bar_scale=" << pendingActionBarScale << "\n"; out << "nameplate_scale=" << nameplateScale_ << "\n"; @@ -15043,6 +16826,7 @@ void GameScreen::saveSettings() { out << "shadow_distance=" << pendingShadowDistance << "\n"; out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; + out << "fxaa=" << (pendingFXAA ? 1 : 0) << "\n"; out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; @@ -15062,6 +16846,10 @@ void GameScreen::saveSettings() { out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; out << "fov=" << pendingFov << "\n"; + // Quest tracker position + out << "quest_tracker_x=" << questTrackerPos_.x << "\n"; + out << "quest_tracker_y=" << questTrackerPos_.y << "\n"; + // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; out << "chat_timestamps=" << (chatShowTimestamps_ ? 1 : 0) << "\n"; @@ -15117,6 +16905,8 @@ void GameScreen::loadSettings() { pendingShowLatencyMeter = showLatencyMeter_; } else if (key == "show_dps_meter") { showDPSMeter_ = (std::stoi(val) != 0); + } else if (key == "show_cooldown_tracker") { + showCooldownTracker_ = (std::stoi(val) != 0); } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); @@ -15175,6 +16965,7 @@ void GameScreen::loadSettings() { else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); + else if (key == "fxaa") pendingFXAA = (std::stoi(val) != 0); else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); else if (key == "pom") pendingPOM = (std::stoi(val) != 0); @@ -15203,6 +16994,15 @@ void GameScreen::loadSettings() { if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov); } } + // Quest tracker position + else if (key == "quest_tracker_x") { + questTrackerPos_.x = std::stof(val); + questTrackerPosInit_ = true; + } + else if (key == "quest_tracker_y") { + questTrackerPos_.y = std::stof(val); + questTrackerPosInit_ = true; + } // Chat else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); @@ -16540,9 +18340,18 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { // Level-Up Ding Animation // ============================================================ -void GameScreen::triggerDing(uint32_t newLevel) { - dingTimer_ = DING_DURATION; - dingLevel_ = newLevel; +void GameScreen::triggerDing(uint32_t newLevel, uint32_t hpDelta, uint32_t manaDelta, + uint32_t str, uint32_t agi, uint32_t sta, + uint32_t intel, uint32_t spi) { + dingTimer_ = DING_DURATION; + dingLevel_ = newLevel; + dingHpDelta_ = hpDelta; + dingManaDelta_ = manaDelta; + dingStats_[0] = str; + dingStats_[1] = agi; + dingStats_[2] = sta; + dingStats_[3] = intel; + dingStats_[4] = spi; auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { @@ -16588,6 +18397,43 @@ void GameScreen::renderDingEffect() { // Gold text draw->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); + + // Stat gains below the main text (shown only if server sent deltas) + bool hasStatGains = (dingHpDelta_ > 0 || dingManaDelta_ > 0 || + dingStats_[0] || dingStats_[1] || dingStats_[2] || + dingStats_[3] || dingStats_[4]); + if (hasStatGains) { + float smallSize = baseSize * 0.95f; + float yOff = ty + sz.y + 6.0f; + + // Build stat delta string: "+150 HP +80 Mana +2 Str +2 Agi ..." + static const char* kStatLabels[] = { "Str", "Agi", "Sta", "Int", "Spi" }; + char statBuf[128]; + int written = 0; + if (dingHpDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u HP ", dingHpDelta_); + if (dingManaDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u Mana ", dingManaDelta_); + for (int i = 0; i < 5 && written < (int)sizeof(statBuf) - 1; ++i) { + if (dingStats_[i] > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u %s ", dingStats_[i], kStatLabels[i]); + } + // Trim trailing spaces + while (written > 0 && statBuf[written - 1] == ' ') --written; + statBuf[written] = '\0'; + + if (written > 0) { + ImVec2 ssz = font->CalcTextSizeA(smallSize, FLT_MAX, 0.0f, statBuf); + float stx = cx - ssz.x * 0.5f; + draw->AddText(font, smallSize, ImVec2(stx + 1, yOff + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), statBuf); + draw->AddText(font, smallSize, ImVec2(stx, yOff), + IM_COL32(100, 220, 100, (int)(alpha * 230)), statBuf); + } + } } void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) { @@ -16667,6 +18513,548 @@ void GameScreen::renderAchievementToast() { } // --------------------------------------------------------------------------- +// Area discovery toast — "Discovered: ! (+XP XP)" centered on screen +// --------------------------------------------------------------------------- + +void GameScreen::renderDiscoveryToast() { + if (discoveryToastTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + discoveryToastTimer_ -= dt; + if (discoveryToastTimer_ < 0.0f) discoveryToastTimer_ = 0.0f; + + // Fade: ramp up in first 0.4s, hold, fade out in last 1.0s + float alpha; + if (discoveryToastTimer_ > DISCOVERY_TOAST_DURATION - 0.4f) + alpha = 1.0f - (discoveryToastTimer_ - (DISCOVERY_TOAST_DURATION - 0.4f)) / 0.4f; + else if (discoveryToastTimer_ < 1.0f) + alpha = discoveryToastTimer_; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImFont* font = ImGui::GetFont(); + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + const char* header = "Discovered!"; + float headerSize = 16.0f; + float nameSize = 28.0f; + float xpSize = 14.0f; + + ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); + ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, discoveryToastName_.c_str()); + + char xpBuf[48]; + if (discoveryToastXP_ > 0) + snprintf(xpBuf, sizeof(xpBuf), "+%u XP", discoveryToastXP_); + else + xpBuf[0] = '\0'; + ImVec2 xpDim = font->CalcTextSizeA(xpSize, FLT_MAX, 0.0f, xpBuf); + + // Position slightly below zone text (at 37% down screen) + float centreY = screenH * 0.37f; + float headerX = (screenW - headerDim.x) * 0.5f; + float nameX = (screenW - nameDim.x) * 0.5f; + float xpX = (screenW - xpDim.x) * 0.5f; + float headerY = centreY; + float nameY = centreY + headerDim.y + 4.0f; + float xpY = nameY + nameDim.y + 4.0f; + + // "Discovered!" in gold + draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), header); + draw->AddText(font, headerSize, ImVec2(headerX, headerY), + IM_COL32(255, 215, 0, (int)(alpha * 255)), header); + + // Area name in white + draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), discoveryToastName_.c_str()); + draw->AddText(font, nameSize, ImVec2(nameX, nameY), + IM_COL32(255, 255, 255, (int)(alpha * 255)), discoveryToastName_.c_str()); + + // XP gain in light green (if any) + if (xpBuf[0] != '\0') { + draw->AddText(font, xpSize, ImVec2(xpX + 1, xpY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 140)), xpBuf); + draw->AddText(font, xpSize, ImVec2(xpX, xpY), + IM_COL32(100, 220, 100, (int)(alpha * 230)), xpBuf); + } +} + +// --------------------------------------------------------------------------- +// Quest objective progress toasts — shown at screen bottom-right on kill/item updates +// --------------------------------------------------------------------------- + +void GameScreen::renderQuestProgressToasts() { + if (questToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : questToasts_) t.age += dt; + questToasts_.erase( + std::remove_if(questToasts_.begin(), questToasts_.end(), + [](const QuestProgressToastEntry& t) { return t.age >= QUEST_TOAST_DURATION; }), + questToasts_.end()); + if (questToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack at bottom-right, just above action bar area + constexpr float TOAST_W = 240.0f; + constexpr float TOAST_H = 48.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = screenW - TOAST_W - 14.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(questToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = questToasts_[i]; + + float remaining = QUEST_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark amber tint (quest color convention) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(35, 25, 5, bgA), 5.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 160, 30, static_cast(160 * alpha)), 5.0f, 0, 1.5f); + + // Quest title (gold, small) + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 5.0f), + IM_COL32(220, 180, 50, fgA), toast.questTitle.c_str()); + + // Progress bar + text: "ObjectiveName X / Y" + float barY = ty + 21.0f; + float barX0 = toastX + 8.0f; + float barX1 = toastX + TOAST_W - 8.0f; + float barH = 8.0f; + float pct = (toast.required > 0) + ? std::min(1.0f, static_cast(toast.current) / static_cast(toast.required)) + : 1.0f; + // Bar background + bgDL->AddRectFilled(ImVec2(barX0, barY), ImVec2(barX1, barY + barH), + IM_COL32(50, 40, 10, static_cast(180 * alpha)), 3.0f); + // Bar fill — green when complete, amber otherwise + ImU32 barCol = (pct >= 1.0f) ? IM_COL32(60, 220, 80, fgA) : IM_COL32(200, 160, 30, fgA); + bgDL->AddRectFilled(ImVec2(barX0, barY), + ImVec2(barX0 + (barX1 - barX0) * pct, barY + barH), + barCol, 3.0f); + + // Objective name + count + char progBuf[48]; + if (!toast.objectiveName.empty()) + snprintf(progBuf, sizeof(progBuf), "%.22s: %u/%u", + toast.objectiveName.c_str(), toast.current, toast.required); + else + snprintf(progBuf, sizeof(progBuf), "%u/%u", toast.current, toast.required); + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 32.0f), + IM_COL32(220, 220, 200, static_cast(210 * alpha)), progBuf); + } +} + +// --------------------------------------------------------------------------- +// Item loot toasts — quality-coloured strip at bottom-left when item received +// --------------------------------------------------------------------------- + +void GameScreen::renderItemLootToasts() { + if (itemLootToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : itemLootToasts_) t.age += dt; + itemLootToasts_.erase( + std::remove_if(itemLootToasts_.begin(), itemLootToasts_.end(), + [](const ItemLootToastEntry& t) { return t.age >= ITEM_LOOT_TOAST_DURATION; }), + itemLootToasts_.end()); + if (itemLootToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Quality colours (matching WoW convention) + static const ImU32 kQualityColors[] = { + IM_COL32(157, 157, 157, 255), // 0 grey (poor) + IM_COL32(255, 255, 255, 255), // 1 white (common) + IM_COL32( 30, 255, 30, 255), // 2 green (uncommon) + IM_COL32( 0, 112, 221, 255), // 3 blue (rare) + IM_COL32(163, 53, 238, 255), // 4 purple (epic) + IM_COL32(255, 128, 0, 255), // 5 orange (legendary) + }; + + // Stack at bottom-left above action bars; each item is 24 px tall + constexpr float TOAST_W = 260.0f; + constexpr float TOAST_H = 24.0f; + constexpr float TOAST_GAP = 2.0f; + constexpr float TOAST_X = 14.0f; + float baseY = screenH * 0.68f; // slightly above the whisper toasts + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(itemLootToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = itemLootToasts_[i]; + + float remaining = ITEM_LOOT_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.7f) + alpha = remaining / 0.7f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left + float slideX = (toast.age < 0.15f) ? (TOAST_W * (1.0f - toast.age / 0.15f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(180 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: very dark with quality-tinted left border accent + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(12, 12, 12, bgA), 3.0f); + + // Quality colour accent bar on left edge (3px wide) + ImU32 qualCol = kQualityColors[std::min(static_cast(5u), toast.quality)]; + ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast(fgA) << 24u); + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f); + + // "Loot:" label in dim white + bgDL->AddText(ImVec2(tx + 7.0f, ty + 5.0f), + IM_COL32(160, 160, 160, static_cast(200 * alpha)), "Loot:"); + + // Item name in quality colour + std::string displayName = toast.name.empty() ? ("Item #" + std::to_string(toast.itemId)) : toast.name; + if (displayName.size() > 26) { displayName.resize(23); displayName += "..."; } + bgDL->AddText(ImVec2(tx + 42.0f, ty + 5.0f), qualColA, displayName.c_str()); + + // Count (if > 1) + if (toast.count > 1) { + char countBuf[12]; + snprintf(countBuf, sizeof(countBuf), "x%u", toast.count); + bgDL->AddText(ImVec2(tx + TOAST_W - 34.0f, ty + 5.0f), + IM_COL32(200, 200, 200, static_cast(200 * alpha)), countBuf); + } + } +} + +// --------------------------------------------------------------------------- +// PvP honor credit toasts — shown at screen top-right on honorable kill +// --------------------------------------------------------------------------- + +void GameScreen::renderPvpHonorToasts() { + if (pvpHonorToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : pvpHonorToasts_) t.age += dt; + pvpHonorToasts_.erase( + std::remove_if(pvpHonorToasts_.begin(), pvpHonorToasts_.end(), + [](const PvpHonorToastEntry& t) { return t.age >= PVP_HONOR_TOAST_DURATION; }), + pvpHonorToasts_.end()); + if (pvpHonorToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + + // Stack toasts at top-right, below any minimap area + constexpr float TOAST_W = 180.0f; + constexpr float TOAST_H = 30.0f; + constexpr float TOAST_GAP = 3.0f; + constexpr float TOAST_TOP = 10.0f; + float toastX = screenW - TOAST_W - 10.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(pvpHonorToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = pvpHonorToasts_[i]; + + float remaining = PVP_HONOR_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.8f) + alpha = remaining / 0.8f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = TOAST_TOP + i * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(190 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark red (PvP theme) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(28, 5, 5, bgA), 4.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 50, 50, static_cast(160 * alpha)), 4.0f, 0, 1.2f); + + // Sword ⚔ icon (U+2694, UTF-8: e2 9a 94) + bgDL->AddText(ImVec2(toastX + 7.0f, ty + 7.0f), + IM_COL32(220, 80, 80, fgA), "\xe2\x9a\x94"); + + // "+N Honor" text in gold + char buf[40]; + snprintf(buf, sizeof(buf), "+%u Honor", toast.honor); + bgDL->AddText(ImVec2(toastX + 24.0f, ty + 8.0f), + IM_COL32(255, 210, 50, fgA), buf); + } +} + +// --------------------------------------------------------------------------- +// Nearby player level-up toasts — shown at screen bottom-centre +// --------------------------------------------------------------------------- + +void GameScreen::renderPlayerLevelUpToasts(game::GameHandler& gameHandler) { + if (playerLevelUpToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : playerLevelUpToasts_) { + t.age += dt; + // Lazy name resolution — fill in once the name cache has it + if (t.playerName.empty() && t.guid != 0) { + t.playerName = gameHandler.lookupName(t.guid); + } + } + playerLevelUpToasts_.erase( + std::remove_if(playerLevelUpToasts_.begin(), playerLevelUpToasts_.end(), + [](const PlayerLevelUpToastEntry& t) { + return t.age >= PLAYER_LEVELUP_TOAST_DURATION; + }), + playerLevelUpToasts_.end()); + if (playerLevelUpToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack toasts at screen bottom-centre, above action bars + constexpr float TOAST_W = 230.0f; + constexpr float TOAST_H = 38.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = (screenW - TOAST_W) * 0.5f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(playerLevelUpToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = playerLevelUpToasts_[i]; + + float remaining = PLAYER_LEVELUP_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Subtle pop-up from below during first 0.2s + float slideY = (toast.age < 0.2f) ? (TOAST_H * (1.0f - toast.age / 0.2f)) : 0.0f; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP) + slideY; + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark gold tint + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(30, 22, 5, bgA), 5.0f); + // Gold border with glow at peak + float glowStr = (toast.age < 0.5f) ? (1.0f - toast.age / 0.5f) : 0.0f; + uint8_t borderA = static_cast((160 + 80 * glowStr) * alpha); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(255, 210, 50, borderA), 5.0f, 0, 1.5f + glowStr * 1.5f); + + // Star ★ icon on left + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 10.0f), + IM_COL32(255, 220, 60, fgA), "\xe2\x98\x85"); // UTF-8 ★ + + // " is now level X!" text + const char* displayName = toast.playerName.empty() ? "A player" : toast.playerName.c_str(); + char buf[64]; + snprintf(buf, sizeof(buf), "%.18s is now level %u!", displayName, toast.newLevel); + bgDL->AddText(ImVec2(toastX + 26.0f, ty + 11.0f), + IM_COL32(255, 230, 100, fgA), buf); + } +} + +// --------------------------------------------------------------------------- +// Resurrection flash — brief screen brightening + "You have been resurrected!" +// banner when the player transitions from ghost back to alive. +// --------------------------------------------------------------------------- + +void GameScreen::renderResurrectFlash() { + if (resurrectFlashTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + resurrectFlashTimer_ -= dt; + if (resurrectFlashTimer_ <= 0.0f) { + resurrectFlashTimer_ = 0.0f; + return; + } + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Normalised age in [0, 1] (0 = just fired, 1 = fully elapsed) + float t = 1.0f - resurrectFlashTimer_ / kResurrectFlashDuration; + + // Alpha envelope: fast fade-in (first 0.15s), hold, then fade-out (last 0.8s) + float alpha; + const float fadeIn = 0.15f / kResurrectFlashDuration; // ~5% of lifetime + const float fadeOut = 0.8f / kResurrectFlashDuration; // ~27% of lifetime + if (t < fadeIn) + alpha = t / fadeIn; + else if (t < 1.0f - fadeOut) + alpha = 1.0f; + else + alpha = (1.0f - t) / fadeOut; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + ImDrawList* bg = ImGui::GetBackgroundDrawList(); + + // Soft golden/white vignette — brightening instead of darkening + uint8_t vigA = static_cast(50 * alpha); + bg->AddRectFilled(ImVec2(0, 0), ImVec2(screenW, screenH), + IM_COL32(200, 230, 255, vigA)); + + // Centered banner panel + constexpr float PANEL_W = 360.0f; + constexpr float PANEL_H = 52.0f; + float px = (screenW - PANEL_W) * 0.5f; + float py = screenH * 0.34f; + + uint8_t bgA = static_cast(210 * alpha); + uint8_t borderA = static_cast(255 * alpha); + uint8_t textA = static_cast(255 * alpha); + + // Background: deep blue-black + bg->AddRectFilled(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(10, 18, 40, bgA), 8.0f); + + // Border glow: bright holy gold + bg->AddRect(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(200, 230, 100, borderA), 8.0f, 0, 2.0f); + // Inner halo line + bg->AddRect(ImVec2(px + 3.0f, py + 3.0f), ImVec2(px + PANEL_W - 3.0f, py + PANEL_H - 3.0f), + IM_COL32(255, 255, 180, static_cast(80 * alpha)), 6.0f, 0, 1.0f); + + // "✦ You have been resurrected! ✦" centered + // UTF-8 heavy four-pointed star U+2726: \xe2\x9c\xa6 + const char* banner = "\xe2\x9c\xa6 You have been resurrected! \xe2\x9c\xa6"; + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, banner); + float tx = px + (PANEL_W - textSz.x) * 0.5f; + float ty = py + (PANEL_H - textSz.y) * 0.5f; + + // Drop shadow + bg->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, static_cast(180 * alpha)), banner); + // Main text in warm gold + bg->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 240, 120, textA), banner); +} + +// --------------------------------------------------------------------------- +// Whisper toast notifications — brief overlay when a player whispers you +// --------------------------------------------------------------------------- + +void GameScreen::renderWhisperToasts() { + if (whisperToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + + // Age and prune expired toasts + for (auto& t : whisperToasts_) t.age += dt; + whisperToasts_.erase( + std::remove_if(whisperToasts_.begin(), whisperToasts_.end(), + [](const WhisperToastEntry& t) { return t.age >= WHISPER_TOAST_DURATION; }), + whisperToasts_.end()); + if (whisperToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack toasts at bottom-left, above the action bars (y ≈ screenH * 0.72) + // Each toast is ~56px tall with a 4px gap between them. + constexpr float TOAST_W = 280.0f; + constexpr float TOAST_H = 56.0f; + constexpr float TOAST_GAP = 4.0f; + constexpr float TOAST_X = 14.0f; // left edge (won't cover action bars) + float baseY = screenH * 0.72f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + + const int count = static_cast(whisperToasts_.size()); + for (int i = 0; i < count; ++i) { + auto& toast = whisperToasts_[i]; + + // Fade in over 0.25s; fade out in last 1.0s + float alpha; + float remaining = WHISPER_TOAST_DURATION - toast.age; + if (toast.age < 0.25f) + alpha = toast.age / 0.25f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left: offset 0→0 after 0.25s + float slideX = (toast.age < 0.25f) ? (TOAST_W * (1.0f - toast.age / 0.25f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(210 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background panel — dark purple tint (whisper color convention) + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(25, 10, 40, bgA), 6.0f); + // Purple border + bgDL->AddRect(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(160, 80, 220, static_cast(180 * alpha)), 6.0f, 0, 1.5f); + + // "Whisper" label (small, purple-ish) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 6.0f), + IM_COL32(190, 110, 255, fgA), "Whisper from:"); + + // Sender name (gold) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 20.0f), + IM_COL32(255, 210, 50, fgA), toast.sender.c_str()); + + // Message preview (white, dimmer) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 36.0f), + IM_COL32(220, 220, 220, static_cast(200 * alpha)), + toast.preview.c_str()); + } +} + // Zone discovery text — "Entering: " fades in/out at screen centre // --------------------------------------------------------------------------- @@ -17498,6 +19886,16 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { ImGui::TextDisabled("%s", timeBuf); ImGui::TableSetColumnIndex(1); ImGui::TextColored(color, "%s", desc); + // Hover tooltip: show rich spell info for entries with a known spell + if (e.spellId != 0 && ImGui::IsItemHovered()) { + auto* assetMgrLog = core::Application::getInstance().getAssetManager(); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog); + if (!richOk) { + ImGui::Text("%s", spellName.c_str()); + } + ImGui::EndTooltip(); + } } // Auto-scroll to bottom @@ -17559,7 +19957,22 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { ImGui::TextUnformatted(display.c_str()); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); - ImGui::Text("Achievement ID: %u", id); + // Points badge + uint32_t pts = gameHandler.getAchievementPoints(id); + if (pts > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), + "%u Achievement Point%s", pts, pts == 1 ? "" : "s"); + ImGui::Separator(); + } + // Description + const std::string& desc = gameHandler.getAchievementDescription(id); + if (!desc.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); + ImGui::TextUnformatted(desc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + // Earn date uint32_t packed = gameHandler.getAchievementDate(id); if (packed != 0) { // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] @@ -17573,7 +19986,7 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { "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::TextDisabled("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); } ImGui::EndTooltip(); } @@ -17588,24 +20001,98 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { char critLabel[32]; snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", (unsigned)criteria.size()); if (ImGui::BeginTabItem(critLabel)) { + // Lazy-load AchievementCriteria.dbc for descriptions + struct CriteriaEntry { uint32_t achievementId; uint64_t quantity; std::string description; }; + static std::unordered_map s_criteriaData; + static bool s_criteriaDataLoaded = false; + if (!s_criteriaDataLoaded) { + s_criteriaDataLoaded = true; + auto* am = core::Application::getInstance().getAssetManager(); + if (am && am->isInitialized()) { + auto dbc = am->loadDBC("AchievementCriteria.dbc"); + if (dbc && dbc->isLoaded() && dbc->getFieldCount() >= 10) { + const auto* acL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("AchievementCriteria") : nullptr; + uint32_t achField = acL ? acL->field("AchievementID") : 1u; + uint32_t qtyField = acL ? acL->field("Quantity") : 4u; + uint32_t descField = acL ? acL->field("Description") : 9u; + if (achField == 0xFFFFFFFF) achField = 1; + if (qtyField == 0xFFFFFFFF) qtyField = 4; + if (descField == 0xFFFFFFFF) descField = 9; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t cid = dbc->getUInt32(r, 0); + if (cid == 0) continue; + CriteriaEntry ce; + ce.achievementId = (achField < fc) ? dbc->getUInt32(r, achField) : 0; + ce.quantity = (qtyField < fc) ? dbc->getUInt32(r, qtyField) : 0; + ce.description = (descField < fc) ? dbc->getString(r, descField) : std::string{}; + s_criteriaData[cid] = std::move(ce); + } + } + } + } + if (criteria.empty()) { ImGui::TextDisabled("No criteria progress received yet."); } else { ImGui::BeginChild("##critlist", ImVec2(0, 0), false); - // Sort criteria by id for stable display std::vector> clist(criteria.begin(), criteria.end()); std::sort(clist.begin(), clist.end()); for (const auto& [cid, cval] : clist) { - std::string label = std::to_string(cid); - if (!filter.empty()) { - std::string lower = label; - for (char& c : lower) c = static_cast(tolower(static_cast(c))); - if (lower.find(filter) == std::string::npos) continue; + auto ceIt = s_criteriaData.find(cid); + + // Build display text for filtering + std::string display; + if (ceIt != s_criteriaData.end() && !ceIt->second.description.empty()) { + display = ceIt->second.description; + } else { + display = std::to_string(cid); } + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + // Also allow filtering by achievement name + if (lower.find(filter) == std::string::npos && ceIt != s_criteriaData.end()) { + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + std::string achLower = achName; + for (char& c : achLower) c = static_cast(tolower(static_cast(c))); + if (achLower.find(filter) == std::string::npos) continue; + } else if (lower.find(filter) == std::string::npos) { + continue; + } + } + ImGui::PushID(static_cast(cid)); - ImGui::TextDisabled("Criteria %u:", cid); - ImGui::SameLine(); - ImGui::Text("%llu", static_cast(cval)); + if (ceIt != s_criteriaData.end()) { + // Show achievement name as header (dim) + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + if (!achName.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.8f), "%s", achName.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled(">"); + ImGui::SameLine(); + } + if (!ceIt->second.description.empty()) { + ImGui::TextUnformatted(ceIt->second.description.c_str()); + } else { + ImGui::TextDisabled("Criteria %u", cid); + } + ImGui::SameLine(); + if (ceIt->second.quantity > 0) { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), + "%llu/%llu", + static_cast(cval), + static_cast(ceIt->second.quantity)); + } else { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), + "%llu", static_cast(cval)); + } + } else { + ImGui::TextDisabled("Criteria %u:", cid); + ImGui::SameLine(); + ImGui::Text("%llu", static_cast(cval)); + } ImGui::PopID(); } ImGui::EndChild(); @@ -17892,110 +20379,114 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { ImGui::End(); } -// ─── Quest Objective Tracker ────────────────────────────────────────────────── -void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) { - if (gameHandler.getState() != game::WorldState::IN_WORLD) return; +// ─── Quest Objective Tracker (legacy stub — superseded by renderQuestObjectiveTracker) ─── +void GameScreen::renderObjectiveTracker(game::GameHandler&) { + // No-op: consolidated into renderQuestObjectiveTracker which renders the + // full-featured draggable tracker with context menus and item icons. +} - const auto& questLog = gameHandler.getQuestLog(); - const auto& tracked = gameHandler.getTrackedQuestIds(); - - // Collect quests to show: tracked ones first, then in-progress quests up to a max of 5 total. - std::vector toShow; - for (const auto& q : questLog) { - if (q.questId == 0) continue; - if (tracked.count(q.questId)) toShow.push_back(&q); +// ─── Book / Scroll / Note Window ────────────────────────────────────────────── +void GameScreen::renderBookWindow(game::GameHandler& gameHandler) { + // Auto-open when new pages arrive + if (gameHandler.hasBookOpen() && !showBookWindow_) { + showBookWindow_ = true; + bookCurrentPage_ = 0; } - if (toShow.empty()) { - // No explicitly tracked quests — show up to 5 in-progress quests - for (const auto& q : questLog) { - if (q.questId == 0) continue; - if (!tracked.count(q.questId)) toShow.push_back(&q); - if (toShow.size() >= 5) break; + if (!showBookWindow_) return; + + const auto& pages = gameHandler.getBookPages(); + if (pages.empty()) { showBookWindow_ = false; return; } + + // Clamp page index + if (bookCurrentPage_ < 0) bookCurrentPage_ = 0; + if (bookCurrentPage_ >= static_cast(pages.size())) + bookCurrentPage_ = static_cast(pages.size()) - 1; + + ImGui::SetNextWindowSize(ImVec2(420, 340), ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImVec2(400, 180), ImGuiCond_Appearing); + + bool open = showBookWindow_; + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.12f, 0.09f, 0.06f, 0.98f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.25f, 0.18f, 0.08f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.37f, 0.18f, 1.0f)); + + char title[64]; + if (pages.size() > 1) + snprintf(title, sizeof(title), "Page %d / %d###BookWin", + bookCurrentPage_ + 1, static_cast(pages.size())); + else + snprintf(title, sizeof(title), "###BookWin"); + + if (ImGui::Begin(title, &open, ImGuiWindowFlags_NoCollapse)) { + // Parchment text colour + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.85f, 0.78f, 0.62f, 1.0f)); + + const std::string& text = pages[bookCurrentPage_].text; + // Use a child region with word-wrap + ImGui::SetNextWindowContentSize(ImVec2(ImGui::GetContentRegionAvail().x, 0)); + if (ImGui::BeginChild("##BookText", + ImVec2(0, ImGui::GetContentRegionAvail().y - 34), + false, ImGuiWindowFlags_HorizontalScrollbar)) { + ImGui::SetNextItemWidth(-1); + ImGui::TextWrapped("%s", text.c_str()); } - } + ImGui::EndChild(); + ImGui::PopStyleColor(); - if (toShow.empty()) return; + // Navigation row + ImGui::Separator(); + bool canPrev = (bookCurrentPage_ > 0); + bool canNext = (bookCurrentPage_ < static_cast(pages.size()) - 1); - ImVec2 display = ImGui::GetIO().DisplaySize; - float screenW = display.x > 0.0f ? display.x : 1280.0f; - float trackerW = 220.0f; - float trackerX = screenW - trackerW - 12.0f; - float trackerY = 230.0f; // below minimap + if (!canPrev) ImGui::BeginDisabled(); + if (ImGui::Button("< Prev", ImVec2(80, 0))) bookCurrentPage_--; + if (!canPrev) ImGui::EndDisabled(); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoFocusOnAppearing; + ImGui::SameLine(); + if (!canNext) ImGui::BeginDisabled(); + if (ImGui::Button("Next >", ImVec2(80, 0))) bookCurrentPage_++; + if (!canNext) ImGui::EndDisabled(); - ImGui::SetNextWindowPos(ImVec2(trackerX, trackerY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(trackerW, 0.0f), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.5f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); - - if (ImGui::Begin("##ObjectiveTracker", nullptr, flags)) { - for (const auto* q : toShow) { - // Quest title - ImVec4 titleColor = q->complete ? ImVec4(0.45f, 1.0f, 0.45f, 1.0f) - : ImVec4(1.0f, 0.84f, 0.0f, 1.0f); - std::string titleStr = q->title.empty() - ? ("Quest #" + std::to_string(q->questId)) : q->title; - // Truncate to fit - if (titleStr.size() > 26) { titleStr.resize(23); titleStr += "..."; } - ImGui::TextColored(titleColor, "%s", titleStr.c_str()); - - // Kill/entity objectives - bool hasObjectives = false; - for (const auto& ko : q->killObjectives) { - if (ko.npcOrGoId == 0 || ko.required == 0) continue; - hasObjectives = true; - uint32_t entry = (uint32_t)std::abs(ko.npcOrGoId); - auto it = q->killCounts.find(entry); - uint32_t cur = it != q->killCounts.end() ? it->second.first : 0; - std::string name = gameHandler.getCachedCreatureName(entry); - if (name.empty()) { - if (ko.npcOrGoId < 0) { - const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); - if (goInfo) name = goInfo->name; - } - if (name.empty()) name = "Objective"; - } - if (name.size() > 20) { name.resize(17); name += "..."; } - bool done = (cur >= ko.required); - ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); - ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, ko.required); - } - - // Item objectives - for (const auto& io : q->itemObjectives) { - if (io.itemId == 0 || io.required == 0) continue; - hasObjectives = true; - auto it = q->itemCounts.find(io.itemId); - uint32_t cur = it != q->itemCounts.end() ? it->second : 0; - std::string name; - if (const auto* info = gameHandler.getItemInfo(io.itemId)) name = info->name; - if (name.empty()) name = "Item #" + std::to_string(io.itemId); - if (name.size() > 20) { name.resize(17); name += "..."; } - bool done = (cur >= io.required); - ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); - ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, io.required); - } - - if (!hasObjectives && q->complete) { - ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), " Ready to turn in!"); - } - - ImGui::Dummy(ImVec2(0.0f, 2.0f)); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60); + if (ImGui::Button("Close", ImVec2(60, 0))) { + open = false; } } ImGui::End(); - ImGui::PopStyleVar(2); + ImGui::PopStyleColor(3); + + if (!open) { + showBookWindow_ = false; + gameHandler.clearBook(); + } } // ─── Inspect Window ─────────────────────────────────────────────────────────── void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { if (!showInspectWindow_) return; + // Lazy-load SpellItemEnchantment.dbc for enchant name lookup + static std::unordered_map s_enchantNames; + static bool s_enchantDbLoaded = false; + auto* assetMgrEnchant = core::Application::getInstance().getAssetManager(); + if (!s_enchantDbLoaded && assetMgrEnchant && assetMgrEnchant->isInitialized()) { + s_enchantDbLoaded = true; + auto dbc = assetMgrEnchant->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") + : nullptr; + uint32_t idField = layout ? (*layout)["ID"] : 0; + uint32_t nameField = layout ? (*layout)["Name"] : 8; + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, idField); + if (id == 0) continue; + std::string nm = dbc->getString(i, nameField); + if (!nm.empty()) s_enchantNames[id] = std::move(nm); + } + } + } + // Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server) static const char* kSlotNames[19] = { "Head", "Neck", "Shoulder", "Shirt", "Chest", @@ -18122,10 +20613,18 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::TextColored(qColor, "%s", info->name.c_str()); // Enchant indicator on the same row as the name if (enchantId != 0) { + auto enchIt = s_enchantNames.find(enchantId); + const std::string& enchName = (enchIt != s_enchantNames.end()) + ? enchIt->second : std::string{}; ImGui::SameLine(); - 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)); + if (!enchName.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), + "\xe2\x9c\xa6 %s", enchName.c_str()); // UTF-8 ✦ + } else { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); + } } ImGui::EndGroup(); hovered = hovered || ImGui::IsItemHovered(); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index e2075f09..1c029217 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1159,7 +1159,9 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { int32_t stats[5]; for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; - renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats); + int32_t resists[6]; + for (int i = 0; i < 6; ++i) resists[i] = gameHandler.getResistance(i + 1); + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists); // Played time (shown if available, fetched on character screen open) uint32_t totalSec = gameHandler.getTotalTimePlayed(); @@ -1340,6 +1342,34 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // Equipment Sets tab (WotLK only) + const auto& eqSets = gameHandler.getEquipmentSets(); + if (!eqSets.empty()) { + if (ImGui::BeginTabItem("Outfits")) { + ImGui::Spacing(); + ImGui::TextDisabled("Saved Equipment Sets"); + ImGui::Separator(); + ImGui::BeginChild("##EqSetsList", ImVec2(0, 0), false); + for (const auto& es : eqSets) { + ImGui::PushID(static_cast(es.setId)); + // Icon placeholder or name + const char* displayName = es.name.empty() ? "(Unnamed)" : es.name.c_str(); + ImGui::Text("%s", displayName); + if (!es.iconName.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", es.iconName.c_str()); + } + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); + if (ImGui::SmallButton("Equip")) { + gameHandler.useEquipmentSet(es.setId); + } + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); } @@ -1557,7 +1587,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { // ============================================================ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, - int32_t serverArmor, const int32_t* serverStats) { + int32_t serverArmor, const int32_t* serverStats, + const int32_t* serverResists) { // Sum equipment stats for item-query bonus display int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; // Secondary stat sums from extraStats @@ -1596,6 +1627,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor; + // Average item level (exclude shirt/tabard as WoW convention) + { + uint32_t iLvlSum = 0; + int iLvlCount = 0; + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + auto eslot = static_cast(s); + if (eslot == game::EquipSlot::SHIRT || eslot == game::EquipSlot::TABARD) continue; + const auto& slot = inventory.getEquipSlot(eslot); + if (!slot.empty() && slot.item.itemLevel > 0) { + iLvlSum += slot.item.itemLevel; + ++iLvlCount; + } + } + if (iLvlCount > 0) { + float avg = static_cast(iLvlSum) / static_cast(iLvlCount); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 1.0f, 1.0f), + "Average Item Level: %.1f (%d/%d slots)", avg, iLvlCount, + game::Inventory::NUM_EQUIP_SLOTS - 2); // -2 for shirt/tabard + } + ImGui::Separator(); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f); ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); @@ -1665,6 +1718,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play renderSecondary("Mana per 5 sec", itemMp5); renderSecondary("Health per 5 sec",itemHp5); } + + // Elemental resistances from server update fields + if (serverResists) { + static const char* kResistNames[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + bool hasResist = false; + for (int i = 0; i < 6; ++i) { + if (serverResists[i] > 0) { hasResist = true; break; } + } + if (hasResist) { + ImGui::Spacing(); + ImGui::Separator(); + for (int i = 0; i < 6; ++i) { + if (serverResists[i] > 0) { + ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 1.0f), + "%s: %d", kResistNames[i], serverResists[i]); + } + } + } + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { @@ -2186,10 +2261,13 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I default: break; } if (!trigger) continue; - const std::string& spName = gameHandler_->getSpellName(sp.spellId); - if (!spName.empty()) { + const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId); + const std::string& spText = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc; + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), - "%s: %s", trigger, spName.c_str()); + "%s: %s", trigger, spText.c_str()); + ImGui::PopTextWrapPos(); } else { ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId); @@ -2474,11 +2552,161 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, } if (!trigger) continue; if (gameHandler_) { - const std::string& spName = gameHandler_->getSpellName(sp.spellId); - if (!spName.empty()) + // Prefer the spell's tooltip text (the actual effect description). + // Fall back to the spell name if the description is empty. + const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId); + const std::string& spName = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc; + if (!spName.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str()); - else + ImGui::PopTextWrapPos(); + } else { ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId); + } + } + } + + // Gem socket slots + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + bool hasSocket = false; + for (int i = 0; i < 3; ++i) { + if (info.socketColor[i] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info.socketColor[i] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info.socketBonus != 0) { + // Socket bonus is a SpellItemEnchantment ID — look up via SpellItemEnchantment.dbc + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetManager_) { + s_enchantNamesLoaded = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNames[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNames.find(info.socketBonus); + if (enchIt != s_enchantNames.end()) + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info.socketBonus); + } + } + + // Item set membership + if (info.itemSetId != 0) { + // Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds) + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetManager_) { + s_setDataLoaded = true; + auto dbc = assetManager_->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = { + "Item0","Item1","Item2","Item3","Item4", + "Item5","Item6","Item7","Item8","Item9" + }; + static const char* spellKeys[10] = { + "Spell0","Spell1","Spell2","Spell3","Spell4", + "Spell5","Spell6","Spell7","Spell8","Spell9" + }; + static const char* thrKeys[10] = { + "Threshold0","Threshold1","Threshold2","Threshold3","Threshold4", + "Threshold5","Threshold6","Threshold7","Threshold8","Threshold9" + }; + uint32_t itemFallback[10], spellFallback[10], thrFallback[10]; + for (int i = 0; i < 10; ++i) { + itemFallback[i] = 18 + i; + spellFallback[i] = 28 + i; + thrFallback[i] = 38 + i; + } + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : itemFallback[i]); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFallback[i]); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFallback[i]); + } + s_setData[id] = std::move(e); + } + } + } + + auto setIt = s_setData.find(info.itemSetId); + ImGui::Spacing(); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + // Count equipped pieces + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + if (inventory) { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& eSlot = inventory->getEquipSlot(static_cast(s)); + if (!eSlot.empty() && eSlot.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + } + if (total > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + } else { + if (!se.name.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + } + // Show set bonuses: gray if not reached, green if active + if (gameHandler_) { + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler_->getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } + } else { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", info.itemSetId); } } diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index c343baa5..92b52bd9 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -485,12 +485,28 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv auto reqIt = sel.requiredItemCounts.find(itemId); if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second; VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + const auto* objInfo = gameHandler.getItemInfo(itemId); if (iconTex) { ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14)); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } ImGui::SameLine(); ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } } else { ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } } } } @@ -534,6 +550,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv ImGui::Text("%s x%u", name.c_str(), ri.count); else ImGui::Text("%s", name.c_str()); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } } @@ -560,6 +581,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv ImGui::Text("%s x%u", name.c_str(), ri.count); else ImGui::Text("%s", name.c_str()); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } } } diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index 5c6bdaf9..b1231f24 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -76,6 +76,7 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { gameHandler.loadTalentDbc(); loadSpellDBC(assetManager); loadSpellIconDBC(assetManager); + loadGlyphPropertiesDBC(assetManager); } uint8_t playerClass = gameHandler.getPlayerClass(); @@ -161,6 +162,18 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } } + + // Glyphs tab (WotLK only — visible when any glyph slot is populated or DBC data loaded) + if (!glyphProperties_.empty() || [&]() { + const auto& g = gameHandler.getGlyphs(); + for (auto id : g) if (id != 0) return true; + return false; }()) { + if (ImGui::BeginTabItem("Glyphs")) { + renderGlyphs(gameHandler); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); } } @@ -616,6 +629,99 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { } } +void TalentScreen::loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager) { + if (glyphDbcLoaded) return; + glyphDbcLoaded = true; + + if (!assetManager || !assetManager->isInitialized()) return; + + auto dbc = assetManager->loadDBC("GlyphProperties.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + // GlyphProperties.dbc: field 0=ID, field 1=SpellID, field 2=GlyphSlotFlags (1=minor), field 3=SpellIconID + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + uint32_t id = dbc->getUInt32(i, 0); + uint32_t spellId = dbc->getUInt32(i, 1); + uint32_t flags = dbc->getUInt32(i, 2); + if (id == 0) continue; + GlyphInfo info; + info.spellId = spellId; + info.isMajor = (flags == 0); // flag 0 = major, flag 1 = minor + glyphProperties_[id] = info; + } +} + +void TalentScreen::renderGlyphs(game::GameHandler& gameHandler) { + auto* assetManager = core::Application::getInstance().getAssetManager(); + const auto& glyphs = gameHandler.getGlyphs(); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Major Glyphs"); + ImGui::Separator(); + + // WotLK: 6 glyph slots total. Slots 0,2,4 are major by convention from the server, + // but we check GlyphProperties.dbc flags when available. + // Display all 6 slots grouped: show major (non-minor) first, then minor. + std::vector> majorSlots, minorSlots; + for (int i = 0; i < game::GameHandler::MAX_GLYPH_SLOTS; i++) { + uint16_t glyphId = glyphs[i]; + bool isMajor = true; + if (glyphId != 0) { + auto git = glyphProperties_.find(glyphId); + if (git != glyphProperties_.end()) isMajor = git->second.isMajor; + else isMajor = (i % 2 == 0); // fallback: even slots = major + } else { + isMajor = (i % 2 == 0); // empty slots follow same pattern + } + if (isMajor) majorSlots.push_back({i, true}); + else minorSlots.push_back({i, false}); + } + + auto renderGlyphSlot = [&](int slotIdx) { + uint16_t glyphId = glyphs[slotIdx]; + char label[64]; + if (glyphId == 0) { + snprintf(label, sizeof(label), "Slot %d [Empty]", slotIdx + 1); + ImGui::TextDisabled("%s", label); + return; + } + + uint32_t spellId = 0; + uint32_t iconId = 0; + auto git = glyphProperties_.find(glyphId); + if (git != glyphProperties_.end()) { + spellId = git->second.spellId; + auto iit = spellIconIds.find(spellId); + if (iit != spellIconIds.end()) iconId = iit->second; + } + + // Icon (24x24) + VkDescriptorSet icon = getSpellIcon(iconId, assetManager); + if (icon != VK_NULL_HANDLE) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(24, 24)); + ImGui::SameLine(0, 6); + } else { + ImGui::Dummy(ImVec2(24, 24)); + ImGui::SameLine(0, 6); + } + + // Spell name + const std::string& name = spellId ? gameHandler.getSpellName(spellId) : ""; + if (!name.empty()) { + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", name.c_str()); + } else { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Glyph #%u", (uint32_t)glyphId); + } + }; + + for (auto& [idx, major] : majorSlots) renderGlyphSlot(idx); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "Minor Glyphs"); + ImGui::Separator(); + for (auto& [idx, major] : minorSlots) renderGlyphSlot(idx); +} + VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { if (iconId == 0 || !assetManager) return VK_NULL_HANDLE;