Revamp talent and spellbook UIs with proper visuals and functionality

Talent screen:
- Remove all debug text and per-frame LOG_INFO spam
- Show class name in window title (e.g. "Warrior Talents")
- Display point distribution in header (0/31/20) and per-tab counts
- Highlighted active spec button with styled spec switcher
- Load and render tree background textures from TalentTab.dbc
- Draw prerequisite arrows with arrowheads (green=met, gray=unmet)
- Fix rank display (was showing rank+1, now correct 1-indexed values)
- Rank counter with dark background pill for readability
- Hover glow effect, rounded corners, centered grid layout
- Wider window (680x600) for 4-column WoW talent grid

Spellbook:
- Add search/filter bar for finding spells by name
- Add spell descriptions from Spell.dbc tooltip field
- Rich tooltips with name, rank, passive indicator, cooldown, description
- Visual icon borders: yellow=passive, red=cooldown, default=active
- Cooldown overlay on icon with countdown number
- Hover highlight on spell rows
- Tab counts update to reflect search filter results
- Rounded corners on icons and hover states
- Extracted renderSpellTooltip helper for consistent tooltip rendering
This commit is contained in:
Kelsi 2026-02-25 14:55:40 -08:00
parent 889cd86fb0
commit da959cfb8f
4 changed files with 530 additions and 297 deletions

View file

@ -18,8 +18,13 @@ struct SpellInfo {
uint32_t spellId = 0;
std::string name;
std::string rank;
uint32_t iconId = 0; // SpellIconID
uint32_t attributes = 0; // Spell attributes (field 75)
std::string description; // Tooltip/description text from Spell.dbc
uint32_t iconId = 0; // SpellIconID
uint32_t attributes = 0; // Spell attributes (field 4)
uint32_t castTimeMs = 0; // Cast time in ms (0 = instant)
uint32_t manaCost = 0; // Mana cost
uint32_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy
uint32_t rangeIndex = 0; // Range index from SpellRange.dbc
bool isPassive() const { return (attributes & 0x40) != 0; }
};
@ -55,19 +60,21 @@ private:
// Icon data (loaded from SpellIcon.dbc)
bool iconDbLoaded = false;
std::unordered_map<uint32_t, std::string> spellIconPaths; // SpellIconID -> path
std::unordered_map<uint32_t, VkDescriptorSet> spellIconCache; // SpellIconID -> GL texture
std::unordered_map<uint32_t, VkDescriptorSet> spellIconCache; // SpellIconID -> texture
// Skill line data (loaded from SkillLine.dbc + SkillLineAbility.dbc)
bool skillLineDbLoaded = false;
std::unordered_map<uint32_t, std::string> skillLineNames; // skillLineID -> name
std::unordered_map<uint32_t, uint32_t> skillLineCategories; // skillLineID -> categoryID
std::unordered_map<uint32_t, uint32_t> spellToSkillLine; // spellID -> skillLineID
std::unordered_map<uint32_t, std::string> skillLineNames;
std::unordered_map<uint32_t, uint32_t> skillLineCategories;
std::unordered_map<uint32_t, uint32_t> spellToSkillLine;
// Categorized spell tabs (rebuilt when spell list changes)
// ordered map so tabs appear in consistent order
// Categorized spell tabs
std::vector<SpellTabInfo> spellTabs;
size_t lastKnownSpellCount = 0;
// Search filter
char searchFilter_[128] = "";
// Drag-and-drop from spellbook to action bar
bool draggingSpell_ = false;
uint32_t dragSpellId_ = 0;
@ -79,6 +86,9 @@ private:
void categorizeSpells(const std::unordered_set<uint32_t>& knownSpells);
VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager);
const SpellInfo* getSpellInfo(uint32_t spellId) const;
// Tooltip rendering helper
void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler);
};
} // namespace ui

View file

@ -20,8 +20,11 @@ public:
private:
void renderTalentTrees(game::GameHandler& gameHandler);
void renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId);
void renderTalent(game::GameHandler& gameHandler, const game::GameHandler::TalentEntry& talent);
void renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId,
const std::string& bgFile);
void renderTalent(game::GameHandler& gameHandler,
const game::GameHandler::TalentEntry& talent,
uint32_t pointsInTree);
void loadSpellDBC(pipeline::AssetManager* assetManager);
void loadSpellIconDBC(pipeline::AssetManager* assetManager);
@ -33,10 +36,11 @@ private:
// DBC caches
bool spellDbcLoaded = false;
bool iconDbcLoaded = false;
std::unordered_map<uint32_t, uint32_t> spellIconIds; // spellId -> iconId
std::unordered_map<uint32_t, uint32_t> spellIconIds; // spellId -> iconId
std::unordered_map<uint32_t, std::string> spellIconPaths; // iconId -> path
std::unordered_map<uint32_t, VkDescriptorSet> spellIconCache; // iconId -> texture
std::unordered_map<uint32_t, std::string> spellTooltips; // spellId -> description
std::unordered_map<uint32_t, std::string> spellTooltips; // spellId -> description
std::unordered_map<uint32_t, VkDescriptorSet> bgTextureCache_; // tabId -> bg texture
};
} // namespace ui

View file

@ -9,9 +9,29 @@
#include "core/logger.hpp"
#include <algorithm>
#include <map>
#include <cctype>
namespace wowee { namespace ui {
// Case-insensitive substring match
static bool containsCI(const std::string& haystack, const char* needle) {
if (!needle || !needle[0]) return true;
size_t needleLen = strlen(needle);
if (needleLen > haystack.size()) return false;
for (size_t i = 0; i <= haystack.size() - needleLen; i++) {
bool match = true;
for (size_t j = 0; j < needleLen; j++) {
if (std::tolower(static_cast<unsigned char>(haystack[i + j])) !=
std::tolower(static_cast<unsigned char>(needle[j]))) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
if (dbcLoadAttempted) return;
dbcLoadAttempted = true;
@ -30,12 +50,11 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
return;
}
// Try expansion-specific layout first, then fall back to WotLK hardcoded indices
// if the DBC is from WotLK MPQs but the active expansion uses different field offsets.
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField,
uint32_t nameField, uint32_t rankField, const char* label) {
uint32_t nameField, uint32_t rankField, uint32_t tooltipField,
const char* label) {
spellData.clear();
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
@ -48,6 +67,7 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
info.iconId = dbc->getUInt32(i, iconField);
info.name = dbc->getString(i, nameField);
info.rank = dbc->getString(i, rankField);
info.description = dbc->getString(i, tooltipField);
if (!info.name.empty()) {
spellData[spellId] = std::move(info);
@ -56,17 +76,17 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
LOG_INFO("Spellbook: Loaded ", spellData.size(), " spells from Spell.dbc (", label, ")");
};
// Try active expansion layout
if (spellL) {
uint32_t tooltipField = 139;
// Try to get Tooltip field from layout, fall back to 139
try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {}
tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"],
(*spellL)["Name"], (*spellL)["Rank"], "expansion layout");
(*spellL)["Name"], (*spellL)["Rank"], tooltipField, "expansion layout");
}
// If layout failed or loaded 0 spells, try WotLK hardcoded indices
// (binary DBC may be from WotLK MPQs regardless of active expansion)
if (spellData.empty() && fieldCount >= 200) {
LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)");
tryLoad(0, 4, 133, 136, 153, "WotLK fallback");
tryLoad(0, 4, 133, 136, 153, 139, "WotLK fallback");
}
dbcLoaded = !spellData.empty();
@ -88,10 +108,7 @@ void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
if (!assetManager || !assetManager->isInitialized()) return;
auto dbc = assetManager->loadDBC("SpellIcon.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Spellbook: Could not load SpellIcon.dbc");
return;
}
if (!dbc || !dbc->isLoaded()) return;
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
@ -101,8 +118,6 @@ void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
spellIconPaths[id] = path;
}
}
LOG_INFO("Spellbook: Loaded ", spellIconPaths.size(), " spell icon paths");
}
void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
@ -111,7 +126,6 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
if (!assetManager || !assetManager->isInitialized()) return;
// Load SkillLine.dbc: field 0 = ID, field 1 = categoryID, field 3 = name_enUS
auto skillLineDbc = assetManager->loadDBC("SkillLine.dbc");
const auto* slL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
if (skillLineDbc && skillLineDbc->isLoaded()) {
@ -124,12 +138,8 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
skillLineCategories[id] = category;
}
}
LOG_INFO("Spellbook: Loaded ", skillLineNames.size(), " skill lines");
} else {
LOG_WARNING("Spellbook: Could not load SkillLine.dbc");
}
// Load SkillLineAbility.dbc: field 0 = ID, field 1 = skillLineID, field 2 = spellID
auto slaDbc = assetManager->loadDBC("SkillLineAbility.dbc");
const auto* slaL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLineAbility") : nullptr;
if (slaDbc && slaDbc->isLoaded()) {
@ -140,17 +150,12 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
spellToSkillLine[spellId] = skillLineId;
}
}
LOG_INFO("Spellbook: Loaded ", spellToSkillLine.size(), " skill line abilities");
} else {
LOG_WARNING("Spellbook: Could not load SkillLineAbility.dbc");
}
}
void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& knownSpells) {
spellTabs.clear();
// Only SkillLine category 7 ("Class") gets its own tab (the 3 specialties).
// Everything else (weapons, professions, racials, general utilities) → General.
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
std::map<uint32_t, std::vector<const SpellInfo*>> specialtySpells;
@ -177,12 +182,10 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& known
auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; };
// Specialty tabs sorted alphabetically by skill line name
std::vector<std::pair<std::string, std::vector<const SpellInfo*>>> named;
for (auto& [skillLineId, spells] : specialtySpells) {
auto nameIt = skillLineNames.find(skillLineId);
std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second
: "Specialty";
std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : "Specialty";
std::sort(spells.begin(), spells.end(), byName);
named.push_back({std::move(tabName), std::move(spells)});
}
@ -193,7 +196,6 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& known
spellTabs.push_back({std::move(name), std::move(spells)});
}
// General tab last
if (!generalSpells.empty()) {
std::sort(generalSpells.begin(), generalSpells.end(), byName);
spellTabs.push_back({"General", std::move(generalSpells)});
@ -244,6 +246,47 @@ const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const {
return (it != spellData.end()) ? &it->second : nullptr;
}
void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(320.0f);
// Spell name in yellow
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", info->name.c_str());
// Rank in gray
if (!info->rank.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "(%s)", info->rank.c_str());
}
// Passive indicator
if (info->isPassive()) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive");
}
// Cooldown if active
float cd = gameHandler.getSpellCooldown(info->spellId);
if (cd > 0.0f) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1fs", cd);
}
// Description
if (!info->description.empty()) {
ImGui::Spacing();
ImGui::TextWrapped("%s", info->description.c_str());
}
// Usage hints
if (!info->isPassive()) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Drag to action bar");
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Double-click to cast");
}
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetManager* assetManager) {
// P key toggle (edge-triggered)
bool wantsTextInput = ImGui::GetIO().WantTextInput;
@ -272,88 +315,156 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float bookW = 360.0f;
float bookH = std::min(520.0f, screenH - 120.0f);
float bookW = 380.0f;
float bookH = std::min(560.0f, screenH - 100.0f);
float bookX = screenW - bookW - 10.0f;
float bookY = 80.0f;
ImGui::SetNextWindowPos(ImVec2(bookX, bookY), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(bookW, bookH), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSizeConstraints(ImVec2(280, 200), ImVec2(screenW, screenH));
ImGui::SetNextWindowSizeConstraints(ImVec2(300, 250), ImVec2(screenW, screenH));
bool windowOpen = open;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8));
if (ImGui::Begin("Spellbook", &windowOpen)) {
// Clamp window position to stay on screen
ImVec2 winPos = ImGui::GetWindowPos();
ImVec2 winSize = ImGui::GetWindowSize();
float clampedX = std::max(0.0f, std::min(winPos.x, screenW - winSize.x));
float clampedY = std::max(0.0f, std::min(winPos.y, screenH - winSize.y));
if (clampedX != winPos.x || clampedY != winPos.y) {
ImGui::SetWindowPos(ImVec2(clampedX, clampedY));
}
// Search bar
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##search", "Search spells...", searchFilter_, sizeof(searchFilter_));
ImGui::Spacing();
// Tab bar
if (ImGui::BeginTabBar("SpellbookTabs")) {
for (size_t tabIdx = 0; tabIdx < spellTabs.size(); tabIdx++) {
const auto& tab = spellTabs[tabIdx];
char tabLabel[64];
snprintf(tabLabel, sizeof(tabLabel), "%s (%zu)",
tab.name.c_str(), tab.spells.size());
// Count visible spells (respecting search filter)
int visibleCount = 0;
for (const SpellInfo* info : tab.spells) {
if (containsCI(info->name, searchFilter_)) visibleCount++;
}
char tabLabel[128];
snprintf(tabLabel, sizeof(tabLabel), "%s (%d)###sbtab%zu",
tab.name.c_str(), visibleCount, tabIdx);
if (ImGui::BeginTabItem(tabLabel)) {
if (tab.spells.empty()) {
ImGui::TextDisabled("No spells in this category.");
if (visibleCount == 0) {
if (searchFilter_[0])
ImGui::TextDisabled("No matching spells.");
else
ImGui::TextDisabled("No spells in this category.");
}
ImGui::BeginChild("SpellList", ImVec2(0, 0), true);
float iconSize = 32.0f;
const float iconSize = 36.0f;
const float rowHeight = iconSize + 4.0f;
for (const SpellInfo* info : tab.spells) {
// Apply search filter
if (!containsCI(info->name, searchFilter_)) continue;
ImGui::PushID(static_cast<int>(info->spellId));
float cd = gameHandler.getSpellCooldown(info->spellId);
bool onCooldown = cd > 0.0f;
bool isPassive = info->isPassive();
bool isDim = isPassive || onCooldown;
VkDescriptorSet iconTex = getSpellIcon(info->iconId, assetManager);
// Selectable consumes clicks properly (prevents window drag)
// Row selectable
ImGui::Selectable("##row", false,
ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, iconSize));
ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight));
bool rowHovered = ImGui::IsItemHovered();
bool rowClicked = ImGui::IsItemClicked(0);
ImVec2 rMin = ImGui::GetItemRectMin();
ImVec2 rMax = ImGui::GetItemRectMax();
auto* dl = ImGui::GetWindowDrawList();
// Draw icon on top of selectable
// Hover highlight
if (rowHovered) {
dl->AddRectFilled(rMin, rMax, IM_COL32(255, 255, 255, 15), 3.0f);
}
// Icon background
ImVec2 iconMin = rMin;
ImVec2 iconMax(rMin.x + iconSize, rMin.y + iconSize);
dl->AddRectFilled(iconMin, iconMax, IM_COL32(25, 25, 35, 200), 3.0f);
// Icon
if (iconTex) {
ImU32 tint = (isPassive || onCooldown) ? IM_COL32(150, 150, 150, 255) : IM_COL32(255, 255, 255, 255);
dl->AddImage((ImTextureID)(uintptr_t)iconTex,
rMin, ImVec2(rMin.x + iconSize, rMin.y + iconSize));
} else {
dl->AddRectFilled(rMin,
ImVec2(rMin.x + iconSize, rMin.y + iconSize),
IM_COL32(60, 60, 80, 255));
ImVec2(iconMin.x + 1, iconMin.y + 1),
ImVec2(iconMax.x - 1, iconMax.y - 1),
ImVec2(0, 0), ImVec2(1, 1), tint);
}
// Draw name and rank text
ImU32 textCol = isDim ? IM_COL32(153, 153, 153, 255)
: ImGui::GetColorU32(ImGuiCol_Text);
ImU32 dimCol = ImGui::GetColorU32(ImGuiCol_TextDisabled);
float textX = rMin.x + iconSize + 4.0f;
dl->AddText(ImVec2(textX, rMin.y), textCol, info->name.c_str());
if (!info->rank.empty()) {
dl->AddText(ImVec2(textX, rMin.y + ImGui::GetTextLineHeight()),
dimCol, info->rank.c_str());
// Icon border
ImU32 borderCol;
if (isPassive) {
borderCol = IM_COL32(180, 180, 50, 200); // Yellow for passive
} else if (onCooldown) {
char cdBuf[32];
snprintf(cdBuf, sizeof(cdBuf), "%.1fs cooldown", cd);
dl->AddText(ImVec2(textX, rMin.y + ImGui::GetTextLineHeight()),
dimCol, cdBuf);
borderCol = IM_COL32(120, 40, 40, 200); // Red for cooldown
} else {
borderCol = IM_COL32(100, 100, 120, 200); // Default border
}
dl->AddRect(iconMin, iconMax, borderCol, 3.0f, 0, 1.5f);
// Cooldown overlay on icon
if (onCooldown) {
// Darkened sweep
dl->AddRectFilled(iconMin, iconMax, IM_COL32(0, 0, 0, 120), 3.0f);
// Cooldown text centered on icon
char cdBuf[16];
snprintf(cdBuf, sizeof(cdBuf), "%.0f", cd);
ImVec2 cdSize = ImGui::CalcTextSize(cdBuf);
ImVec2 cdPos(iconMin.x + (iconSize - cdSize.x) * 0.5f,
iconMin.y + (iconSize - cdSize.y) * 0.5f);
dl->AddText(ImVec2(cdPos.x + 1, cdPos.y + 1), IM_COL32(0, 0, 0, 255), cdBuf);
dl->AddText(cdPos, IM_COL32(255, 80, 80, 255), cdBuf);
}
// Spell name
float textX = rMin.x + iconSize + 8.0f;
float nameY = rMin.y + 2.0f;
ImU32 nameCol;
if (isPassive) {
nameCol = IM_COL32(255, 255, 130, 255); // Yellow-ish for passive
} else if (onCooldown) {
nameCol = IM_COL32(150, 150, 150, 255);
} else {
nameCol = IM_COL32(255, 255, 255, 255);
}
dl->AddText(ImVec2(textX, nameY), nameCol, info->name.c_str());
// Second line: rank or passive/cooldown indicator
float subY = nameY + ImGui::GetTextLineHeight() + 1.0f;
if (!info->rank.empty()) {
dl->AddText(ImVec2(textX, subY),
IM_COL32(150, 150, 150, 255), info->rank.c_str());
}
if (isPassive) {
float afterRank = textX;
if (!info->rank.empty()) {
afterRank += ImGui::CalcTextSize(info->rank.c_str()).x + 8.0f;
}
dl->AddText(ImVec2(afterRank, subY),
IM_COL32(200, 200, 80, 200), "Passive");
} else if (onCooldown) {
float afterRank = textX;
if (!info->rank.empty()) {
afterRank += ImGui::CalcTextSize(info->rank.c_str()).x + 8.0f;
}
char cdText[32];
snprintf(cdText, sizeof(cdText), "%.1fs", cd);
dl->AddText(ImVec2(afterRank, subY),
IM_COL32(255, 100, 100, 200), cdText);
}
// Interaction
if (rowHovered) {
// Start drag on click (not passive)
if (rowClicked && !isPassive) {
@ -362,31 +473,18 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
dragSpellIconTex_ = iconTex;
}
// Double-click to cast
if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) {
draggingSpell_ = false;
dragSpellId_ = 0;
dragSpellIconTex_ = 0;
dragSpellIconTex_ = VK_NULL_HANDLE;
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(info->spellId, target);
}
// Tooltip (only when not dragging)
if (!draggingSpell_) {
ImGui::BeginTooltip();
ImGui::Text("%s", info->name.c_str());
if (!info->rank.empty()) {
ImGui::TextDisabled("%s", info->rank.c_str());
}
ImGui::TextDisabled("Spell ID: %u", info->spellId);
if (isPassive) {
ImGui::TextDisabled("Passive");
} else {
ImGui::TextDisabled("Drag to action bar to assign");
if (!onCooldown) {
ImGui::TextDisabled("Double-click to cast");
}
}
ImGui::EndTooltip();
renderSpellTooltip(info, gameHandler);
}
}
@ -402,6 +500,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
}
}
ImGui::End();
ImGui::PopStyleVar();
if (!windowOpen) {
open = false;
@ -410,7 +509,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
// Render dragged spell icon at cursor
if (draggingSpell_ && dragSpellId_ != 0) {
ImVec2 mousePos = ImGui::GetMousePos();
float dragSize = 32.0f;
float dragSize = 36.0f;
if (dragSpellIconTex_) {
ImGui::GetForegroundDrawList()->AddImage(
(ImTextureID)(uintptr_t)dragSpellIconTex_,
@ -420,14 +519,13 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
ImGui::GetForegroundDrawList()->AddRectFilled(
ImVec2(mousePos.x - dragSize * 0.5f, mousePos.y - dragSize * 0.5f),
ImVec2(mousePos.x + dragSize * 0.5f, mousePos.y + dragSize * 0.5f),
IM_COL32(80, 80, 120, 180));
IM_COL32(80, 80, 120, 180), 3.0f);
}
// Cancel drag on mouse release (action bar consumes it before this if dropped on a slot)
if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
draggingSpell_ = false;
dragSpellId_ = 0;
dragSpellIconTex_ = 0;
dragSpellIconTex_ = VK_NULL_HANDLE;
}
}
}

