From 78442f8aea8054d72e59e67ab363946edb8d0271 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 5 Feb 2026 12:07:58 -0800 Subject: [PATCH] Add XP tracking with level-up, kill XP formula, and server-compatible SMSG_LOG_XPGAIN support --- include/game/game_handler.hpp | 19 +++++ include/game/opcodes.hpp | 3 + include/game/world_packets.hpp | 19 +++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 130 +++++++++++++++++++++++++++++++++ src/game/world_packets.cpp | 17 +++++ src/ui/game_screen.cpp | 60 +++++++++++++++ 7 files changed, 249 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ab99eaf6..c4e5658e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -219,8 +219,15 @@ public: localPlayerLevel_ = level; localPlayerHealth_ = hp; localPlayerMaxHealth_ = maxHp; + playerNextLevelXp_ = xpForLevel(level); + playerXp_ = 0; } + // XP tracking (works in both single-player and server modes) + uint32_t getPlayerXp() const { return playerXp_; } + uint32_t getPlayerNextLevelXp() const { return playerNextLevelXp_; } + uint32_t getPlayerLevel() const { return singlePlayerMode_ ? localPlayerLevel_ : serverPlayerLevel_; } + // Hearthstone callback (single-player teleport) using HearthstoneCallback = std::function; void setHearthstoneCallback(HearthstoneCallback cb) { hearthstoneCallback = std::move(cb); } @@ -361,6 +368,9 @@ private: void handleGroupUninvite(network::Packet& packet); void handlePartyCommandResult(network::Packet& packet); + // ---- XP handler ---- + void handleXpGain(network::Packet& packet); + // ---- Phase 5 handlers ---- void handleLootResponse(network::Packet& packet); void handleLootReleaseResponse(network::Packet& packet); @@ -486,6 +496,15 @@ private: WorldConnectSuccessCallback onSuccess; WorldConnectFailureCallback onFailure; + // ---- XP tracking ---- + uint32_t playerXp_ = 0; + uint32_t playerNextLevelXp_ = 0; + uint32_t serverPlayerLevel_ = 1; + void awardLocalXp(uint32_t victimLevel); + void levelUp(); + static uint32_t xpForLevel(uint32_t level); + static uint32_t killXp(uint32_t playerLevel, uint32_t victimLevel); + // ---- Single-player combat ---- bool singlePlayerMode_ = false; float swingTimer_ = 0.0f; diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 8bbc199e..3c78809c 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -60,6 +60,9 @@ enum class Opcode : uint16_t { SMSG_GAMEOBJECT_QUERY_RESPONSE = 0x05F, CMSG_SET_ACTIVE_MOVER = 0x26A, + // ---- XP ---- + SMSG_LOG_XPGAIN = 0x1D0, + // ---- Phase 2: Combat Core ---- CMSG_ATTACKSWING = 0x141, CMSG_ATTACKSTOP = 0x142, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index eddbba26..6956b648 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -742,6 +742,25 @@ public: static bool parse(network::Packet& packet, SpellHealLogData& data); }; +// ============================================================ +// XP Gain +// ============================================================ + +/** SMSG_LOG_XPGAIN data */ +struct XpGainData { + uint64_t victimGuid = 0; // 0 for non-kill XP (quest, exploration) + uint32_t totalXp = 0; + uint8_t type = 0; // 0 = kill, 1 = non-kill + uint32_t groupBonus = 0; + + bool isValid() const { return totalXp > 0; } +}; + +class XpGainParser { +public: + static bool parse(network::Packet& packet, XpGainData& data); +}; + // ============================================================ // Phase 3: Spells, Action Bar, Auras // ============================================================ diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f15da1d3..e9db591f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -110,6 +110,7 @@ private: // ---- New UI renders ---- void renderActionBar(game::GameHandler& gameHandler); + void renderXpBar(game::GameHandler& gameHandler); void renderCastBar(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 97c0a96a..7045e572 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -253,6 +253,11 @@ void GameHandler::handlePacket(network::Packet& packet) { handleCreatureQueryResponse(packet); break; + // ---- XP ---- + case Opcode::SMSG_LOG_XPGAIN: + handleXpGain(packet); + break; + // ---- Phase 2: Combat ---- case Opcode::SMSG_ATTACKSTART: handleAttackStart(packet); @@ -824,6 +829,17 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } } + // Extract XP fields for player entity + if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + for (const auto& [key, val] : block.fields) { + switch (key) { + case 634: playerXp_ = val; break; // PLAYER_XP + case 635: playerNextLevelXp_ = val; break; // PLAYER_NEXT_LEVEL_XP + case 54: serverPlayerLevel_ = val; break; // UNIT_FIELD_LEVEL + default: break; + } + } + } break; } @@ -849,6 +865,17 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } } + // Update XP fields for player entity + if (block.guid == playerGuid) { + for (const auto& [key, val] : block.fields) { + switch (key) { + case 634: playerXp_ = val; break; // PLAYER_XP + case 635: playerNextLevelXp_ = val; break; // PLAYER_NEXT_LEVEL_XP + case 54: serverPlayerLevel_ = val; break; // UNIT_FIELD_LEVEL + default: break; + } + } + } LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); } else { @@ -1692,6 +1719,13 @@ void GameHandler::performPlayerSwing() { } void GameHandler::handleNpcDeath(uint64_t guid) { + // Award XP from kill + auto entity = entityManager.getEntity(guid); + if (entity && entity->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + awardLocalXp(unit->getLevel()); + } + // Remove from aggro list aggroList_.erase( std::remove_if(aggroList_.begin(), aggroList_.end(), @@ -1795,6 +1829,102 @@ void GameHandler::performNpcSwing(uint64_t guid) { damage, 0, false); } +// ============================================================ +// XP tracking +// ============================================================ + +// WotLK 3.3.5a XP-to-next-level table (from player_xp_for_level) +static const uint32_t XP_TABLE[] = { + 0, // level 0 (unused) + 400, 900, 1400, 2100, 2800, 3600, 4500, 5400, 6500, 7600, // 1-10 + 8700, 9800, 11000, 12300, 13600, 15000, 16400, 17800, 19300, 20800, // 11-20 + 22400, 24000, 25500, 27200, 28900, 30500, 32200, 33900, 36300, 38800, // 21-30 + 41600, 44600, 48000, 51400, 55000, 58700, 62400, 66200, 70200, 74300, // 31-40 + 78500, 82800, 87100, 91600, 96300, 101000, 105800, 110700, 115700, 120900, // 41-50 + 126100, 131500, 137000, 142500, 148200, 154000, 159900, 165800, 172000, 290000, // 51-60 + 317000, 349000, 386000, 428000, 475000, 527000, 585000, 648000, 717000, 1523800, // 61-70 + 1539600, 1555700, 1571800, 1587900, 1604200, 1620700, 1637400, 1653900, 1670800 // 71-79 +}; +static constexpr uint32_t XP_TABLE_SIZE = sizeof(XP_TABLE) / sizeof(XP_TABLE[0]); + +uint32_t GameHandler::xpForLevel(uint32_t level) { + if (level == 0 || level >= XP_TABLE_SIZE) return 0; + return XP_TABLE[level]; +} + +uint32_t GameHandler::killXp(uint32_t playerLevel, uint32_t victimLevel) { + if (playerLevel == 0 || victimLevel == 0) return 0; + + // Gray level check (too low = 0 XP) + int32_t grayLevel; + if (playerLevel <= 5) grayLevel = 0; + else if (playerLevel <= 39) grayLevel = static_cast(playerLevel) - 5 - static_cast(playerLevel) / 10; + else if (playerLevel <= 59) grayLevel = static_cast(playerLevel) - 1 - static_cast(playerLevel) / 5; + else grayLevel = static_cast(playerLevel) - 9; + + if (static_cast(victimLevel) <= grayLevel) return 0; + + // Base XP = 45 + 5 * victimLevel (WoW-like ZeroDifference formula) + uint32_t baseXp = 45 + 5 * victimLevel; + + // Level difference multiplier + int32_t diff = static_cast(victimLevel) - static_cast(playerLevel); + float multiplier = 1.0f + diff * 0.05f; + if (multiplier < 0.1f) multiplier = 0.1f; + if (multiplier > 2.0f) multiplier = 2.0f; + + return static_cast(baseXp * multiplier); +} + +void GameHandler::awardLocalXp(uint32_t victimLevel) { + if (localPlayerLevel_ >= 80) return; // Level cap + + uint32_t xp = killXp(localPlayerLevel_, victimLevel); + if (xp == 0) return; + + playerXp_ += xp; + + // Show XP gain in combat text as a heal-type (gold text) + addCombatText(CombatTextEntry::HEAL, static_cast(xp), 0, true); + + LOG_INFO("XP gained: +", xp, " (total: ", playerXp_, "/", playerNextLevelXp_, ")"); + + // Check for level-up + while (playerXp_ >= playerNextLevelXp_ && localPlayerLevel_ < 80) { + playerXp_ -= playerNextLevelXp_; + levelUp(); + } +} + +void GameHandler::levelUp() { + localPlayerLevel_++; + playerNextLevelXp_ = xpForLevel(localPlayerLevel_); + + // Scale HP with level + uint32_t newMaxHp = 20 + localPlayerLevel_ * 10; + localPlayerMaxHealth_ = newMaxHp; + localPlayerHealth_ = newMaxHp; // Full heal on level-up + + LOG_INFO("LEVEL UP! Now level ", localPlayerLevel_, + " (HP: ", newMaxHp, ", next level: ", playerNextLevelXp_, " XP)"); + + // Announce in chat + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.language = ChatLanguage::UNIVERSAL; + msg.message = "You have reached level " + std::to_string(localPlayerLevel_) + "!"; + addLocalChatMessage(msg); +} + +void GameHandler::handleXpGain(network::Packet& packet) { + XpGainData data; + if (!XpGainParser::parse(packet, data)) return; + + // Server already updates PLAYER_XP via update fields, + // but we can show combat text for XP gains + addCombatText(CombatTextEntry::HEAL, static_cast(data.totalXp), 0, true); +} + uint32_t GameHandler::generateClientSeed() { // Generate cryptographically random seed std::random_device rd; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 87713b0b..1ab3011d 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1052,6 +1052,23 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) return true; } +// ============================================================ +// XP Gain +// ============================================================ + +bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { + data.victimGuid = packet.readUInt64(); + data.totalXp = packet.readUInt32(); + data.type = packet.readUInt8(); + if (data.type == 0) { + // Kill XP: has group bonus float (unused) + group bonus uint32 + packet.readFloat(); + data.groupBonus = packet.readUInt32(); + } + LOG_INFO("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); + return data.totalXp > 0; +} + // ============================================================ // Phase 3: Spells, Action Bar, Auras // ============================================================ diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2db68923..c5303ab7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -72,6 +72,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // ---- New UI elements ---- renderActionBar(gameHandler); + renderXpBar(gameHandler); renderCastBar(gameHandler); renderCombatText(gameHandler); renderPartyFrames(gameHandler); @@ -1090,6 +1091,65 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// XP Bar +// ============================================================ + +void GameScreen::renderXpBar(game::GameHandler& gameHandler) { + uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); + if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized) + + uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t level = gameHandler.getPlayerLevel(); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Position just above the action bar + float slotSize = 48.0f; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH = slotSize + 24.0f; + float actionBarY = screenH - barH; + + float xpBarH = 14.0f; + float xpBarW = barW; + float xpBarX = (screenW - xpBarW) / 2.0f; + float xpBarY = actionBarY - xpBarH - 2.0f; + + ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); + + if (ImGui::Begin("##XpBar", nullptr, flags)) { + float pct = static_cast(currentXp) / static_cast(nextLevelXp); + if (pct > 1.0f) pct = 1.0f; + + // Purple XP bar (WoW-style) + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.58f, 0.2f, 0.93f, 1.0f)); + + char overlay[96]; + snprintf(overlay, sizeof(overlay), "Lv %u - %u / %u XP", level, currentXp, nextLevelXp); + ImGui::ProgressBar(pct, ImVec2(-1, xpBarH - 4.0f), overlay); + + ImGui::PopStyleColor(); + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); +} + // ============================================================ // Cast Bar (Phase 3) // ============================================================