feat: achievement name in toast, parse earned achievements, loot item tooltips

- Parse SMSG_ALL_ACHIEVEMENT_DATA on login to populate earnedAchievements_ set
- Pass achievement name through callback so toast shows name instead of ID
- Add renderItemTooltip(ItemQueryResponseData) overload for loot/non-inventory contexts
- Loot window now shows full item tooltip on hover (stats, sell price, bind type, etc.)
This commit is contained in:
Kelsi 2026-03-10 20:53:21 -07:00
parent 984decd664
commit 6275a45ec0
7 changed files with 225 additions and 10 deletions

View file

@ -1134,8 +1134,9 @@ public:
void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); }
// Achievement earned callback — fires when SMSG_ACHIEVEMENT_EARNED is received
using AchievementEarnedCallback = std::function<void(uint32_t achievementId)>;
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_; }
// Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received.
// The soundId corresponds to a SoundEntries.dbc record. The receiver is
@ -2246,6 +2247,9 @@ private:
std::unordered_map<uint32_t, std::string> achievementNameCache_;
bool achievementNameCacheLoaded_ = false;
void loadAchievementNameCache();
// Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA)
std::unordered_set<uint32_t> earnedAchievements_;
void handleAllAchievementData(network::Packet& packet);
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
std::unordered_map<uint32_t, std::string> areaNameCache_;

View file

@ -366,6 +366,7 @@ private:
static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f;
float achievementToastTimer_ = 0.0f;
uint32_t achievementToastId_ = 0;
std::string achievementToastName_;
void renderAchievementToast();
// Zone discovery text ("Entering: <ZoneName>")
@ -377,7 +378,7 @@ private:
public:
void triggerDing(uint32_t newLevel);
void triggerAchievementToast(uint32_t achievementId);
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
};
} // namespace ui

View file

@ -96,6 +96,7 @@ private:
std::unordered_map<uint32_t, VkDescriptorSet> iconCache_;
public:
VkDescriptorSet getItemIcon(uint32_t displayInfoId);
void renderItemTooltip(const game::ItemQueryResponseData& info);
private:
// Character model preview

View file

@ -2335,9 +2335,9 @@ void Application::setupUICallbacks() {
});
// Achievement earned callback — show toast banner
gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) {
gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) {
if (uiManager) {
uiManager->getGameScreen().triggerAchievementToast(achievementId);
uiManager->getGameScreen().triggerAchievementToast(achievementId, name);
}
});

View file

@ -2695,7 +2695,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleAchievementEarned(packet);
break;
case Opcode::SMSG_ALL_ACHIEVEMENT_DATA:
// Initial data burst on login — ignored for now (no achievement tracker UI).
handleAllAchievementData(packet);
break;
case Opcode::SMSG_ITEM_COOLDOWN: {
// uint64 itemGuid + uint32 spellId + uint32 cooldownMs
@ -18711,8 +18711,9 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) {
}
addSystemChatMessage(buf);
earnedAchievements_.insert(achievementId);
if (achievementEarnedCallback_) {
achievementEarnedCallback_(achievementId);
achievementEarnedCallback_(achievementId, achName);
}
} else {
// Another player in the zone earned an achievement
@ -18743,6 +18744,38 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) {
achName.empty() ? "" : " name=", achName);
}
// ---------------------------------------------------------------------------
// SMSG_ALL_ACHIEVEMENT_DATA (WotLK 3.3.5a)
// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel
// Criteria records: repeated { uint32 id, uint64 counter, uint32 packedDate, ... } until 0xFFFFFFFF
// ---------------------------------------------------------------------------
void GameHandler::handleAllAchievementData(network::Packet& packet) {
loadAchievementNameCache();
earnedAchievements_.clear();
// Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF)
while (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t id = packet.readUInt32();
if (id == 0xFFFFFFFF) break;
if (packet.getSize() - packet.getReadPos() < 4) break;
/*uint32_t date =*/ packet.readUInt32();
earnedAchievements_.insert(id);
}
// Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF)
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
packet.readUInt32(); // date
packet.readUInt32(); // unknown / flags
}
LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements");
}
// ---------------------------------------------------------------------------
// Faction name cache (lazily loaded from Faction.dbc)
// ---------------------------------------------------------------------------

View file

