Implement complete talent system with dual spec support

Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks

DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))

UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation

Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
This commit is contained in:
Kelsi 2026-02-10 02:00:13 -08:00
parent bf03044a63
commit e7556605d7
8 changed files with 860 additions and 29 deletions

View file

@ -589,6 +589,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleUnlearnSpells(packet);
break;
// ---- Talents ----
case Opcode::SMSG_TALENTS_INFO:
handleTalentsInfo(packet);
break;
// ---- Phase 4: Group ----
case Opcode::SMSG_GROUP_INVITE:
handleGroupInvite(packet);
@ -4457,6 +4462,96 @@ void GameHandler::handleUnlearnSpells(network::Packet& packet) {
}
}
// ============================================================
// Talents
// ============================================================
void GameHandler::handleTalentsInfo(network::Packet& packet) {
TalentsInfoData data;
if (!TalentsInfoParser::parse(packet, data)) return;
// Ensure talent DBCs are loaded
loadTalentDbc();
// Validate spec number
if (data.talentSpec > 1) {
LOG_WARNING("Invalid talent spec: ", (int)data.talentSpec);
return;
}
// Store talents for this spec
unspentTalentPoints_[data.talentSpec] = data.unspentPoints;
// Clear and rebuild learned talents map for this spec
learnedTalents_[data.talentSpec].clear();
for (const auto& talent : data.talents) {
if (talent.currentRank > 0) {
learnedTalents_[data.talentSpec][talent.talentId] = talent.currentRank;
}
}
LOG_INFO("Talents loaded: spec=", (int)data.talentSpec,
" unspent=", (int)unspentTalentPoints_[data.talentSpec],
" learned=", learnedTalents_[data.talentSpec].size());
// If this is the first spec received, set it as active
static bool firstSpecReceived = false;
if (!firstSpecReceived) {
firstSpecReceived = true;
activeTalentSpec_ = data.talentSpec;
// Show message to player about active spec
if (unspentTalentPoints_[data.talentSpec] > 0) {
std::string msg = "You have " + std::to_string(unspentTalentPoints_[data.talentSpec]) +
" unspent talent point";
if (unspentTalentPoints_[data.talentSpec] > 1) msg += "s";
msg += " in spec " + std::to_string(data.talentSpec + 1);
addSystemChatMessage(msg);
}
}
}
void GameHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("learnTalent: Not in world or no socket connection");
return;
}
LOG_INFO("Requesting to learn talent: id=", talentId, " rank=", requestedRank);
auto packet = LearnTalentPacket::build(talentId, requestedRank);
socket->send(packet);
}
void GameHandler::switchTalentSpec(uint8_t newSpec) {
if (newSpec > 1) {
LOG_WARNING("Invalid talent spec: ", (int)newSpec);
return;
}
if (newSpec == activeTalentSpec_) {
LOG_INFO("Already on spec ", (int)newSpec);
return;
}
// For now, just switch locally. In a real implementation, we'd send
// MSG_TALENT_WIPE_CONFIRM to the server to trigger a spec switch.
// The server would respond with new SMSG_TALENTS_INFO for the new spec.
activeTalentSpec_ = newSpec;
LOG_INFO("Switched to talent spec ", (int)newSpec,
" (unspent=", (int)unspentTalentPoints_[newSpec],
", learned=", learnedTalents_[newSpec].size(), ")");
std::string msg = "Switched to spec " + std::to_string(newSpec + 1);
if (unspentTalentPoints_[newSpec] > 0) {
msg += " (" + std::to_string(unspentTalentPoints_[newSpec]) + " unspent point";
if (unspentTalentPoints_[newSpec] > 1) msg += "s";
msg += ")";
}
addSystemChatMessage(msg);
}
// ============================================================
// Phase 4: Group/Party
// ============================================================
@ -5195,6 +5290,99 @@ void GameHandler::categorizeTrainerSpells() {
LOG_INFO("Trainer: Categorized into ", trainerTabs_.size(), " tabs");
}
void GameHandler::loadTalentDbc() {
if (talentDbcLoaded_) return;
talentDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
// Load Talent.dbc
auto talentDbc = am->loadDBC("Talent.dbc");
if (talentDbc && talentDbc->isLoaded()) {
// Talent.dbc structure (WoW 3.3.5a):
// 0: TalentID
// 1: TalentTabID
// 2: Row (tier)
// 3: Column
// 4-8: RankID[0-4] (spell IDs for ranks 1-5)
// 9-11: PrereqTalent[0-2]
// 12-14: PrereqRank[0-2]
// (other fields less relevant for basic functionality)
uint32_t count = talentDbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
TalentEntry entry;
entry.talentId = talentDbc->getUInt32(i, 0);
if (entry.talentId == 0) continue;
entry.tabId = talentDbc->getUInt32(i, 1);
entry.row = static_cast<uint8_t>(talentDbc->getUInt32(i, 2));
entry.column = static_cast<uint8_t>(talentDbc->getUInt32(i, 3));
// Rank spells (1-5 ranks)
for (int r = 0; r < 5; ++r) {
entry.rankSpells[r] = talentDbc->getUInt32(i, 4 + r);
}
// Prerequisites
for (int p = 0; p < 3; ++p) {
entry.prereqTalent[p] = talentDbc->getUInt32(i, 9 + p);
entry.prereqRank[p] = static_cast<uint8_t>(talentDbc->getUInt32(i, 12 + p));
}
// Calculate max rank
entry.maxRank = 0;
for (int r = 0; r < 5; ++r) {
if (entry.rankSpells[r] != 0) {
entry.maxRank = r + 1;
}
}
talentCache_[entry.talentId] = entry;
}
LOG_INFO("Loaded ", talentCache_.size(), " talents from Talent.dbc");
} else {
LOG_WARNING("Could not load Talent.dbc");
}
// Load TalentTab.dbc
auto tabDbc = am->loadDBC("TalentTab.dbc");
if (tabDbc && tabDbc->isLoaded()) {
// TalentTab.dbc structure (WoW 3.3.5a):
// 0: TalentTabID
// 1-17: Name (16 localized strings + flags = 17 fields)
// 18: SpellIconID
// 19: RaceMask
// 20: ClassMask
// 21: PetTalentMask
// 22: OrderIndex
// 23-39: BackgroundFile (16 localized strings + flags = 17 fields)
uint32_t count = tabDbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
TalentTabEntry entry;
entry.tabId = tabDbc->getUInt32(i, 0);
if (entry.tabId == 0) continue;
entry.name = tabDbc->getString(i, 1);
entry.classMask = tabDbc->getUInt32(i, 20);
entry.orderIndex = static_cast<uint8_t>(tabDbc->getUInt32(i, 22));
entry.backgroundFile = tabDbc->getString(i, 23);
talentTabCache_[entry.tabId] = entry;
// Log first few tabs to debug class mask issue
if (talentTabCache_.size() <= 10) {
LOG_INFO(" Tab ", entry.tabId, ": ", entry.name, " (classMask=0x", std::hex, entry.classMask, std::dec, ")");
}
}
LOG_INFO("Loaded ", talentTabCache_.size(), " talent tabs from TalentTab.dbc");
} else {
LOG_WARNING("Could not load TalentTab.dbc");
}
}
static const std::string EMPTY_STRING;
const std::string& GameHandler::getSpellName(uint32_t spellId) const {

View file

@ -2728,6 +2728,65 @@ network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t spel
return packet;
}
// ============================================================
// Talents
// ============================================================
bool TalentsInfoParser::parse(network::Packet& packet, TalentsInfoData& data) {
// WotLK 3.3.5a SMSG_TALENTS_INFO format:
// uint8 talentSpec (0 or 1 for dual-spec)
// uint8 unspentPoints
// uint8 talentCount
// for each talent:
// uint32 talentId
// uint8 currentRank (0-5)
data = TalentsInfoData{};
if (packet.getSize() - packet.getReadPos() < 3) {
LOG_ERROR("TalentsInfoParser: packet too short");
return false;
}
data.talentSpec = packet.readUInt8();
data.unspentPoints = packet.readUInt8();
uint8_t talentCount = packet.readUInt8();
LOG_INFO("SMSG_TALENTS_INFO: spec=", (int)data.talentSpec,
" unspentPoints=", (int)data.unspentPoints,
" talentCount=", (int)talentCount);
data.talents.reserve(talentCount);
for (uint8_t i = 0; i < talentCount; ++i) {
if (packet.getSize() - packet.getReadPos() < 5) {
LOG_WARNING("TalentsInfoParser: truncated talent data at index ", (int)i);
break;
}
TalentInfo talent;
talent.talentId = packet.readUInt32();
talent.currentRank = packet.readUInt8();
data.talents.push_back(talent);
LOG_INFO(" Talent: id=", talent.talentId, " rank=", (int)talent.currentRank);
}
return true;
}
network::Packet LearnTalentPacket::build(uint32_t talentId, uint32_t requestedRank) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_LEARN_TALENT));
packet.writeUInt32(talentId);
packet.writeUInt32(requestedRank);
return packet;
}
network::Packet TalentWipeConfirmPacket::build(bool accept) {
network::Packet packet(static_cast<uint16_t>(Opcode::MSG_TALENT_WIPE_CONFIRM));
packet.writeUInt32(accept ? 1 : 0);
return packet;
}
// ============================================================
// Death/Respawn
// ============================================================

View file

@ -1,6 +1,11 @@
#include "ui/talent_screen.hpp"
#include "core/input.hpp"
#include "core/application.hpp"
#include "core/logger.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/blp_loader.hpp"
#include <algorithm>
#include <GL/glew.h>
namespace wowee { namespace ui {
@ -19,8 +24,8 @@ void TalentScreen::render(game::GameHandler& gameHandler) {
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float winW = 400.0f;
float winH = 450.0f;
float winW = 600.0f; // Wider for talent grid
float winH = 550.0f;
float winX = (screenW - winW) * 0.5f;
float winY = (screenH - winH) * 0.5f;
@ -29,33 +34,7 @@ void TalentScreen::render(game::GameHandler& gameHandler) {
bool windowOpen = open;
if (ImGui::Begin("Talents", &windowOpen)) {
// Placeholder tabs
if (ImGui::BeginTabBar("TalentTabs")) {
if (ImGui::BeginTabItem("Spec 1")) {
ImGui::Spacing();
ImGui::TextDisabled("Talents coming soon.");
ImGui::Spacing();
ImGui::TextDisabled("Talent trees will be implemented in a future update.");
uint32_t level = gameHandler.getPlayerLevel();
uint32_t talentPoints = (level >= 10) ? (level - 9) : 0;
ImGui::Spacing();
ImGui::Separator();
ImGui::Text("Level: %u", level);
ImGui::Text("Talent points available: %u", talentPoints);
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Spec 2")) {
ImGui::TextDisabled("Talents coming soon.");
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Spec 3")) {
ImGui::TextDisabled("Talents coming soon.");
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
renderTalentTrees(gameHandler);
}
ImGui::End();
@ -64,4 +43,477 @@ void TalentScreen::render(game::GameHandler& gameHandler) {
}
}
void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
auto* assetManager = core::Application::getInstance().getAssetManager();
// Ensure talent DBCs are loaded (even if server hasn't sent SMSG_TALENTS_INFO)
static bool dbcLoadAttempted = false;
if (!dbcLoadAttempted) {
dbcLoadAttempted = true;
gameHandler.loadTalentDbc();
loadSpellDBC(assetManager);
loadSpellIconDBC(assetManager);
LOG_INFO("Talent window opened, DBC load triggered");
}
uint8_t playerClass = gameHandler.getPlayerClass();
LOG_INFO("Talent window: playerClass=", static_cast<int>(playerClass));
// Active spec indicator and switcher
uint8_t activeSpec = gameHandler.getActiveTalentSpec();
ImGui::Text("Active Spec: %u", activeSpec + 1);
ImGui::SameLine();
// Spec buttons
if (ImGui::SmallButton("Spec 1")) {
gameHandler.switchTalentSpec(0);
}
ImGui::SameLine();
if (ImGui::SmallButton("Spec 2")) {
gameHandler.switchTalentSpec(1);
}
ImGui::SameLine();
// Show unspent points for both specs
ImGui::Text("| Unspent: Spec1=%u Spec2=%u",
gameHandler.getUnspentTalentPoints(0),
gameHandler.getUnspentTalentPoints(1));
ImGui::Separator();
// Debug info
ImGui::Text("Player Class: %u", playerClass);
ImGui::Text("Total Talent Tabs: %zu", gameHandler.getAllTalentTabs().size());
ImGui::Text("Total Talents: %zu", gameHandler.getAllTalents().size());
ImGui::Separator();
if (playerClass == 0) {
ImGui::TextDisabled("Class information not available.");
LOG_WARNING("Talent window: getPlayerClass() returned 0");
return;
}
// Get talent tabs for this class (class mask: 1 << (class - 1))
uint32_t classMask = 1u << (playerClass - 1);
LOG_INFO("Talent window: classMask=0x", std::hex, classMask, std::dec);
// Collect talent tabs for this class, sorted by orderIndex
std::vector<const game::GameHandler::TalentTabEntry*> classTabs;
for (const auto& [tabId, tab] : gameHandler.getAllTalentTabs()) {
if (tab.classMask & classMask) {
classTabs.push_back(&tab);
}
}
std::sort(classTabs.begin(), classTabs.end(),
[](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; });
LOG_INFO("Talent window: found ", classTabs.size(), " tabs for class mask 0x", std::hex, classMask, std::dec);
ImGui::Text("Class Mask: 0x%X", classMask);
ImGui::Text("Tabs for this class: %zu", classTabs.size());
if (classTabs.empty()) {
ImGui::TextDisabled("No talent trees available for your class.");
ImGui::Spacing();
ImGui::TextDisabled("Available tabs:");
for (const auto& [tabId, tab] : gameHandler.getAllTalentTabs()) {
ImGui::Text(" Tab %u: %s (mask: 0x%X)", tabId, tab.name.c_str(), tab.classMask);
}
return;
}
// Display points
uint8_t unspentPoints = gameHandler.getUnspentTalentPoints();
ImGui::Text("Unspent Points: %u", unspentPoints);
ImGui::Separator();
// Render tabs
if (ImGui::BeginTabBar("TalentTabs")) {
for (const auto* tab : classTabs) {
if (ImGui::BeginTabItem(tab->name.c_str())) {
renderTalentTree(gameHandler, tab->tabId);
ImGui::EndTabItem();
}
}
ImGui::EndTabBar();
}
}
void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId) {
// Collect all talents for this tab
std::vector<const game::GameHandler::TalentEntry*> talents;
for (const auto& [talentId, talent] : gameHandler.getAllTalents()) {
if (talent.tabId == tabId) {
talents.push_back(&talent);
}
}
if (talents.empty()) {
ImGui::TextDisabled("No talents in this tree.");
return;
}
// Find grid dimensions
uint8_t maxRow = 0, maxCol = 0;
for (const auto* talent : talents) {
maxRow = std::max(maxRow, talent->row);
maxCol = std::max(maxCol, talent->column);
}
const float iconSize = 40.0f;
ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false);
// Render grid
for (uint8_t row = 0; row <= maxRow; ++row) {
// Row label
ImGui::Text("Tier %u", row);
ImGui::SameLine(80);
for (uint8_t col = 0; col <= maxCol; ++col) {
// Find talent at this position
const game::GameHandler::TalentEntry* talent = nullptr;
for (const auto* t : talents) {
if (t->row == row && t->column == col) {
talent = t;
break;
}
}
if (col > 0) ImGui::SameLine();
if (talent) {
renderTalent(gameHandler, *talent);
} else {
// Empty slot
ImGui::InvisibleButton(("empty_" + std::to_string(row) + "_" + std::to_string(col)).c_str(),
ImVec2(iconSize, iconSize));
}
}
}
ImGui::EndChild();
}
void TalentScreen::renderTalent(game::GameHandler& gameHandler,
const game::GameHandler::TalentEntry& talent) {
auto* assetManager = core::Application::getInstance().getAssetManager();
uint8_t currentRank = gameHandler.getTalentRank(talent.talentId);
uint8_t nextRank = currentRank + 1;
// Check if can learn
bool canLearn = currentRank < talent.maxRank &&
gameHandler.getUnspentTalentPoints() > 0;
// Check prerequisites
bool prereqsMet = true;
for (int i = 0; i < 3; ++i) {
if (talent.prereqTalent[i] != 0) {
uint8_t prereqRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
if (prereqRank < talent.prereqRank[i]) {
prereqsMet = false;
canLearn = false;
break;
}
}
}
// Check tier requirement (need 5 points in previous tier)
if (talent.row > 0) {
// Count points spent in this tree
uint32_t pointsInTree = 0;
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == talent.tabId) {
pointsInTree += rank;
}
}
uint32_t requiredPoints = talent.row * 5;
if (pointsInTree < requiredPoints) {
canLearn = false;
}
}
// Determine state color and tint
ImVec4 borderColor;
ImVec4 tint;
if (currentRank == talent.maxRank) {
borderColor = ImVec4(0.3f, 0.9f, 0.3f, 1.0f); // Green border (maxed)
tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color
} else if (currentRank > 0) {
borderColor = ImVec4(1.0f, 0.9f, 0.3f, 1.0f); // Yellow border (partial)
tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color
} else if (canLearn && prereqsMet) {
borderColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White border (available)
tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color
} else {
borderColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); // Gray border (locked)
tint = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); // Desaturated
}
const float iconSize = 40.0f;
ImGui::PushID(static_cast<int>(talent.talentId));
// Get spell icon
uint32_t spellId = talent.rankSpells[0];
GLuint iconTex = 0;
if (spellId != 0) {
auto it = spellIconIds.find(spellId);
if (it != spellIconIds.end()) {
iconTex = getSpellIcon(it->second, assetManager);
}
}
// Use InvisibleButton for click handling
bool clicked = ImGui::InvisibleButton("##talent", ImVec2(iconSize, iconSize));
bool hovered = ImGui::IsItemHovered();
// Draw icon and border
ImVec2 pMin = ImGui::GetItemRectMin();
ImVec2 pMax = ImGui::GetItemRectMax();
auto* drawList = ImGui::GetWindowDrawList();
// Border
float borderThickness = hovered ? 3.0f : 2.0f;
ImU32 borderCol = IM_COL32(borderColor.x * 255, borderColor.y * 255, borderColor.z * 255, 255);
drawList->AddRect(pMin, pMax, borderCol, 0.0f, 0, borderThickness);
// Icon or colored background
if (iconTex) {
ImU32 tintCol = IM_COL32(tint.x * 255, tint.y * 255, tint.z * 255, tint.w * 255);
drawList->AddImage((ImTextureID)(uintptr_t)iconTex,
ImVec2(pMin.x + 2, pMin.y + 2),
ImVec2(pMax.x - 2, pMax.y - 2),
ImVec2(0, 0), ImVec2(1, 1), tintCol);
} else {
ImU32 bgCol = IM_COL32(borderColor.x * 80, borderColor.y * 80, borderColor.z * 80, 255);
drawList->AddRectFilled(ImVec2(pMin.x + 2, pMin.y + 2),
ImVec2(pMax.x - 2, pMax.y - 2), bgCol);
}
// Rank indicator overlay
if (talent.maxRank > 1) {
ImVec2 pMin = ImGui::GetItemRectMin();
ImVec2 pMax = ImGui::GetItemRectMax();
auto* drawList = ImGui::GetWindowDrawList();
char rankText[16];
snprintf(rankText, sizeof(rankText), "%u/%u", currentRank, talent.maxRank);
ImVec2 textSize = ImGui::CalcTextSize(rankText);
ImVec2 textPos(pMax.x - textSize.x - 2, pMax.y - textSize.y - 2);
// Shadow
drawList->AddText(ImVec2(textPos.x + 1, textPos.y + 1), IM_COL32(0, 0, 0, 255), rankText);
// Text
ImU32 rankCol = currentRank == talent.maxRank ? IM_COL32(0, 255, 0, 255) :
currentRank > 0 ? IM_COL32(255, 255, 0, 255) :
IM_COL32(255, 255, 255, 255);
drawList->AddText(textPos, rankCol, rankText);
}
// Enhanced tooltip
if (hovered) {
ImGui::BeginTooltip();
// Spell name
const std::string& spellName = gameHandler.getSpellName(spellId);
if (!spellName.empty()) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", spellName.c_str());
} else {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Talent #%u", talent.talentId);
}
// Rank
ImGui::TextColored(borderColor, "Rank %u/%u", currentRank, talent.maxRank);
// Current rank description
if (currentRank > 0 && talent.rankSpells[currentRank - 1] != 0) {
auto tooltipIt = spellTooltips.find(talent.rankSpells[currentRank - 1]);
if (tooltipIt != spellTooltips.end() && !tooltipIt->second.empty()) {
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 300.0f);
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Current:");
ImGui::TextWrapped("%s", tooltipIt->second.c_str());
ImGui::PopTextWrapPos();
}
}
// Next rank description
if (currentRank < talent.maxRank && talent.rankSpells[currentRank] != 0) {
auto tooltipIt = spellTooltips.find(talent.rankSpells[currentRank]);
if (tooltipIt != spellTooltips.end() && !tooltipIt->second.empty()) {
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 300.0f);
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Next Rank:");
ImGui::TextWrapped("%s", tooltipIt->second.c_str());
ImGui::PopTextWrapPos();
}
}
// Prerequisites
for (int i = 0; i < 3; ++i) {
if (talent.prereqTalent[i] != 0) {
const auto* prereq = gameHandler.getTalentEntry(talent.prereqTalent[i]);
if (prereq && prereq->rankSpells[0] != 0) {
uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
bool met = prereqCurrentRank >= talent.prereqRank[i];
ImVec4 prereqColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f);
const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]);
ImGui::Spacing();
ImGui::TextColored(prereqColor, "Requires %u point%s in %s",
talent.prereqRank[i],
talent.prereqRank[i] > 1 ? "s" : "",
prereqName.empty() ? "prerequisite" : prereqName.c_str());
}
}
}
// Tier requirement
if (talent.row > 0 && currentRank == 0) {
uint32_t pointsInTree = 0;
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == talent.tabId) {
pointsInTree += rank;
}
}
uint32_t requiredPoints = talent.row * 5;
if (pointsInTree < requiredPoints) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Requires %u points in this tree (%u/%u)",
requiredPoints, pointsInTree, requiredPoints);
}
}
// Action hint
if (canLearn && prereqsMet) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Click to learn");
} else if (currentRank >= talent.maxRank) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Maxed");
}
ImGui::EndTooltip();
}
// Handle click
if (clicked) {
LOG_INFO("Talent clicked: id=", talent.talentId, " canLearn=", canLearn, " prereqsMet=", prereqsMet,
" currentRank=", static_cast<int>(currentRank), " maxRank=", static_cast<int>(talent.maxRank),
" unspent=", static_cast<int>(gameHandler.getUnspentTalentPoints()));
if (canLearn && prereqsMet) {
LOG_INFO("Sending CMSG_LEARN_TALENT for talent ", talent.talentId, " rank ", static_cast<int>(nextRank));
gameHandler.learnTalent(talent.talentId, nextRank);
} else {
if (!canLearn) LOG_WARNING("Cannot learn: canLearn=false");
if (!prereqsMet) LOG_WARNING("Cannot learn: prereqsMet=false");
}
}
ImGui::PopID();
}
void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
if (spellDbcLoaded) return;
spellDbcLoaded = true;
if (!assetManager || !assetManager->isInitialized()) return;
auto dbc = assetManager->loadDBC("Spell.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Talent screen: Could not load Spell.dbc");
return;
}
// WoW 3.3.5a Spell.dbc fields: 0=SpellID, 133=SpellIconID, 136=SpellName_enUS, 139=Tooltip_enUS
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t spellId = dbc->getUInt32(i, 0);
if (spellId == 0) continue;
uint32_t iconId = dbc->getUInt32(i, 133);
spellIconIds[spellId] = iconId;
std::string tooltip = dbc->getString(i, 139);
if (!tooltip.empty()) {
spellTooltips[spellId] = tooltip;
}
}
LOG_INFO("Talent screen: Loaded ", spellIconIds.size(), " spell icons from Spell.dbc");
}
void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
if (iconDbcLoaded) return;
iconDbcLoaded = true;
if (!assetManager || !assetManager->isInitialized()) return;
auto dbc = assetManager->loadDBC("SpellIcon.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Talent screen: Could not load SpellIcon.dbc");
return;
}
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
uint32_t id = dbc->getUInt32(i, 0);
std::string path = dbc->getString(i, 1);
if (!path.empty() && id > 0) {
spellIconPaths[id] = path;
}
}
LOG_INFO("Talent screen: Loaded ", spellIconPaths.size(), " spell icon paths from SpellIcon.dbc");
}
GLuint TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) {
if (iconId == 0 || !assetManager) return 0;
// Check cache
auto cit = spellIconCache.find(iconId);
if (cit != spellIconCache.end()) return cit->second;
// Look up icon path
auto pit = spellIconPaths.find(iconId);
if (pit == spellIconPaths.end()) {
spellIconCache[iconId] = 0;
return 0;
}
// Load BLP file
std::string iconPath = pit->second + ".blp";
auto blpData = assetManager->readFile(iconPath);
if (blpData.empty()) {
spellIconCache[iconId] = 0;
return 0;
}
// Decode BLP
auto image = pipeline::BLPLoader::load(blpData);
if (!image.isValid()) {
spellIconCache[iconId] = 0;
return 0;
}
// Create OpenGL texture
GLuint texId = 0;
glGenTextures(1, &texId);
glBindTexture(GL_TEXTURE_2D, texId);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, image.data.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
spellIconCache[iconId] = texId;
return texId;
}
}} // namespace wowee::ui