Handle SMSG_ACHIEVEMENT_EARNED with toast banner and chat notification

- Parse SMSG_ACHIEVEMENT_EARNED (guid + achievementId + PackedTime date)
  and fire AchievementEarnedCallback for self, chat notify for others
- Add renderAchievementToast() to GameScreen: slides in from right,
  gold-bordered panel with "Achievement Earned!" title + ID, 5s duration
  with 0.4s slide-in/out animation and fade at end
- Add triggerAchievementToast(uint32_t) public method on GameScreen
- Wire AchievementEarnedCallback in application.cpp
- Add playAchievementAlert() to UiSoundManager, loads
  Sound\Interface\AchievementSound.wav with level-up fallback
- SMSG_ALL_ACHIEVEMENT_DATA silently consumed (no tracker UI yet)
This commit is contained in:
Kelsi 2026-03-09 13:53:42 -07:00
parent 200a00d4f5
commit e4f53ce0c3
7 changed files with 159 additions and 0 deletions

View file

@ -67,6 +67,9 @@ public:
// Level up
void playLevelUp();
// Achievement
void playAchievementAlert();
// Error/feedback
void playError();
void playTargetSelect();
@ -114,6 +117,7 @@ private:
std::vector<UISample> drinkingSounds_;
std::vector<UISample> levelUpSounds_;
std::vector<UISample> achievementSounds_;
std::vector<UISample> errorSounds_;
std::vector<UISample> selectTargetSounds_;

View file

@ -834,6 +834,10 @@ public:
using OtherPlayerLevelUpCallback = std::function<void(uint64_t guid, uint32_t newLevel)>;
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)>;
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
// Mount state
using MountCallback = std::function<void(uint32_t mountDisplayId)>; // 0 = dismount
void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); }
@ -1166,6 +1170,7 @@ private:
void handleSpellGo(network::Packet& packet);
void handleSpellCooldown(network::Packet& packet);
void handleCooldownEvent(network::Packet& packet);
void handleAchievementEarned(network::Packet& packet);
void handleAuraUpdate(network::Packet& packet, bool isAll);
void handleLearnedSpell(network::Packet& packet);
void handleSupercededSpell(network::Packet& packet);
@ -1873,6 +1878,7 @@ private:
ChargeCallback chargeCallback_;
LevelUpCallback levelUpCallback_;
OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_;
AchievementEarnedCallback achievementEarnedCallback_;
MountCallback mountCallback_;
TaxiPrecacheCallback taxiPrecacheCallback_;
TaxiOrientationCallback taxiOrientationCallback_;

View file

@ -326,8 +326,15 @@ private:
uint32_t dingLevel_ = 0;
void renderDingEffect();
// Achievement toast banner
static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f;
float achievementToastTimer_ = 0.0f;
uint32_t achievementToastId_ = 0;
void renderAchievementToast();
public:
void triggerDing(uint32_t newLevel);
void triggerAchievementToast(uint32_t achievementId);
};
} // namespace ui

View file

