mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-17 17:43:52 +00:00
Compare commits
111 commits
6dd7213083
...
5adb5e0e9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5adb5e0e9f | ||
|
|
d14982d125 | ||
|
|
797bb5d964 | ||
|
|
66ec35b106 | ||
|
|
6068d0d68d | ||
|
|
fa947eb9c7 | ||
|
|
63c8e82913 | ||
|
|
d70db7fa0b | ||
|
|
6cf511aa7f | ||
|
|
40a98f2436 | ||
|
|
6ab9ba65f9 | ||
|
|
46eb66b77f | ||
|
|
920950dfbd | ||
|
|
43de2be1f2 | ||
|
|
92db25038c | ||
|
|
2bdd024f19 | ||
|
|
3964a33c55 | ||
|
|
adf8e6414e | ||
|
|
f4754797bc | ||
|
|
7a1f330655 | ||
|
|
1bc3e6b677 | ||
|
|
fb6630a7ae | ||
|
|
06456faa63 | ||
|
|
25e2c60603 | ||
|
|
955b22841e | ||
|
|
eb9ca8e227 | ||
|
|
9e5f7c481e | ||
|
|
d8f2fedae1 | ||
|
|
c89dc50b6c | ||
|
|
c13e18cb55 | ||
|
|
c0f19f5883 | ||
|
|
d0f2916885 | ||
|
|
778363bfaf | ||
|
|
e13993de9b | ||
|
|
928f00de41 | ||
|
|
d072c852f3 | ||
|
|
5fdcb5df81 | ||
|
|
1cab2e1156 | ||
|
|
347e958703 | ||
|
|
c170216e1c | ||
|
|
00a66b7114 | ||
|
|
9705904052 | ||
|
|
7943edf252 | ||
|
|
716c0c0e4c | ||
|
|
109b0a984a | ||
|
|
d3221ff253 | ||
|
|
2cd4672912 | ||
|
|
72e07fbe3f | ||
|
|
8eb451aab5 | ||
|
|
9b8bc2e977 | ||
|
|
54750d4656 | ||
|
|
08bdd9eb36 | ||
|
|
971ada6397 | ||
|
|
88436fa400 | ||
|
|
c433188edb | ||
|
|
fe61d6acce | ||
|
|
b3d3814ce9 | ||
|
|
3446fffe86 | ||
|
|
7e271df032 | ||
|
|
745768511b | ||
|
|
682cb8d44b | ||
|
|
e002266607 | ||
|
|
ae8f900410 | ||
|
|
97662800d5 | ||
|
|
6e7a32ec7f | ||
|
|
d31c483944 | ||
|
|
823e2bcec6 | ||
|
|
b2d1edc9db | ||
|
|
355001c236 | ||
|
|
69fd0b03a2 | ||
|
|
18e42c22d4 | ||
|
|
c9c20ce433 | ||
|
|
1693abffd3 | ||
|
|
b44857dad9 | ||
|
|
9a199e20b6 | ||
|
|
2f1c9eb01b | ||
|
|
861bb3404f | ||
|
|
c9cfa864bf | ||
|
|
c9ea61aba7 | ||
|
|
a207ceef6c | ||
|
|
386de826af | ||
|
|
f5de4d2031 | ||
|
|
25c5d257ae | ||
|
|
c09ebae5af | ||
|
|
fc7cc44ef7 | ||
|
|
300e3ba71f | ||
|
|
54eae9bffc | ||
|
|
23cfb9b640 | ||
|
|
99d1f5778c | ||
|
|
7cfeed1e28 | ||
|
|
3d40e4dee5 | ||
|
|
0075fdd5e1 | ||
|
|
43c0e9b2e8 | ||
|
|
bbf4806fe8 | ||
|
|
7ab0b036c7 | ||
|
|
95ac97a41c | ||
|
|
e34357a0a4 | ||
|
|
4394f93a17 | ||
|
|
43c239ee2f | ||
|
|
e415451f89 | ||
|
|
5bafacc372 | ||
|
|
458c9ebe8c | ||
|
|
3c0e58bff4 | ||
|
|
647967cccb | ||
|
|
764cf86e38 | ||
|
|
d9a58115f9 | ||
|
|
4ceb313fb2 | ||
|
|
1e8c85d850 | ||
|
|
26fab2d5d0 | ||
|
|
2e92ec903c | ||
|
|
8c2f69ca0e |
14 changed files with 4104 additions and 353 deletions
|
|
@ -332,10 +332,25 @@ public:
|
|||
// Inspection
|
||||
void inspectTarget();
|
||||
|
||||
struct InspectResult {
|
||||
uint64_t guid = 0;
|
||||
std::string playerName;
|
||||
uint32_t totalTalents = 0;
|
||||
uint32_t unspentTalents = 0;
|
||||
uint8_t talentGroups = 0;
|
||||
uint8_t activeTalentGroup = 0;
|
||||
std::array<uint32_t, 19> itemEntries{}; // 0=head…18=ranged
|
||||
};
|
||||
const InspectResult* getInspectResult() const {
|
||||
return inspectResult_.guid ? &inspectResult_ : nullptr;
|
||||
}
|
||||
|
||||
// Server info commands
|
||||
void queryServerTime();
|
||||
void requestPlayedTime();
|
||||
void queryWho(const std::string& playerName = "");
|
||||
uint32_t getTotalTimePlayed() const { return totalTimePlayed_; }
|
||||
uint32_t getLevelTimePlayed() const { return levelTimePlayed_; }
|
||||
|
||||
// Social commands
|
||||
void addFriend(const std::string& playerName, const std::string& note = "");
|
||||
|
|
@ -343,6 +358,7 @@ public:
|
|||
void setFriendNote(const std::string& playerName, const std::string& note);
|
||||
void addIgnore(const std::string& playerName);
|
||||
void removeIgnore(const std::string& playerName);
|
||||
const std::unordered_map<std::string, uint64_t>& getIgnoreCache() const { return ignoreCache; }
|
||||
|
||||
// Random roll
|
||||
void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100);
|
||||
|
|
@ -380,6 +396,8 @@ public:
|
|||
// Display toggles
|
||||
void toggleHelm();
|
||||
void toggleCloak();
|
||||
bool isHelmVisible() const { return helmVisible_; }
|
||||
bool isCloakVisible() const { return cloakVisible_; }
|
||||
|
||||
// Follow/Assist
|
||||
void followTarget();
|
||||
|
|
@ -388,6 +406,9 @@ public:
|
|||
// PvP
|
||||
void togglePvp();
|
||||
|
||||
// Minimap ping (Ctrl+click on minimap; wowX/wowY in canonical WoW coords)
|
||||
void sendMinimapPing(float wowX, float wowY);
|
||||
|
||||
// Guild commands
|
||||
void requestGuildInfo();
|
||||
void requestGuildRoster();
|
||||
|
|
@ -403,6 +424,10 @@ public:
|
|||
void setGuildOfficerNote(const std::string& name, const std::string& note);
|
||||
void acceptGuildInvite();
|
||||
void declineGuildInvite();
|
||||
|
||||
// GM Ticket
|
||||
void submitGmTicket(const std::string& text);
|
||||
void deleteGmTicket();
|
||||
void queryGuildInfo(uint32_t guildId);
|
||||
void createGuild(const std::string& guildName);
|
||||
void addGuildRank(const std::string& rankName);
|
||||
|
|
@ -444,6 +469,8 @@ public:
|
|||
// AFK/DND status
|
||||
void toggleAfk(const std::string& message = "");
|
||||
void toggleDnd(const std::string& message = "");
|
||||
bool isAfk() const { return afkStatus_; }
|
||||
bool isDnd() const { return dndStatus_; }
|
||||
void replyToLastWhisper(const std::string& message);
|
||||
std::string getLastWhisperSender() const { return lastWhisperSender_; }
|
||||
void setLastWhisperSender(const std::string& name) { lastWhisperSender_ = name; }
|
||||
|
|
@ -489,6 +516,21 @@ public:
|
|||
const std::vector<CombatTextEntry>& getCombatText() const { return combatText; }
|
||||
void updateCombatText(float deltaTime);
|
||||
|
||||
// Threat
|
||||
struct ThreatEntry {
|
||||
uint64_t victimGuid = 0;
|
||||
uint32_t threat = 0;
|
||||
};
|
||||
// Returns the current threat list for a given unit GUID (from last SMSG_THREAT_UPDATE)
|
||||
const std::vector<ThreatEntry>* getThreatList(uint64_t unitGuid) const {
|
||||
auto it = threatLists_.find(unitGuid);
|
||||
return (it != threatLists_.end()) ? &it->second : nullptr;
|
||||
}
|
||||
// Returns the threat list for the player's current target, or nullptr
|
||||
const std::vector<ThreatEntry>* getTargetThreatList() const {
|
||||
return targetGuid ? getThreatList(targetGuid) : nullptr;
|
||||
}
|
||||
|
||||
// ---- Phase 3: Spells ----
|
||||
void castSpell(uint32_t spellId, uint64_t targetGuid = 0);
|
||||
void cancelCast();
|
||||
|
|
@ -546,6 +588,7 @@ public:
|
|||
}
|
||||
|
||||
bool isCasting() const { return casting; }
|
||||
bool isChanneling() const { return casting && castIsChannel; }
|
||||
bool isGameObjectInteractionCasting() const {
|
||||
return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0;
|
||||
}
|
||||
|
|
@ -888,6 +931,22 @@ public:
|
|||
void cancelTalentWipe() { talentWipePending_ = false; }
|
||||
/** True when ghost is within 40 yards of corpse position (same map). */
|
||||
bool canReclaimCorpse() const;
|
||||
/** Distance (yards) from ghost to corpse, or -1 if no corpse data. */
|
||||
float getCorpseDistance() const {
|
||||
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f;
|
||||
float dx = movementInfo.x - corpseX_;
|
||||
float dy = movementInfo.y - corpseY_;
|
||||
float dz = movementInfo.z - corpseZ_;
|
||||
return std::sqrt(dx*dx + dy*dy + dz*dz);
|
||||
}
|
||||
/** Corpse position in canonical WoW coords (X=north, Y=west).
|
||||
* Returns false if no corpse data or on a different map. */
|
||||
bool getCorpseCanonicalPos(float& outX, float& outY) const {
|
||||
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return false;
|
||||
outX = corpseY_; // server Y = canonical X (north)
|
||||
outY = corpseX_; // server X = canonical Y (west)
|
||||
return true;
|
||||
}
|
||||
/** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */
|
||||
void reclaimCorpse();
|
||||
void releaseSpirit();
|
||||
|
|
@ -1008,6 +1067,8 @@ public:
|
|||
if (raidTargetGuids_[i] == guid) return static_cast<uint8_t>(i);
|
||||
return 0xFF;
|
||||
}
|
||||
// Set or clear a raid mark on a guid (icon 0-7, or 0xFF to clear)
|
||||
void setRaidMark(uint64_t guid, uint8_t icon);
|
||||
|
||||
// ---- LFG / Dungeon Finder ----
|
||||
enum class LfgState : uint8_t {
|
||||
|
|
@ -1034,6 +1095,22 @@ public:
|
|||
uint32_t getLfgProposalId() const { return lfgProposalId_; }
|
||||
int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; }
|
||||
uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; }
|
||||
uint32_t getLfgBootVotes() const { return lfgBootVotes_; }
|
||||
uint32_t getLfgBootTotal() const { return lfgBootTotal_; }
|
||||
uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; }
|
||||
uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; }
|
||||
|
||||
// ---- Arena Team Stats ----
|
||||
struct ArenaTeamStats {
|
||||
uint32_t teamId = 0;
|
||||
uint32_t rating = 0;
|
||||
uint32_t weekGames = 0;
|
||||
uint32_t weekWins = 0;
|
||||
uint32_t seasonGames = 0;
|
||||
uint32_t seasonWins = 0;
|
||||
uint32_t rank = 0;
|
||||
};
|
||||
const std::vector<ArenaTeamStats>& getArenaTeamStats() const { return arenaTeamStats_; }
|
||||
|
||||
// ---- Phase 5: Loot ----
|
||||
void lootTarget(uint64_t guid);
|
||||
|
|
@ -1131,6 +1208,10 @@ public:
|
|||
uint32_t required = 0;
|
||||
};
|
||||
std::array<ItemObjective, 6> itemObjectives{}; // zeroed by default
|
||||
// Reward data parsed from SMSG_QUEST_QUERY_RESPONSE
|
||||
int32_t rewardMoney = 0; // copper; positive=reward, negative=cost
|
||||
std::array<QuestRewardItem, 4> rewardItems{}; // guaranteed reward items
|
||||
std::array<QuestRewardItem, 6> rewardChoiceItems{}; // player picks one of these
|
||||
};
|
||||
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
|
||||
void abandonQuest(uint32_t questId);
|
||||
|
|
@ -1215,6 +1296,14 @@ public:
|
|||
using AchievementEarnedCallback = std::function<void(uint32_t achievementId, const std::string& name)>;
|
||||
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
|
||||
const std::unordered_set<uint32_t>& getEarnedAchievements() const { return earnedAchievements_; }
|
||||
const std::unordered_map<uint32_t, uint64_t>& getCriteriaProgress() const { return criteriaProgress_; }
|
||||
/// Returns the name of an achievement by ID, or empty string if unknown.
|
||||
const std::string& getAchievementName(uint32_t id) const {
|
||||
auto it = achievementNameCache_.find(id);
|
||||
if (it != achievementNameCache_.end()) return it->second;
|
||||
static const std::string kEmpty;
|
||||
return kEmpty;
|
||||
}
|
||||
|
||||
// Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received.
|
||||
// The soundId corresponds to a SoundEntries.dbc record. The receiver is
|
||||
|
|
@ -1232,6 +1321,15 @@ public:
|
|||
using PlayPositionalSoundCallback = std::function<void(uint32_t soundId, uint64_t sourceGuid)>;
|
||||
void setPlayPositionalSoundCallback(PlayPositionalSoundCallback cb) { playPositionalSoundCallback_ = std::move(cb); }
|
||||
|
||||
// UI error frame: prominent on-screen error messages (spell can't be cast, etc.)
|
||||
using UIErrorCallback = std::function<void(const std::string& msg)>;
|
||||
void setUIErrorCallback(UIErrorCallback cb) { uiErrorCallback_ = std::move(cb); }
|
||||
void addUIError(const std::string& msg) { if (uiErrorCallback_) uiErrorCallback_(msg); }
|
||||
|
||||
// Reputation change toast: factionName, delta, new standing
|
||||
using RepChangeCallback = std::function<void(const std::string& factionName, int32_t delta, int32_t standing)>;
|
||||
void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); }
|
||||
|
||||
// Mount state
|
||||
using MountCallback = std::function<void(uint32_t mountDisplayId)>; // 0 = dismount
|
||||
void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); }
|
||||
|
|
@ -1719,6 +1817,7 @@ private:
|
|||
void handleArenaTeamQueryResponse(network::Packet& packet);
|
||||
void handleArenaTeamInvite(network::Packet& packet);
|
||||
void handleArenaTeamEvent(network::Packet& packet);
|
||||
void handleArenaTeamStats(network::Packet& packet);
|
||||
void handleArenaError(network::Packet& packet);
|
||||
|
||||
// ---- Bank handlers ----
|
||||
|
|
@ -1951,6 +2050,7 @@ private:
|
|||
|
||||
// Inspect fallback (when visible item fields are missing/unreliable)
|
||||
std::unordered_map<uint64_t, std::array<uint32_t, 19>> inspectedPlayerItemEntries_;
|
||||
InspectResult inspectResult_; // most-recently received inspect response
|
||||
std::unordered_set<uint64_t> pendingAutoInspect_;
|
||||
float inspectRateLimit_ = 0.0f;
|
||||
|
||||
|
|
@ -1965,6 +2065,8 @@ private:
|
|||
float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing
|
||||
std::unordered_set<uint64_t> hostileAttackers_;
|
||||
std::vector<CombatTextEntry> combatText;
|
||||
// unitGuid → sorted threat list (descending by threat value)
|
||||
std::unordered_map<uint64_t, std::vector<ThreatEntry>> threatLists_;
|
||||
|
||||
// ---- Phase 3: Spells ----
|
||||
WorldEntryCallback worldEntryCallback_;
|
||||
|
|
@ -2010,6 +2112,7 @@ private:
|
|||
std::vector<MinimapPing> minimapPings_;
|
||||
uint8_t castCount = 0;
|
||||
bool casting = false;
|
||||
bool castIsChannel = false;
|
||||
uint32_t currentCastSpellId = 0;
|
||||
float castTimeRemaining = 0.0f;
|
||||
// Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START)
|
||||
|
|
@ -2071,6 +2174,9 @@ private:
|
|||
// Instance / raid lockouts
|
||||
std::vector<InstanceLockout> instanceLockouts_;
|
||||
|
||||
// Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS)
|
||||
std::vector<ArenaTeamStats> arenaTeamStats_;
|
||||
|
||||
// Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
|
||||
std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot
|
||||
|
||||
|
|
@ -2080,6 +2186,10 @@ private:
|
|||
uint32_t lfgProposalId_ = 0; // pending proposal id (0 = none)
|
||||
int32_t lfgAvgWaitSec_ = -1; // estimated wait, -1=unknown
|
||||
uint32_t lfgTimeInQueueMs_= 0; // ms already in queue
|
||||
uint32_t lfgBootVotes_ = 0; // current boot-yes votes
|
||||
uint32_t lfgBootTotal_ = 0; // total votes cast
|
||||
uint32_t lfgBootTimeLeft_ = 0; // seconds remaining
|
||||
uint32_t lfgBootNeeded_ = 0; // votes needed to kick
|
||||
|
||||
// Ready check state
|
||||
bool pendingReadyCheck_ = false;
|
||||
|
|
@ -2116,6 +2226,8 @@ private:
|
|||
uint64_t summonerGuid_ = 0;
|
||||
std::string summonerName_;
|
||||
float summonTimeoutSec_ = 0.0f;
|
||||
uint32_t totalTimePlayed_ = 0;
|
||||
uint32_t levelTimePlayed_ = 0;
|
||||
|
||||
// Trade state
|
||||
TradeStatus tradeStatus_ = TradeStatus::None;
|
||||
|
|
@ -2332,6 +2444,8 @@ private:
|
|||
void loadAchievementNameCache();
|
||||
// Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA)
|
||||
std::unordered_set<uint32_t> earnedAchievements_;
|
||||
// Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE)
|
||||
std::unordered_map<uint32_t, uint64_t> criteriaProgress_;
|
||||
void handleAllAchievementData(network::Packet& packet);
|
||||
|
||||
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
|
||||
|
|
@ -2510,6 +2624,12 @@ private:
|
|||
PlayMusicCallback playMusicCallback_;
|
||||
PlaySoundCallback playSoundCallback_;
|
||||
PlayPositionalSoundCallback playPositionalSoundCallback_;
|
||||
|
||||
// ---- UI error frame callback ----
|
||||
UIErrorCallback uiErrorCallback_;
|
||||
|
||||
// ---- Reputation change callback ----
|
||||
RepChangeCallback repChangeCallback_;
|
||||
};
|
||||
|
||||
} // namespace game
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ private:
|
|||
int lastChatType = 0; // Track chat type changes
|
||||
bool chatInputMoveCursorToEnd = false;
|
||||
|
||||
// Chat sent-message history (Up/Down arrow recall)
|
||||
std::vector<std::string> chatSentHistory_;
|
||||
int chatHistoryIdx_ = -1; // -1 = not browsing history
|
||||
|
||||
// Chat tabs
|
||||
int activeChatTab_ = 0;
|
||||
struct ChatTab {
|
||||
|
|
@ -65,6 +69,37 @@ private:
|
|||
bool showChatWindow = true;
|
||||
bool showMinimap_ = true; // M key toggles minimap
|
||||
bool showNameplates_ = true; // V key toggles nameplates
|
||||
float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions
|
||||
uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none)
|
||||
ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click
|
||||
uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection
|
||||
float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0)
|
||||
bool damageFlashEnabled_ = true;
|
||||
float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0)
|
||||
uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text
|
||||
|
||||
// Raid Warning / Boss Emote big-text overlay (center-screen, fades after 5s)
|
||||
struct RaidWarnEntry {
|
||||
std::string text;
|
||||
float age = 0.0f;
|
||||
bool isBossEmote = false; // true = amber, false (raid warning) = red+yellow
|
||||
static constexpr float LIFETIME = 5.0f;
|
||||
};
|
||||
std::vector<RaidWarnEntry> raidWarnEntries_;
|
||||
bool raidWarnCallbackSet_ = false;
|
||||
size_t raidWarnChatSeenCount_ = 0; // index into chat history for unread scan
|
||||
|
||||
// UIErrorsFrame: WoW-style center-bottom error messages (spell fails, out of range, etc.)
|
||||
struct UIErrorEntry { std::string text; float age = 0.0f; };
|
||||
std::vector<UIErrorEntry> uiErrors_;
|
||||
bool uiErrorCallbackSet_ = false;
|
||||
static constexpr float kUIErrorLifetime = 2.5f;
|
||||
|
||||
// Reputation change toast: brief colored slide-in below minimap
|
||||
struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; };
|
||||
std::vector<RepToastEntry> repToasts_;
|
||||
bool repChangeCallbackSet_ = false;
|
||||
static constexpr float kRepToastLifetime = 3.5f;
|
||||
bool showPlayerInfo = false;
|
||||
bool showSocialFrame_ = false; // O key toggles social/friends list
|
||||
bool showGuildRoster_ = false;
|
||||
|
|
@ -82,6 +117,8 @@ private:
|
|||
bool showAddRankModal_ = false;
|
||||
bool refocusChatInput = false;
|
||||
bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session
|
||||
bool chatScrolledUp_ = false; // true when user has scrolled above the latest messages
|
||||
bool chatForceScrollToBottom_ = false; // set to true to jump to bottom next frame
|
||||
bool chatWindowLocked = true;
|
||||
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);
|
||||
bool chatWindowPosInit_ = false;
|
||||
|
|
@ -109,6 +146,7 @@ private:
|
|||
float pendingMouseSensitivity = 0.2f;
|
||||
bool pendingInvertMouse = false;
|
||||
bool pendingExtendedZoom = false;
|
||||
float pendingFov = 70.0f; // degrees, default matches WoW's ~70° horizontal FOV
|
||||
int pendingUiOpacity = 65;
|
||||
bool pendingMinimapRotate = false;
|
||||
bool pendingMinimapSquare = false;
|
||||
|
|
@ -122,6 +160,7 @@ private:
|
|||
bool awaitingKeyPress = false;
|
||||
bool pendingUseOriginalSoundtrack = true;
|
||||
bool pendingShowActionBar2 = true; // Show second action bar above main bar
|
||||
float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5)
|
||||
float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position
|
||||
float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1)
|
||||
bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35)
|
||||
|
|
@ -239,8 +278,11 @@ private:
|
|||
void renderCastBar(game::GameHandler& gameHandler);
|
||||
void renderMirrorTimers(game::GameHandler& gameHandler);
|
||||
void renderCombatText(game::GameHandler& gameHandler);
|
||||
void renderRaidWarningOverlay(game::GameHandler& gameHandler);
|
||||
void renderPartyFrames(game::GameHandler& gameHandler);
|
||||
void renderBossFrames(game::GameHandler& gameHandler);
|
||||
void renderUIErrors(game::GameHandler& gameHandler, float deltaTime);
|
||||
void renderRepToasts(float deltaTime);
|
||||
void renderGroupInvitePopup(game::GameHandler& gameHandler);
|
||||
void renderDuelRequestPopup(game::GameHandler& gameHandler);
|
||||
void renderLootRollPopup(game::GameHandler& gameHandler);
|
||||
|
|
@ -282,9 +324,11 @@ private:
|
|||
void renderGuildBankWindow(game::GameHandler& gameHandler);
|
||||
void renderAuctionHouseWindow(game::GameHandler& gameHandler);
|
||||
void renderDungeonFinderWindow(game::GameHandler& gameHandler);
|
||||
void renderObjectiveTracker(game::GameHandler& gameHandler);
|
||||
void renderInstanceLockouts(game::GameHandler& gameHandler);
|
||||
void renderNameplates(game::GameHandler& gameHandler);
|
||||
void renderBattlegroundScore(game::GameHandler& gameHandler);
|
||||
void renderDPSMeter(game::GameHandler& gameHandler);
|
||||
|
||||
/**
|
||||
* Inventory screen
|
||||
|
|
@ -325,6 +369,24 @@ private:
|
|||
|
||||
// Dungeon Finder state
|
||||
bool showDungeonFinder_ = false;
|
||||
|
||||
// Achievements window
|
||||
bool showAchievementWindow_ = false;
|
||||
char achievementSearchBuf_[128] = {};
|
||||
void renderAchievementWindow(game::GameHandler& gameHandler);
|
||||
|
||||
// GM Ticket window
|
||||
bool showGmTicketWindow_ = false;
|
||||
char gmTicketBuf_[2048] = {};
|
||||
void renderGmTicketWindow(game::GameHandler& gameHandler);
|
||||
|
||||
// Inspect window
|
||||
bool showInspectWindow_ = false;
|
||||
void renderInspectWindow(game::GameHandler& gameHandler);
|
||||
|
||||
// Threat window
|
||||
bool showThreatWindow_ = false;
|
||||
void renderThreatWindow(game::GameHandler& gameHandler);
|
||||
uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps)
|
||||
uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK)
|
||||
|
||||
|
|
@ -355,6 +417,8 @@ private:
|
|||
};
|
||||
std::vector<ChatBubble> chatBubbles_;
|
||||
bool chatBubbleCallbackSet_ = false;
|
||||
bool levelUpCallbackSet_ = false;
|
||||
bool achievementCallbackSet_ = false;
|
||||
|
||||
// Mail compose state
|
||||
char mailRecipientBuffer_[256] = "";
|
||||
|
|
@ -362,6 +426,12 @@ private:
|
|||
char mailBodyBuffer_[2048] = "";
|
||||
int mailComposeMoney_[3] = {0, 0, 0}; // gold, silver, copper
|
||||
|
||||
// Vendor search filter
|
||||
char vendorSearchFilter_[128] = "";
|
||||
|
||||
// Trainer search filter
|
||||
char trainerSearchFilter_[128] = "";
|
||||
|
||||
// Auction house UI state
|
||||
char auctionSearchName_[256] = "";
|
||||
int auctionLevelMin_ = 0;
|
||||
|
|
@ -403,6 +473,11 @@ private:
|
|||
std::string lastKnownZoneName_;
|
||||
void renderZoneText();
|
||||
|
||||
// DPS / HPS meter
|
||||
bool showDPSMeter_ = false;
|
||||
float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS)
|
||||
bool dpsWasInCombat_ = false;
|
||||
|
||||
public:
|
||||
void triggerDing(uint32_t newLevel);
|
||||
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
|
||||
|
|
|
|||
|
|
@ -171,11 +171,21 @@ private:
|
|||
void renderHeldItem();
|
||||
bool bagHasAnyItems(const game::Inventory& inventory, int bagIndex) const;
|
||||
|
||||
// Drop confirmation
|
||||
// Drop confirmation (drag-outside-window destroy)
|
||||
bool dropConfirmOpen_ = false;
|
||||
int dropBackpackIndex_ = -1;
|
||||
std::string dropItemName_;
|
||||
|
||||
// Destroy confirmation (Shift+right-click destroy)
|
||||
bool destroyConfirmOpen_ = false;
|
||||
uint8_t destroyBag_ = 0xFF;
|
||||
uint8_t destroySlot_ = 0;
|
||||
uint8_t destroyCount_ = 1;
|
||||
std::string destroyItemName_;
|
||||
|
||||
// Pending chat item link from shift-click
|
||||
std::string pendingChatItemLink_;
|
||||
|
||||
public:
|
||||
static ImVec4 getQualityColor(game::ItemQuality quality);
|
||||
|
||||
|
|
@ -190,6 +200,13 @@ public:
|
|||
/// Drop the currently held item into a specific equipment slot.
|
||||
/// Returns true if the drop was accepted and consumed.
|
||||
bool dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipSlot slot);
|
||||
/// Returns a WoW item link string if the user shift-clicked a bag item, then clears it.
|
||||
std::string getAndClearPendingChatLink() {
|
||||
std::string out = std::move(pendingChatItemLink_);
|
||||
pendingChatItemLink_.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Drop the currently held item into a bank slot via CMSG_SWAP_ITEM.
|
||||
void dropIntoBankSlot(game::GameHandler& gh, uint8_t dstBag, uint8_t dstSlot);
|
||||
/// Pick up an item from main bank slot (click-and-hold from bank window).
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public:
|
|||
TOGGLE_NAMEPLATES,
|
||||
TOGGLE_RAID_FRAMES,
|
||||
TOGGLE_QUEST_LOG,
|
||||
TOGGLE_ACHIEVEMENTS,
|
||||
ACTION_COUNT
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@
|
|||
|
||||
namespace wowee { namespace ui {
|
||||
|
||||
class InventoryScreen;
|
||||
|
||||
class QuestLogScreen {
|
||||
public:
|
||||
void render(game::GameHandler& gameHandler);
|
||||
void render(game::GameHandler& gameHandler, InventoryScreen& invScreen);
|
||||
bool isOpen() const { return open; }
|
||||
void toggle() { open = !open; }
|
||||
void setOpen(bool o) { open = o; }
|
||||
|
|
@ -29,6 +31,10 @@ private:
|
|||
uint32_t lastDetailRequestQuestId_ = 0;
|
||||
double lastDetailRequestAt_ = 0.0;
|
||||
std::unordered_set<uint32_t> questDetailQueryNoResponse_;
|
||||
// Search / filter
|
||||
char questSearchFilter_[64] = {};
|
||||
// 0=all, 1=active only, 2=complete only
|
||||
int questFilterMode_ = 0;
|
||||
};
|
||||
|
||||
}} // namespace wowee::ui
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ public:
|
|||
uint32_t getDragSpellId() const { return dragSpellId_; }
|
||||
void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; }
|
||||
|
||||
/// Returns a WoW spell link string if the user shift-clicked a spell, then clears it.
|
||||
std::string getAndClearPendingChatLink() {
|
||||
std::string out = std::move(pendingChatSpellLink_);
|
||||
pendingChatSpellLink_.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
bool open = false;
|
||||
bool pKeyWasDown = false;
|
||||
|
|
@ -87,6 +94,9 @@ private:
|
|||
uint32_t dragSpellId_ = 0;
|
||||
VkDescriptorSet dragSpellIconTex_ = VK_NULL_HANDLE;
|
||||
|
||||
// Pending chat spell link from shift-click
|
||||
std::string pendingChatSpellLink_;
|
||||
|
||||
void loadSpellDBC(pipeline::AssetManager* assetManager);
|
||||
void loadSpellIconDBC(pipeline::AssetManager* assetManager);
|
||||
void loadSkillLineDBCs(pipeline::AssetManager* assetManager);
|
||||
|
|
|
|||
|
|
@ -438,6 +438,49 @@ QuestQueryObjectives extractQuestQueryObjectives(const std::vector<uint8_t>& dat
|
|||
}
|
||||
}
|
||||
|
||||
// Parse quest reward fields from SMSG_QUEST_QUERY_RESPONSE fixed header.
|
||||
// Classic/TBC: 40 fixed fields; WotLK: 55 fixed fields.
|
||||
struct QuestQueryRewards {
|
||||
int32_t rewardMoney = 0;
|
||||
std::array<uint32_t, 4> itemId{};
|
||||
std::array<uint32_t, 4> itemCount{};
|
||||
std::array<uint32_t, 6> choiceItemId{};
|
||||
std::array<uint32_t, 6> choiceItemCount{};
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
static QuestQueryRewards tryParseQuestRewards(const std::vector<uint8_t>& data,
|
||||
bool classicLayout) {
|
||||
const size_t base = 8; // after questId(4) + questMethod(4)
|
||||
const size_t fieldCount = classicLayout ? 40u : 55u;
|
||||
const size_t headerEnd = base + fieldCount * 4u;
|
||||
if (data.size() < headerEnd) return {};
|
||||
|
||||
// Field indices (0-based) for each expansion:
|
||||
// Classic/TBC: rewardMoney=[14], rewardItemId[4]=[20..23], rewardItemCount[4]=[24..27],
|
||||
// rewardChoiceItemId[6]=[28..33], rewardChoiceItemCount[6]=[34..39]
|
||||
// WotLK: rewardMoney=[17], rewardItemId[4]=[30..33], rewardItemCount[4]=[34..37],
|
||||
// rewardChoiceItemId[6]=[38..43], rewardChoiceItemCount[6]=[44..49]
|
||||
const size_t moneyField = classicLayout ? 14u : 17u;
|
||||
const size_t itemIdField = classicLayout ? 20u : 30u;
|
||||
const size_t itemCountField = classicLayout ? 24u : 34u;
|
||||
const size_t choiceIdField = classicLayout ? 28u : 38u;
|
||||
const size_t choiceCntField = classicLayout ? 34u : 44u;
|
||||
|
||||
QuestQueryRewards out;
|
||||
out.rewardMoney = static_cast<int32_t>(readU32At(data, base + moneyField * 4u));
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
out.itemId[i] = readU32At(data, base + (itemIdField + i) * 4u);
|
||||
out.itemCount[i] = readU32At(data, base + (itemCountField + i) * 4u);
|
||||
}
|
||||
for (size_t i = 0; i < 6; ++i) {
|
||||
out.choiceItemId[i] = readU32At(data, base + (choiceIdField + i) * 4u);
|
||||
out.choiceItemCount[i] = readU32At(data, base + (choiceCntField + i) * 4u);
|
||||
}
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
|
|
@ -861,8 +904,10 @@ void GameHandler::update(float deltaTime) {
|
|||
(autoAttacking || autoAttackRequested_)) {
|
||||
pendingGameObjectInteractGuid_ = 0;
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
addUIError("Interrupted.");
|
||||
addSystemChatMessage("Interrupted.");
|
||||
}
|
||||
if (casting && castTimeRemaining > 0.0f) {
|
||||
|
|
@ -874,6 +919,7 @@ void GameHandler::update(float deltaTime) {
|
|||
performGameObjectInteractionNow(interactGuid);
|
||||
}
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
}
|
||||
|
|
@ -1079,6 +1125,7 @@ void GameHandler::update(float deltaTime) {
|
|||
autoAttackOutOfRangeTime_ += deltaTime;
|
||||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||||
addSystemChatMessage("Target is too far away.");
|
||||
addUIError("Target is too far away.");
|
||||
autoAttackRangeWarnCooldown_ = 1.25f;
|
||||
}
|
||||
// Stop chasing stale swings when the target remains out of range.
|
||||
|
|
@ -1904,6 +1951,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) {
|
||||
if (castResult != 0) {
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
// Pass player's power type so result 85 says "Not enough rage/energy/etc."
|
||||
|
|
@ -1913,11 +1961,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
playerPowerType = static_cast<int>(pu->getPowerType());
|
||||
}
|
||||
const char* reason = getSpellCastResultString(castResult, playerPowerType);
|
||||
std::string errMsg = reason ? reason
|
||||
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
|
||||
addUIError(errMsg);
|
||||
MessageChatData msg;
|
||||
msg.type = ChatType::SYSTEM;
|
||||
msg.language = ChatLanguage::UNIVERSAL;
|
||||
msg.message = reason ? reason
|
||||
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
|
||||
msg.message = errMsg;
|
||||
addLocalChatMessage(msg);
|
||||
}
|
||||
}
|
||||
|
|
@ -2163,9 +2213,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_FORCE_ANIM: {
|
||||
// packed_guid + uint32 animId — force entity to play animation
|
||||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
uint64_t animGuid = UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
/*uint32_t animId =*/ packet.readUInt32();
|
||||
uint32_t animId = packet.readUInt32();
|
||||
if (emoteAnimCallback_)
|
||||
emoteAnimCallback_(animGuid, animId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
@ -2226,6 +2278,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
|
||||
break;
|
||||
case Opcode::SMSG_FEIGN_DEATH_RESISTED:
|
||||
addUIError("Your Feign Death was resisted.");
|
||||
addSystemChatMessage("Your Feign Death attempt was resisted.");
|
||||
LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED");
|
||||
break;
|
||||
|
|
@ -2278,24 +2331,51 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
break;
|
||||
case Opcode::SMSG_THREAT_CLEAR:
|
||||
// All threat dropped on the local player (e.g. Vanish, Feign Death)
|
||||
// No local state to clear — informational
|
||||
threatLists_.clear();
|
||||
LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped");
|
||||
break;
|
||||
case Opcode::SMSG_THREAT_REMOVE: {
|
||||
// packed_guid (unit) + packed_guid (victim whose threat was removed)
|
||||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
}
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet);
|
||||
auto it = threatLists_.find(unitGuid);
|
||||
if (it != threatLists_.end()) {
|
||||
auto& list = it->second;
|
||||
list.erase(std::remove_if(list.begin(), list.end(),
|
||||
[victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }),
|
||||
list.end());
|
||||
if (list.empty()) threatLists_.erase(it);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_HIGHEST_THREAT_UPDATE: {
|
||||
// packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count
|
||||
// + count × (packed_guid victim + uint32 threat)
|
||||
// Informational — no threat UI yet; consume to suppress warnings
|
||||
packet.setReadPos(packet.getSize());
|
||||
case Opcode::SMSG_HIGHEST_THREAT_UPDATE:
|
||||
case Opcode::SMSG_THREAT_UPDATE: {
|
||||
// Both packets share the same format:
|
||||
// packed_guid (unit) + packed_guid (highest-threat target or target, unused here)
|
||||
// + uint32 count + count × (packed_guid victim + uint32 threat)
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
(void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target
|
||||
if (packet.getSize() - packet.getReadPos() < 4) break;
|
||||
uint32_t cnt = packet.readUInt32();
|
||||
if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity
|
||||
std::vector<ThreatEntry> list;
|
||||
list.reserve(cnt);
|
||||
for (uint32_t i = 0; i < cnt; ++i) {
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
ThreatEntry entry;
|
||||
entry.victimGuid = UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() < 4) break;
|
||||
entry.threat = packet.readUInt32();
|
||||
list.push_back(entry);
|
||||
}
|
||||
// Sort descending by threat so highest is first
|
||||
std::sort(list.begin(), list.end(),
|
||||
[](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; });
|
||||
threatLists_[unitGuid] = std::move(list);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -2776,13 +2856,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
// Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table
|
||||
uint8_t failReason = isClassic ? static_cast<uint8_t>(rawFailReason + 1) : rawFailReason;
|
||||
if (failGuid == playerGuid && failReason != 0) {
|
||||
// Show interruption/failure reason in chat for player
|
||||
// Show interruption/failure reason in chat and error overlay for player
|
||||
int pt = -1;
|
||||
if (auto pe = entityManager.getEntity(playerGuid))
|
||||
if (auto pu = std::dynamic_pointer_cast<Unit>(pe))
|
||||
pt = static_cast<int>(pu->getPowerType());
|
||||
const char* reason = getSpellCastResultString(failReason, pt);
|
||||
if (reason) {
|
||||
addUIError(reason);
|
||||
MessageChatData emsg;
|
||||
emsg.type = ChatType::SYSTEM;
|
||||
emsg.language = ChatLanguage::UNIVERSAL;
|
||||
|
|
@ -2794,6 +2875,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
if (failGuid == playerGuid || failGuid == 0) {
|
||||
// Player's own cast failed
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
||||
if (auto* ssm = renderer->getSpellSoundManager()) {
|
||||
|
|
@ -2862,18 +2944,26 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
// uint64 itemGuid + uint32 spellId + uint32 cooldownMs
|
||||
size_t rem = packet.getSize() - packet.getReadPos();
|
||||
if (rem >= 16) {
|
||||
/*uint64_t itemGuid =*/ packet.readUInt64();
|
||||
uint32_t spellId = packet.readUInt32();
|
||||
uint32_t cdMs = packet.readUInt32();
|
||||
uint64_t itemGuid = packet.readUInt64();
|
||||
uint32_t spellId = packet.readUInt32();
|
||||
uint32_t cdMs = packet.readUInt32();
|
||||
float cdSec = cdMs / 1000.0f;
|
||||
if (spellId != 0 && cdSec > 0.0f) {
|
||||
spellCooldowns[spellId] = cdSec;
|
||||
if (cdSec > 0.0f) {
|
||||
if (spellId != 0) spellCooldowns[spellId] = cdSec;
|
||||
// Resolve itemId from the GUID so item-type slots are also updated
|
||||
uint32_t itemId = 0;
|
||||
auto iit = onlineItems_.find(itemGuid);
|
||||
if (iit != onlineItems_.end()) itemId = iit->second.entry;
|
||||
for (auto& slot : actionBar) {
|
||||
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
||||
bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|
||||
|| (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId);
|
||||
if (match) {
|
||||
slot.cooldownTotal = cdSec;
|
||||
slot.cooldownRemaining = cdSec;
|
||||
}
|
||||
}
|
||||
LOG_DEBUG("SMSG_ITEM_COOLDOWN: spellId=", spellId, " cd=", cdSec, "s");
|
||||
LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec,
|
||||
" spellId=", spellId, " itemId=", itemId, " cd=", cdSec, "s");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
@ -3116,6 +3206,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
handleDuelWinner(packet);
|
||||
break;
|
||||
case Opcode::SMSG_DUEL_OUTOFBOUNDS:
|
||||
addUIError("You are out of the duel area!");
|
||||
addSystemChatMessage("You are out of the duel area!");
|
||||
break;
|
||||
case Opcode::SMSG_DUEL_INBOUNDS:
|
||||
|
|
@ -3453,6 +3544,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
delta > 0 ? "increased" : "decreased",
|
||||
std::abs(delta));
|
||||
addSystemChatMessage(buf);
|
||||
if (repChangeCallback_) repChangeCallback_(name, delta, standing);
|
||||
}
|
||||
LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing);
|
||||
}
|
||||
|
|
@ -3728,11 +3820,22 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
break;
|
||||
|
||||
case Opcode::SMSG_SERVER_MESSAGE: {
|
||||
// uint32 type, string message
|
||||
// uint32 type + string message
|
||||
// Types: 1=shutdown_time, 2=restart_time, 3=string, 4=shutdown_cancelled, 5=restart_cancelled
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
/*uint32_t msgType =*/ packet.readUInt32();
|
||||
uint32_t msgType = packet.readUInt32();
|
||||
std::string msg = packet.readString();
|
||||
if (!msg.empty()) addSystemChatMessage("[Server] " + msg);
|
||||
if (!msg.empty()) {
|
||||
std::string prefix;
|
||||
switch (msgType) {
|
||||
case 1: prefix = "[Shutdown] "; addUIError("Server shutdown: " + msg); break;
|
||||
case 2: prefix = "[Restart] "; addUIError("Server restart: " + msg); break;
|
||||
case 4: prefix = "[Shutdown cancelled] "; break;
|
||||
case 5: prefix = "[Restart cancelled] "; break;
|
||||
default: prefix = "[Server] "; break;
|
||||
}
|
||||
addSystemChatMessage(prefix + msg);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -4050,12 +4153,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
}
|
||||
case Opcode::SMSG_CRITERIA_UPDATE: {
|
||||
// uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime
|
||||
// Achievement criteria progress (informational — no criteria UI yet).
|
||||
if (packet.getSize() - packet.getReadPos() >= 20) {
|
||||
uint32_t criteriaId = packet.readUInt32();
|
||||
uint64_t progress = packet.readUInt64();
|
||||
/*uint32_t elapsedTime =*/ packet.readUInt32();
|
||||
/*uint32_t createTime =*/ packet.readUInt32();
|
||||
packet.readUInt32(); // elapsedTime
|
||||
packet.readUInt32(); // creationTime
|
||||
criteriaProgress_[criteriaId] = progress;
|
||||
LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress);
|
||||
}
|
||||
break;
|
||||
|
|
@ -4528,6 +4631,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4;
|
||||
const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout);
|
||||
const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout);
|
||||
const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout);
|
||||
|
||||
for (auto& q : questLog_) {
|
||||
if (q.questId != questId) continue;
|
||||
|
|
@ -4583,6 +4687,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ",
|
||||
objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]");
|
||||
}
|
||||
|
||||
// Store reward data and pre-fetch item info for icons.
|
||||
if (rwds.valid) {
|
||||
q.rewardMoney = rwds.rewardMoney;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
q.rewardItems[i].itemId = rwds.itemId[i];
|
||||
q.rewardItems[i].count = (rwds.itemId[i] != 0) ? rwds.itemCount[i] : 0;
|
||||
if (rwds.itemId[i] != 0) queryItemInfo(rwds.itemId[i], 0);
|
||||
}
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
q.rewardChoiceItems[i].itemId = rwds.choiceItemId[i];
|
||||
q.rewardChoiceItems[i].count = (rwds.choiceItemId[i] != 0) ? rwds.choiceItemCount[i] : 0;
|
||||
if (rwds.choiceItemId[i] != 0) queryItemInfo(rwds.choiceItemId[i], 0);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -4832,7 +4951,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
handleArenaTeamEvent(packet);
|
||||
break;
|
||||
case Opcode::SMSG_ARENA_TEAM_STATS:
|
||||
LOG_INFO("Received SMSG_ARENA_TEAM_STATS");
|
||||
handleArenaTeamStats(packet);
|
||||
break;
|
||||
case Opcode::SMSG_ARENA_ERROR:
|
||||
handleArenaError(packet);
|
||||
|
|
@ -4909,10 +5028,34 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::MSG_QUERY_NEXT_MAIL_TIME:
|
||||
handleQueryNextMailTime(packet);
|
||||
break;
|
||||
case Opcode::SMSG_CHANNEL_LIST:
|
||||
// Channel member listing currently not rendered in UI.
|
||||
packet.setReadPos(packet.getSize());
|
||||
case Opcode::SMSG_CHANNEL_LIST: {
|
||||
// string channelName + uint8 flags + uint32 count + count×(uint64 guid + uint8 memberFlags)
|
||||
std::string chanName = packet.readString();
|
||||
if (packet.getSize() - packet.getReadPos() < 5) break;
|
||||
/*uint8_t chanFlags =*/ packet.readUInt8();
|
||||
uint32_t memberCount = packet.readUInt32();
|
||||
memberCount = std::min(memberCount, 200u);
|
||||
addSystemChatMessage(chanName + " has " + std::to_string(memberCount) + " member(s):");
|
||||
for (uint32_t i = 0; i < memberCount; ++i) {
|
||||
if (packet.getSize() - packet.getReadPos() < 9) break;
|
||||
uint64_t memberGuid = packet.readUInt64();
|
||||
uint8_t memberFlags = packet.readUInt8();
|
||||
// Look up the name from our entity manager
|
||||
auto entity = entityManager.getEntity(memberGuid);
|
||||
std::string name = "(unknown)";
|
||||
if (entity) {
|
||||
auto player = std::dynamic_pointer_cast<Player>(entity);
|
||||
if (player && !player->getName().empty()) name = player->getName();
|
||||
}
|
||||
std::string entry = " " + name;
|
||||
if (memberFlags & 0x01) entry += " [Moderator]";
|
||||
if (memberFlags & 0x02) entry += " [Muted]";
|
||||
addSystemChatMessage(entry);
|
||||
LOG_DEBUG(" channel member: 0x", std::hex, memberGuid, std::dec,
|
||||
" flags=", (int)memberFlags, " name=", name);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_INSPECT_RESULTS_UPDATE:
|
||||
handleInspectResults(packet);
|
||||
break;
|
||||
|
|
@ -5468,6 +5611,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
if (totalMs > 0) {
|
||||
if (caster == playerGuid) {
|
||||
casting = true;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = spellId;
|
||||
castTimeTotal = totalMs / 1000.0f;
|
||||
castTimeRemaining = remainMs / 1000.0f;
|
||||
|
|
@ -5496,6 +5640,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
if (chanTotalMs > 0 && chanCaster != 0) {
|
||||
if (chanCaster == playerGuid) {
|
||||
casting = true;
|
||||
castIsChannel = true;
|
||||
currentCastSpellId = chanSpellId;
|
||||
castTimeTotal = chanTotalMs / 1000.0f;
|
||||
castTimeRemaining = castTimeTotal;
|
||||
|
|
@ -5523,6 +5668,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
castTimeRemaining = chanRemainMs / 1000.0f;
|
||||
if (chanRemainMs == 0) {
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
}
|
||||
} else if (chanCaster2 != 0) {
|
||||
|
|
@ -5537,22 +5683,6 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
break;
|
||||
}
|
||||
|
||||
case Opcode::SMSG_THREAT_UPDATE: {
|
||||
// packed_guid (unit) + packed_guid (target) + uint32 count
|
||||
// + count × (packed_guid victim + uint32 threat) — consume to suppress warnings
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() < 4) break;
|
||||
uint32_t cnt = packet.readUInt32();
|
||||
for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) {
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
if (packet.getSize() - packet.getReadPos() >= 4)
|
||||
packet.readUInt32();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: {
|
||||
// uint32 slot + packed_guid unit (0 packed = clear slot)
|
||||
if (packet.getSize() - packet.getReadPos() < 5) {
|
||||
|
|
@ -6030,10 +6160,33 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA:
|
||||
case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER:
|
||||
case Opcode::SMSG_PROFILEDATA_RESPONSE:
|
||||
case Opcode::SMSG_PLAY_TIME_WARNING:
|
||||
packet.setReadPos(packet.getSize());
|
||||
break;
|
||||
|
||||
case Opcode::SMSG_PLAY_TIME_WARNING: {
|
||||
// uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
uint32_t warnType = packet.readUInt32();
|
||||
uint32_t minutesPlayed = (packet.getSize() - packet.getReadPos() >= 4)
|
||||
? packet.readUInt32() : 0;
|
||||
const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] ";
|
||||
char buf[128];
|
||||
if (minutesPlayed > 0) {
|
||||
uint32_t h = minutesPlayed / 60;
|
||||
uint32_t m = minutesPlayed % 60;
|
||||
if (h > 0)
|
||||
std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m);
|
||||
else
|
||||
std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity);
|
||||
}
|
||||
addSystemChatMessage(buf);
|
||||
addUIError(buf);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- Item query multiple (same format as single, re-use handler) ----
|
||||
case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE:
|
||||
handleItemQueryResponse(packet);
|
||||
|
|
@ -6525,6 +6678,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
|
|||
autoAttacking = false;
|
||||
autoAttackTarget = 0;
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
pendingGameObjectInteractGuid_ = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
|
|
@ -7650,6 +7804,29 @@ void GameHandler::sendPing() {
|
|||
socket->send(packet);
|
||||
}
|
||||
|
||||
void GameHandler::sendMinimapPing(float wowX, float wowY) {
|
||||
if (state != WorldState::IN_WORLD) return;
|
||||
|
||||
// MSG_MINIMAP_PING (CMSG direction): float posX + float posY
|
||||
// Server convention: posX = east/west axis = canonical Y (west)
|
||||
// posY = north/south axis = canonical X (north)
|
||||
const float serverX = wowY; // canonical Y (west) → server posX
|
||||
const float serverY = wowX; // canonical X (north) → server posY
|
||||
|
||||
network::Packet pkt(wireOpcode(Opcode::MSG_MINIMAP_PING));
|
||||
pkt.writeFloat(serverX);
|
||||
pkt.writeFloat(serverY);
|
||||
socket->send(pkt);
|
||||
|
||||
// Add ping locally so the sender sees their own ping immediately
|
||||
MinimapPing localPing;
|
||||
localPing.senderGuid = activeCharacterGuid_;
|
||||
localPing.wowX = wowX;
|
||||
localPing.wowY = wowY;
|
||||
localPing.age = 0.0f;
|
||||
minimapPings_.push_back(localPing);
|
||||
}
|
||||
|
||||
void GameHandler::handlePong(network::Packet& packet) {
|
||||
LOG_DEBUG("Handling SMSG_PONG");
|
||||
|
||||
|
|
@ -10463,6 +10640,29 @@ void GameHandler::clearMainAssist() {
|
|||
LOG_INFO("Cleared main assist");
|
||||
}
|
||||
|
||||
void GameHandler::setRaidMark(uint64_t guid, uint8_t icon) {
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
|
||||
static const char* kMarkNames[] = {
|
||||
"Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull"
|
||||
};
|
||||
|
||||
if (icon == 0xFF) {
|
||||
// Clear mark: find which slot this guid holds and send 0 GUID
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (raidTargetGuids_[i] == guid) {
|
||||
auto packet = RaidTargetUpdatePacket::build(static_cast<uint8_t>(i), 0);
|
||||
socket->send(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (icon < 8) {
|
||||
auto packet = RaidTargetUpdatePacket::build(icon, guid);
|
||||
socket->send(packet);
|
||||
LOG_INFO("Set raid mark %s on guid %llu", kMarkNames[icon], (unsigned long long)guid);
|
||||
}
|
||||
}
|
||||
|
||||
void GameHandler::requestRaidInfo() {
|
||||
if (state != WorldState::IN_WORLD || !socket) {
|
||||
LOG_WARNING("Cannot request raid info: not in world or not connected");
|
||||
|
|
@ -10527,6 +10727,7 @@ void GameHandler::stopCasting() {
|
|||
|
||||
// Reset casting state
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
pendingGameObjectInteractGuid_ = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
|
|
@ -10991,8 +11192,51 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
|
|||
uint8_t talentType = packet.readUInt8();
|
||||
|
||||
if (talentType == 0) {
|
||||
// Own talent info — silently consume (sent on login, talent changes, respecs)
|
||||
LOG_DEBUG("SMSG_TALENTS_INFO: received own talent data, ignoring");
|
||||
// Own talent info (type 0): uint32 unspentTalents, uint8 groupCount, uint8 activeGroup
|
||||
// Per group: uint8 talentCount, [talentId(4)+rank(1)]..., uint8 glyphCount, [glyphId(2)]...
|
||||
if (packet.getSize() - packet.getReadPos() < 6) {
|
||||
LOG_DEBUG("SMSG_TALENTS_INFO type=0: too short");
|
||||
return;
|
||||
}
|
||||
uint32_t unspentTalents = packet.readUInt32();
|
||||
uint8_t talentGroupCount = packet.readUInt8();
|
||||
uint8_t activeTalentGroup = packet.readUInt8();
|
||||
|
||||
if (activeTalentGroup > 1) activeTalentGroup = 0;
|
||||
activeTalentSpec_ = activeTalentGroup;
|
||||
|
||||
for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) {
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
uint8_t talentCount = packet.readUInt8();
|
||||
learnedTalents_[g].clear();
|
||||
for (uint8_t t = 0; t < talentCount; ++t) {
|
||||
if (packet.getSize() - packet.getReadPos() < 5) break;
|
||||
uint32_t talentId = packet.readUInt32();
|
||||
uint8_t rank = packet.readUInt8();
|
||||
learnedTalents_[g][talentId] = rank;
|
||||
}
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
uint8_t glyphCount = packet.readUInt8();
|
||||
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
|
||||
if (packet.getSize() - packet.getReadPos() < 2) break;
|
||||
packet.readUInt16(); // glyphId (skip)
|
||||
}
|
||||
}
|
||||
|
||||
unspentTalentPoints_[activeTalentGroup] = static_cast<uint8_t>(
|
||||
unspentTalents > 255 ? 255 : unspentTalents);
|
||||
|
||||
if (!talentsInitialized_) {
|
||||
talentsInitialized_ = true;
|
||||
if (unspentTalents > 0) {
|
||||
addSystemChatMessage("You have " + std::to_string(unspentTalents)
|
||||
+ " unspent talent point" + (unspentTalents != 1 ? "s" : "") + ".");
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INFO("SMSG_TALENTS_INFO type=0: unspent=", unspentTalents,
|
||||
" groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup,
|
||||
" learned=", learnedTalents_[activeTalentGroup].size());
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -11068,16 +11312,21 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
|
|||
}
|
||||
}
|
||||
|
||||
// Display inspect results
|
||||
std::string msg = "Inspect: " + playerName;
|
||||
msg += " - " + std::to_string(totalTalents) + " talent points spent";
|
||||
if (unspentTalents > 0) {
|
||||
msg += ", " + std::to_string(unspentTalents) + " unspent";
|
||||
// Store inspect result for UI display
|
||||
inspectResult_.guid = guid;
|
||||
inspectResult_.playerName = playerName;
|
||||
inspectResult_.totalTalents = totalTalents;
|
||||
inspectResult_.unspentTalents = unspentTalents;
|
||||
inspectResult_.talentGroups = talentGroupCount;
|
||||
inspectResult_.activeTalentGroup = activeTalentGroup;
|
||||
|
||||
// Merge any gear we already have from a prior inspect request
|
||||
auto gearIt = inspectedPlayerItemEntries_.find(guid);
|
||||
if (gearIt != inspectedPlayerItemEntries_.end()) {
|
||||
inspectResult_.itemEntries = gearIt->second;
|
||||
} else {
|
||||
inspectResult_.itemEntries = {};
|
||||
}
|
||||
if (talentGroupCount > 1) {
|
||||
msg += " (dual spec, active: " + std::to_string(activeTalentGroup + 1) + ")";
|
||||
}
|
||||
addSystemChatMessage(msg);
|
||||
|
||||
LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ",
|
||||
unspentTalents, " unspent, ", (int)talentGroupCount, " specs");
|
||||
|
|
@ -12805,15 +13054,18 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) {
|
|||
uint32_t timeLeft = packet.readUInt32();
|
||||
uint32_t votesNeeded = packet.readUInt32();
|
||||
|
||||
(void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded;
|
||||
(void)myVote;
|
||||
|
||||
lfgBootVotes_ = bootVotes;
|
||||
lfgBootTotal_ = totalVotes;
|
||||
lfgBootTimeLeft_ = timeLeft;
|
||||
lfgBootNeeded_ = votesNeeded;
|
||||
|
||||
if (inProgress) {
|
||||
lfgState_ = LfgState::Boot;
|
||||
addSystemChatMessage(
|
||||
std::string("Dungeon Finder: Vote to kick in progress (") +
|
||||
std::to_string(timeLeft) + "s remaining).");
|
||||
} else {
|
||||
// Boot vote ended — return to InDungeon state regardless of outcome
|
||||
lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0;
|
||||
lfgState_ = LfgState::InDungeon;
|
||||
if (myAnswer) {
|
||||
addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed.");
|
||||
|
|
@ -13083,6 +13335,35 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) {
|
|||
LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2);
|
||||
}
|
||||
|
||||
void GameHandler::handleArenaTeamStats(network::Packet& packet) {
|
||||
// SMSG_ARENA_TEAM_STATS (WotLK 3.3.5a):
|
||||
// uint32 teamId, uint32 rating, uint32 weekGames, uint32 weekWins,
|
||||
// uint32 seasonGames, uint32 seasonWins, uint32 rank
|
||||
if (packet.getSize() - packet.getReadPos() < 28) return;
|
||||
|
||||
ArenaTeamStats stats;
|
||||
stats.teamId = packet.readUInt32();
|
||||
stats.rating = packet.readUInt32();
|
||||
stats.weekGames = packet.readUInt32();
|
||||
stats.weekWins = packet.readUInt32();
|
||||
stats.seasonGames = packet.readUInt32();
|
||||
stats.seasonWins = packet.readUInt32();
|
||||
stats.rank = packet.readUInt32();
|
||||
|
||||
// Update or insert for this team
|
||||
for (auto& s : arenaTeamStats_) {
|
||||
if (s.teamId == stats.teamId) {
|
||||
s = stats;
|
||||
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
|
||||
" rating=", stats.rating, " rank=", stats.rank);
|
||||
return;
|
||||
}
|
||||
}
|
||||
arenaTeamStats_.push_back(stats);
|
||||
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
|
||||
" rating=", stats.rating, " rank=", stats.rank);
|
||||
}
|
||||
|
||||
void GameHandler::handleArenaError(network::Packet& packet) {
|
||||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||||
uint32_t error = packet.readUInt32();
|
||||
|
|
@ -13831,6 +14112,7 @@ void GameHandler::cancelCast() {
|
|||
}
|
||||
pendingGameObjectInteractGuid_ = 0;
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
}
|
||||
|
|
@ -13969,6 +14251,7 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
|
|||
if (!ok) return;
|
||||
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
|
||||
|
|
@ -14027,6 +14310,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
|
|||
// If this is the player's own cast, start cast bar
|
||||
if (data.casterUnit == playerGuid && data.castTime > 0) {
|
||||
casting = true;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = data.spellId;
|
||||
castTimeTotal = data.castTime / 1000.0f;
|
||||
castTimeRemaining = castTimeTotal;
|
||||
|
|
@ -14097,6 +14381,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
|
|||
}
|
||||
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
|
||||
|
|
@ -14167,14 +14452,17 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) {
|
|||
const size_t entrySize = isClassicFormat ? 12u : 8u;
|
||||
while (packet.getSize() - packet.getReadPos() >= entrySize) {
|
||||
uint32_t spellId = packet.readUInt32();
|
||||
if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used
|
||||
uint32_t cdItemId = 0;
|
||||
if (isClassicFormat) cdItemId = packet.readUInt32(); // itemId in Classic format
|
||||
uint32_t cooldownMs = packet.readUInt32();
|
||||
|
||||
float seconds = cooldownMs / 1000.0f;
|
||||
spellCooldowns[spellId] = seconds;
|
||||
for (auto& slot : actionBar) {
|
||||
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
||||
slot.cooldownTotal = seconds;
|
||||
bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|
||||
|| (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId);
|
||||
if (match) {
|
||||
slot.cooldownTotal = seconds;
|
||||
slot.cooldownRemaining = seconds;
|
||||
}
|
||||
}
|
||||
|
|
@ -14768,6 +15056,34 @@ void GameHandler::declineGuildInvite() {
|
|||
LOG_INFO("Declined guild invite");
|
||||
}
|
||||
|
||||
void GameHandler::submitGmTicket(const std::string& text) {
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
|
||||
// CMSG_GMTICKET_CREATE (WotLK 3.3.5a):
|
||||
// string ticket_text
|
||||
// float[3] position (server coords)
|
||||
// float facing
|
||||
// uint32 mapId
|
||||
// uint8 need_response (1 = yes)
|
||||
network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_CREATE));
|
||||
pkt.writeString(text);
|
||||
pkt.writeFloat(movementInfo.x);
|
||||
pkt.writeFloat(movementInfo.y);
|
||||
pkt.writeFloat(movementInfo.z);
|
||||
pkt.writeFloat(movementInfo.orientation);
|
||||
pkt.writeUInt32(currentMapId_);
|
||||
pkt.writeUInt8(1); // need_response = yes
|
||||
socket->send(pkt);
|
||||
LOG_INFO("Submitted GM ticket: '", text, "'");
|
||||
}
|
||||
|
||||
void GameHandler::deleteGmTicket() {
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET));
|
||||
socket->send(pkt);
|
||||
LOG_INFO("Deleting GM ticket");
|
||||
}
|
||||
|
||||
void GameHandler::queryGuildInfo(uint32_t guildId) {
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
auto packet = GuildQueryPacket::build(guildId);
|
||||
|
|
@ -17100,6 +17416,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
|
|||
areaTriggerSuppressFirst_ = true; // first check just marks active triggers, doesn't fire
|
||||
stopAutoAttack();
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
pendingGameObjectInteractGuid_ = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
|
|
@ -17875,6 +18192,9 @@ void GameHandler::handlePlayedTime(network::Packet& packet) {
|
|||
return;
|
||||
}
|
||||
|
||||
totalTimePlayed_ = data.totalTimePlayed;
|
||||
levelTimePlayed_ = data.levelTimePlayed;
|
||||
|
||||
if (data.triggerMessage) {
|
||||
// Format total time played
|
||||
uint32_t totalDays = data.totalTimePlayed / 86400;
|
||||
|
|
@ -19806,18 +20126,21 @@ void GameHandler::handleAllAchievementData(network::Packet& packet) {
|
|||
earnedAchievements_.insert(id);
|
||||
}
|
||||
|
||||
// Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF)
|
||||
// Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF
|
||||
criteriaProgress_.clear();
|
||||
while (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
uint32_t id = packet.readUInt32();
|
||||
if (id == 0xFFFFFFFF) break;
|
||||
// counter(8) + date(4) + unknown(4) = 16 bytes
|
||||
if (packet.getSize() - packet.getReadPos() < 16) break;
|
||||
packet.readUInt64(); // counter
|
||||
uint64_t counter = packet.readUInt64();
|
||||
packet.readUInt32(); // date
|
||||
packet.readUInt32(); // unknown / flags
|
||||
criteriaProgress_[id] = counter;
|
||||
}
|
||||
|
||||
LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements");
|
||||
LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(),
|
||||
" achievements, ", criteriaProgress_.size(), " criteria");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -752,7 +752,6 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) {
|
|||
return (serverExplorationMask[word] & (1u << (bitIndex % 32))) != 0;
|
||||
};
|
||||
|
||||
bool markedAny = false;
|
||||
if (hasServerExplorationMask) {
|
||||
exploredZones.clear();
|
||||
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
|
||||
|
|
@ -761,15 +760,19 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) {
|
|||
for (uint32_t bit : z.exploreBits) {
|
||||
if (isBitSet(bit)) {
|
||||
exploredZones.insert(i);
|
||||
markedAny = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always trust the server mask when available — even if empty (unexplored character).
|
||||
// Also reveal the zone the player is currently standing in so the map isn't pitch-black
|
||||
// the moment they first enter a new zone (the server bit arrives on the next update).
|
||||
int curZone = findZoneForPlayer(playerRenderPos);
|
||||
if (curZone >= 0) exploredZones.insert(curZone);
|
||||
return;
|
||||
}
|
||||
if (markedAny) return;
|
||||
|
||||
// Server mask unavailable or empty — fall back to locally-accumulated position tracking.
|
||||
// Server mask unavailable — fall back to locally-accumulated position tracking.
|
||||
// Add the zone the player is currently in to the local set and display that.
|
||||
float wowX = playerRenderPos.y;
|
||||
float wowY = playerRenderPos.x;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -101,6 +101,14 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) {
|
|||
auto it = iconCache_.find(displayInfoId);
|
||||
if (it != iconCache_.end()) return it->second;
|
||||
|
||||
// Rate-limit GPU uploads per frame to avoid stalling when many items appear at once
|
||||
// (e.g., opening a full bag, vendor window, or loot from a boss with many drops).
|
||||
static int iiLoadsThisFrame = 0;
|
||||
static int iiLastImGuiFrame = -1;
|
||||
int iiCurFrame = ImGui::GetFrameCount();
|
||||
if (iiCurFrame != iiLastImGuiFrame) { iiLoadsThisFrame = 0; iiLastImGuiFrame = iiCurFrame; }
|
||||
if (iiLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
|
||||
|
||||
// Load ItemDisplayInfo.dbc
|
||||
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
|
||||
if (!displayInfoDbc) {
|
||||
|
|
@ -143,6 +151,7 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) {
|
|||
return VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
++iiLoadsThisFrame;
|
||||
VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
|
||||
iconCache_[displayInfoId] = ds;
|
||||
return ds;
|
||||
|
|
@ -721,6 +730,9 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
|
|||
KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN, false);
|
||||
if (characterDown && !cKeyWasDown) {
|
||||
characterOpen = !characterOpen;
|
||||
if (characterOpen && gameHandler_) {
|
||||
gameHandler_->requestPlayedTime();
|
||||
}
|
||||
}
|
||||
cKeyWasDown = characterDown;
|
||||
|
||||
|
|
@ -825,6 +837,33 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
|
|||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// Shift+right-click destroy confirmation popup
|
||||
if (destroyConfirmOpen_) {
|
||||
ImVec2 mousePos = ImGui::GetIO().MousePos;
|
||||
ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always);
|
||||
ImGui::OpenPopup("##DestroyItem");
|
||||
destroyConfirmOpen_ = false;
|
||||
}
|
||||
if (ImGui::BeginPopup("##DestroyItem", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Destroy");
|
||||
ImGui::TextUnformatted(destroyItemName_.c_str());
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Button("Yes, Destroy", ImVec2(110, 0))) {
|
||||
if (gameHandler_) {
|
||||
gameHandler_->destroyItem(destroyBag_, destroySlot_, destroyCount_);
|
||||
}
|
||||
destroyItemName_.clear();
|
||||
inventoryDirty = true;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel", ImVec2(70, 0))) {
|
||||
destroyItemName_.clear();
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// Draw held item at cursor
|
||||
renderHeldItem();
|
||||
}
|
||||
|
|
@ -1085,6 +1124,18 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
|
|||
if (ImGui::BeginTabBar("##CharacterTabs")) {
|
||||
if (ImGui::BeginTabItem("Equipment")) {
|
||||
renderEquipmentPanel(inventory);
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
// Appearance visibility toggles
|
||||
bool helmVis = gameHandler.isHelmVisible();
|
||||
bool cloakVis = gameHandler.isCloakVisible();
|
||||
if (ImGui::Checkbox("Show Helm", &helmVis)) {
|
||||
gameHandler.toggleHelm();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Checkbox("Show Cloak", &cloakVis)) {
|
||||
gameHandler.toggleCloak();
|
||||
}
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
|
||||
|
|
@ -1094,6 +1145,30 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
|
|||
for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i);
|
||||
const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr;
|
||||
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats);
|
||||
|
||||
// Played time (shown if available, fetched on character screen open)
|
||||
uint32_t totalSec = gameHandler.getTotalTimePlayed();
|
||||
uint32_t levelSec = gameHandler.getLevelTimePlayed();
|
||||
if (totalSec > 0 || levelSec > 0) {
|
||||
ImGui::Separator();
|
||||
// Helper lambda to format seconds as "Xd Xh Xm"
|
||||
auto fmtTime = [](uint32_t sec) -> std::string {
|
||||
uint32_t d = sec / 86400, h = (sec % 86400) / 3600, m = (sec % 3600) / 60;
|
||||
char buf[48];
|
||||
if (d > 0) snprintf(buf, sizeof(buf), "%ud %uh %um", d, h, m);
|
||||
else if (h > 0) snprintf(buf, sizeof(buf), "%uh %um", h, m);
|
||||
else snprintf(buf, sizeof(buf), "%um", m);
|
||||
return buf;
|
||||
};
|
||||
ImGui::TextDisabled("Time Played");
|
||||
ImGui::Columns(2, "##playtime", false);
|
||||
ImGui::SetColumnWidth(0, 130);
|
||||
ImGui::Text("Total:"); ImGui::NextColumn();
|
||||
ImGui::Text("%s", fmtTime(totalSec).c_str()); ImGui::NextColumn();
|
||||
ImGui::Text("This level:"); ImGui::NextColumn();
|
||||
ImGui::Text("%s", fmtTime(levelSec).c_str()); ImGui::NextColumn();
|
||||
ImGui::Columns(1);
|
||||
}
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
|
||||
|
|
@ -1171,6 +1246,85 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
|
|||
ImGui::EndTabItem();
|
||||
}
|
||||
|
||||
if (ImGui::BeginTabItem("Achievements")) {
|
||||
const auto& earned = gameHandler.getEarnedAchievements();
|
||||
if (earned.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::TextDisabled("No achievements earned yet.");
|
||||
} else {
|
||||
static char achieveFilter[128] = {};
|
||||
ImGui::SetNextItemWidth(-1.0f);
|
||||
ImGui::InputTextWithHint("##achsearch", "Search achievements...",
|
||||
achieveFilter, sizeof(achieveFilter));
|
||||
ImGui::Separator();
|
||||
|
||||
char filterLower[128];
|
||||
for (size_t i = 0; i < sizeof(achieveFilter); ++i)
|
||||
filterLower[i] = static_cast<char>(tolower(static_cast<unsigned char>(achieveFilter[i])));
|
||||
|
||||
ImGui::BeginChild("##AchList", ImVec2(0, 0), false);
|
||||
// Sort by ID for stable ordering
|
||||
std::vector<uint32_t> sortedIds(earned.begin(), earned.end());
|
||||
std::sort(sortedIds.begin(), sortedIds.end());
|
||||
int shown = 0;
|
||||
for (uint32_t id : sortedIds) {
|
||||
const std::string& name = gameHandler.getAchievementName(id);
|
||||
const char* displayName = name.empty() ? nullptr : name.c_str();
|
||||
if (displayName == nullptr) continue; // skip unknown achievements
|
||||
|
||||
// Apply filter
|
||||
if (filterLower[0] != '\0') {
|
||||
// simple case-insensitive substring match
|
||||
std::string lower;
|
||||
lower.reserve(name.size());
|
||||
for (char c : name) lower += static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
||||
if (lower.find(filterLower) == std::string::npos) continue;
|
||||
}
|
||||
|
||||
ImGui::PushID(static_cast<int>(id));
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "[Achievement]");
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("%s", displayName);
|
||||
ImGui::PopID();
|
||||
++shown;
|
||||
}
|
||||
if (shown == 0 && filterLower[0] != '\0') {
|
||||
ImGui::TextDisabled("No achievements match the filter.");
|
||||
}
|
||||
ImGui::Text("Total: %d", static_cast<int>(earned.size()));
|
||||
ImGui::EndChild();
|
||||
}
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
|
||||
if (ImGui::BeginTabItem("PvP")) {
|
||||
const auto& arenaStats = gameHandler.getArenaTeamStats();
|
||||
if (arenaStats.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::TextDisabled("Not a member of any Arena team.");
|
||||
} else {
|
||||
for (const auto& team : arenaStats) {
|
||||
ImGui::PushID(static_cast<int>(team.teamId));
|
||||
char header[64];
|
||||
snprintf(header, sizeof(header), "Team ID %u (Rating: %u)", team.teamId, team.rating);
|
||||
if (ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) {
|
||||
ImGui::Columns(2, "##arenacols", false);
|
||||
ImGui::Text("Rating:"); ImGui::NextColumn();
|
||||
ImGui::Text("%u", team.rating); ImGui::NextColumn();
|
||||
ImGui::Text("Rank:"); ImGui::NextColumn();
|
||||
ImGui::Text("#%u", team.rank); ImGui::NextColumn();
|
||||
ImGui::Text("This week:"); ImGui::NextColumn();
|
||||
ImGui::Text("%u / %u (W/G)", team.weekWins, team.weekGames); ImGui::NextColumn();
|
||||
ImGui::Text("Season:"); ImGui::NextColumn();
|
||||
ImGui::Text("%u / %u (W/G)", team.seasonWins, team.seasonGames); ImGui::NextColumn();
|
||||
ImGui::Columns(1);
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
}
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
|
||||
ImGui::EndTabBar();
|
||||
}
|
||||
|
||||
|
|
@ -1211,8 +1365,9 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
|
|||
{ "Exalted", 42000, 42000, ImVec4(1.0f, 0.84f, 0.0f, 1.0f) },
|
||||
};
|
||||
|
||||
constexpr int kNumTiers = static_cast<int>(sizeof(tiers) / sizeof(tiers[0]));
|
||||
auto getTier = [&](int32_t val) -> const RepTier& {
|
||||
for (int i = 6; i >= 0; --i) {
|
||||
for (int i = kNumTiers - 1; i >= 0; --i) {
|
||||
if (val >= tiers[i].floor) return tiers[i];
|
||||
}
|
||||
return tiers[0];
|
||||
|
|
@ -1390,6 +1545,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
|
|||
int32_t serverArmor, const int32_t* serverStats) {
|
||||
// Sum equipment stats for item-query bonus display
|
||||
int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0;
|
||||
// Secondary stat sums from extraStats
|
||||
int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0;
|
||||
int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0;
|
||||
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
|
||||
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
|
||||
if (slot.empty()) continue;
|
||||
|
|
@ -1398,6 +1556,20 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
|
|||
itemSta += slot.item.stamina;
|
||||
itemInt += slot.item.intellect;
|
||||
itemSpi += slot.item.spirit;
|
||||
for (const auto& es : slot.item.extraStats) {
|
||||
switch (es.statType) {
|
||||
case 16: case 17: case 18: case 31: itemHit += es.statValue; break;
|
||||
case 19: case 20: case 21: case 32: itemCrit += es.statValue; break;
|
||||
case 28: case 29: case 30: case 36: itemHaste += es.statValue; break;
|
||||
case 35: itemResil += es.statValue; break;
|
||||
case 37: itemExpertise += es.statValue; break;
|
||||
case 38: case 39: itemAP += es.statValue; break;
|
||||
case 41: case 42: case 45: itemSP += es.statValue; break;
|
||||
case 43: itemMp5 += es.statValue; break;
|
||||
case 46: itemHp5 += es.statValue; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available.
|
||||
|
|
@ -1456,6 +1628,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
|
|||
renderStat("Intellect", itemInt);
|
||||
renderStat("Spirit", itemSpi);
|
||||
}
|
||||
|
||||
// Secondary stats from equipped items
|
||||
bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste ||
|
||||
itemResil || itemExpertise || itemMp5 || itemHp5;
|
||||
if (hasSecondary) {
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
auto renderSecondary = [&](const char* name, int32_t val) {
|
||||
if (val > 0) {
|
||||
ImGui::TextColored(green, "+%d %s", val, name);
|
||||
}
|
||||
};
|
||||
renderSecondary("Attack Power", itemAP);
|
||||
renderSecondary("Spell Power", itemSP);
|
||||
renderSecondary("Hit Rating", itemHit);
|
||||
renderSecondary("Crit Rating", itemCrit);
|
||||
renderSecondary("Haste Rating", itemHaste);
|
||||
renderSecondary("Resilience", itemResil);
|
||||
renderSecondary("Expertise", itemExpertise);
|
||||
renderSecondary("Mana per 5 sec", itemMp5);
|
||||
renderSecondary("Health per 5 sec",itemHp5);
|
||||
}
|
||||
}
|
||||
|
||||
void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) {
|
||||
|
|
@ -1683,9 +1877,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
}
|
||||
}
|
||||
|
||||
// Shift+right-click: open destroy confirmation for non-quest items
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) &&
|
||||
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) {
|
||||
destroyConfirmOpen_ = true;
|
||||
destroyItemName_ = item.name;
|
||||
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
|
||||
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
|
||||
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
|
||||
destroyBag_ = 0xFF;
|
||||
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
|
||||
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
|
||||
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
|
||||
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
|
||||
} else if (kind == SlotKind::EQUIPMENT) {
|
||||
destroyBag_ = 0xFF;
|
||||
destroySlot_ = static_cast<uint8_t>(equipSlot);
|
||||
}
|
||||
}
|
||||
|
||||
// Right-click: bank deposit (if bank open), vendor sell (if vendor mode), or auto-equip/use
|
||||
// Note: InvisibleButton only tracks left-click by default, so use IsItemHovered+IsMouseClicked
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) {
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && !ImGui::GetIO().KeyShift && gameHandler_) {
|
||||
LOG_WARNING("Right-click slot: kind=", (int)kind,
|
||||
" backpackIndex=", backpackIndex,
|
||||
" bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex,
|
||||
|
|
@ -1728,6 +1941,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
}
|
||||
}
|
||||
|
||||
// Shift+left-click: insert item link into chat input
|
||||
if (ImGui::IsItemHovered() && !holdingItem &&
|
||||
ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
||||
ImGui::GetIO().KeyShift &&
|
||||
item.itemId != 0 && !item.name.empty()) {
|
||||
// Build WoW item link: |cff<qualHex>|Hitem:<id>:0:0:0:0:0:0:0:0|h[<name>]|h|r
|
||||
const char* qualHex = "9d9d9d";
|
||||
switch (item.quality) {
|
||||
case game::ItemQuality::COMMON: qualHex = "ffffff"; break;
|
||||
case game::ItemQuality::UNCOMMON: qualHex = "1eff00"; break;
|
||||
case game::ItemQuality::RARE: qualHex = "0070dd"; break;
|
||||
case game::ItemQuality::EPIC: qualHex = "a335ee"; break;
|
||||
case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break;
|
||||
default: break;
|
||||
}
|
||||
char linkBuf[512];
|
||||
snprintf(linkBuf, sizeof(linkBuf),
|
||||
"|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r",
|
||||
qualHex, item.itemId, item.name.c_str());
|
||||
pendingChatItemLink_ = linkBuf;
|
||||
}
|
||||
|
||||
if (ImGui::IsItemHovered() && !holdingItem) {
|
||||
// Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise
|
||||
const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory;
|
||||
|
|
@ -2070,6 +2305,16 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
|
|||
}
|
||||
}
|
||||
|
||||
// Destroy hint (not shown for quest items)
|
||||
if (item.itemId != 0 && item.bindType != 4) {
|
||||
ImGui::Spacing();
|
||||
if (ImGui::GetIO().KeyShift) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.45f, 0.9f), "Shift+RClick to destroy");
|
||||
} else {
|
||||
ImGui::TextDisabled("Shift+RClick to destroy");
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndTooltip();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ void KeybindingManager::initializeDefaults() {
|
|||
bindings_[static_cast<int>(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset)
|
||||
bindings_[static_cast<int>(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail)
|
||||
}
|
||||
|
||||
bool KeybindingManager::isActionPressed(Action action, bool repeat) {
|
||||
|
|
@ -71,6 +72,7 @@ const char* KeybindingManager::getActionName(Action action) {
|
|||
case Action::TOGGLE_NAMEPLATES: return "Nameplates";
|
||||
case Action::TOGGLE_RAID_FRAMES: return "Raid Frames";
|
||||
case Action::TOGGLE_QUEST_LOG: return "Quest Log";
|
||||
case Action::TOGGLE_ACHIEVEMENTS: return "Achievements";
|
||||
case Action::ACTION_COUNT: break;
|
||||
}
|
||||
return "Unknown";
|
||||
|
|
@ -135,6 +137,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) {
|
|||
else if (action == "toggle_nameplates") actionIdx = static_cast<int>(Action::TOGGLE_NAMEPLATES);
|
||||
else if (action == "toggle_raid_frames") actionIdx = static_cast<int>(Action::TOGGLE_RAID_FRAMES);
|
||||
else if (action == "toggle_quest_log") actionIdx = static_cast<int>(Action::TOGGLE_QUEST_LOG);
|
||||
else if (action == "toggle_achievements") actionIdx = static_cast<int>(Action::TOGGLE_ACHIEVEMENTS);
|
||||
|
||||
if (actionIdx < 0) continue;
|
||||
|
||||
|
|
@ -226,6 +229,7 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const {
|
|||
{Action::TOGGLE_NAMEPLATES, "toggle_nameplates"},
|
||||
{Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"},
|
||||
{Action::TOGGLE_QUEST_LOG, "toggle_quest_log"},
|
||||
{Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"},
|
||||
};
|
||||
|
||||
for (const auto& [action, nameStr] : actionMap) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include "ui/quest_log_screen.hpp"
|
||||
#include "ui/inventory_screen.hpp"
|
||||
#include "ui/keybinding_manager.hpp"
|
||||
#include "core/application.hpp"
|
||||
#include "core/input.hpp"
|
||||
|
|
@ -206,7 +207,7 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) {
|
|||
}
|
||||
} // anonymous namespace
|
||||
|
||||
void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
||||
void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) {
|
||||
// Quests toggle via keybinding (edge-triggered)
|
||||
// Customizable key (default: L) from KeybindingManager
|
||||
bool questsDown = KeybindingManager::getInstance().isActionPressed(
|
||||
|
|
@ -247,6 +248,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
|||
else activeCount++;
|
||||
}
|
||||
|
||||
// Search bar + filter buttons on one row
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 210.0f);
|
||||
ImGui::InputTextWithHint("##qsearch", "Search quests...", questSearchFilter_, sizeof(questSearchFilter_));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::RadioButton("All", questFilterMode_ == 0)) questFilterMode_ = 0;
|
||||
ImGui::SameLine();
|
||||
if (ImGui::RadioButton("Active", questFilterMode_ == 1)) questFilterMode_ = 1;
|
||||
ImGui::SameLine();
|
||||
if (ImGui::RadioButton("Ready", questFilterMode_ == 2)) questFilterMode_ = 2;
|
||||
|
||||
// Summary counts
|
||||
ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.35f, 1.0f), "Active: %d", activeCount);
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.45f, 1.0f), "Ready: %d", completeCount);
|
||||
|
|
@ -269,14 +281,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
|||
for (size_t i = 0; i < quests.size(); i++) {
|
||||
if (quests[i].questId == pendingSelectQuestId_) {
|
||||
selectedIndex = static_cast<int>(i);
|
||||
// Clear filter so the target quest is visible
|
||||
questSearchFilter_[0] = '\0';
|
||||
questFilterMode_ = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pendingSelectQuestId_ = 0;
|
||||
}
|
||||
|
||||
// Build a case-insensitive lowercase copy of the search filter once
|
||||
char filterLower[64] = {};
|
||||
for (size_t fi = 0; fi < sizeof(questSearchFilter_) && questSearchFilter_[fi]; ++fi)
|
||||
filterLower[fi] = static_cast<char>(std::tolower(static_cast<unsigned char>(questSearchFilter_[fi])));
|
||||
|
||||
int visibleQuestCount = 0;
|
||||
for (size_t i = 0; i < quests.size(); i++) {
|
||||
const auto& q = quests[i];
|
||||
|
||||
// Apply mode filter
|
||||
if (questFilterMode_ == 1 && q.complete) continue;
|
||||
if (questFilterMode_ == 2 && !q.complete) continue;
|
||||
|
||||
// Apply name search filter
|
||||
if (filterLower[0]) {
|
||||
std::string titleLower = cleanQuestTitleForUi(q.title, q.questId);
|
||||
for (char& c : titleLower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (titleLower.find(filterLower) == std::string::npos) continue;
|
||||
}
|
||||
|
||||
visibleQuestCount++;
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
|
||||
bool selected = (selectedIndex == static_cast<int>(i));
|
||||
|
|
@ -318,8 +352,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
|||
questDetailQueryNoResponse_.erase(q.questId);
|
||||
}
|
||||
}
|
||||
|
||||
// Right-click context menu on quest row
|
||||
if (ImGui::BeginPopupContextItem("QuestRowCtx")) {
|
||||
selectedIndex = static_cast<int>(i); // select on right-click too
|
||||
ImGui::TextDisabled("%s", displayTitle.c_str());
|
||||
ImGui::Separator();
|
||||
bool tracked = gameHandler.isQuestTracked(q.questId);
|
||||
if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) {
|
||||
gameHandler.setQuestTracked(q.questId, !tracked);
|
||||
}
|
||||
if (!q.complete) {
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Abandon Quest")) {
|
||||
gameHandler.abandonQuest(q.questId);
|
||||
gameHandler.setQuestTracked(q.questId, false);
|
||||
selectedIndex = -1;
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (visibleQuestCount == 0) {
|
||||
ImGui::Spacing();
|
||||
if (filterLower[0] || questFilterMode_ != 0)
|
||||
ImGui::TextDisabled("No quests match the filter.");
|
||||
else
|
||||
ImGui::TextDisabled("No active quests.");
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
|
@ -392,13 +454,98 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
|||
}
|
||||
for (const auto& [itemId, count] : sel.itemCounts) {
|
||||
std::string itemLabel = "Item " + std::to_string(itemId);
|
||||
uint32_t dispId = 0;
|
||||
if (const auto* info = gameHandler.getItemInfo(itemId)) {
|
||||
if (!info->name.empty()) itemLabel = info->name;
|
||||
dispId = info->displayInfoId;
|
||||
} else {
|
||||
gameHandler.ensureItemInfo(itemId);
|
||||
}
|
||||
uint32_t required = 1;
|
||||
auto reqIt = sel.requiredItemCounts.find(itemId);
|
||||
if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second;
|
||||
ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required);
|
||||
VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
|
||||
if (iconTex) {
|
||||
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14));
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required);
|
||||
} else {
|
||||
ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reward summary
|
||||
bool hasAnyReward = (sel.rewardMoney != 0);
|
||||
for (const auto& ri : sel.rewardItems) if (ri.itemId) hasAnyReward = true;
|
||||
for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) hasAnyReward = true;
|
||||
if (hasAnyReward) {
|
||||
ImGui::Separator();
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "Rewards");
|
||||
|
||||
// Money reward
|
||||
if (sel.rewardMoney > 0) {
|
||||
uint32_t rg = static_cast<uint32_t>(sel.rewardMoney) / 10000;
|
||||
uint32_t rs = static_cast<uint32_t>(sel.rewardMoney % 10000) / 100;
|
||||
uint32_t rc = static_cast<uint32_t>(sel.rewardMoney % 100);
|
||||
if (rg > 0)
|
||||
ImGui::Text("%ug %us %uc", rg, rs, rc);
|
||||
else if (rs > 0)
|
||||
ImGui::Text("%us %uc", rs, rc);
|
||||
else
|
||||
ImGui::Text("%uc", rc);
|
||||
}
|
||||
|
||||
// Guaranteed reward items
|
||||
bool anyFixed = false;
|
||||
for (const auto& ri : sel.rewardItems) if (ri.itemId) { anyFixed = true; break; }
|
||||
if (anyFixed) {
|
||||
ImGui::TextDisabled("You will receive:");
|
||||
for (const auto& ri : sel.rewardItems) {
|
||||
if (!ri.itemId) continue;
|
||||
std::string name = "Item " + std::to_string(ri.itemId);
|
||||
uint32_t dispId = 0;
|
||||
const auto* info = gameHandler.getItemInfo(ri.itemId);
|
||||
if (info && info->valid) {
|
||||
if (!info->name.empty()) name = info->name;
|
||||
dispId = info->displayInfoId;
|
||||
}
|
||||
VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
|
||||
if (icon) {
|
||||
ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16));
|
||||
ImGui::SameLine();
|
||||
}
|
||||
if (ri.count > 1)
|
||||
ImGui::Text("%s x%u", name.c_str(), ri.count);
|
||||
else
|
||||
ImGui::Text("%s", name.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Choice reward items
|
||||
bool anyChoice = false;
|
||||
for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) { anyChoice = true; break; }
|
||||
if (anyChoice) {
|
||||
ImGui::TextDisabled("Choose one of:");
|
||||
for (const auto& ri : sel.rewardChoiceItems) {
|
||||
if (!ri.itemId) continue;
|
||||
std::string name = "Item " + std::to_string(ri.itemId);
|
||||
uint32_t dispId = 0;
|
||||
const auto* info = gameHandler.getItemInfo(ri.itemId);
|
||||
if (info && info->valid) {
|
||||
if (!info->name.empty()) name = info->name;
|
||||
dispId = info->displayInfoId;
|
||||
}
|
||||
VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
|
||||
if (icon) {
|
||||
ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16));
|
||||
ImGui::SameLine();
|
||||
}
|
||||
if (ri.count > 1)
|
||||
ImGui::Text("%s x%u", name.c_str(), ri.count);
|
||||
else
|
||||
ImGui::Text("%s", name.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -411,6 +411,14 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa
|
|||
auto cit = spellIconCache.find(iconId);
|
||||
if (cit != spellIconCache.end()) return cit->second;
|
||||
|
||||
// Rate-limit GPU uploads to avoid a multi-frame stall when switching tabs.
|
||||
// Icons not loaded this frame will be retried next frame (progressive load).
|
||||
static int loadsThisFrame = 0;
|
||||
static int lastImGuiFrame = -1;
|
||||
int curFrame = ImGui::GetFrameCount();
|
||||
if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; }
|
||||
if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
|
||||
|
||||
auto pit = spellIconPaths.find(iconId);
|
||||
if (pit == spellIconPaths.end()) {
|
||||
spellIconCache[iconId] = VK_NULL_HANDLE;
|
||||
|
|
@ -437,6 +445,7 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa
|
|||
return VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
++loadsThisFrame;
|
||||
VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
|
||||
spellIconCache[iconId] = ds;
|
||||
return ds;
|
||||
|
|
@ -657,9 +666,49 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
|
|||
|
||||
// Row selectable
|
||||
ImGui::Selectable("##row", false,
|
||||
ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight));
|
||||
ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_DontClosePopups,
|
||||
ImVec2(0, rowHeight));
|
||||
bool rowHovered = ImGui::IsItemHovered();
|
||||
bool rowClicked = ImGui::IsItemClicked(0);
|
||||
|
||||
// Right-click context menu
|
||||
if (ImGui::BeginPopupContextItem("##SpellCtx")) {
|
||||
ImGui::TextDisabled("%s", info->name.c_str());
|
||||
if (!info->rank.empty()) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("(%s)", info->rank.c_str());
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (!isPassive) {
|
||||
if (onCooldown) ImGui::BeginDisabled();
|
||||
if (ImGui::MenuItem("Cast")) {
|
||||
uint64_t tgt = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
|
||||
gameHandler.castSpell(info->spellId, tgt);
|
||||
}
|
||||
if (onCooldown) ImGui::EndDisabled();
|
||||
}
|
||||
if (!isPassive) {
|
||||
if (ImGui::MenuItem("Add to Action Bar")) {
|
||||
const auto& bar = gameHandler.getActionBar();
|
||||
int firstEmpty = -1;
|
||||
for (int si = 0; si < game::GameHandler::SLOTS_PER_BAR; ++si) {
|
||||
if (bar[si].isEmpty()) { firstEmpty = si; break; }
|
||||
}
|
||||
if (firstEmpty >= 0) {
|
||||
gameHandler.setActionBarSlot(firstEmpty,
|
||||
game::ActionBarSlot::SPELL, info->spellId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ImGui::MenuItem("Copy Spell Link")) {
|
||||
char linkBuf[256];
|
||||
snprintf(linkBuf, sizeof(linkBuf),
|
||||
"|cffffd000|Hspell:%u|h[%s]|h|r",
|
||||
info->spellId, info->name.c_str());
|
||||
pendingChatSpellLink_ = linkBuf;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImVec2 rMin = ImGui::GetItemRectMin();
|
||||
ImVec2 rMax = ImGui::GetItemRectMax();
|
||||
auto* dl = ImGui::GetWindowDrawList();
|
||||
|
|
@ -748,15 +797,25 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
|
|||
|
||||
// Interaction
|
||||
if (rowHovered) {
|
||||
// Start drag on click (not passive)
|
||||
if (rowClicked && !isPassive) {
|
||||
// Shift-click to insert spell link into chat
|
||||
if (rowClicked && ImGui::GetIO().KeyShift && !info->name.empty()) {
|
||||
// WoW spell link format: |cffffd000|Hspell:<spellId>|h[Name]|h|r
|
||||
char linkBuf[256];
|
||||
snprintf(linkBuf, sizeof(linkBuf),
|
||||
"|cffffd000|Hspell:%u|h[%s]|h|r",
|
||||
info->spellId, info->name.c_str());
|
||||
pendingChatSpellLink_ = linkBuf;
|
||||
}
|
||||
// Start drag on click (not passive, not shift-click)
|
||||
else if (rowClicked && !isPassive && !ImGui::GetIO().KeyShift) {
|
||||
draggingSpell_ = true;
|
||||
dragSpellId_ = info->spellId;
|
||||
dragSpellIconTex_ = iconTex;
|
||||
}
|
||||
|
||||
// Double-click to cast
|
||||
if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) {
|
||||
if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown
|
||||
&& !ImGui::GetIO().KeyShift) {
|
||||
draggingSpell_ = false;
|
||||
dragSpellId_ = 0;
|
||||
dragSpellIconTex_ = VK_NULL_HANDLE;
|
||||
|
|
|
|||
|
|
@ -216,7 +216,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
|
|||
float availW = ImGui::GetContentRegionAvail().x;
|
||||
float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f);
|
||||
|
||||
ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false);
|
||||
char childId[32];
|
||||
snprintf(childId, sizeof(childId), "TalentGrid_%u", tabId);
|
||||
ImGui::BeginChild(childId, ImVec2(0, 0), false);
|
||||
|
||||
ImVec2 gridOrigin = ImGui::GetCursorScreenPos();
|
||||
gridOrigin.x += offsetX;
|
||||
|
|
@ -326,8 +328,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
|
|||
renderTalent(gameHandler, *talent, pointsInTree);
|
||||
} else {
|
||||
// Empty cell — invisible placeholder
|
||||
ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(),
|
||||
ImVec2(iconSize, iconSize));
|
||||
char emptyId[32];
|
||||
snprintf(emptyId, sizeof(emptyId), "e_%u_%u_%u", tabId, row, col);
|
||||
ImGui::InvisibleButton(emptyId, ImVec2(iconSize, iconSize));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue