#include "ui/spellbook_screen.hpp" #include "ui/keybinding_manager.hpp" #include "core/input.hpp" #include "core/application.hpp" #include "rendering/vk_context.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 #include 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(haystack[i + j])) != std::tolower(static_cast(needle[j]))) { match = false; break; } } if (match) return true; } return false; } 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(); // Classic 1.12 Spell.dbc has 148 fields (Tooltip at index 147), TBC has ~220+ (SchoolMask at 215), WotLK has 234. // Require at least 148 fields so all expansions can load spell names/icons via the DBC layout. if (fieldCount < 148) { LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, too few to load"); return; } const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; // Load SpellCastTimes.dbc: field 0=ID, field 1=Base(ms), field 2=PerLevel, field 3=Minimum std::unordered_map castTimeMap; // index → base ms auto castTimeDbc = assetManager->loadDBC("SpellCastTimes.dbc"); if (castTimeDbc && castTimeDbc->isLoaded()) { for (uint32_t i = 0; i < castTimeDbc->getRecordCount(); ++i) { uint32_t id = castTimeDbc->getUInt32(i, 0); int32_t base = static_cast(castTimeDbc->getUInt32(i, 1)); if (id > 0 && base > 0) castTimeMap[id] = static_cast(base); } } // Load SpellRange.dbc. Field layout differs by expansion: // Classic 1.12: 0=ID, 1=MinRange, 2=MaxRange, 3=Flags, 4+=strings // TBC / WotLK: 0=ID, 1=MinRangeFriendly, 2=MinRangeHostile, // 3=MaxRangeFriendly, 4=MaxRangeHostile, 5=Flags, 6+=strings // The correct field is declared in each expansion's dbc_layouts.json. uint32_t spellRangeMaxField = 4; // WotLK / TBC default: MaxRangeHostile const auto* spellRangeL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") : nullptr; if (spellRangeL) { try { spellRangeMaxField = (*spellRangeL)["MaxRange"]; } catch (...) {} } std::unordered_map rangeMap; // index → max yards auto rangeDbc = assetManager->loadDBC("SpellRange.dbc"); if (rangeDbc && rangeDbc->isLoaded()) { uint32_t rangeFieldCount = rangeDbc->getFieldCount(); if (rangeFieldCount > spellRangeMaxField) { for (uint32_t i = 0; i < rangeDbc->getRecordCount(); ++i) { uint32_t id = rangeDbc->getUInt32(i, 0); float maxRange = rangeDbc->getFloat(i, spellRangeMaxField); if (id > 0 && maxRange > 0.0f) rangeMap[id] = maxRange; } } } // schoolField / isSchoolEnum are declared before the lambda so the WotLK fallback path // can override them before the second tryLoad call. uint32_t schoolField_ = UINT32_MAX; bool isSchoolEnum_ = false; auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField, uint32_t nameField, uint32_t rankField, uint32_t tooltipField, uint32_t powerTypeField, uint32_t manaCostField, uint32_t castTimeIndexField, uint32_t rangeIndexField, const char* label) { spellData.clear(); uint32_t count = dbc->getRecordCount(); const uint32_t fc = dbc->getFieldCount(); 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); if (rankField < fc) info.rank = dbc->getString(i, rankField); if (tooltipField < fc) info.description = dbc->getString(i, tooltipField); // Optional fields: only read if field index is valid for this DBC version if (powerTypeField < fc) info.powerType = dbc->getUInt32(i, powerTypeField); if (manaCostField < fc) info.manaCost = dbc->getUInt32(i, manaCostField); if (castTimeIndexField < fc) { uint32_t ctIdx = dbc->getUInt32(i, castTimeIndexField); if (ctIdx > 0) { auto ctIt = castTimeMap.find(ctIdx); if (ctIt != castTimeMap.end()) info.castTimeMs = ctIt->second; } } if (rangeIndexField < fc) { uint32_t rangeIdx = dbc->getUInt32(i, rangeIndexField); if (rangeIdx > 0) { auto rangeIt = rangeMap.find(rangeIdx); if (rangeIt != rangeMap.end()) info.rangeIndex = static_cast(rangeIt->second); } } if (schoolField_ < fc) { uint32_t raw = dbc->getUInt32(i, schoolField_); // Classic/Turtle use a 0-6 school enum; TBC/WotLK use a bitmask. // enum→mask: schoolEnum N maps to bit (1u << N), e.g. 0→1 (physical), 4→16 (frost). info.schoolMask = isSchoolEnum_ ? (raw <= 6 ? (1u << raw) : 0u) : raw; } if (!info.name.empty()) { spellData[spellId] = std::move(info); } } LOG_INFO("Spellbook: Loaded ", spellData.size(), " spells from Spell.dbc (", label, ")"); }; if (spellL) { // Default to UINT32_MAX for optional fields; tryLoad will skip them if >= fieldCount. // Avoids reading wrong data from expansion DBCs that lack these fields (e.g. Classic/TBC). uint32_t tooltipField = UINT32_MAX; uint32_t powerTypeField = UINT32_MAX; uint32_t manaCostField = UINT32_MAX; uint32_t castTimeIdxField = UINT32_MAX; uint32_t rangeIdxField = UINT32_MAX; try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} try { powerTypeField = (*spellL)["PowerType"]; } catch (...) {} try { manaCostField = (*spellL)["ManaCost"]; } catch (...) {} try { castTimeIdxField = (*spellL)["CastingTimeIndex"]; } catch (...) {} try { rangeIdxField = (*spellL)["RangeIndex"]; } catch (...) {} // Try SchoolMask (TBC/WotLK bitmask) then SchoolEnum (Classic/Turtle 0-6 value) schoolField_ = UINT32_MAX; isSchoolEnum_ = false; try { schoolField_ = (*spellL)["SchoolMask"]; } catch (...) {} if (schoolField_ == UINT32_MAX) { try { schoolField_ = (*spellL)["SchoolEnum"]; isSchoolEnum_ = true; } catch (...) {} } tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], (*spellL)["Name"], (*spellL)["Rank"], tooltipField, powerTypeField, manaCostField, castTimeIdxField, rangeIdxField, "expansion layout"); } if (spellData.empty() && fieldCount >= 200) { LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)"); // WotLK Spell.dbc field indices (verified against 3.3.5a schema); SchoolMask at field 225 schoolField_ = 225; isSchoolEnum_ = false; tryLoad(0, 4, 133, 136, 153, 139, 14, 39, 47, 49, "WotLK fallback"); } dbcLoaded = !spellData.empty(); } bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler, pipeline::AssetManager* assetManager) { if (!dbcLoadAttempted) loadSpellDBC(assetManager); const SpellInfo* info = getSpellInfo(spellId); if (!info) return false; renderSpellTooltip(info, gameHandler, /*showUsageHints=*/false); return true; } std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager) { if (!dbcLoadAttempted) { loadSpellDBC(assetManager); } auto it = spellData.find(spellId); if (it != spellData.end()) return it->second.name; return {}; } uint32_t SpellbookScreen::getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager) { if (!dbcLoadAttempted) { loadSpellDBC(assetManager); } auto it = spellData.find(spellId); if (it != spellData.end()) return it->second.rangeIndex; return 0; } 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()) 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; } } } void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) { if (skillLineDbLoaded) return; skillLineDbLoaded = true; if (!assetManager || !assetManager->isInitialized()) return; 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) { if (!name.empty()) { skillLineNames[id] = name; } skillLineCategories[id] = category; } } LOG_INFO("Spellbook: Loaded ", skillLineNames.size(), " skill line names, ", skillLineCategories.size(), " categories from SkillLine.dbc"); } else { LOG_WARNING("Spellbook: Could not load SkillLine.dbc"); } 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.emplace(spellId, skillLineId); } } LOG_INFO("Spellbook: Loaded ", spellToSkillLine.size(), " spell-to-skillline mappings from SkillLineAbility.dbc"); } else { LOG_WARNING("Spellbook: Could not load SkillLineAbility.dbc"); } } void SpellbookScreen::categorizeSpells(const std::unordered_set& knownSpells) { spellTabs.clear(); // SkillLine.dbc category IDs static constexpr uint32_t CAT_CLASS = 7; // Class abilities (spec trees) static constexpr uint32_t CAT_PROFESSION = 11; // Primary professions static constexpr uint32_t CAT_SECONDARY = 9; // Secondary skills (Cooking, First Aid, Fishing, Riding, Companions) // Special skill line IDs that get their own tabs static constexpr uint32_t SKILLLINE_MOUNTS = 777; // Mount summon spells (category 7) static constexpr uint32_t SKILLLINE_RIDING = 762; // Riding skill ranks (category 9) static constexpr uint32_t SKILLLINE_COMPANIONS = 778; // Vanity/companion pets (category 7) // Buckets std::map> specSpells; // class spec trees std::map> profSpells; // professions + secondary std::vector mountSpells; std::vector companionSpells; std::vector generalSpells; for (uint32_t spellId : knownSpells) { auto it = spellData.find(spellId); if (it == spellData.end()) continue; const SpellInfo* info = &it->second; // Check all skill lines this spell belongs to, prefer class (cat 7) > profession > secondary > special auto range = spellToSkillLine.equal_range(spellId); bool categorized = false; uint32_t bestSkillLine = 0; int bestPriority = -1; // 4=class, 3=profession, 2=secondary, 1=mount/companion for (auto slIt = range.first; slIt != range.second; ++slIt) { uint32_t skillLineId = slIt->second; if (skillLineId == SKILLLINE_MOUNTS || skillLineId == SKILLLINE_RIDING) { if (bestPriority < 1) { bestPriority = 1; bestSkillLine = SKILLLINE_MOUNTS; } continue; } if (skillLineId == SKILLLINE_COMPANIONS) { if (bestPriority < 1) { bestPriority = 1; bestSkillLine = skillLineId; } continue; } auto catIt = skillLineCategories.find(skillLineId); if (catIt != skillLineCategories.end()) { uint32_t cat = catIt->second; if (cat == CAT_CLASS && bestPriority < 4) { bestPriority = 4; bestSkillLine = skillLineId; } else if (cat == CAT_PROFESSION && bestPriority < 3) { bestPriority = 3; bestSkillLine = skillLineId; } else if (cat == CAT_SECONDARY && bestPriority < 2) { bestPriority = 2; bestSkillLine = skillLineId; } } } if (bestSkillLine > 0) { if (bestSkillLine == SKILLLINE_MOUNTS) { mountSpells.push_back(info); categorized = true; } else if (bestSkillLine == SKILLLINE_COMPANIONS) { companionSpells.push_back(info); categorized = true; } else { auto catIt = skillLineCategories.find(bestSkillLine); if (catIt != skillLineCategories.end()) { uint32_t cat = catIt->second; if (cat == CAT_CLASS) { specSpells[bestSkillLine].push_back(info); categorized = true; } else if (cat == CAT_PROFESSION || cat == CAT_SECONDARY) { profSpells[bestSkillLine].push_back(info); categorized = true; } } } } if (!categorized) { generalSpells.push_back(info); } } LOG_INFO("Spellbook categorize: ", specSpells.size(), " spec groups, ", generalSpells.size(), " general, ", profSpells.size(), " prof groups, ", mountSpells.size(), " mounts, ", companionSpells.size(), " companions"); for (const auto& [slId, spells] : specSpells) { auto nameIt = skillLineNames.find(slId); LOG_INFO(" Spec tab: skillLine=", slId, " name='", (nameIt != skillLineNames.end() ? nameIt->second : "?"), "' spells=", spells.size()); } auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; }; // Helper: add sorted skill-line-grouped tabs auto addGroupedTabs = [&](std::map>& groups, const char* fallbackName) { std::vector>> named; for (auto& [skillLineId, spells] : groups) { auto nameIt = skillLineNames.find(skillLineId); std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : fallbackName; 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)}); } }; // 1. Class spec tabs addGroupedTabs(specSpells, "Spec"); // 2. General tab if (!generalSpells.empty()) { std::sort(generalSpells.begin(), generalSpells.end(), byName); spellTabs.push_back({"General", std::move(generalSpells)}); } // 3. Professions tabs addGroupedTabs(profSpells, "Profession"); // 4. Mounts tab if (!mountSpells.empty()) { std::sort(mountSpells.begin(), mountSpells.end(), byName); spellTabs.push_back({"Mounts", std::move(mountSpells)}); } // 5. Companions tab if (!companionSpells.empty()) { std::sort(companionSpells.begin(), companionSpells.end(), byName); spellTabs.push_back({"Companions", std::move(companionSpells)}); } lastKnownSpellCount = knownSpells.size(); categorizedWithSkillLines = !spellToSkillLine.empty(); } VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { if (iconId == 0 || !assetManager) return VK_NULL_HANDLE; auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; // Rate-limit GPU uploads to avoid a multi-frame stall when switching tabs. // Icons not loaded this frame will be retried next frame (progressive load). static int loadsThisFrame = 0; static int lastImGuiFrame = -1; int curFrame = ImGui::GetFrameCount(); if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; } if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { spellIconCache[iconId] = VK_NULL_HANDLE; return VK_NULL_HANDLE; } std::string iconPath = pit->second + ".blp"; auto blpData = assetManager->readFile(iconPath); if (blpData.empty()) { spellIconCache[iconId] = VK_NULL_HANDLE; return VK_NULL_HANDLE; } auto image = pipeline::BLPLoader::load(blpData); if (!image.isValid()) { spellIconCache[iconId] = VK_NULL_HANDLE; return VK_NULL_HANDLE; } auto* window = core::Application::getInstance().getWindow(); auto* vkCtx = window ? window->getVkContext() : nullptr; if (!vkCtx) { spellIconCache[iconId] = VK_NULL_HANDLE; return VK_NULL_HANDLE; } ++loadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); spellIconCache[iconId] = ds; return ds; } const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const { auto it = spellData.find(spellId); return (it != spellData.end()) ? &it->second : nullptr; } void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints) { 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"); } // Spell school — only show for non-physical schools (physical is the default/implicit) if (info->schoolMask != 0 && info->schoolMask != 1 /*physical*/) { struct SchoolEntry { uint32_t mask; const char* name; ImVec4 color; }; static constexpr SchoolEntry kSchools[] = { { 2, "Holy", { 1.0f, 1.0f, 0.6f, 1.0f } }, { 4, "Fire", { 1.0f, 0.5f, 0.1f, 1.0f } }, { 8, "Nature", { 0.4f, 0.9f, 0.3f, 1.0f } }, { 16, "Frost", { 0.5f, 0.8f, 1.0f, 1.0f } }, { 32, "Shadow", { 0.7f, 0.4f, 1.0f, 1.0f } }, { 64, "Arcane", { 0.9f, 0.5f, 1.0f, 1.0f } }, }; bool first = true; for (const auto& s : kSchools) { if (info->schoolMask & s.mask) { if (!first) ImGui::SameLine(0, 0); if (first) { ImGui::TextColored(s.color, "%s", s.name); first = false; } else { ImGui::SameLine(0, 2); ImGui::TextColored(s.color, "/%s", s.name); } } } } // Resource cost + cast time on same row (WoW style) if (!info->isPassive()) { // Left: resource cost char costBuf[64] = ""; if (info->manaCost > 0) { const char* powerName = "Mana"; switch (info->powerType) { case 1: powerName = "Rage"; break; case 3: powerName = "Energy"; break; case 4: powerName = "Focus"; break; default: break; } std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName); } // Right: cast time char castBuf[32] = ""; if (info->castTimeMs == 0) { std::snprintf(castBuf, sizeof(castBuf), "Instant cast"); } else { float secs = info->castTimeMs / 1000.0f; std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs); } if (costBuf[0] || castBuf[0]) { float wrapW = 320.0f; if (costBuf[0] && castBuf[0]) { float castW = ImGui::CalcTextSize(castBuf).x; ImGui::Text("%s", costBuf); ImGui::SameLine(wrapW - castW); ImGui::Text("%s", castBuf); } else if (castBuf[0]) { ImGui::Text("%s", castBuf); } else { ImGui::Text("%s", costBuf); } } // Range if (info->rangeIndex > 0) { char rangeBuf[32]; if (info->rangeIndex <= 5) std::snprintf(rangeBuf, sizeof(rangeBuf), "Melee range"); else std::snprintf(rangeBuf, sizeof(rangeBuf), "%u yd range", info->rangeIndex); ImGui::Text("%s", rangeBuf); } } // 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 — only shown when browsing the spellbook, not on action bar hover if (!info->isPassive() && showUsageHints) { 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) { // Spellbook toggle via keybinding (edge-triggered) // Customizable key (default: P) from KeybindingManager bool spellbookDown = KeybindingManager::getInstance().isActionPressed( KeybindingManager::Action::TOGGLE_SPELLBOOK, false); if (spellbookDown && !pKeyWasDown) { open = !open; } pKeyWasDown = spellbookDown; if (!open) return; // Lazy-load DBC data on first open if (!dbcLoadAttempted) { loadSpellDBC(assetManager); } if (!iconDbLoaded) { loadSpellIconDBC(assetManager); } if (!skillLineDbLoaded) { loadSkillLineDBCs(assetManager); } // Rebuild categories if spell list changed or skill line data became available const auto& spells = gameHandler.getKnownSpells(); bool skillLinesNowAvailable = !spellToSkillLine.empty() && !categorizedWithSkillLines; if (spells.size() != lastKnownSpellCount || skillLinesNowAvailable) { 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 = 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(300, 250), ImVec2(screenW, screenH)); bool windowOpen = open; ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8)); if (ImGui::Begin("Spellbook", &windowOpen)) { // 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]; // 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 (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); 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(info->spellId)); float cd = gameHandler.getSpellCooldown(info->spellId); bool onCooldown = cd > 0.0f; bool isPassive = info->isPassive(); VkDescriptorSet iconTex = getSpellIcon(info->iconId, assetManager); // Row selectable ImGui::Selectable("##row", false, ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_DontClosePopups, ImVec2(0, rowHeight)); bool rowHovered = ImGui::IsItemHovered(); bool rowClicked = ImGui::IsItemClicked(0); // Right-click context menu if (ImGui::BeginPopupContextItem("##SpellCtx")) { ImGui::TextDisabled("%s", info->name.c_str()); if (!info->rank.empty()) { ImGui::SameLine(); ImGui::TextDisabled("(%s)", info->rank.c_str()); } ImGui::Separator(); if (!isPassive) { if (onCooldown) ImGui::BeginDisabled(); if (ImGui::MenuItem("Cast")) { uint64_t tgt = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; gameHandler.castSpell(info->spellId, tgt); } if (onCooldown) ImGui::EndDisabled(); } if (!isPassive) { if (ImGui::MenuItem("Add to Action Bar")) { const auto& bar = gameHandler.getActionBar(); int firstEmpty = -1; for (int si = 0; si < game::GameHandler::SLOTS_PER_BAR; ++si) { if (bar[si].isEmpty()) { firstEmpty = si; break; } } if (firstEmpty >= 0) { gameHandler.setActionBarSlot(firstEmpty, game::ActionBarSlot::SPELL, info->spellId); } } } if (ImGui::MenuItem("Copy Spell Link")) { char linkBuf[256]; snprintf(linkBuf, sizeof(linkBuf), "|cffffd000|Hspell:%u|h[%s]|h|r", info->spellId, info->name.c_str()); pendingChatSpellLink_ = linkBuf; } ImGui::EndPopup(); } ImVec2 rMin = ImGui::GetItemRectMin(); ImVec2 rMax = ImGui::GetItemRectMax(); auto* dl = ImGui::GetWindowDrawList(); // 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, ImVec2(iconMin.x + 1, iconMin.y + 1), ImVec2(iconMax.x - 1, iconMax.y - 1), ImVec2(0, 0), ImVec2(1, 1), tint); } // Icon border ImU32 borderCol; if (isPassive) { borderCol = IM_COL32(180, 180, 50, 200); // Yellow for passive } else if (onCooldown) { 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) { // Shift-click to insert spell link into chat if (rowClicked && ImGui::GetIO().KeyShift && !info->name.empty()) { // WoW spell link format: |cffffd000|Hspell:|h[Name]|h|r char linkBuf[256]; snprintf(linkBuf, sizeof(linkBuf), "|cffffd000|Hspell:%u|h[%s]|h|r", info->spellId, info->name.c_str()); pendingChatSpellLink_ = linkBuf; } // Start drag on click (not passive, not shift-click) else if (rowClicked && !isPassive && !ImGui::GetIO().KeyShift) { draggingSpell_ = true; dragSpellId_ = info->spellId; dragSpellIconTex_ = iconTex; } // Double-click to cast if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown && !ImGui::GetIO().KeyShift) { draggingSpell_ = false; dragSpellId_ = 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_) { renderSpellTooltip(info, gameHandler); } } ImGui::PopID(); } ImGui::EndChild(); ImGui::EndTabItem(); } } ImGui::EndTabBar(); } } ImGui::End(); ImGui::PopStyleVar(); if (!windowOpen) { open = false; } // Render dragged spell icon at cursor if (draggingSpell_ && dragSpellId_ != 0) { ImVec2 mousePos = ImGui::GetMousePos(); float dragSize = 36.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), 3.0f); } if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; } } } }} // namespace wowee::ui