@ -6499,6 +6499,13 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
}
bool hovered = ImGui::IsItemHovered();
// Show item tooltip on hover
if (hovered && info && info->valid) {
inventoryScreen.renderItemTooltip(*info);
} else if (hovered && !itemName.empty() && itemName[0] != 'I') {
ImGui::SetTooltip("%s", itemName.c_str());
}
ImDrawList* drawList = ImGui::GetWindowDrawList();
// Draw hover highlight
@ -10799,8 +10806,9 @@ void GameScreen::renderDingEffect() {
IM_COL32(255, 210, 0, (int)(alpha * 255)), buf);
}
void GameScreen::triggerAchievementToast(uint32_t achievementId) {
void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) {
achievementToastId_ = achievementId;
achievementToastName_ = std::move(name);
achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION;
// Play a UI sound if available
@ -10859,9 +10867,15 @@ void GameScreen::renderAchievementToast() {
draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8),
IM_COL32(255, 215, 0, (int)(alpha * 255)), title);
// Achievement ID line (until we have Achievement.dbc name lookup)
char idBuf[64];
std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_);
// Achievement name (falls back to ID if name not available)
char idBuf[256];
const char* achText = achievementToastName_.empty()
? nullptr : achievementToastName_.c_str();
if (achText) {
std::snprintf(idBuf, sizeof(idBuf), "%s", achText);
} else {
std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_);
}
float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x;
float idX = toastX + (TOAST_W - idW) * 0.5f;
draw->AddText(font, bodySize, ImVec2(idX, toastY + 28),

View file

@ -2027,5 +2027,167 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
ImGui::EndTooltip();
}
// ---------------------------------------------------------------------------
// Tooltip overload for ItemQueryResponseData (used by loot window, etc.)
// ---------------------------------------------------------------------------
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) {
ImGui::BeginTooltip();
ImVec4 qColor = getQualityColor(static_cast<game::ItemQuality>(info.quality));
ImGui::TextColored(qColor, "%s", info.name.c_str());
if (info.itemLevel > 0) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel);
}
// Binding type
switch (info.bindType) {
case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break;
case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break;
case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break;
case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break;
default: break;
}
// Slot / subclass
if (info.inventoryType > 0) {
const char* slotName = "";
switch (info.inventoryType) {
case 1: slotName = "Head"; break;
case 2: slotName = "Neck"; break;
case 3: slotName = "Shoulder"; break;
case 4: slotName = "Shirt"; break;
case 5: slotName = "Chest"; break;
case 6: slotName = "Waist"; break;
case 7: slotName = "Legs"; break;
case 8: slotName = "Feet"; break;
case 9: slotName = "Wrist"; break;
case 10: slotName = "Hands"; break;
case 11: slotName = "Finger"; break;
case 12: slotName = "Trinket"; break;
case 13: slotName = "One-Hand"; break;
case 14: slotName = "Shield"; break;
case 15: slotName = "Ranged"; break;
case 16: slotName = "Back"; break;
case 17: slotName = "Two-Hand"; break;
case 18: slotName = "Bag"; break;
case 19: slotName = "Tabard"; break;
case 20: slotName = "Robe"; break;
case 21: slotName = "Main Hand"; break;
case 22: slotName = "Off Hand"; break;
case 23: slotName = "Held In Off-hand"; break;
case 25: slotName = "Thrown"; break;
case 26: slotName = "Ranged"; break;
default: break;
}
if (slotName[0]) {
if (!info.subclassName.empty())
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s %s", slotName, info.subclassName.c_str());
else
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName);
}
}
// Weapon stats
auto isWeaponInvType = [](uint32_t t) {
return t == 13 || t == 15 || t == 17 || t == 21 || t == 25 || t == 26;
};
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
if (isWeaponInvType(info.inventoryType) && info.damageMax > 0.0f && info.delayMs > 0) {
float speed = static_cast<float>(info.delayMs) / 1000.0f;
float dps = ((info.damageMin + info.damageMax) * 0.5f) / speed;
ImGui::Text("%.0f - %.0f Damage", info.damageMin, info.damageMax);
ImGui::SameLine(160.0f);
ImGui::TextDisabled("Speed %.2f", speed);
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps);
}
if (info.armor > 0) ImGui::Text("%d Armor", info.armor);
auto appendBonus = [](std::string& out, int32_t val, const char* name) {
if (val <= 0) return;
if (!out.empty()) out += " ";
out += "+" + std::to_string(val) + " " + name;
};
std::string bonusLine;
appendBonus(bonusLine, info.strength, "Str");
appendBonus(bonusLine, info.agility, "Agi");
appendBonus(bonusLine, info.stamina, "Sta");
appendBonus(bonusLine, info.intellect, "Int");
appendBonus(bonusLine, info.spirit, "Spi");
if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str());
// Extra stats
for (const auto& es : info.extraStats) {
const char* statName = nullptr;
switch (es.statType) {
case 12: statName = "Defense Rating"; break;
case 13: statName = "Dodge Rating"; break;
case 14: statName = "Parry Rating"; break;
case 16: case 17: case 18: case 31: statName = "Hit Rating"; break;
case 19: case 20: case 21: case 32: statName = "Crit Rating"; break;
case 28: case 29: case 30: case 36: statName = "Haste Rating"; break;
case 35: statName = "Resilience"; break;
case 37: statName = "Expertise Rating"; break;
case 38: statName = "Attack Power"; break;
case 39: statName = "Ranged Attack Power"; break;
case 41: statName = "Healing Power"; break;
case 42: statName = "Spell Damage"; break;
case 43: statName = "Mana per 5 sec"; break;
case 44: statName = "Armor Penetration"; break;
case 45: statName = "Spell Power"; break;
case 46: statName = "Health per 5 sec"; break;
case 47: statName = "Spell Penetration"; break;
case 48: statName = "Block Value"; break;
default: statName = nullptr; break;
}
char buf[64];
if (statName)
std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName);
else
std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType);
ImGui::TextColored(green, "%s", buf);
}
if (info.requiredLevel > 1) {
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", info.requiredLevel);
}
// Spell effects
for (const auto& sp : info.spells) {
if (sp.spellId == 0) continue;
const char* trigger = nullptr;
switch (sp.spellTrigger) {
case 0: trigger = "Use"; break;
case 1: trigger = "Equip"; break;
case 2: trigger = "Chance on Hit"; break;
default: break;
}
if (!trigger) continue;
if (gameHandler_) {
const std::string& spName = gameHandler_->getSpellName(sp.spellId);
if (!spName.empty())
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str());
else
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId);
}
}
if (info.startQuestId != 0) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest");
}
if (!info.description.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", info.description.c_str());
}
if (info.sellPrice > 0) {
uint32_t g = info.sellPrice / 10000;
uint32_t s = (info.sellPrice / 100) % 100;
uint32_t c = info.sellPrice % 100;
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c);
}
ImGui::EndTooltip();
}
} // namespace ui
} // namespace wowee