View file

@ -7,9 +7,20 @@
#include "pipeline/blp_loader.hpp"
#include "pipeline/dbc_layout.hpp"
#include <algorithm>
#include <cmath>
namespace wowee { namespace ui {
// WoW class names indexed by class ID (1-11)
static const char* classNames[] = {
"Unknown", "Warrior", "Paladin", "Hunter", "Rogue", "Priest",
"Death Knight", "Shaman", "Mage", "Warlock", "Unknown", "Druid"
};
static const char* getClassName(uint8_t classId) {
return (classId >= 1 && classId <= 11) ? classNames[classId] : "Unknown";
}
void TalentScreen::render(game::GameHandler& gameHandler) {
// N key toggle (edge-triggered)
bool wantsTextInput = ImGui::GetIO().WantTextInput;
@ -25,19 +36,28 @@ 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 = 600.0f; // Wider for talent grid
float winH = 550.0f;
float winW = 680.0f;
float winH = 600.0f;
float winX = (screenW - winW) * 0.5f;
float winY = (screenH - winH) * 0.5f;
ImGui::SetNextWindowPos(ImVec2(winX, winY), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_FirstUseEver);
// Build title with point distribution
uint8_t playerClass = gameHandler.getPlayerClass();
std::string title = "Talents";
if (playerClass > 0) {
title = std::string(getClassName(playerClass)) + " Talents";
}
bool windowOpen = open;
if (ImGui::Begin("Talents", &windowOpen)) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8));
if (ImGui::Begin(title.c_str(), &windowOpen)) {
renderTalentTrees(gameHandler);
}
ImGui::End();
ImGui::PopStyleVar();
if (!windowOpen) {
open = false;
@ -47,93 +67,95 @@ 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)
// Ensure talent DBCs are loaded once
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))
// Get talent tabs for this class, sorted by orderIndex
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);
// Compute points-per-tree for display
uint32_t treeTotals[3] = {0, 0, 0};
for (size_t ti = 0; ti < classTabs.size() && ti < 3; ti++) {
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == classTabs[ti]->tabId) {
treeTotals[ti] += rank;
}
}
}
// Header: spec switcher + unspent points + point distribution
uint8_t activeSpec = gameHandler.getActiveTalentSpec();
uint8_t unspent = gameHandler.getUnspentTalentPoints();
// Spec buttons
for (uint8_t s = 0; s < 2; s++) {
if (s > 0) ImGui::SameLine();
bool isActive = (s == activeSpec);
if (isActive) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.8f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.6f, 0.9f, 1.0f));
}
char specLabel[32];
snprintf(specLabel, sizeof(specLabel), "Spec %u", s + 1);
if (ImGui::Button(specLabel, ImVec2(70, 0))) {
if (!isActive) gameHandler.switchTalentSpec(s);
}
if (isActive) ImGui::PopStyleColor(2);
}
// Point distribution
ImGui::SameLine(0, 20);
if (classTabs.size() >= 3) {
ImGui::Text("(%u / %u / %u)", treeTotals[0], treeTotals[1], treeTotals[2]);
}
// Unspent points
ImGui::SameLine(0, 20);
if (unspent > 0) {
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "%u point%s available",
unspent, unspent > 1 ? "s" : "");
} else {
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "No points available");
}
ImGui::Separator();
// Render tabs
// Render tabs with point counts in tab labels
if (ImGui::BeginTabBar("TalentTabs")) {
for (const auto* tab : classTabs) {
if (ImGui::BeginTabItem(tab->name.c_str())) {
renderTalentTree(gameHandler, tab->tabId);
for (size_t ti = 0; ti < classTabs.size(); ti++) {
const auto* tab = classTabs[ti];
char tabLabel[128];
uint32_t pts = (ti < 3) ? treeTotals[ti] : 0;
snprintf(tabLabel, sizeof(tabLabel), "%s (%u)###tab%u", tab->name.c_str(), pts, tab->tabId);
if (ImGui::BeginTabItem(tabLabel)) {
renderTalentTree(gameHandler, tab->tabId, tab->backgroundFile);
ImGui::EndTabItem();
}
}
@ -141,7 +163,10 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
}
}
void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId) {
void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId,
const std::string& bgFile) {
auto* assetManager = core::Application::getInstance().getAssetManager();
// Collect all talents for this tab
std::vector<const game::GameHandler::TalentEntry*> talents;
for (const auto& [talentId, talent] : gameHandler.getAllTalents()) {
@ -155,25 +180,132 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
return;
}
// Sort talents by row then column for consistent rendering
std::sort(talents.begin(), talents.end(), [](const auto* a, const auto* b) {
if (a->row != b->row) return a->row < b->row;
return a->column < b->column;
});
// 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);
}
// WoW talent grids are always 4 columns wide
if (maxCol < 3) maxCol = 3;
const float iconSize = 40.0f;
const float spacing = 8.0f;
const float cellSize = iconSize + spacing;
const float gridWidth = (maxCol + 1) * cellSize + spacing;
const float gridHeight = (maxRow + 1) * cellSize + spacing;
// Points in this tree
uint32_t pointsInTree = 0;
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == tabId) {
pointsInTree += rank;
}
}
// Center the grid
float availW = ImGui::GetContentRegionAvail().x;
float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f);
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);
ImVec2 gridOrigin = ImGui::GetCursorScreenPos();
gridOrigin.x += offsetX;
// Draw background texture if available
if (!bgFile.empty() && assetManager) {
VkDescriptorSet bgTex = VK_NULL_HANDLE;
auto bgIt = bgTextureCache_.find(tabId);
if (bgIt != bgTextureCache_.end()) {
bgTex = bgIt->second;
} else {
// Try to load the background texture
std::string bgPath = bgFile;
// Normalize path separators
for (auto& c : bgPath) { if (c == '\\') c = '/'; }
bgPath += ".blp";
auto blpData = assetManager->readFile(bgPath);
if (!blpData.empty()) {
auto image = pipeline::BLPLoader::load(blpData);
if (image.isValid()) {
auto* window = core::Application::getInstance().getWindow();
auto* vkCtx = window ? window->getVkContext() : nullptr;
if (vkCtx) {
bgTex = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
}
}
}
bgTextureCache_[tabId] = bgTex;
}
if (bgTex) {
auto* drawList = ImGui::GetWindowDrawList();
float bgW = gridWidth + spacing * 2;
float bgH = gridHeight + spacing * 2;
drawList->AddImage((ImTextureID)(uintptr_t)bgTex,
ImVec2(gridOrigin.x - spacing, gridOrigin.y - spacing),
ImVec2(gridOrigin.x + bgW - spacing, gridOrigin.y + bgH - spacing),
ImVec2(0, 0), ImVec2(1, 1),
IM_COL32(255, 255, 255, 60)); // Subtle background
}
}
// Build a position lookup for prerequisite arrows
struct TalentPos {
const game::GameHandler::TalentEntry* talent;
ImVec2 center;
};
std::unordered_map<uint32_t, TalentPos> talentPositions;
// First pass: compute positions
for (const auto* talent : talents) {
float x = gridOrigin.x + talent->column * cellSize + spacing;
float y = gridOrigin.y + talent->row * cellSize + spacing;
ImVec2 center(x + iconSize * 0.5f, y + iconSize * 0.5f);
talentPositions[talent->talentId] = {talent, center};
}
// Draw prerequisite arrows
auto* drawList = ImGui::GetWindowDrawList();
for (const auto* talent : talents) {
for (int i = 0; i < 3; ++i) {
if (talent->prereqTalent[i] == 0) continue;
auto fromIt = talentPositions.find(talent->prereqTalent[i]);
auto toIt = talentPositions.find(talent->talentId);
if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue;
uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]);
bool met = prereqRank >= talent->prereqRank[i];
ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150);
ImVec2 from = fromIt->second.center;
ImVec2 to = toIt->second.center;
// Draw line from bottom of prerequisite to top of dependent
ImVec2 lineStart(from.x, from.y + iconSize * 0.5f);
ImVec2 lineEnd(to.x, to.y - iconSize * 0.5f);
drawList->AddLine(lineStart, lineEnd, lineCol, 2.0f);
// Arrow head
float arrowSize = 5.0f;
drawList->AddTriangleFilled(
ImVec2(lineEnd.x, lineEnd.y),
ImVec2(lineEnd.x - arrowSize, lineEnd.y - arrowSize * 1.5f),
ImVec2(lineEnd.x + arrowSize, lineEnd.y - arrowSize * 1.5f),
lineCol);
}
}
// Render talent icons
for (uint8_t row = 0; row <= maxRow; ++row) {
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) {
@ -182,23 +314,31 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
}
}
if (col > 0) ImGui::SameLine();
float x = gridOrigin.x + col * cellSize + spacing;
float y = gridOrigin.y + row * cellSize + spacing;
ImGui::SetCursorScreenPos(ImVec2(x, y));
if (talent) {
renderTalent(gameHandler, *talent);
renderTalent(gameHandler, *talent, pointsInTree);
} else {
// Empty slot
ImGui::InvisibleButton(("empty_" + std::to_string(row) + "_" + std::to_string(col)).c_str(),
ImVec2(iconSize, iconSize));
// Empty cell — invisible placeholder
ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(),
ImVec2(iconSize, iconSize));
}
}
}
// Reserve space for the full grid so scrolling works
ImGui::SetCursorScreenPos(ImVec2(gridOrigin.x, gridOrigin.y + gridHeight));
ImGui::Dummy(ImVec2(gridWidth, 0));
ImGui::EndChild();
}
void TalentScreen::renderTalent(game::GameHandler& gameHandler,
const game::GameHandler::TalentEntry& talent) {
const game::GameHandler::TalentEntry& talent,
uint32_t pointsInTree) {
auto* assetManager = core::Application::getInstance().getAssetManager();
uint8_t currentRank = gameHandler.getTalentRank(talent.talentId);
@ -220,38 +360,35 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
}
}
// Check tier requirement (need 5 points in previous tier)
// Check tier requirement (need row*5 points in tree)
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
// Determine visual state
enum TalentState { MAXED, PARTIAL, AVAILABLE, LOCKED };
TalentState state;
if (currentRank >= talent.maxRank) {
state = MAXED;
} else if (currentRank > 0) {
state = PARTIAL;
} else if (canLearn && prereqsMet) {
state = AVAILABLE;
} else {
state = LOCKED;
}
// Colors per state
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
switch (state) {
case MAXED: borderColor = ImVec4(0.2f, 0.9f, 0.2f, 1.0f); tint = ImVec4(1,1,1,1); break;
case PARTIAL: borderColor = ImVec4(0.2f, 0.8f, 0.2f, 1.0f); tint = ImVec4(1,1,1,1); break;
case AVAILABLE:borderColor = ImVec4(1.0f, 1.0f, 1.0f, 0.8f); tint = ImVec4(1,1,1,1); break;
case LOCKED: borderColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); tint = ImVec4(0.4f,0.4f,0.4f,1); break;
}
const float iconSize = 40.0f;
@ -267,60 +404,76 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
}
}
// Use InvisibleButton for click handling
bool clicked = ImGui::InvisibleButton("##talent", ImVec2(iconSize, iconSize));
// Click target
bool clicked = ImGui::InvisibleButton("##t", ImVec2(iconSize, iconSize));
bool hovered = ImGui::IsItemHovered();
// Draw icon and border
ImVec2 pMin = ImGui::GetItemRectMin();
ImVec2 pMax = ImGui::GetItemRectMax();
auto* drawList = ImGui::GetWindowDrawList();
auto* dl = ImGui::GetWindowDrawList();
// Background fill
ImU32 bgCol;
if (state == LOCKED) {
bgCol = IM_COL32(20, 20, 25, 200);
} else {
bgCol = IM_COL32(30, 30, 40, 200);
}
dl->AddRectFilled(pMin, pMax, bgCol, 3.0f);
// Icon
if (iconTex) {
ImU32 tintCol = IM_COL32(
static_cast<int>(tint.x * 255), static_cast<int>(tint.y * 255),
static_cast<int>(tint.z * 255), static_cast<int>(tint.w * 255));
dl->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);
}
// 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);
float borderThick = hovered ? 2.5f : 1.5f;
ImU32 borderCol = IM_COL32(
static_cast<int>(borderColor.x * 255), static_cast<int>(borderColor.y * 255),
static_cast<int>(borderColor.z * 255), static_cast<int>(borderColor.w * 255));
dl->AddRect(pMin, pMax, borderCol, 3.0f, 0, borderThick);
// 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);
// Hover glow
if (hovered && state != LOCKED) {
dl->AddRect(ImVec2(pMin.x - 1, pMin.y - 1), ImVec2(pMax.x + 1, pMax.y + 1),
IM_COL32(255, 255, 255, 60), 3.0f, 0, 1.0f);
}
// Rank indicator overlay
if (talent.maxRank > 1) {
ImVec2 pMax = ImGui::GetItemRectMax();
auto* drawList = ImGui::GetWindowDrawList();
// Display rank: if learned, show (rank+1) since ranks are 0-indexed
const auto& learned = gameHandler.getLearnedTalents();
uint8_t displayRank = (learned.find(talent.talentId) != learned.end()) ? currentRank + 1 : 0;
// Rank counter (bottom-right corner)
{
char rankText[16];
snprintf(rankText, sizeof(rankText), "%u/%u", displayRank, talent.maxRank);
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);
ImVec2 textPos(pMax.x - textSize.x - 2, pMax.y - textSize.y - 1);
// Shadow
drawList->AddText(ImVec2(textPos.x + 1, textPos.y + 1), IM_COL32(0, 0, 0, 255), rankText);
// Text
ImU32 rankCol = displayRank == talent.maxRank ? IM_COL32(0, 255, 0, 255) :
displayRank > 0 ? IM_COL32(255, 255, 0, 255) :
IM_COL32(255, 255, 255, 255);
drawList->AddText(textPos, rankCol, rankText);
// Background pill for readability
dl->AddRectFilled(ImVec2(textPos.x - 2, textPos.y - 1),
ImVec2(pMax.x, pMax.y),
IM_COL32(0, 0, 0, 180), 2.0f);
// Text shadow
dl->AddText(ImVec2(textPos.x + 1, textPos.y + 1), IM_COL32(0, 0, 0, 255), rankText);
// Rank text color
ImU32 rankCol;
switch (state) {
case MAXED: rankCol = IM_COL32(80, 255, 80, 255); break;
case PARTIAL: rankCol = IM_COL32(80, 255, 80, 255); break;
default: rankCol = IM_COL32(200, 200, 200, 255); break;
}
dl->AddText(textPos, rankCol, rankText);
}
// Enhanced tooltip
// Tooltip
if (hovered) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(320.0f);
// Spell name
const std::string& spellName = gameHandler.getSpellName(spellId);
@ -330,60 +483,55 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
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);
// Rank display
ImVec4 rankColor;
switch (state) {
case MAXED: rankColor = ImVec4(0.3f, 0.9f, 0.3f, 1); break;
case PARTIAL: rankColor = ImVec4(0.3f, 0.9f, 0.3f, 1); break;
default: rankColor = ImVec4(0.7f, 0.7f, 0.7f, 1); break;
}
ImGui::TextColored(rankColor, "Rank %u/%u", currentRank, talent.maxRank);
// Current rank description
if (currentRank > 0 && talent.rankSpells[currentRank - 1] != 0) {
if (currentRank > 0 && currentRank <= 5 && 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::Spacing();
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) {
if (currentRank < talent.maxRank && currentRank < 5 && 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);
if (talent.prereqTalent[i] == 0) continue;
const auto* prereq = gameHandler.getTalentEntry(talent.prereqTalent[i]);
if (!prereq || prereq->rankSpells[0] == 0) continue;
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());
}
}
uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
bool met = prereqCurrentRank >= talent.prereqRank[i];
ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1);
const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]);
ImGui::Spacing();
ImGui::TextColored(pColor, "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();
@ -397,38 +545,22 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
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::PopTextWrapPos();
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) {
// Rank is 0-indexed: first point = rank 0, second = rank 1, etc.
// Check if talent is already learned
const auto& learned = gameHandler.getLearnedTalents();
uint8_t desiredRank;
if (learned.find(talent.talentId) == learned.end()) {
// Not learned yet, learn first rank (0)
desiredRank = 0;
} else {
// Already learned, upgrade to next rank
desiredRank = currentRank + 1;
}
LOG_INFO("Sending CMSG_LEARN_TALENT for talent ", talent.talentId, " rank ", static_cast<int>(desiredRank), " (0-indexed)");
gameHandler.learnTalent(talent.talentId, desiredRank);
if (clicked && canLearn && prereqsMet) {
const auto& learned = gameHandler.getLearnedTalents();
uint8_t desiredRank;
if (learned.find(talent.talentId) == learned.end()) {
desiredRank = 0; // First rank (0-indexed on wire)
} else {
if (!canLearn) LOG_WARNING("Cannot learn: canLearn=false");
if (!prereqsMet) LOG_WARNING("Cannot learn: prereqsMet=false");
desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn
}
gameHandler.learnTalent(talent.talentId, desiredRank);
}
ImGui::PopID();
@ -441,12 +573,8 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
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;
}
if (!dbc || !dbc->isLoaded()) return;
// WoW 3.3.5a Spell.dbc fields: 0=SpellID, 133=SpellIconID, 136=SpellName_enUS, 139=Tooltip_enUS
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
@ -461,8 +589,6 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
spellTooltips[spellId] = tooltip;
}
}
LOG_INFO("Talent screen: Loaded ", spellIconIds.size(), " spell icons from Spell.dbc");
}
void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
@ -472,10 +598,7 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
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;
}
if (!dbc || !dbc->isLoaded()) return;
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
@ -485,8 +608,6 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
spellIconPaths[id] = path;
}
}
LOG_INFO("Talent screen: Loaded ", spellIconPaths.size(), " spell icon paths from SpellIcon.dbc");
}
VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) {