#include "ui/spellbook_screen.hpp" #include "core/input.hpp" #include "core/application.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "pipeline/blp_loader.hpp" #include "pipeline/dbc_layout.hpp" #include "core/logger.hpp" #include #include namespace wowee { namespace ui { void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { if (dbcLoadAttempted) return; dbcLoadAttempted = true; if (!assetManager || !assetManager->isInitialized()) return; auto dbc = assetManager->loadDBC("Spell.dbc"); if (!dbc || !dbc->isLoaded()) { LOG_WARNING("Spellbook: Could not load Spell.dbc"); return; } uint32_t fieldCount = dbc->getFieldCount(); if (fieldCount < 154) { LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, expected 234+"); 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) { spellData.clear(); uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t spellId = dbc->getUInt32(i, idField); if (spellId == 0) continue; SpellInfo info; info.spellId = spellId; info.attributes = dbc->getUInt32(i, attrField); info.iconId = dbc->getUInt32(i, iconField); info.name = dbc->getString(i, nameField); info.rank = dbc->getString(i, rankField); if (!info.name.empty()) { spellData[spellId] = std::move(info); } } LOG_INFO("Spellbook: Loaded ", spellData.size(), " spells from Spell.dbc (", label, ")"); }; // Try active expansion layout if (spellL) { tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], (*spellL)["Name"], (*spellL)["Rank"], "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"); } dbcLoaded = !spellData.empty(); } void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { if (iconDbLoaded) return; iconDbLoaded = true; if (!assetManager || !assetManager->isInitialized()) return; auto dbc = assetManager->loadDBC("SpellIcon.dbc"); if (!dbc || !dbc->isLoaded()) { LOG_WARNING("Spellbook: Could not load SpellIcon.dbc"); return; } const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr; for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { uint32_t id = dbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0); std::string path = dbc->getString(i, iconL ? (*iconL)["Path"] : 1); if (!path.empty() && id > 0) { spellIconPaths[id] = path; } } LOG_INFO("Spellbook: Loaded ", spellIconPaths.size(), " spell icon paths"); } void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) { if (skillLineDbLoaded) return; skillLineDbLoaded = true; 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()) { for (uint32_t i = 0; i < skillLineDbc->getRecordCount(); i++) { uint32_t id = skillLineDbc->getUInt32(i, slL ? (*slL)["ID"] : 0); uint32_t category = skillLineDbc->getUInt32(i, slL ? (*slL)["Category"] : 1); std::string name = skillLineDbc->getString(i, slL ? (*slL)["Name"] : 3); if (id > 0 && !name.empty()) { skillLineNames[id] = name; 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()) { for (uint32_t i = 0; i < slaDbc->getRecordCount(); i++) { uint32_t skillLineId = slaDbc->getUInt32(i, slaL ? (*slaL)["SkillLineID"] : 1); uint32_t spellId = slaDbc->getUInt32(i, slaL ? (*slaL)["SpellID"] : 2); if (spellId > 0 && skillLineId > 0) { 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& 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> specialtySpells; std::vector generalSpells; for (uint32_t spellId : knownSpells) { auto it = spellData.find(spellId); if (it == spellData.end()) continue; const SpellInfo* info = &it->second; auto slIt = spellToSkillLine.find(spellId); if (slIt != spellToSkillLine.end()) { uint32_t skillLineId = slIt->second; auto catIt = skillLineCategories.find(skillLineId); if (catIt != skillLineCategories.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { specialtySpells[skillLineId].push_back(info); continue; } } generalSpells.push_back(info); } auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; }; // Specialty tabs sorted alphabetically by skill line name std::vector>> named; for (auto& [skillLineId, spells] : specialtySpells) { auto nameIt = skillLineNames.find(skillLineId); 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)}); } std::sort(named.begin(), named.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); for (auto& [name, spells] : named) { 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)}); } lastKnownSpellCount = knownSpells.size(); } GLuint SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { if (iconId == 0 || !assetManager) return 0; auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { spellIconCache[iconId] = 0; return 0; } std::string iconPath = pit->second + ".blp"; auto blpData = assetManager->readFile(iconPath); if (blpData.empty()) { spellIconCache[iconId] = 0; return 0; } auto image = pipeline::BLPLoader::load(blpData); if (!image.isValid()) { spellIconCache[iconId] = 0; return 0; } 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); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glBindTexture(GL_TEXTURE_2D, 0); spellIconCache[iconId] = texId; return texId; } const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const { auto it = spellData.find(spellId); return (it != spellData.end()) ? &it->second : nullptr; } void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetManager* assetManager) { // P key toggle (edge-triggered) bool wantsTextInput = ImGui::GetIO().WantTextInput; bool pDown = !wantsTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_P); if (pDown && !pKeyWasDown) { open = !open; } pKeyWasDown = pDown; if (!open) return; // Lazy-load DBC data on first open if (!dbcLoadAttempted) { loadSpellDBC(assetManager); loadSpellIconDBC(assetManager); loadSkillLineDBCs(assetManager); } // Rebuild categories if spell list changed const auto& spells = gameHandler.getKnownSpells(); if (spells.size() != lastKnownSpellCount) { categorizeSpells(spells); } auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; float bookW = 360.0f; float bookH = std::min(520.0f, screenH - 120.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)); bool windowOpen = open; 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)); } // 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()); if (ImGui::BeginTabItem(tabLabel)) { if (tab.spells.empty()) { ImGui::TextDisabled("No spells in this category."); } ImGui::BeginChild("SpellList", ImVec2(0, 0), true); float iconSize = 32.0f; for (const SpellInfo* info : tab.spells) { ImGui::PushID(static_cast(info->spellId)); float cd = gameHandler.getSpellCooldown(info->spellId); bool onCooldown = cd > 0.0f; bool isPassive = info->isPassive(); bool isDim = isPassive || onCooldown; GLuint iconTex = getSpellIcon(info->iconId, assetManager); // Selectable consumes clicks properly (prevents window drag) ImGui::Selectable("##row", false, ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, iconSize)); bool rowHovered = ImGui::IsItemHovered(); bool rowClicked = ImGui::IsItemClicked(0); ImVec2 rMin = ImGui::GetItemRectMin(); auto* dl = ImGui::GetWindowDrawList(); // Draw icon on top of selectable if (iconTex) { 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)); } // 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()); } else if (onCooldown) { char cdBuf[32]; snprintf(cdBuf, sizeof(cdBuf), "%.1fs cooldown", cd); dl->AddText(ImVec2(textX, rMin.y + ImGui::GetTextLineHeight()), dimCol, cdBuf); } if (rowHovered) { // Start drag on click (not passive) if (rowClicked && !isPassive) { draggingSpell_ = true; dragSpellId_ = info->spellId; dragSpellIconTex_ = iconTex; } if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = 0; 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(); } } ImGui::PopID(); } ImGui::EndChild(); ImGui::EndTabItem(); } } ImGui::EndTabBar(); } } ImGui::End(); if (!windowOpen) { open = false; } // Render dragged spell icon at cursor if (draggingSpell_ && dragSpellId_ != 0) { ImVec2 mousePos = ImGui::GetMousePos(); float dragSize = 32.0f; if (dragSpellIconTex_) { ImGui::GetForegroundDrawList()->AddImage( (ImTextureID)(uintptr_t)dragSpellIconTex_, ImVec2(mousePos.x - dragSize * 0.5f, mousePos.y - dragSize * 0.5f), ImVec2(mousePos.x + dragSize * 0.5f, mousePos.y + dragSize * 0.5f)); } else { 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)); } // 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; } } } }} // namespace wowee::ui