feat: show quality-coloured loot toast when items are received

SMSG_ITEM_PUSH_RESULT now fires a new ItemLootCallback that
game_screen.cpp uses to push a compact slide-in toast at the
bottom-left of the screen.  Each toast:

- Shows a quality-tinted left accent bar (grey/white/green/blue/
  purple/orange matching WoW quality colours)
- Displays "Loot: <item name>" with the name in quality colour
- Appends " x<N>" for stacked pickups
- Coalesces repeated pickups of the same item (adds count, resets timer)
- Stacks up to 5 entries, 3 s lifetime with 0.15 s slide-in and 0.7 s
  fade-out
This commit is contained in:
Kelsi 2026-03-12 16:24:11 -07:00
parent 129fa84fe3
commit 42d66bc876
4 changed files with 132 additions and 0 deletions

View file

@ -1494,6 +1494,10 @@ public:
using PvpHonorCallback = std::function<void(uint32_t honorAmount, uint64_t victimGuid, uint32_t victimRank)>;
void setPvpHonorCallback(PvpHonorCallback cb) { pvpHonorCallback_ = std::move(cb); }
// Item looted / received callback (SMSG_ITEM_PUSH_RESULT when showInChat is set)
using ItemLootCallback = std::function<void(uint32_t itemId, uint32_t count, uint32_t quality, const std::string& name)>;
void setItemLootCallback(ItemLootCallback cb) { itemLootCallback_ = std::move(cb); }
// Quest turn-in completion callback
using QuestCompleteCallback = std::function<void(uint32_t questId, const std::string& questTitle)>;
void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); }
@ -2838,6 +2842,9 @@ private:
// ---- PvP honor credit callback ----
PvpHonorCallback pvpHonorCallback_;
// ---- Item loot callback ----
ItemLootCallback itemLootCallback_;
// ---- Quest completion callback ----
QuestCompleteCallback questCompleteCallback_;
};

View file

@ -574,6 +574,19 @@ private:
bool pvpHonorCallbackSet_ = false;
void renderPvpHonorToasts();
// Item loot toast — quality-coloured popup when an item is received
struct ItemLootToastEntry {
uint32_t itemId = 0;
uint32_t count = 0;
uint32_t quality = 1; // 0=grey,1=white,2=green,3=blue,4=purple,5=orange
std::string name;
float age = 0.0f;
};
static constexpr float ITEM_LOOT_TOAST_DURATION = 3.0f;
std::vector<ItemLootToastEntry> itemLootToasts_;
bool itemLootCallbackSet_ = false;
void renderItemLootToasts();
// Zone discovery text ("Entering: <ZoneName>")
static constexpr float ZONE_TEXT_DURATION = 5.0f;
float zoneTextTimer_ = 0.0f;

View file

@ -1701,12 +1701,17 @@ void GameHandler::handlePacket(network::Packet& packet) {
queryItemInfo(itemId, 0);
if (showInChat) {
std::string itemName = "item #" + std::to_string(itemId);
uint32_t quality = 1; // white default
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
if (!info->name.empty()) itemName = info->name;
quality = info->quality;
}
std::string msg = "Received: " + itemName;
if (count > 1) msg += " x" + std::to_string(count);
addSystemChatMessage(msg);
if (itemLootCallback_) {
itemLootCallback_(itemId, count, quality, itemName);
}
}
LOG_INFO("Item push: itemId=", itemId, " count=", count,
" showInChat=", static_cast<int>(showInChat));

View file

