Add XP tracking with level-up, kill XP formula, and server-compatible SMSG_LOG_XPGAIN support

This commit is contained in:
Kelsi 2026-02-05 12:07:58 -08:00
parent ed5d10ec01
commit 78442f8aea
7 changed files with 249 additions and 0 deletions

View file

@ -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()>;
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;

View file

@ -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,

View file

@ -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
// ============================================================

View file

@ -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);

View file

@ -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<Unit>(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<int32_t>(playerLevel) - 5 - static_cast<int32_t>(playerLevel) / 10;
else if (playerLevel <= 59) grayLevel = static_cast<int32_t>(playerLevel) - 1 - static_cast<int32_t>(playerLevel) / 5;
else grayLevel = static_cast<int32_t>(playerLevel) - 9;
if (static_cast<int32_t>(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<int32_t>(victimLevel) - static_cast<int32_t>(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<uint32_t>(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<int32_t>(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<int32_t>(data.totalXp), 0, true);
}
uint32_t GameHandler::generateClientSeed() {
// Generate cryptographically random seed
std::random_device rd;

View file

@ -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<int>(data.type), ")");
return data.totalXp > 0;
}
// ============================================================
// Phase 3: Spells, Action Bar, Auras
// ============================================================

View file

@ -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<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(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<float>(currentXp) / static_cast<float>(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)
// ============================================================