Kelsidavis-WoWee/src/ui/toast_manager.cpp

1251 lines
52 KiB
C++
Raw Normal View History

#include "ui/toast_manager.hpp"
#include "game/game_handler.hpp"
#include "core/application.hpp"
#include "rendering/renderer.hpp"
#include "audio/ui_sound_manager.hpp"
#include <imgui.h>
#include <algorithm>
#include <cstdio>
#include <cmath>
namespace wowee { namespace ui {
// ---------------------------------------------------------------------------
// Setup toast callbacks on GameHandler (idempotent — safe to call every frame)
// ---------------------------------------------------------------------------
void ToastManager::setupCallbacks(game::GameHandler& gameHandler) {
// NOTE: Level-up and achievement callbacks are registered by Application
// (which also triggers the 3D level-up effect). Application routes to
// triggerDing() / triggerAchievementToast() via the public API.
// Area discovery toast callback
if (!areaDiscoveryCallbackSet_) {
gameHandler.setAreaDiscoveryCallback([this](const std::string& areaName, uint32_t xpGained) {
discoveryToastName_ = areaName.empty() ? "New Area" : areaName;
discoveryToastXP_ = xpGained;
discoveryToastTimer_ = DISCOVERY_TOAST_DURATION;
});
areaDiscoveryCallbackSet_ = true;
}
// Quest objective progress toast callback
if (!questProgressCallbackSet_) {
gameHandler.setQuestProgressCallback([this](const std::string& questTitle,
const std::string& objectiveName,
uint32_t current, uint32_t required) {
for (auto& t : questToasts_) {
if (t.questTitle == questTitle && t.objectiveName == objectiveName) {
t.current = current;
t.required = required;
t.age = 0.0f;
return;
}
}
if (questToasts_.size() >= 4) questToasts_.erase(questToasts_.begin());
questToasts_.push_back({questTitle, objectiveName, current, required, 0.0f});
});
questProgressCallbackSet_ = true;
}
// Other-player level-up toast callback
if (!otherPlayerLevelUpCallbackSet_) {
gameHandler.setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) {
for (auto& t : playerLevelUpToasts_) {
if (t.guid == guid) {
t.newLevel = newLevel;
t.age = 0.0f;
return;
}
}
if (playerLevelUpToasts_.size() >= 3)
playerLevelUpToasts_.erase(playerLevelUpToasts_.begin());
playerLevelUpToasts_.push_back({guid, "", newLevel, 0.0f});
});
otherPlayerLevelUpCallbackSet_ = true;
}
// PvP honor credit toast callback
if (!pvpHonorCallbackSet_) {
gameHandler.setPvpHonorCallback([this](uint32_t honor, uint64_t /*victimGuid*/, uint32_t rank) {
if (honor == 0) return;
pvpHonorToasts_.push_back({honor, rank, 0.0f});
if (pvpHonorToasts_.size() > 4)
pvpHonorToasts_.erase(pvpHonorToasts_.begin());
});
pvpHonorCallbackSet_ = true;
}
// Item loot toast callback
if (!itemLootCallbackSet_) {
gameHandler.setItemLootCallback([this](uint32_t itemId, uint32_t count,
uint32_t quality, const std::string& name) {
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;
}
// Ghost-state callback to flash "You have been resurrected!" on revival
if (!ghostStateCallbackSet_) {
gameHandler.setGhostStateCallback([this](bool isGhost) {
if (!isGhost) {
resurrectFlashTimer_ = kResurrectFlashDuration;
}
});
ghostStateCallbackSet_ = true;
}
// Reputation change toast callback
if (!repChangeCallbackSet_) {
gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) {
repToasts_.push_back({name, delta, standing, 0.0f});
if (repToasts_.size() > 4) repToasts_.erase(repToasts_.begin());
});
repChangeCallbackSet_ = true;
}
// Quest completion toast callback
if (!questCompleteCallbackSet_) {
gameHandler.setQuestCompleteCallback([this](uint32_t id, const std::string& title) {
questCompleteToasts_.push_back({id, title, 0.0f});
if (questCompleteToasts_.size() > 3) questCompleteToasts_.erase(questCompleteToasts_.begin());
});
questCompleteCallbackSet_ = true;
}
}
// ---------------------------------------------------------------------------
// Render early toasts (before action bars)
// ---------------------------------------------------------------------------
void ToastManager::renderEarlyToasts(float deltaTime, game::GameHandler& gameHandler) {
// Zone entry detection — fire a toast when the renderer's zone name changes
if (auto* rend = services_.renderer) {
const std::string& curZone = rend->getCurrentZoneName();
if (!curZone.empty() && curZone != lastKnownZone_) {
if (!lastKnownZone_.empty()) {
zoneToasts_.push_back({curZone, 0.0f});
if (zoneToasts_.size() > 3)
zoneToasts_.erase(zoneToasts_.begin());
}
lastKnownZone_ = curZone;
}
}
renderRepToasts(deltaTime);
renderQuestCompleteToasts(deltaTime);
renderZoneToasts(deltaTime);
renderAreaTriggerToasts(deltaTime, gameHandler);
}
// ---------------------------------------------------------------------------
// Render late toasts (after escape menu / settings)
// ---------------------------------------------------------------------------
void ToastManager::renderLateToasts(game::GameHandler& gameHandler) {
renderDingEffect();
renderAchievementToast();
renderDiscoveryToast();
renderWhisperToasts();
renderQuestProgressToasts();
renderPlayerLevelUpToasts(gameHandler);
renderPvpHonorToasts();
renderItemLootToasts();
renderResurrectFlash();
renderZoneText(gameHandler);
}
// ============================================================
// Reputation change toasts
// ============================================================
void ToastManager::renderRepToasts(float deltaTime) {
for (auto& e : repToasts_) e.age += deltaTime;
repToasts_.erase(
std::remove_if(repToasts_.begin(), repToasts_.end(),
[](const RepToastEntry& e) { return e.age >= kRepToastLifetime; }),
repToasts_.end());
if (repToasts_.empty()) return;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Stack toasts in the lower-right corner (above the action bar), newest on top
const float toastW = 220.0f;
const float toastH = 26.0f;
const float padY = 4.0f;
const float rightEdge = screenW - 14.0f;
const float baseY = screenH - 180.0f;
const int count = static_cast<int>(repToasts_.size());
ImDrawList* draw = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
// Compute standing tier label (Exalted, Revered, Honored, Friendly, Neutral, Unfriendly, Hostile, Hated)
auto standingLabel = [](int32_t s) -> const char* {
if (s >= 42000) return "Exalted";
if (s >= 21000) return "Revered";
if (s >= 9000) return "Honored";
if (s >= 3000) return "Friendly";
if (s >= 0) return "Neutral";
if (s >= -3000) return "Unfriendly";
if (s >= -6000) return "Hostile";
return "Hated";
};
for (int i = 0; i < count; ++i) {
const auto& e = repToasts_[i];
// Slide in from right on appear, slide out at end
constexpr float kSlideDur = 0.3f;
float slideIn = std::min(e.age, kSlideDur) / kSlideDur;
float slideOut = std::min(std::max(0.0f, kRepToastLifetime - e.age), kSlideDur) / kSlideDur;
float slide = std::min(slideIn, slideOut);
float alpha = std::clamp(slide, 0.0f, 1.0f);
float xFull = rightEdge - toastW;
float xStart = screenW + 10.0f;
float toastX = xStart + (xFull - xStart) * slide;
float toastY = baseY - i * (toastH + padY);
ImVec2 tl(toastX, toastY);
ImVec2 br(toastX + toastW, toastY + toastH);
// Background
draw->AddRectFilled(tl, br, IM_COL32(15, 15, 20, static_cast<int>(alpha * 200)), 4.0f);
// Border: green for gain, red for loss
ImU32 borderCol = (e.delta > 0)
? IM_COL32(80, 200, 80, static_cast<int>(alpha * 220))
: IM_COL32(200, 60, 60, static_cast<int>(alpha * 220));
draw->AddRect(tl, br, borderCol, 4.0f, 0, 1.5f);
// Delta text: "+250" or "-250"
char deltaBuf[16];
snprintf(deltaBuf, sizeof(deltaBuf), "%+d", e.delta);
ImU32 deltaCol = (e.delta > 0) ? IM_COL32(80, 220, 80, static_cast<int>(alpha * 255))
: IM_COL32(220, 70, 70, static_cast<int>(alpha * 255));
draw->AddText(font, fontSize, ImVec2(tl.x + 6.0f, tl.y + (toastH - fontSize) * 0.5f),
deltaCol, deltaBuf);
// Faction name + standing
char nameBuf[64];
snprintf(nameBuf, sizeof(nameBuf), "%s (%s)", e.factionName.c_str(), standingLabel(e.standing));
draw->AddText(font, fontSize * 0.85f, ImVec2(tl.x + 44.0f, tl.y + (toastH - fontSize * 0.85f) * 0.5f),
IM_COL32(210, 210, 210, static_cast<int>(alpha * 220)), nameBuf);
}
}
void ToastManager::renderQuestCompleteToasts(float deltaTime) {
for (auto& e : questCompleteToasts_) e.age += deltaTime;
questCompleteToasts_.erase(
std::remove_if(questCompleteToasts_.begin(), questCompleteToasts_.end(),
[](const QuestCompleteToastEntry& e) { return e.age >= kQuestCompleteToastLifetime; }),
questCompleteToasts_.end());
if (questCompleteToasts_.empty()) return;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
const float toastW = 260.0f;
const float toastH = 40.0f;
const float padY = 4.0f;
const float baseY = screenH - 220.0f; // above rep toasts
ImDrawList* draw = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
for (int i = 0; i < static_cast<int>(questCompleteToasts_.size()); ++i) {
const auto& e = questCompleteToasts_[i];
constexpr float kSlideDur = 0.3f;
float slideIn = std::min(e.age, kSlideDur) / kSlideDur;
float slideOut = std::min(std::max(0.0f, kQuestCompleteToastLifetime - e.age), kSlideDur) / kSlideDur;
float slide = std::min(slideIn, slideOut);
float alpha = std::clamp(slide, 0.0f, 1.0f);
float xFull = screenW - 14.0f - toastW;
float xStart = screenW + 10.0f;
float toastX = xStart + (xFull - xStart) * slide;
float toastY = baseY - i * (toastH + padY);
ImVec2 tl(toastX, toastY);
ImVec2 br(toastX + toastW, toastY + toastH);
// Background + gold border (quest completion)
draw->AddRectFilled(tl, br, IM_COL32(20, 18, 8, static_cast<int>(alpha * 210)), 5.0f);
draw->AddRect(tl, br, IM_COL32(220, 180, 30, static_cast<int>(alpha * 230)), 5.0f, 0, 1.5f);
// Scroll icon placeholder (gold diamond)
float iconCx = tl.x + 18.0f;
float iconCy = tl.y + toastH * 0.5f;
draw->AddCircleFilled(ImVec2(iconCx, iconCy), 7.0f, IM_COL32(210, 170, 20, static_cast<int>(alpha * 230)));
draw->AddCircle (ImVec2(iconCx, iconCy), 7.0f, IM_COL32(255, 220, 50, static_cast<int>(alpha * 200)));
// "Quest Complete" header in gold
const char* header = "Quest Complete";
draw->AddText(font, fontSize * 0.78f,
ImVec2(tl.x + 34.0f, tl.y + 4.0f),
IM_COL32(240, 200, 40, static_cast<int>(alpha * 240)), header);
// Quest title in off-white
const char* titleStr = e.title.empty() ? "Unknown Quest" : e.title.c_str();
draw->AddText(font, fontSize * 0.82f,
ImVec2(tl.x + 34.0f, tl.y + toastH * 0.5f + 1.0f),
IM_COL32(220, 215, 195, static_cast<int>(alpha * 220)), titleStr);
}
}
// ============================================================
// Zone Entry Toast
// ============================================================
void ToastManager::renderZoneToasts(float deltaTime) {
for (auto& e : zoneToasts_) e.age += deltaTime;
zoneToasts_.erase(
std::remove_if(zoneToasts_.begin(), zoneToasts_.end(),
[](const ZoneToastEntry& e) { return e.age >= kZoneToastLifetime; }),
zoneToasts_.end());
// Suppress toasts while the zone text overlay is showing the same zone —
// avoids duplicate "Entering: Stormwind City" messages.
if (zoneTextTimer_ > 0.0f) {
zoneToasts_.erase(
std::remove_if(zoneToasts_.begin(), zoneToasts_.end(),
[this](const ZoneToastEntry& e) { return e.zoneName == zoneTextName_; }),
zoneToasts_.end());
}
if (zoneToasts_.empty()) return;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImDrawList* draw = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
for (int i = 0; i < static_cast<int>(zoneToasts_.size()); ++i) {
const auto& e = zoneToasts_[i];
constexpr float kSlideDur = 0.35f;
float slideIn = std::min(e.age, kSlideDur) / kSlideDur;
float slideOut = std::min(std::max(0.0f, kZoneToastLifetime - e.age), kSlideDur) / kSlideDur;
float slide = std::min(slideIn, slideOut);
float alpha = std::clamp(slide, 0.0f, 1.0f);
// Measure text to size the toast
ImVec2 nameSz = font->CalcTextSizeA(14.0f, FLT_MAX, 0.0f, e.zoneName.c_str());
const char* header = "Entering:";
ImVec2 hdrSz = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, header);
float toastW = std::max(nameSz.x, hdrSz.x) + 28.0f;
float toastH = 42.0f;
// Center the toast horizontally, appear just below the zone name area (top-center)
float toastX = (screenW - toastW) * 0.5f;
float toastY = 56.0f + i * (toastH + 4.0f);
// Slide down from above
float offY = (1.0f - slide) * (-toastH - 10.0f);
toastY += offY;
ImVec2 tl(toastX, toastY);
ImVec2 br(toastX + toastW, toastY + toastH);
draw->AddRectFilled(tl, br, IM_COL32(10, 10, 16, static_cast<int>(alpha * 200)), 6.0f);
draw->AddRect(tl, br, IM_COL32(160, 140, 80, static_cast<int>(alpha * 220)), 6.0f, 0, 1.2f);
float cx = tl.x + toastW * 0.5f;
draw->AddText(font, 11.0f,
ImVec2(cx - hdrSz.x * 0.5f, tl.y + 5.0f),
IM_COL32(180, 170, 120, static_cast<int>(alpha * 200)), header);
draw->AddText(font, 14.0f,
ImVec2(cx - nameSz.x * 0.5f, tl.y + toastH * 0.5f + 1.0f),
IM_COL32(255, 230, 140, static_cast<int>(alpha * 240)), e.zoneName.c_str());
}
}
// ─── Area Trigger Message Toasts ─────────────────────────────────────────────
void ToastManager::renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler) {
// Drain any pending messages from GameHandler
while (gameHandler.hasAreaTriggerMsg()) {
AreaTriggerToast t;
t.text = gameHandler.popAreaTriggerMsg();
t.age = 0.0f;
areaTriggerToasts_.push_back(std::move(t));
if (areaTriggerToasts_.size() > 4)
areaTriggerToasts_.erase(areaTriggerToasts_.begin());
}
// Age and prune
constexpr float kLifetime = 4.5f;
for (auto& t : areaTriggerToasts_) t.age += deltaTime;
areaTriggerToasts_.erase(
std::remove_if(areaTriggerToasts_.begin(), areaTriggerToasts_.end(),
[](const AreaTriggerToast& t) { return t.age >= kLifetime; }),
areaTriggerToasts_.end());
if (areaTriggerToasts_.empty()) return;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImDrawList* draw = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
constexpr float kSlideDur = 0.35f;
for (int i = 0; i < static_cast<int>(areaTriggerToasts_.size()); ++i) {
const auto& t = areaTriggerToasts_[i];
float slideIn = std::min(t.age, kSlideDur) / kSlideDur;
float slideOut = std::min(std::max(0.0f, kLifetime - t.age), kSlideDur) / kSlideDur;
float alpha = std::clamp(std::min(slideIn, slideOut), 0.0f, 1.0f);
// Measure text
ImVec2 txtSz = font->CalcTextSizeA(13.0f, FLT_MAX, 0.0f, t.text.c_str());
float toastW = txtSz.x + 30.0f;
float toastH = 30.0f;
// Center horizontally, place below zone text (center of lower-third)
float toastX = (screenW - toastW) * 0.5f;
float toastY = screenH * 0.62f + i * (toastH + 3.0f);
// Slide up from below
float offY = (1.0f - std::min(slideIn, slideOut)) * (toastH + 12.0f);
toastY += offY;
ImVec2 tl(toastX, toastY);
ImVec2 br(toastX + toastW, toastY + toastH);
draw->AddRectFilled(tl, br, IM_COL32(8, 12, 22, static_cast<int>(alpha * 190)), 5.0f);
draw->AddRect(tl, br, IM_COL32(100, 160, 220, static_cast<int>(alpha * 200)), 5.0f, 0, 1.0f);
float cx = tl.x + toastW * 0.5f;
// Shadow
draw->AddText(font, 13.0f,
ImVec2(cx - txtSz.x * 0.5f + 1, tl.y + (toastH - txtSz.y) * 0.5f + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 180)), t.text.c_str());
// Text in light blue
draw->AddText(font, 13.0f,
ImVec2(cx - txtSz.x * 0.5f, tl.y + (toastH - txtSz.y) * 0.5f),
IM_COL32(180, 220, 255, static_cast<int>(alpha * 240)), t.text.c_str());
}
}
// ============================================================
// Level-Up Ding Animation
// ============================================================
void ToastManager::triggerDing(uint32_t newLevel, uint32_t hpDelta, uint32_t manaDelta,
uint32_t str, uint32_t agi, uint32_t sta,
uint32_t intel, uint32_t spi) {
// Set golden burst overlay state (consumed by GameScreen)
levelUpFlashAlpha = 1.0f;
levelUpDisplayLevel = newLevel;
dingTimer_ = DING_DURATION;
dingLevel_ = newLevel;
dingHpDelta_ = hpDelta;
dingManaDelta_ = manaDelta;
dingStats_[0] = str;
dingStats_[1] = agi;
dingStats_[2] = sta;
dingStats_[3] = intel;
dingStats_[4] = spi;
auto* renderer = services_.renderer;
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
sfx->playLevelUp();
}
renderer->playEmote("cheer");
}
}
void ToastManager::renderDingEffect() {
if (dingTimer_ <= 0.0f) return;
float dt = ImGui::GetIO().DeltaTime;
dingTimer_ -= dt;
if (dingTimer_ < 0.0f) dingTimer_ = 0.0f;
// Show "You have reached level X!" for the first 2.5s, fade out over last 0.5s.
// The 3D visual effect is handled by Renderer::triggerLevelUpEffect (LevelUp.m2).
constexpr float kFadeTime = 0.5f;
float alpha = dingTimer_ < kFadeTime ? (dingTimer_ / kFadeTime) : 1.0f;
if (alpha <= 0.0f) return;
ImGuiIO& io = ImGui::GetIO();
float cx = io.DisplaySize.x * 0.5f;
float cy = io.DisplaySize.y * 0.38f; // Upper-center, like WoW
ImDrawList* draw = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
float baseSize = ImGui::GetFontSize();
float fontSize = baseSize * 1.8f;
char buf[64];
snprintf(buf, sizeof(buf), "You have reached level %u!", dingLevel_);
ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf);
float tx = cx - sz.x * 0.5f;
float ty = cy - sz.y * 0.5f;
// Slight black outline for readability
draw->AddText(font, fontSize, ImVec2(tx + 2, ty + 2),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 180)), buf);
// Gold text
draw->AddText(font, fontSize, ImVec2(tx, ty),
IM_COL32(255, 210, 0, static_cast<int>(alpha * 255)), buf);
// Stat gains below the main text (shown only if server sent deltas)
bool hasStatGains = (dingHpDelta_ > 0 || dingManaDelta_ > 0 ||
dingStats_[0] || dingStats_[1] || dingStats_[2] ||
dingStats_[3] || dingStats_[4]);
if (hasStatGains) {
float smallSize = baseSize * 0.95f;
float yOff = ty + sz.y + 6.0f;
// Build stat delta string: "+150 HP +80 Mana +2 Str +2 Agi ..."
static constexpr const char* kStatLabels[] = { "Str", "Agi", "Sta", "Int", "Spi" };
char statBuf[128];
int written = 0;
if (dingHpDelta_ > 0)
written += snprintf(statBuf + written, sizeof(statBuf) - written,
"+%u HP ", dingHpDelta_);
if (dingManaDelta_ > 0)
written += snprintf(statBuf + written, sizeof(statBuf) - written,
"+%u Mana ", dingManaDelta_);
for (int i = 0; i < 5 && written < static_cast<int>(sizeof(statBuf)) - 1; ++i) {
if (dingStats_[i] > 0)
written += snprintf(statBuf + written, sizeof(statBuf) - written,
"+%u %s ", dingStats_[i], kStatLabels[i]);
}
// Trim trailing spaces
while (written > 0 && statBuf[written - 1] == ' ') --written;
statBuf[written] = '\0';
if (written > 0) {
ImVec2 ssz = font->CalcTextSizeA(smallSize, FLT_MAX, 0.0f, statBuf);
float stx = cx - ssz.x * 0.5f;
draw->AddText(font, smallSize, ImVec2(stx + 1, yOff + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 160)), statBuf);
draw->AddText(font, smallSize, ImVec2(stx, yOff),
IM_COL32(100, 220, 100, static_cast<int>(alpha * 230)), statBuf);
}
}
}
void ToastManager::triggerAchievementToast(uint32_t achievementId, std::string name) {
achievementToastId_ = achievementId;
achievementToastName_ = std::move(name);
achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION;
// Play a UI sound if available
auto* renderer = services_.renderer;
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
sfx->playAchievementAlert();
}
}
}
void ToastManager::renderAchievementToast() {
if (achievementToastTimer_ <= 0.0f) return;
float dt = ImGui::GetIO().DeltaTime;
achievementToastTimer_ -= dt;
if (achievementToastTimer_ < 0.0f) achievementToastTimer_ = 0.0f;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Slide in from the right — fully visible for most of the duration, slides out at end
constexpr float SLIDE_TIME = 0.4f;
float slideIn = std::min(achievementToastTimer_, ACHIEVEMENT_TOAST_DURATION - achievementToastTimer_);
float slideFrac = (ACHIEVEMENT_TOAST_DURATION > 0.0f && SLIDE_TIME > 0.0f)
? std::min(slideIn / SLIDE_TIME, 1.0f)
: 1.0f;
constexpr float TOAST_W = 280.0f;
constexpr float TOAST_H = 60.0f;
float xFull = screenW - TOAST_W - 20.0f;
float xHidden = screenW + 10.0f;
float toastX = xHidden + (xFull - xHidden) * slideFrac;
float toastY = screenH - TOAST_H - 80.0f; // above action bar area
float alpha = std::min(1.0f, achievementToastTimer_ / 0.5f); // fade at very end
ImDrawList* draw = ImGui::GetForegroundDrawList();
// Background panel (gold border, dark fill)
ImVec2 tl(toastX, toastY);
ImVec2 br(toastX + TOAST_W, toastY + TOAST_H);
draw->AddRectFilled(tl, br, IM_COL32(30, 20, 10, static_cast<int>(alpha * 230)), 6.0f);
draw->AddRect(tl, br, IM_COL32(200, 170, 50, static_cast<int>(alpha * 255)), 6.0f, 0, 2.0f);
// Title
ImFont* font = ImGui::GetFont();
float titleSize = 14.0f;
float bodySize = 12.0f;
const char* title = "Achievement Earned!";
float titleW = font->CalcTextSizeA(titleSize, FLT_MAX, 0.0f, title).x;
float titleX = toastX + (TOAST_W - titleW) * 0.5f;
draw->AddText(font, titleSize, ImVec2(titleX + 1, toastY + 8 + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 180)), title);
draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8),
IM_COL32(255, 215, 0, static_cast<int>(alpha * 255)), title);
// 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),
IM_COL32(220, 200, 150, static_cast<int>(alpha * 255)), idBuf);
}
// ---------------------------------------------------------------------------
// Area discovery toast — "Discovered: <AreaName>! (+XP XP)" centered on screen
// ---------------------------------------------------------------------------
void ToastManager::renderDiscoveryToast() {
if (discoveryToastTimer_ <= 0.0f) return;
float dt = ImGui::GetIO().DeltaTime;
discoveryToastTimer_ -= dt;
if (discoveryToastTimer_ < 0.0f) discoveryToastTimer_ = 0.0f;
// Fade: ramp up in first 0.4s, hold, fade out in last 1.0s
float alpha;
if (discoveryToastTimer_ > DISCOVERY_TOAST_DURATION - 0.4f)
alpha = 1.0f - (discoveryToastTimer_ - (DISCOVERY_TOAST_DURATION - 0.4f)) / 0.4f;
else if (discoveryToastTimer_ < 1.0f)
alpha = discoveryToastTimer_;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImFont* font = ImGui::GetFont();
ImDrawList* draw = ImGui::GetForegroundDrawList();
const char* header = "Discovered!";
float headerSize = 16.0f;
float nameSize = 28.0f;
float xpSize = 14.0f;
ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header);
ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, discoveryToastName_.c_str());
char xpBuf[48];
if (discoveryToastXP_ > 0)
snprintf(xpBuf, sizeof(xpBuf), "+%u XP", discoveryToastXP_);
else
xpBuf[0] = '\0';
ImVec2 xpDim = font->CalcTextSizeA(xpSize, FLT_MAX, 0.0f, xpBuf);
// Position slightly below zone text (at 37% down screen)
float centreY = screenH * 0.37f;
float headerX = (screenW - headerDim.x) * 0.5f;
float nameX = (screenW - nameDim.x) * 0.5f;
float xpX = (screenW - xpDim.x) * 0.5f;
float headerY = centreY;
float nameY = centreY + headerDim.y + 4.0f;
float xpY = nameY + nameDim.y + 4.0f;
// "Discovered!" in gold
draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 160)), header);
draw->AddText(font, headerSize, ImVec2(headerX, headerY),
IM_COL32(255, 215, 0, static_cast<int>(alpha * 255)), header);
// Area name in white
draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 160)), discoveryToastName_.c_str());
draw->AddText(font, nameSize, ImVec2(nameX, nameY),
IM_COL32(255, 255, 255, static_cast<int>(alpha * 255)), discoveryToastName_.c_str());
// XP gain in light green (if any)
if (xpBuf[0] != '\0') {
draw->AddText(font, xpSize, ImVec2(xpX + 1, xpY + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 140)), xpBuf);
draw->AddText(font, xpSize, ImVec2(xpX, xpY),
IM_COL32(100, 220, 100, static_cast<int>(alpha * 230)), xpBuf);
}
}
// ---------------------------------------------------------------------------
// Quest objective progress toasts — shown at screen bottom-right on kill/item updates
// ---------------------------------------------------------------------------
void ToastManager::renderQuestProgressToasts() {
if (questToasts_.empty()) return;
float dt = ImGui::GetIO().DeltaTime;
for (auto& t : questToasts_) t.age += dt;
questToasts_.erase(
std::remove_if(questToasts_.begin(), questToasts_.end(),
[](const QuestProgressToastEntry& t) { return t.age >= QUEST_TOAST_DURATION; }),
questToasts_.end());
if (questToasts_.empty()) return;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
// Stack at bottom-right, just above action bar area
constexpr float TOAST_W = 240.0f;
constexpr float TOAST_H = 48.0f;
constexpr float TOAST_GAP = 4.0f;
float baseY = screenH * 0.72f;
float toastX = screenW - TOAST_W - 14.0f;
ImDrawList* bgDL = ImGui::GetBackgroundDrawList();
const int count = static_cast<int>(questToasts_.size());
for (int i = 0; i < count; ++i) {
const auto& toast = questToasts_[i];
float remaining = QUEST_TOAST_DURATION - toast.age;
float alpha;
if (toast.age < 0.2f)
alpha = toast.age / 0.2f;
else if (remaining < 1.0f)
alpha = remaining;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP);
uint8_t bgA = static_cast<uint8_t>(200 * alpha);
uint8_t fgA = static_cast<uint8_t>(255 * alpha);
// Background: dark amber tint (quest color convention)
bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H),
IM_COL32(35, 25, 5, bgA), 5.0f);
bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H),
IM_COL32(200, 160, 30, static_cast<uint8_t>(160 * alpha)), 5.0f, 0, 1.5f);
// Quest title (gold, small)
bgDL->AddText(ImVec2(toastX + 8.0f, ty + 5.0f),
IM_COL32(220, 180, 50, fgA), toast.questTitle.c_str());
// Progress bar + text: "ObjectiveName X / Y"
float barY = ty + 21.0f;
float barX0 = toastX + 8.0f;
float barX1 = toastX + TOAST_W - 8.0f;
float barH = 8.0f;
float pct = (toast.required > 0)
? std::min(1.0f, static_cast<float>(toast.current) / static_cast<float>(toast.required))
: 1.0f;
// Bar background
bgDL->AddRectFilled(ImVec2(barX0, barY), ImVec2(barX1, barY + barH),
IM_COL32(50, 40, 10, static_cast<uint8_t>(180 * alpha)), 3.0f);
// Bar fill — green when complete, amber otherwise
ImU32 barCol = (pct >= 1.0f) ? IM_COL32(60, 220, 80, fgA) : IM_COL32(200, 160, 30, fgA);
bgDL->AddRectFilled(ImVec2(barX0, barY),
ImVec2(barX0 + (barX1 - barX0) * pct, barY + barH),
barCol, 3.0f);
// Objective name + count
char progBuf[48];
if (!toast.objectiveName.empty())
snprintf(progBuf, sizeof(progBuf), "%.22s: %u/%u",
toast.objectiveName.c_str(), toast.current, toast.required);
else
snprintf(progBuf, sizeof(progBuf), "%u/%u", toast.current, toast.required);
bgDL->AddText(ImVec2(toastX + 8.0f, ty + 32.0f),
IM_COL32(220, 220, 200, static_cast<uint8_t>(210 * alpha)), progBuf);
}
}
// ---------------------------------------------------------------------------
// Item loot toasts — quality-coloured strip at bottom-left when item received
// ---------------------------------------------------------------------------
void ToastManager::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)
IM_COL32(230, 204, 128, 255), // 6 light gold (artifact)
IM_COL32(230, 204, 128, 255), // 7 light gold (heirloom)
};
// 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>(7u), 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
// ---------------------------------------------------------------------------
void ToastManager::renderPvpHonorToasts() {
if (pvpHonorToasts_.empty()) return;
float dt = ImGui::GetIO().DeltaTime;
for (auto& t : pvpHonorToasts_) t.age += dt;
pvpHonorToasts_.erase(
std::remove_if(pvpHonorToasts_.begin(), pvpHonorToasts_.end(),
[](const PvpHonorToastEntry& t) { return t.age >= PVP_HONOR_TOAST_DURATION; }),
pvpHonorToasts_.end());
if (pvpHonorToasts_.empty()) return;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
// Stack toasts at top-right, below any minimap area
constexpr float TOAST_W = 180.0f;
constexpr float TOAST_H = 30.0f;
constexpr float TOAST_GAP = 3.0f;
constexpr float TOAST_TOP = 10.0f;
float toastX = screenW - TOAST_W - 10.0f;
ImDrawList* bgDL = ImGui::GetBackgroundDrawList();
const int count = static_cast<int>(pvpHonorToasts_.size());
for (int i = 0; i < count; ++i) {
const auto& toast = pvpHonorToasts_[i];
float remaining = PVP_HONOR_TOAST_DURATION - toast.age;
float alpha;
if (toast.age < 0.15f)
alpha = toast.age / 0.15f;
else if (remaining < 0.8f)
alpha = remaining / 0.8f;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
float ty = TOAST_TOP + i * (TOAST_H + TOAST_GAP);
uint8_t bgA = static_cast<uint8_t>(190 * alpha);
uint8_t fgA = static_cast<uint8_t>(255 * alpha);
// Background: dark red (PvP theme)
bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H),
IM_COL32(28, 5, 5, bgA), 4.0f);
bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H),
IM_COL32(200, 50, 50, static_cast<uint8_t>(160 * alpha)), 4.0f, 0, 1.2f);
// Sword ⚔ icon (U+2694, UTF-8: e2 9a 94)
bgDL->AddText(ImVec2(toastX + 7.0f, ty + 7.0f),
IM_COL32(220, 80, 80, fgA), "\xe2\x9a\x94");
// "+N Honor" text in gold
char buf[40];
snprintf(buf, sizeof(buf), "+%u Honor", toast.honor);
bgDL->AddText(ImVec2(toastX + 24.0f, ty + 8.0f),
IM_COL32(255, 210, 50, fgA), buf);
}
}
// ---------------------------------------------------------------------------
// Nearby player level-up toasts — shown at screen bottom-centre
// ---------------------------------------------------------------------------
void ToastManager::renderPlayerLevelUpToasts(game::GameHandler& gameHandler) {
if (playerLevelUpToasts_.empty()) return;
float dt = ImGui::GetIO().DeltaTime;
for (auto& t : playerLevelUpToasts_) {
t.age += dt;
// Lazy name resolution — fill in once the name cache has it
if (t.playerName.empty() && t.guid != 0) {
t.playerName = gameHandler.lookupName(t.guid);
}
}
playerLevelUpToasts_.erase(
std::remove_if(playerLevelUpToasts_.begin(), playerLevelUpToasts_.end(),
[](const PlayerLevelUpToastEntry& t) {
return t.age >= PLAYER_LEVELUP_TOAST_DURATION;
}),
playerLevelUpToasts_.end());
if (playerLevelUpToasts_.empty()) return;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
// Stack toasts at screen bottom-centre, above action bars
constexpr float TOAST_W = 230.0f;
constexpr float TOAST_H = 38.0f;
constexpr float TOAST_GAP = 4.0f;
float baseY = screenH * 0.72f;
float toastX = (screenW - TOAST_W) * 0.5f;
ImDrawList* bgDL = ImGui::GetBackgroundDrawList();
const int count = static_cast<int>(playerLevelUpToasts_.size());
for (int i = 0; i < count; ++i) {
const auto& toast = playerLevelUpToasts_[i];
float remaining = PLAYER_LEVELUP_TOAST_DURATION - toast.age;
float alpha;
if (toast.age < 0.2f)
alpha = toast.age / 0.2f;
else if (remaining < 1.0f)
alpha = remaining;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
// Subtle pop-up from below during first 0.2s
float slideY = (toast.age < 0.2f) ? (TOAST_H * (1.0f - toast.age / 0.2f)) : 0.0f;
float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP) + slideY;
uint8_t bgA = static_cast<uint8_t>(200 * alpha);
uint8_t fgA = static_cast<uint8_t>(255 * alpha);
// Background: dark gold tint
bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H),
IM_COL32(30, 22, 5, bgA), 5.0f);
// Gold border with glow at peak
float glowStr = (toast.age < 0.5f) ? (1.0f - toast.age / 0.5f) : 0.0f;
uint8_t borderA = static_cast<uint8_t>((160 + 80 * glowStr) * alpha);
bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H),
IM_COL32(255, 210, 50, borderA), 5.0f, 0, 1.5f + glowStr * 1.5f);
// Star ★ icon on left
bgDL->AddText(ImVec2(toastX + 8.0f, ty + 10.0f),
IM_COL32(255, 220, 60, fgA), "\xe2\x98\x85"); // UTF-8 ★
// "<Name> is now level X!" text
const char* displayName = toast.playerName.empty() ? "A player" : toast.playerName.c_str();
char buf[64];
snprintf(buf, sizeof(buf), "%.18s is now level %u!", displayName, toast.newLevel);
bgDL->AddText(ImVec2(toastX + 26.0f, ty + 11.0f),
IM_COL32(255, 230, 100, fgA), buf);
}
}
// ---------------------------------------------------------------------------
// Resurrection flash — brief screen brightening + "You have been resurrected!"
// banner when the player transitions from ghost back to alive.
// ---------------------------------------------------------------------------
void ToastManager::renderResurrectFlash() {
if (resurrectFlashTimer_ <= 0.0f) return;
float dt = ImGui::GetIO().DeltaTime;
resurrectFlashTimer_ -= dt;
if (resurrectFlashTimer_ <= 0.0f) {
resurrectFlashTimer_ = 0.0f;
return;
}
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
// Normalised age in [0, 1] (0 = just fired, 1 = fully elapsed)
float t = 1.0f - resurrectFlashTimer_ / kResurrectFlashDuration;
// Alpha envelope: fast fade-in (first 0.15s), hold, then fade-out (last 0.8s)
float alpha;
const float fadeIn = 0.15f / kResurrectFlashDuration; // ~5% of lifetime
const float fadeOut = 0.8f / kResurrectFlashDuration; // ~27% of lifetime
if (t < fadeIn)
alpha = t / fadeIn;
else if (t < 1.0f - fadeOut)
alpha = 1.0f;
else
alpha = (1.0f - t) / fadeOut;
alpha = std::clamp(alpha, 0.0f, 1.0f);
ImDrawList* bg = ImGui::GetBackgroundDrawList();
// Soft golden/white vignette — brightening instead of darkening
uint8_t vigA = static_cast<uint8_t>(50 * alpha);
bg->AddRectFilled(ImVec2(0, 0), ImVec2(screenW, screenH),
IM_COL32(200, 230, 255, vigA));
// Centered banner panel
constexpr float PANEL_W = 360.0f;
constexpr float PANEL_H = 52.0f;
float px = (screenW - PANEL_W) * 0.5f;
float py = screenH * 0.34f;
uint8_t bgA = static_cast<uint8_t>(210 * alpha);
uint8_t borderA = static_cast<uint8_t>(255 * alpha);
uint8_t textA = static_cast<uint8_t>(255 * alpha);
// Background: deep blue-black
bg->AddRectFilled(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H),
IM_COL32(10, 18, 40, bgA), 8.0f);
// Border glow: bright holy gold
bg->AddRect(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H),
IM_COL32(200, 230, 100, borderA), 8.0f, 0, 2.0f);
// Inner halo line
bg->AddRect(ImVec2(px + 3.0f, py + 3.0f), ImVec2(px + PANEL_W - 3.0f, py + PANEL_H - 3.0f),
IM_COL32(255, 255, 180, static_cast<uint8_t>(80 * alpha)), 6.0f, 0, 1.0f);
// "✦ You have been resurrected! ✦" centered
// UTF-8 heavy four-pointed star U+2726: \xe2\x9c\xa6
const char* banner = "\xe2\x9c\xa6 You have been resurrected! \xe2\x9c\xa6";
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, banner);
float tx = px + (PANEL_W - textSz.x) * 0.5f;
float ty = py + (PANEL_H - textSz.y) * 0.5f;
// Drop shadow
bg->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f),
IM_COL32(0, 0, 0, static_cast<uint8_t>(180 * alpha)), banner);
// Main text in warm gold
bg->AddText(font, fontSize, ImVec2(tx, ty),
IM_COL32(255, 240, 120, textA), banner);
}
// ---------------------------------------------------------------------------
// Whisper toast notifications — brief overlay when a player whispers you
// ---------------------------------------------------------------------------
void ToastManager::renderWhisperToasts() {
if (whisperToasts_.empty()) return;
float dt = ImGui::GetIO().DeltaTime;
// Age and prune expired toasts
for (auto& t : whisperToasts_) t.age += dt;
whisperToasts_.erase(
std::remove_if(whisperToasts_.begin(), whisperToasts_.end(),
[](const WhisperToastEntry& t) { return t.age >= WHISPER_TOAST_DURATION; }),
whisperToasts_.end());
if (whisperToasts_.empty()) return;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
// Stack toasts at bottom-left, above the action bars (y ≈ screenH * 0.72)
// Each toast is ~56px tall with a 4px gap between them.
constexpr float TOAST_W = 280.0f;
constexpr float TOAST_H = 56.0f;
constexpr float TOAST_GAP = 4.0f;
constexpr float TOAST_X = 14.0f; // left edge (won't cover action bars)
float baseY = screenH * 0.72f;
ImDrawList* bgDL = ImGui::GetBackgroundDrawList();
const int count = static_cast<int>(whisperToasts_.size());
for (int i = 0; i < count; ++i) {
auto& toast = whisperToasts_[i];
// Fade in over 0.25s; fade out in last 1.0s
float alpha;
float remaining = WHISPER_TOAST_DURATION - toast.age;
if (toast.age < 0.25f)
alpha = toast.age / 0.25f;
else if (remaining < 1.0f)
alpha = remaining;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
// Slide-in from left: offset 0→0 after 0.25s
float slideX = (toast.age < 0.25f) ? (TOAST_W * (1.0f - toast.age / 0.25f)) : 0.0f;
float tx = TOAST_X - slideX;
float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP);
uint8_t bgA = static_cast<uint8_t>(210 * alpha);
uint8_t fgA = static_cast<uint8_t>(255 * alpha);
// Background panel — dark purple tint (whisper color convention)
bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H),
IM_COL32(25, 10, 40, bgA), 6.0f);
// Purple border
bgDL->AddRect(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H),
IM_COL32(160, 80, 220, static_cast<uint8_t>(180 * alpha)), 6.0f, 0, 1.5f);
// "Whisper" label (small, purple-ish)
bgDL->AddText(ImVec2(tx + 10.0f, ty + 6.0f),
IM_COL32(190, 110, 255, fgA), "Whisper from:");
// Sender name (gold)
bgDL->AddText(ImVec2(tx + 10.0f, ty + 20.0f),
IM_COL32(255, 210, 50, fgA), toast.sender.c_str());
// Message preview (white, dimmer)
bgDL->AddText(ImVec2(tx + 10.0f, ty + 36.0f),
IM_COL32(220, 220, 220, static_cast<uint8_t>(200 * alpha)),
toast.preview.c_str());
}
}
// Zone discovery text — "Entering: <ZoneName>" fades in/out at screen centre
// ---------------------------------------------------------------------------
void ToastManager::renderZoneText(game::GameHandler& gameHandler) {
// Poll worldStateZoneId for server-driven zone changes (fires on every zone crossing,
// including sub-zones like Ironforge within Dun Morogh).
uint32_t wsZoneId = gameHandler.getWorldStateZoneId();
if (wsZoneId != 0 && wsZoneId != lastKnownWorldStateZoneId_) {
lastKnownWorldStateZoneId_ = wsZoneId;
std::string wsName = gameHandler.getWhoAreaName(wsZoneId);
if (!wsName.empty()) {
zoneTextName_ = wsName;
zoneTextTimer_ = ZONE_TEXT_DURATION;
}
}
// Also poll the renderer for zone name changes (covers map-level transitions
// where worldStateZoneId may not change immediately).
auto* appRenderer = services_.renderer;
if (appRenderer) {
const std::string& zoneName = appRenderer->getCurrentZoneName();
if (!zoneName.empty() && zoneName != lastKnownZoneName_) {
lastKnownZoneName_ = zoneName;
// Only override if the worldState hasn't already queued this zone
if (zoneTextName_ != zoneName) {
zoneTextName_ = zoneName;
zoneTextTimer_ = ZONE_TEXT_DURATION;
}
}
}
if (zoneTextTimer_ <= 0.0f || zoneTextName_.empty()) return;
float dt = ImGui::GetIO().DeltaTime;
zoneTextTimer_ -= dt;
if (zoneTextTimer_ < 0.0f) zoneTextTimer_ = 0.0f;
auto* window = services_.window;
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Fade: ramp up in first 0.5 s, hold, fade out in last 1.0 s
float alpha;
if (zoneTextTimer_ > ZONE_TEXT_DURATION - 0.5f)
alpha = 1.0f - (zoneTextTimer_ - (ZONE_TEXT_DURATION - 0.5f)) / 0.5f;
else if (zoneTextTimer_ < 1.0f)
alpha = zoneTextTimer_;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
ImFont* font = ImGui::GetFont();
// "Entering:" header
const char* header = "Entering:";
float headerSize = 16.0f;
float nameSize = 26.0f;
ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header);
ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, zoneTextName_.c_str());
float centreY = screenH * 0.30f; // upper third, like WoW
float headerX = (screenW - headerDim.x) * 0.5f;
float nameX = (screenW - nameDim.x) * 0.5f;
float headerY = centreY;
float nameY = centreY + headerDim.y + 4.0f;
ImDrawList* draw = ImGui::GetForegroundDrawList();
// "Entering:" in gold
draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 160)), header);
draw->AddText(font, headerSize, ImVec2(headerX, headerY),
IM_COL32(255, 215, 0, static_cast<int>(alpha * 255)), header);
// Zone name in white
draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 160)), zoneTextName_.c_str());
draw->AddText(font, nameSize, ImVec2(nameX, nameY),
IM_COL32(255, 255, 255, static_cast<int>(alpha * 255)), zoneTextName_.c_str());
}
} } // namespace wowee::ui