@ -359,6 +359,25 @@ void GameScreen::render(game::GameHandler& gameHandler) {
pvpHonorCallbackSet_ = true;
}
// Set up item loot toast callback (once)
if (!itemLootCallbackSet_) {
gameHandler.setItemLootCallback([this](uint32_t itemId, uint32_t count,
uint32_t quality, const std::string& name) {
// Coalesce: if same item already in queue, bump count and reset age
for (auto& t : itemLootToasts_) {
if (t.itemId == itemId) {
t.count += count;
t.age = 0.0f;
return;
}
}
if (itemLootToasts_.size() >= 5)
itemLootToasts_.erase(itemLootToasts_.begin());
itemLootToasts_.push_back({itemId, count, quality, name, 0.0f});
});
itemLootCallbackSet_ = true;
}
// Set up UI error frame callback (once)
if (!uiErrorCallbackSet_) {
gameHandler.setUIErrorCallback([this](const std::string& msg) {
@ -692,6 +711,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderQuestProgressToasts();
renderPlayerLevelUpToasts(gameHandler);
renderPvpHonorToasts();
renderItemLootToasts();
renderZoneText();
// World map (M key toggle handled inside)
@ -18226,6 +18246,93 @@ void GameScreen::renderQuestProgressToasts() {
}
}
// ---------------------------------------------------------------------------
// Item loot toasts — quality-coloured strip at bottom-left when item received
// ---------------------------------------------------------------------------
void GameScreen::renderItemLootToasts() {
if (itemLootToasts_.empty()) return;
float dt = ImGui::GetIO().DeltaTime;
for (auto& t : itemLootToasts_) t.age += dt;
itemLootToasts_.erase(
std::remove_if(itemLootToasts_.begin(), itemLootToasts_.end(),
[](const ItemLootToastEntry& t) { return t.age >= ITEM_LOOT_TOAST_DURATION; }),
itemLootToasts_.end());
if (itemLootToasts_.empty()) return;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
// Quality colours (matching WoW convention)
static const ImU32 kQualityColors[] = {
IM_COL32(157, 157, 157, 255), // 0 grey (poor)
IM_COL32(255, 255, 255, 255), // 1 white (common)
IM_COL32( 30, 255, 30, 255), // 2 green (uncommon)
IM_COL32( 0, 112, 221, 255), // 3 blue (rare)
IM_COL32(163, 53, 238, 255), // 4 purple (epic)
IM_COL32(255, 128, 0, 255), // 5 orange (legendary)
};
// Stack at bottom-left above action bars; each item is 24 px tall
constexpr float TOAST_W = 260.0f;
constexpr float TOAST_H = 24.0f;
constexpr float TOAST_GAP = 2.0f;
constexpr float TOAST_X = 14.0f;
float baseY = screenH * 0.68f; // slightly above the whisper toasts
ImDrawList* bgDL = ImGui::GetBackgroundDrawList();
const int count = static_cast<int>(itemLootToasts_.size());
for (int i = 0; i < count; ++i) {
const auto& toast = itemLootToasts_[i];
float remaining = ITEM_LOOT_TOAST_DURATION - toast.age;
float alpha;
if (toast.age < 0.15f)
alpha = toast.age / 0.15f;
else if (remaining < 0.7f)
alpha = remaining / 0.7f;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
// Slide-in from left
float slideX = (toast.age < 0.15f) ? (TOAST_W * (1.0f - toast.age / 0.15f)) : 0.0f;
float tx = TOAST_X - slideX;
float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP);
uint8_t bgA = static_cast<uint8_t>(180 * alpha);
uint8_t fgA = static_cast<uint8_t>(255 * alpha);
// Background: very dark with quality-tinted left border accent
bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H),
IM_COL32(12, 12, 12, bgA), 3.0f);
// Quality colour accent bar on left edge (3px wide)
ImU32 qualCol = kQualityColors[std::min(static_cast<uint32_t>(5u), toast.quality)];
ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast<uint32_t>(fgA) << 24u);
bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f);
// "Loot:" label in dim white
bgDL->AddText(ImVec2(tx + 7.0f, ty + 5.0f),
IM_COL32(160, 160, 160, static_cast<uint8_t>(200 * alpha)), "Loot:");
// Item name in quality colour
std::string displayName = toast.name.empty() ? ("Item #" + std::to_string(toast.itemId)) : toast.name;
if (displayName.size() > 26) { displayName.resize(23); displayName += "..."; }
bgDL->AddText(ImVec2(tx + 42.0f, ty + 5.0f), qualColA, displayName.c_str());
// Count (if > 1)
if (toast.count > 1) {
char countBuf[12];
snprintf(countBuf, sizeof(countBuf), "x%u", toast.count);
bgDL->AddText(ImVec2(tx + TOAST_W - 34.0f, ty + 5.0f),
IM_COL32(200, 200, 200, static_cast<uint8_t>(200 * alpha)), countBuf);
}
}
}
// ---------------------------------------------------------------------------
// PvP honor credit toasts — shown at screen top-right on honorable kill
// ---------------------------------------------------------------------------