@ -105,6 +105,13 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) {
levelUpSounds_.resize(1);
bool levelUpLoaded = loadSound("Sound\\Interface\\LevelUp.wav", levelUpSounds_[0], assets);
// Load achievement sound (WotLK: Sound\Interface\AchievementSound.wav)
achievementSounds_.resize(1);
if (!loadSound("Sound\\Interface\\AchievementSound.wav", achievementSounds_[0], assets)) {
// Fallback to level-up sound if achievement sound is missing
achievementSounds_ = levelUpSounds_;
}
// Load error/feedback sounds
errorSounds_.resize(1);
loadSound("Sound\\Interface\\Error.wav", errorSounds_[0], assets);
@ -210,6 +217,9 @@ void UiSoundManager::playDrinking() { playSound(drinkingSounds_); }
// Level up
void UiSoundManager::playLevelUp() { playSound(levelUpSounds_); }
// Achievement
void UiSoundManager::playAchievementAlert() { playSound(achievementSounds_); }
// Error/feedback
void UiSoundManager::playError() { playSound(errorSounds_); }
void UiSoundManager::playTargetSelect() { playSound(selectTargetSounds_); }

View file

@ -2089,6 +2089,13 @@ void Application::setupUICallbacks() {
}
});
// Achievement earned callback — show toast banner
gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) {
if (uiManager) {
uiManager->getGameScreen().triggerAchievementToast(achievementId);
}
});
// Other player level-up callback — trigger 3D effect + chat notification
gameHandler->setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) {
if (!gameHandler || !renderer) return;

View file

@ -1825,6 +1825,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
}
case Opcode::SMSG_ACHIEVEMENT_EARNED:
handleAchievementEarned(packet);
break;
case Opcode::SMSG_ALL_ACHIEVEMENT_DATA:
// Initial data burst on login — ignored for now (no achievement tracker UI).
break;
case Opcode::SMSG_CANCEL_AUTO_REPEAT:
break; // Server signals to stop a repeating spell (wand/shoot); no client action needed
case Opcode::SMSG_AURA_UPDATE:
@ -14870,5 +14876,54 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) {
" error=", result.errorCode);
}
// ---------------------------------------------------------------------------
// SMSG_ACHIEVEMENT_EARNED (WotLK 3.3.5a wire 0x4AB)
// uint64 guid — player who earned it (may be another player)
// uint32 achievementId — Achievement.dbc ID
// PackedTime date — uint32 bitfield (seconds since epoch)
// uint32 realmFirst — how many on realm also got it (0 = realm first)
// ---------------------------------------------------------------------------
void GameHandler::handleAchievementEarned(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 16) return; // guid(8) + id(4) + date(4)
uint64_t guid = packet.readUInt64();
uint32_t achievementId = packet.readUInt32();
/*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed
// Show chat notification
bool isSelf = (guid == playerGuid);
if (isSelf) {
char buf[128];
std::snprintf(buf, sizeof(buf),
"Achievement earned! (ID %u)", achievementId);
addSystemChatMessage(buf);
if (achievementEarnedCallback_) {
achievementEarnedCallback_(achievementId);
}
} else {
// Another player in the zone earned an achievement
std::string senderName;
auto entity = entityManager.getEntity(guid);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
senderName = unit->getName();
}
if (senderName.empty()) {
char tmp[32];
std::snprintf(tmp, sizeof(tmp), "0x%llX",
static_cast<unsigned long long>(guid));
senderName = tmp;
}
char buf[256];
std::snprintf(buf, sizeof(buf),
"%s has earned an achievement! (ID %u)", senderName.c_str(), achievementId);
addSystemChatMessage(buf);
}
LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec,
" achievementId=", achievementId, " self=", isSelf);
}
} // namespace game
} // namespace wowee

View file

@ -421,6 +421,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderEscapeMenu();
renderSettingsWindow();
renderDingEffect();
renderAchievementToast();
// World map (M key toggle handled inside)
renderWorldMap(gameHandler);
@ -8878,6 +8879,75 @@ void GameScreen::renderDingEffect() {
}
}
void GameScreen::triggerAchievementToast(uint32_t achievementId) {
achievementToastId_ = achievementId;
achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION;
// Play a UI sound if available
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
sfx->playAchievementAlert();
}
}
}
void GameScreen::renderAchievementToast() {
if (achievementToastTimer_ <= 0.0f) return;
float dt = ImGui::GetIO().DeltaTime;
achievementToastTimer_ -= dt;
if (achievementToastTimer_ < 0.0f) achievementToastTimer_ = 0.0f;
auto* window = core::Application::getInstance().getWindow();
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, (int)(alpha * 230)), 6.0f);
draw->AddRect(tl, br, IM_COL32(200, 170, 50, (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, (int)(alpha * 180)), title);
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_);
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, (int)(alpha * 255)), idBuf);
}
// ---------------------------------------------------------------------------
// Dungeon Finder window (toggle with hotkey or bag-bar button)
// ---------------------------------------------------------------------------