From 6e18269bfdfe4c2a37ea9397726c884cf6550034 Mon Sep 17 00:00:00 2001 From: rucky Date: Tue, 31 Mar 2026 18:03:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=84=A6=E7=82=B9=E7=AD=89?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ActionBars.lua | 126 ++- Bags/Bank.lua | 5 +- Bags/Container.lua | 12 +- Bags/Sort.lua | 118 ++- BeastTrainingUI.lua | 121 ++- CharacterPanel.lua | 598 +++++++++++- Chat.lua | 48 +- ConfigUI.lua | 1891 ++++++++++++++++++++++++++++++++++++-- Core.lua | 183 +++- Focus.lua | 1631 ++++++++++++++++++++++++++++++-- LootDisplay.lua | 35 +- Mail.lua | 235 ++++- MapIcons.lua | 1 - Media.lua | 39 + MinimapBuffs.lua | 3 +- Nanami-UI.toc | 1 - QuestUI.lua | 30 + SellPriceDB.lua | 2 +- SpellBookUI.lua | 13 + StatSummary.lua | 2 +- Tooltip.lua | 118 ++- TrainerUI.lua | 428 ++++++--- Tweaks.lua | 250 ++++- Units/Party.lua | 103 ++- Units/Pet.lua | 144 +++ Units/Player.lua | 366 ++++++-- Units/Raid.lua | 175 +++- Units/Target.lua | 332 ++++++- WorldMap.lua | 2 - docs/插件功能概览.md | 333 +++++++ img/bar/bar_elvui.tga | Bin 0 -> 32812 bytes img/bar/bar_gradient.tga | Bin 0 -> 24202 bytes img/bar/bar_tukui.tga | Bin 0 -> 14690 bytes img/progress.tga | Bin 0 -> 1048594 bytes 34 files changed, 6803 insertions(+), 542 deletions(-) create mode 100644 docs/插件功能概览.md create mode 100644 img/bar/bar_elvui.tga create mode 100644 img/bar/bar_gradient.tga create mode 100644 img/bar/bar_tukui.tga create mode 100644 img/progress.tga diff --git a/ActionBars.lua b/ActionBars.lua index a5eb4d5..a3f2976 100644 --- a/ActionBars.lua +++ b/ActionBars.lua @@ -26,6 +26,7 @@ local DEFAULTS = { showHotkey = true, showMacroName = false, rangeColoring = true, + behindGlow = true, showPetBar = true, showStanceBar = true, showRightBars = true, @@ -1149,6 +1150,91 @@ local function GetOrCreateRangeOverlay(b) return ov end +-------------------------------------------------------------------------------- +-- Behind-skill glow: highlight skills that require being behind the target +-------------------------------------------------------------------------------- +-- Icon texture substrings (lowercase) that identify "must be behind" skills. +-- Matching by texture is the most reliable method in Vanilla (no GetActionInfo). +local BEHIND_SKILL_ICONS = { + -- Rogue + "ability_backstab", -- 背刺 (Backstab) + "ability_rogue_ambush", -- 伏击 (Ambush) + "ability_ambush", -- 伏击 (Ambush, alternate icon) + -- Druid (cat form) + "spell_shadow_vampiricaura",-- 撕碎 (Shred) + "ability_shred", -- 撕碎 (Shred, alternate) + "ability_druid_ravage", -- 突袭 (Ravage) +} + +-- Build a fast lookup set (lowercased) +local behindIconSet = {} +for _, v in ipairs(BEHIND_SKILL_ICONS) do + behindIconSet[string.lower(v)] = true +end + +-- Check if an action slot's icon matches a behind-only skill +local function IsBehindSkillAction(action) + if not action or not HasAction(action) then return false end + local tex = GetActionTexture(action) + if not tex then return false end + tex = string.lower(tex) + for pattern, _ in pairs(behindIconSet) do + if string.find(tex, pattern) then return true end + end + return false +end + +-- Create / retrieve the green glow overlay for behind-skill highlighting +local function GetOrCreateBehindGlow(b) + if b.sfBehindGlow then return b.sfBehindGlow end + local inset = b.sfIconInset or 2 + + -- Outer glow frame (slightly larger than button) + local glow = CreateFrame("Frame", nil, b) + glow:SetFrameLevel(b:GetFrameLevel() + 3) + glow:SetPoint("TOPLEFT", b, "TOPLEFT", -2, 2) + glow:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", 2, -2) + + -- Green border textures (4 edges) + local thickness = 2 + local r, g, a = 0.2, 1.0, 0.7 + + local top = glow:CreateTexture(nil, "OVERLAY") + top:SetTexture("Interface\\Buttons\\WHITE8X8") + top:SetPoint("TOPLEFT", glow, "TOPLEFT", 0, 0) + top:SetPoint("TOPRIGHT", glow, "TOPRIGHT", 0, 0) + top:SetHeight(thickness) + top:SetVertexColor(r, g, 0.3, a) + + local bot = glow:CreateTexture(nil, "OVERLAY") + bot:SetTexture("Interface\\Buttons\\WHITE8X8") + bot:SetPoint("BOTTOMLEFT", glow, "BOTTOMLEFT", 0, 0) + bot:SetPoint("BOTTOMRIGHT", glow, "BOTTOMRIGHT", 0, 0) + bot:SetHeight(thickness) + bot:SetVertexColor(r, g, 0.3, a) + + local left = glow:CreateTexture(nil, "OVERLAY") + left:SetTexture("Interface\\Buttons\\WHITE8X8") + left:SetPoint("TOPLEFT", glow, "TOPLEFT", 0, 0) + left:SetPoint("BOTTOMLEFT", glow, "BOTTOMLEFT", 0, 0) + left:SetWidth(thickness) + left:SetVertexColor(r, g, 0.3, a) + + local right = glow:CreateTexture(nil, "OVERLAY") + right:SetTexture("Interface\\Buttons\\WHITE8X8") + right:SetPoint("TOPRIGHT", glow, "TOPRIGHT", 0, 0) + right:SetPoint("BOTTOMRIGHT", glow, "BOTTOMRIGHT", 0, 0) + right:SetWidth(thickness) + right:SetVertexColor(r, g, 0.3, a) + + glow:Hide() + b.sfBehindGlow = glow + return glow +end + +-- Cached behind state, updated by the range-check timer +local isBehindTarget = false + function AB:SetupRangeCheck() local rangeFrame = CreateFrame("Frame", "SFramesActionBarRangeCheck", UIParent) rangeFrame.timer = 0 @@ -1160,7 +1246,18 @@ function AB:SetupRangeCheck() this.timer = 0 local db = AB:GetDB() - if not db.rangeColoring then return end + + -- Update behind state (shared with behind-glow logic) + local behindGlowEnabled = db.behindGlow ~= false + local hasEnemy = UnitExists("target") and UnitCanAttack("player", "target") + and not UnitIsDead("target") + if behindGlowEnabled and hasEnemy + and type(UnitXP) == "function" then + local ok, val = pcall(UnitXP, "behind", "player", "target") + isBehindTarget = ok and val and true or false + else + isBehindTarget = false + end local function CheckRange(buttons, idFunc) local getID = idFunc or ActionButton_GetPagedID @@ -1168,15 +1265,30 @@ function AB:SetupRangeCheck() for _, b in ipairs(buttons) do local ok, action = pcall(getID, b) if ok and action and HasAction(action) then - local inRange = IsActionInRange(action) - local ov = GetOrCreateRangeOverlay(b) - if inRange == 0 then - ov:Show() - else - ov:Hide() + -- Range coloring + if db.rangeColoring then + local inRange = IsActionInRange(action) + local ov = GetOrCreateRangeOverlay(b) + if inRange == 0 then + ov:Show() + else + ov:Hide() + end + end + -- Behind-skill glow + if behindGlowEnabled and IsBehindSkillAction(action) then + local glow = GetOrCreateBehindGlow(b) + if isBehindTarget then + glow:Show() + else + glow:Hide() + end + elseif b.sfBehindGlow then + b.sfBehindGlow:Hide() end else if b.sfRangeOverlay then b.sfRangeOverlay:Hide() end + if b.sfBehindGlow then b.sfBehindGlow:Hide() end end end end diff --git a/Bags/Bank.lua b/Bags/Bank.lua index 0ac687a..1947232 100644 --- a/Bags/Bank.lua +++ b/Bags/Bank.lua @@ -1700,17 +1700,20 @@ function SFrames.Bags.Bank:Initialize() local sortBtn = CreateHeaderIconButton("SFramesBankSortBtn", SFBankFrame, "Interface\\Icons\\INV_Misc_Note_05") sortBtn:SetPoint("LEFT", searchEB, "RIGHT", 6, 0) SFrames:SetIcon(sortBtn.icon, "gold") + sortBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") sortBtn:SetScript("OnEnter", function() GameTooltip:SetOwner(this, "ANCHOR_RIGHT") GameTooltip:SetText(TEXT_SORT, 1, 1, 1) + GameTooltip:AddLine("\229\183\166\233\148\174\230\149\180\231\144\134 | \229\143\179\233\148\174\229\143\141\229\186\143\230\149\180\231\144\134", 0.7, 0.7, 0.7) GameTooltip:Show() end) sortBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) sortBtn:SetScript("OnClick", function() + local reverse = (arg1 == "RightButton") if SFrames.Bags.Sort and SFrames.Bags.Sort.StartBank then - SFrames.Bags.Sort:StartBank() + SFrames.Bags.Sort:StartBank(reverse) return end diff --git a/Bags/Container.lua b/Bags/Container.lua index e7ac283..a7128b0 100644 --- a/Bags/Container.lua +++ b/Bags/Container.lua @@ -920,11 +920,12 @@ function SFrames.Bags.Container:Initialize() insets = { left = 3, right = 3, top = 3, bottom = 3 }, }) local _A = SFrames.ActiveTheme + local _bagBgA = (SFramesDB and SFramesDB.Bags and type(SFramesDB.Bags.bgAlpha) == "number") and SFramesDB.Bags.bgAlpha or 0.95 if _A and _A.panelBg then - BagFrame:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], _A.panelBg[4] or 0.95) + BagFrame:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], _bagBgA) BagFrame:SetBackdropBorderColor(_A.panelBorder[1], _A.panelBorder[2], _A.panelBorder[3], _A.panelBorder[4] or 0.9) else - BagFrame:SetBackdropColor(0.12, 0.06, 0.10, 0.95) + BagFrame:SetBackdropColor(0.12, 0.06, 0.10, _bagBgA) BagFrame:SetBackdropBorderColor(0.55, 0.30, 0.42, 0.9) end local bagShadow = CreateFrame("Frame", nil, BagFrame) @@ -1418,16 +1419,21 @@ function SFrames.Bags.Container:Initialize() local sortBtn = CreateHeaderIconButton("SFramesBagSortBtn", BagFrame, "Interface\\Icons\\INV_Misc_Note_05") sortBtn:SetPoint("LEFT", eb, "RIGHT", 6, 0) SFrames:SetIcon(sortBtn.icon, "backpack") + sortBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") sortBtn:SetScript("OnEnter", function() GameTooltip:SetOwner(this, "ANCHOR_RIGHT") GameTooltip:SetText(TEXT_SORT, 1, 1, 1) + GameTooltip:AddLine("\229\183\166\233\148\174\230\149\180\231\144\134 | \229\143\179\233\148\174\229\143\141\229\186\143\230\149\180\231\144\134", 0.7, 0.7, 0.7) GameTooltip:Show() end) sortBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) sortBtn:SetScript("OnClick", function() - if SFrames.Bags.Sort then SFrames.Bags.Sort:Start() end + if SFrames.Bags.Sort then + local reverse = (arg1 == "RightButton") + SFrames.Bags.Sort:Start(reverse) + end end) local hsBtn = CreateHeaderIconButton("SFramesBagHSBtn", BagFrame, "Interface\\Icons\\INV_Misc_Rune_01") diff --git a/Bags/Sort.lua b/Bags/Sort.lua index aba630a..d25f81e 100644 --- a/Bags/Sort.lua +++ b/Bags/Sort.lua @@ -108,16 +108,30 @@ local function GetSpecialType(link, isBag) return "Normal" end +local activeReverse = false + local function SortItemsByRule(items) - table.sort(items, function(a, b) - if a.score ~= b.score then - return a.score > b.score - elseif a.name ~= b.name then - return a.name < b.name - else - return a.count > b.count - end - end) + if activeReverse then + table.sort(items, function(a, b) + if a.score ~= b.score then + return a.score < b.score + elseif a.name ~= b.name then + return a.name > b.name + else + return a.count < b.count + end + end) + else + table.sort(items, function(a, b) + if a.score ~= b.score then + return a.score > b.score + elseif a.name ~= b.name then + return a.name < b.name + else + return a.count > b.count + end + end) + end end local function ResetSortState() @@ -127,6 +141,7 @@ local function ResetSortState() activeCompleteUpdate = nil activeBagOrder = nil activePhase = nil + activeReverse = false timeSinceLast = 0 sortTimer:Hide() end @@ -178,13 +193,22 @@ function SFrames.Bags.Sort:ExecuteSimpleSort(items, bagOrder) end -- Now assign target slot to each item + -- When reverse sorting, fill from the last slot (bottom-right) instead of first for _, item in ipairs(items) do local targetSlot = nil if item.specialType ~= "Normal" and slotPool[item.specialType] and table.getn(slotPool[item.specialType]) > 0 then - targetSlot = table.remove(slotPool[item.specialType], 1) + if activeReverse then + targetSlot = table.remove(slotPool[item.specialType]) + else + targetSlot = table.remove(slotPool[item.specialType], 1) + end elseif table.getn(slotPool.Normal) > 0 then - targetSlot = table.remove(slotPool.Normal, 1) + if activeReverse then + targetSlot = table.remove(slotPool.Normal) + else + targetSlot = table.remove(slotPool.Normal, 1) + end end if targetSlot then @@ -356,9 +380,73 @@ function SFrames.Bags.Sort:StartPlacementPhase(ignoreLocked) end function SFrames.Bags.Sort:StartForBags(bagOrder, completeMessage, completeUpdate) - if isSorting then return end + -- If a previous sort is still running, force-cancel it first + if isSorting then + -- Drop cursor item if held + if CursorHasItem() then + for _, bag in ipairs(activeBagOrder or bagOrder) do + local bagSlots = GetContainerNumSlots(bag) or 0 + for slot = 1, bagSlots do + local _, _, locked = GetContainerItemInfo(bag, slot) + if locked then + PickupContainerItem(bag, slot) + break + end + end + if not CursorHasItem() then break end + end + end + ResetSortState() + end + + -- Save reverse flag before any potential defer (ResetSortState clears it) + local savedReverse = activeReverse + + -- Wait for any lingering locked items before starting + local hasLocked = false + for _, bag in ipairs(bagOrder) do + local bagSlots = GetContainerNumSlots(bag) or 0 + for slot = 1, bagSlots do + local _, _, locked = GetContainerItemInfo(bag, slot) + if locked then + hasLocked = true + break + end + end + if hasLocked then break end + end + + if hasLocked then + -- Defer start until locks clear + local waitTimer = CreateFrame("Frame") + local waitElapsed = 0 + local selfRef = self + waitTimer:SetScript("OnUpdate", function() + waitElapsed = waitElapsed + arg1 + if waitElapsed < 0.05 then return end + waitElapsed = 0 + + local still = false + for _, bag in ipairs(bagOrder) do + local bagSlots = GetContainerNumSlots(bag) or 0 + for slot = 1, bagSlots do + local _, _, locked = GetContainerItemInfo(bag, slot) + if locked then still = true; break end + end + if still then break end + end + + if not still then + this:SetScript("OnUpdate", nil) + activeReverse = savedReverse + selfRef:StartForBags(bagOrder, completeMessage, completeUpdate) + end + end) + return + end isSorting = true + activeReverse = savedReverse sortQueue = {} timeSinceLast = 0 activeCompleteMessage = completeMessage @@ -384,12 +472,13 @@ function SFrames.Bags.Sort:StartForBags(bagOrder, completeMessage, completeUpdat self:StartPlacementPhase(false) end -function SFrames.Bags.Sort:Start() +function SFrames.Bags.Sort:Start(reverse) if SFrames.Bags.Container and SFrames.Bags.Container.isOffline then SFrames:Print(TEXT_OFFLINE_BAGS) return end + activeReverse = reverse or false self:StartForBags( {0, 1, 2, 3, 4}, TEXT_BAG_DONE, @@ -401,12 +490,13 @@ function SFrames.Bags.Sort:Start() ) end -function SFrames.Bags.Sort:StartBank() +function SFrames.Bags.Sort:StartBank(reverse) if SFrames.Bags.Bank and SFrames.Bags.Bank.isOffline then SFrames:Print(TEXT_OFFLINE_BANK) return end + activeReverse = reverse or false self:StartForBags( {-1, 5, 6, 7, 8, 9, 10, 11}, TEXT_BANK_DONE, diff --git a/BeastTrainingUI.lua b/BeastTrainingUI.lua index 355e873..2600e56 100644 --- a/BeastTrainingUI.lua +++ b/BeastTrainingUI.lua @@ -54,7 +54,7 @@ L.LIST_ROW_W = L.LEFT_W - L.SIDE_PAD * 2 - L.SCROLLBAR_W - 4 local S = { MainFrame = nil, selectedIndex = nil, - currentFilter = "all", + currentFilter = "available", displayList = {}, rowButtons = {}, collapsedCats = {}, @@ -67,9 +67,17 @@ local scanTip = nil function BTUI.GetCraftExtendedInfo(index) local name, rank, skillType, v4, _, tpCost, reqLevel = GetCraftInfo(index) - local canLearn = (tonumber(reqLevel) or 0) > 0 tpCost = tonumber(tpCost) or 0 - return name, rank, skillType, canLearn, tpCost + local canLearn = false + local isLearned = false + if skillType ~= "header" then + if skillType == "used" then + isLearned = true + else + canLearn = (tonumber(reqLevel) or 0) > 0 + end + end + return name, rank, skillType, canLearn, tpCost, isLearned end function BTUI.GetSkillTooltipLines(index) @@ -356,7 +364,11 @@ function BTUI.CreateListRow(parent, idx) self.rankFS:Hide() end - if skill.canLearn then + if skill.isLearned then + self.nameFS:SetTextColor(T.learned[1], T.learned[2], T.learned[3]) + self.icon:SetVertexColor(0.5, 0.5, 0.5) + self.tpFS:Hide() + elseif skill.canLearn then self.nameFS:SetTextColor(T.available[1], T.available[2], T.available[3]) self.icon:SetVertexColor(1, 1, 1) local tp = skill.tpCost or 0 @@ -373,8 +385,8 @@ function BTUI.CreateListRow(parent, idx) self.tpFS:Hide() end else - self.nameFS:SetTextColor(T.learned[1], T.learned[2], T.learned[3]) - self.icon:SetVertexColor(0.5, 0.5, 0.5) + self.nameFS:SetTextColor(T.unavailable[1], T.unavailable[2], T.unavailable[3]) + self.icon:SetVertexColor(0.6, 0.3, 0.3) self.tpFS:Hide() end end @@ -392,17 +404,47 @@ end -------------------------------------------------------------------------------- -- Logic -------------------------------------------------------------------------------- + +-- Strip rank suffix to get base skill name (e.g. "自然抗性 2" -> "自然抗性") +function BTUI.GetBaseSkillName(name) + if not name then return "" end + -- Remove trailing " Rank X" / " X" patterns (both EN and CN) + local base = string.gsub(name, "%s+%d+$", "") + base = string.gsub(base, "%s+[Rr]ank%s+%d+$", "") + return base +end + +-- Build a lookup: baseName -> highest tpCost among learned ranks +function BTUI.BuildLearnedCostMap() + local map = {} -- baseName -> highest learned tpCost + local numCrafts = GetNumCrafts and GetNumCrafts() or 0 + for i = 1, numCrafts do + local name, rank, skillType, canLearn, tpCost, isLearned = BTUI.GetCraftExtendedInfo(i) + if name and skillType ~= "header" and isLearned then + local base = BTUI.GetBaseSkillName(name) + local cost = tpCost or 0 + if not map[base] or cost > map[base] then + map[base] = cost + end + end + end + return map +end + function BTUI.BuildDisplayList() S.displayList = {} local numCrafts = GetNumCrafts and GetNumCrafts() or 0 if numCrafts == 0 then return end + -- Pre-compute learned cost for each base skill name + local learnedCostMap = BTUI.BuildLearnedCostMap() + local currentCat = nil local catSkills = {} local catOrder = {} for i = 1, numCrafts do - local name, rank, skillType, canLearn, tpCost = BTUI.GetCraftExtendedInfo(i) + local name, rank, skillType, canLearn, tpCost, isLearned = BTUI.GetCraftExtendedInfo(i) if name then if skillType == "header" then currentCat = name @@ -418,11 +460,18 @@ function BTUI.BuildDisplayList() table.insert(catOrder, currentCat) end end + -- Calculate effective (incremental) TP cost + local effectiveCost = tpCost or 0 + if not isLearned and effectiveCost > 0 then + local base = BTUI.GetBaseSkillName(name) + local prevCost = learnedCostMap[base] or 0 + effectiveCost = math.max(0, effectiveCost - prevCost) + end local show = true if S.currentFilter == "available" then show = canLearn elseif S.currentFilter == "used" then - show = (not canLearn) + show = isLearned end if show then table.insert(catSkills[currentCat], { @@ -430,7 +479,8 @@ function BTUI.BuildDisplayList() name = name, rank = rank or "", canLearn = canLearn, - tpCost = tpCost, + tpCost = effectiveCost, + isLearned = isLearned, }) end end @@ -524,16 +574,18 @@ function BTUI.UpdateDetail() return end - local name, rank, skillType, canLearn, tpCost = BTUI.GetCraftExtendedInfo(S.selectedIndex) + local name, rank, skillType, canLearn, tpCost, isLearned = BTUI.GetCraftExtendedInfo(S.selectedIndex) local iconTex = GetCraftIcon and GetCraftIcon(S.selectedIndex) detail.icon:SetTexture(iconTex); detail.iconFrame:Show() detail.nameFS:SetText(name or "") - if canLearn then + if isLearned then + detail.nameFS:SetTextColor(T.learned[1], T.learned[2], T.learned[3]) + elseif canLearn then detail.nameFS:SetTextColor(T.available[1], T.available[2], T.available[3]) else - detail.nameFS:SetTextColor(T.learned[1], T.learned[2], T.learned[3]) + detail.nameFS:SetTextColor(T.unavailable[1], T.unavailable[2], T.unavailable[3]) end if rank and rank ~= "" then @@ -554,10 +606,23 @@ function BTUI.UpdateDetail() detail.descScroll:GetScrollChild():SetHeight(math.max(1, descH)) detail.descScroll:SetVerticalScroll(0) - if tpCost > 0 then + if isLearned then + detail.costFS:SetText("|cff808080已学会|r") + elseif tpCost > 0 then + -- Calculate effective (incremental) cost + local effectiveCost = tpCost + local base = BTUI.GetBaseSkillName(name) + local learnedMap = BTUI.BuildLearnedCostMap() + local prevCost = learnedMap[base] or 0 + effectiveCost = math.max(0, tpCost - prevCost) + local remaining = BTUI.GetRemainingTP() - local costColor = remaining >= tpCost and "|cff40ff40" or "|cffff4040" - detail.costFS:SetText("训练点数: " .. costColor .. tpCost .. "|r (剩余: " .. remaining .. ")") + local costColor = remaining >= effectiveCost and "|cff40ff40" or "|cffff4040" + if prevCost > 0 then + detail.costFS:SetText("训练点数: " .. costColor .. effectiveCost .. "|r (总" .. tpCost .. " - 已学" .. prevCost .. ") 剩余: " .. remaining) + else + detail.costFS:SetText("训练点数: " .. costColor .. effectiveCost .. "|r (剩余: " .. remaining .. ")") + end elseif canLearn then detail.costFS:SetText("训练点数: |cff40ff40免费|r") else @@ -706,13 +771,8 @@ function BTUI:Initialize() fb:SetPoint("TOPLEFT", MF, "TOPLEFT", L.SIDE_PAD, -(L.HEADER_H + 4)) fb:SetWidth(L.LEFT_W - L.SIDE_PAD * 2); fb:SetHeight(22) - local fAll = BTUI.CreateFilterBtn(fb, "全部", 52) - fAll:SetPoint("LEFT", fb, "LEFT", 0, 0) - fAll:SetScript("OnClick", function() S.currentFilter = "all"; BTUI.FullUpdate() end) - MF.filterAll = fAll - local fAvail = BTUI.CreateFilterBtn(fb, "可学", 52) - fAvail:SetPoint("LEFT", fAll, "RIGHT", 3, 0) + fAvail:SetPoint("LEFT", fb, "LEFT", 0, 0) fAvail:SetScript("OnClick", function() S.currentFilter = "available"; BTUI.FullUpdate() end) MF.filterAvail = fAvail @@ -721,11 +781,16 @@ function BTUI:Initialize() fUsed:SetScript("OnClick", function() S.currentFilter = "used"; BTUI.FullUpdate() end) MF.filterUsed = fUsed + local fAll = BTUI.CreateFilterBtn(fb, "全部", 52) + fAll:SetPoint("LEFT", fUsed, "RIGHT", 3, 0) + fAll:SetScript("OnClick", function() S.currentFilter = "all"; BTUI.FullUpdate() end) + MF.filterAll = fAll + -- List scroll area local listTop = L.HEADER_H + L.FILTER_H + 8 local ls = CreateFrame("ScrollFrame", "SFramesBTListScroll", MF) ls:SetPoint("TOPLEFT", MF, "TOPLEFT", L.SIDE_PAD, -listTop) - ls:SetPoint("BOTTOMRIGHT", MF, "BOTTOMLEFT", L.SIDE_PAD + L.LIST_ROW_W, 6) + ls:SetPoint("BOTTOMRIGHT", MF, "BOTTOMLEFT", L.SIDE_PAD + L.LIST_ROW_W, L.BOTTOM_H + 4) local lc = CreateFrame("Frame", "SFramesBTListContent", ls) lc:SetWidth(L.LIST_ROW_W); lc:SetHeight(1) @@ -807,10 +872,18 @@ function BTUI:Initialize() function BTUI.UpdateScrollbar() if not MF.sbThumb or not MF.sbTrack then return end local scrollMax = BTUI.GetScrollMax() - if scrollMax <= 0 then MF.sbThumb:Hide(); return end + if scrollMax <= 0 then + MF.sbThumb:Hide() + if ls:GetVerticalScroll() > 0 then ls:SetVerticalScroll(0) end + return + end MF.sbThumb:Show() local trackH = MF.sbTrack:GetHeight() local curScroll = ls:GetVerticalScroll() + if curScroll > scrollMax then + curScroll = scrollMax + ls:SetVerticalScroll(scrollMax) + end local ratio = curScroll / scrollMax ratio = math.max(0, math.min(1, ratio)) local thumbH = math.max(20, trackH * (trackH / (trackH + scrollMax))) @@ -1016,7 +1089,7 @@ function BTUI:Initialize() CraftFrame:SetScript("OnHide", function() end) CraftFrame:SetAlpha(0); CraftFrame:EnableMouse(false) end - S.selectedIndex = nil; S.currentFilter = "all"; S.collapsedCats = {} + S.selectedIndex = nil; S.currentFilter = "available"; S.collapsedCats = {} local petName = UnitName("pet") or "" if petName ~= "" then MF.titleFS:SetText("训练野兽 - " .. petName) diff --git a/CharacterPanel.lua b/CharacterPanel.lua index bd6e285..1422773 100644 --- a/CharacterPanel.lua +++ b/CharacterPanel.lua @@ -70,7 +70,7 @@ local EQUIP_SLOTS_RIGHT = { } local EQUIP_SLOTS_BOTTOM = { { id = 16, name = "MainHandSlot" }, { id = 17, name = "SecondaryHandSlot" }, - { id = 18, name = "RangedSlot" }, { id = 0, name = "AmmoSlot" }, + { id = 18, name = "RangedSlot" }, { name = "AmmoSlot" }, } local TAB_NAMES = { "装备", "声望", "技能", "荣誉" } @@ -438,11 +438,15 @@ local TALENT_DB = { meleeCrit = { { names = {"Cruelty", "残忍"}, perRank = 1 }, { names = {"Malice", "恶意"}, perRank = 1 }, - { names = {"Lethal Shots", "致命射击"}, perRank = 1 }, + { names = {"Killer Instinct", "杀戮本能", "致命本能"}, perRank = 1 }, { names = {"Conviction", "信念", "坚定信念"}, perRank = 1 }, { names = {"Sharpened Claws", "利爪强化", "尖锐之爪"}, perRank = 2 }, { names = {"Thundering Strikes", "雷霆一击", "雷霆之击"}, perRank = 1 }, }, + rangedCrit = { + { names = {"Lethal Shots", "夺命射击"}, perRank = 1 }, + { names = {"Killer Instinct", "杀戮本能", "致命本能"}, perRank = 1 }, + }, spellCrit = { { names = {"Arcane Instability", "奥术不稳定"}, perRank = 1 }, { names = {"Critical Mass", "临界质量"}, perRank = 2 }, @@ -458,7 +462,7 @@ local function ScanTalentBonuses() if _talentCache and _talentCacheTime and (now - _talentCacheTime) < 5 then return _talentCache end - local r = { meleeHit = 0, spellHit = 0, meleeCrit = 0, spellCrit = 0, + local r = { meleeHit = 0, spellHit = 0, meleeCrit = 0, rangedCrit = 0, spellCrit = 0, talentDetails = {} } if not GetNumTalentTabs then _talentCache = r; _talentCacheTime = now; return r end @@ -532,7 +536,7 @@ local function FullRangedCrit() local _, agi = UnitStat("player", 2); agiCrit = (agi or 0) / ratio end local gearCrit = GetGearBonus("RANGEDCRIT") + GetGearBonus("CRIT") - local talentCrit = GetTalentBonus("meleeCrit") + local talentCrit = GetTalentBonus("rangedCrit") return baseCrit + agiCrit + gearCrit + talentCrit, baseCrit, agiCrit, gearCrit, talentCrit end @@ -1320,6 +1324,14 @@ function CP:BuildEquipmentPage() if page.built then return end page.built = true + -- Resolve AmmoSlot ID dynamically (may vary by client) + for _, si in ipairs(EQUIP_SLOTS_BOTTOM) do + if si.name == "AmmoSlot" and not si.id then + local slotId = GetInventorySlotInfo("AmmoSlot") + si.id = slotId or 0 + end + end + local contentH = FRAME_H - (HEADER_H + TAB_BAR_H) - INNER_PAD - 4 local scrollArea = CreateScrollFrame(page, CONTENT_W, contentH) scrollArea:SetPoint("TOPLEFT", page, "TOPLEFT", 0, 0) @@ -1547,6 +1559,72 @@ function CP:BuildEquipmentPage() end) page.rotRight = rotRight + -- Gear action toolbar: small icon buttons next to model toolbar + local gearTbW = segW * 3 + 2 + local gearToolbar = CreateFrame("Frame", nil, page) + gearToolbar:SetWidth(gearTbW) + gearToolbar:SetHeight(tbH) + gearToolbar:SetPoint("BOTTOM", modelBg, "BOTTOM", 0, 5 + tbH + 2) + gearToolbar:SetFrameLevel(page:GetFrameLevel() + 6) + SetPixelBackdrop(gearToolbar, { 0.04, 0.04, 0.06, 0.75 }, { 0.22, 0.22, 0.28, 0.5 }) + page.gearToolbar = gearToolbar + + local function MakeGearToolBtn(parent, w, text, ox, tooltip, onClick) + local btn = CreateFrame("Button", nil, parent) + btn:SetWidth(w) + btn:SetHeight(tbH - 2) + btn:SetPoint("LEFT", parent, "LEFT", ox, 0) + btn:SetFrameLevel(parent:GetFrameLevel() + 1) + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 0, + }) + btn:SetBackdropColor(0, 0, 0, 0) + local fs = MakeFS(btn, 8, "CENTER", { 0.5, 0.5, 0.55 }) + fs:SetPoint("CENTER", btn, "CENTER", 0, 0) + fs:SetText(text) + btn.label = fs + btn:SetScript("OnEnter", function() + this:SetBackdropColor(T.slotBg[1], T.slotBg[2], T.slotBg[3], T.slotBg[4] or 0.5) + this.label:SetTextColor(T.nameText[1], T.nameText[2], T.nameText[3]) + if tooltip then + GameTooltip:SetOwner(this, "ANCHOR_BOTTOM") + GameTooltip:AddLine(tooltip, 1, 1, 1) + GameTooltip:Show() + end + end) + btn:SetScript("OnLeave", function() + this:SetBackdropColor(0, 0, 0, 0) + this.label:SetTextColor(T.dimText[1], T.dimText[2], T.dimText[3]) + GameTooltip:Hide() + end) + btn:SetScript("OnClick", onClick) + return btn + end + + local gStripBtn = MakeGearToolBtn(gearToolbar, segW, "脱", 1, "一键脱装", function() CP:StripAllGear() end) + page.gStripBtn = gStripBtn + + local gSep1 = gearToolbar:CreateTexture(nil, "OVERLAY") + gSep1:SetTexture("Interface\\Buttons\\WHITE8X8") + gSep1:SetVertexColor(T.sepColor[1], T.sepColor[2], T.sepColor[3], T.sepColor[4] or 0.4) + gSep1:SetWidth(1) + gSep1:SetHeight(tbH - 4) + gSep1:SetPoint("LEFT", gearToolbar, "LEFT", segW + 1, 0) + + local gEquipBtn = MakeGearToolBtn(gearToolbar, segW, "穿", segW + 1, "一键穿装", function() CP:QuickEquipLastSet() end) + page.gEquipBtn = gEquipBtn + + local gSep2 = gearToolbar:CreateTexture(nil, "OVERLAY") + gSep2:SetTexture("Interface\\Buttons\\WHITE8X8") + gSep2:SetVertexColor(T.sepColor[1], T.sepColor[2], T.sepColor[3], T.sepColor[4] or 0.4) + gSep2:SetWidth(1) + gSep2:SetHeight(tbH - 4) + gSep2:SetPoint("LEFT", gearToolbar, "LEFT", segW * 2 + 1, 0) + + local gSetsBtn = MakeGearToolBtn(gearToolbar, segW, "案", segW * 2 + 1, "装备方案管理", function() CP:ToggleGearSetPopup() end) + page.gSetsBtn = gSetsBtn + -- Equipment slots local slotY = -6 page.equipSlots = {} @@ -2064,7 +2142,7 @@ function CP:BuildEquipmentPage() if gearC > 0 then CS.TipKV(" 装备暴击:", string.format("+%d%%", gearC)) end if talC > 0 then CS.TipKV(" 天赋暴击:", string.format("+%d%%", talC)) - for _, d in ipairs(CS.GetTalentDetailsFor("meleeCrit")) do + for _, d in ipairs(CS.GetTalentDetailsFor("rangedCrit")) do CS.TipLine(string.format(" %s (%d/%d) +%d%%", d.name, d.rank, d.maxRank, d.bonus), 0.55,0.55,0.6) end @@ -2765,6 +2843,13 @@ function CP:CreateEquipSlot(parent, slotID, slotName) ilvlText:SetText("") frame.ilvlText = ilvlText + local durText = frame:CreateFontString(nil, "OVERLAY") + durText:SetFont(GetFont(), 7, "OUTLINE") + durText:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) + durText:SetTextColor(0.4, 1, 0.4) + durText:SetText("") + frame.durText = durText + local glow = frame:CreateTexture(nil, "OVERLAY") glow:SetTexture("Interface\\Buttons\\UI-ActionButton-Border") glow:SetBlendMode("ADD") @@ -2822,7 +2907,7 @@ function CP:CreateEquipSlot(parent, slotID, slotName) CP:ShowSwapPopup(this) else GameTooltip:SetOwner(this, "ANCHOR_RIGHT") - if GetInventoryItemLink("player", this.slotID) then + if GetInventoryItemLink("player", this.slotID) or GetInventoryItemTexture("player", this.slotID) then GameTooltip:SetInventoryItem("player", this.slotID) else local label = SLOT_LABEL[this.slotName] or this.slotName @@ -2926,6 +3011,34 @@ function CP:UpdateEquipment() if slot then local tex = GetInventoryItemTexture("player", si.id) local link = GetInventoryItemLink("player", si.id) + -- Ammo slot special: try tooltip-based detection when texture/link fail + if not tex and si.name == "AmmoSlot" then + if not CP._ammoTip then + CP._ammoTip = CreateFrame("GameTooltip", "SFramesCPAmmoTip", UIParent, "GameTooltipTemplate") + CP._ammoTip:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMRIGHT", -300, -300) + end + local tip = CP._ammoTip + tip:SetOwner(UIParent, "ANCHOR_NONE") + tip:ClearLines() + tip:SetInventoryItem("player", si.id) + local nLines = tip:NumLines() or 0 + if nLines > 0 then + local txtObj = getglobal("SFramesCPAmmoTipTextLeft1") + if txtObj then + local ammoName = txtObj:GetText() + if ammoName and ammoName ~= "" then + -- Ammo is equipped but API can't return texture; use a generic icon + tex = "Interface\\Icons\\INV_Ammo_Arrow_02" + -- Try to get the real icon via GetItemInfo if available + if GetItemInfo then + local _, _, _, _, _, _, _, _, _, itemTex = GetItemInfo(ammoName) + if itemTex then tex = itemTex end + end + end + end + end + tip:Hide() + end if tex then slot.icon:SetTexture(tex) slot.icon:SetVertexColor(1, 1, 1) @@ -2939,6 +3052,54 @@ function CP:UpdateEquipment() slot.ilvlText:SetText("") end end + -- Durability display (tooltip scan for 1.12 clients) + if slot.durText then + local dur, durMax + -- Try direct API first (pcall for safety) + if GetInventoryItemDurability then + local ok, c, m = pcall(GetInventoryItemDurability, si.id) + if ok then dur, durMax = c, m end + end + -- Fallback: scan tooltip + if not dur and link then + if not CP._durTip then + CP._durTip = CreateFrame("GameTooltip", "SFramesCPDurTip", UIParent, "GameTooltipTemplate") + CP._durTip:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMRIGHT", -300, -300) + end + local tip = CP._durTip + tip:SetOwner(UIParent, "ANCHOR_NONE") + tip:ClearLines() + tip:SetInventoryItem("player", si.id) + local nLines = tip:NumLines() or 0 + for li = 1, nLines do + local txtObj = getglobal("SFramesCPDurTipTextLeft" .. li) + if txtObj then + local line = txtObj:GetText() + if line then + local _, _, c, m = string.find(line, "(%d+)%s*/%s*(%d+)") + if c and m then + dur, durMax = tonumber(c), tonumber(m) + break + end + end + end + end + tip:Hide() + end + if dur and durMax and durMax > 0 then + local pct = math.floor(dur / durMax * 100) + slot.durText:SetText(pct .. "%") + if pct <= 25 then + slot.durText:SetTextColor(1, 0.2, 0.2) + elseif pct <= 50 then + slot.durText:SetTextColor(1, 0.8, 0.2) + else + slot.durText:SetTextColor(0.4, 1, 0.4) + end + else + slot.durText:SetText("") + end + end local quality = GetItemQualityFromLink(link) if quality and quality >= 2 and QUALITY_COLORS[quality] then local qc = QUALITY_COLORS[quality] @@ -2960,6 +3121,7 @@ function CP:UpdateEquipment() slot.currentBorderColor = nil slot.qualGlow:Hide() if slot.ilvlText then slot.ilvlText:SetText("") end + if slot.durText then slot.durText:SetText("") end end end end @@ -3848,6 +4010,430 @@ function CP:UpdatePet() end end +-------------------------------------------------------------------------------- +-- Gear Set Management: Strip / Equip / Save / Load / Delete +-------------------------------------------------------------------------------- + +-- All equippable slot IDs (excluding shirt=4, tabard=19, ammo=0) +local GEAR_SLOT_IDS = { 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 } + +-- One-click strip: unequip all gear into bags +function CP:StripAllGear() + -- Save current gear as "_lastStripped" before stripping + self:SaveGearSetInternal("_lastStripped") + local count = 0 + for _, sid in ipairs(GEAR_SLOT_IDS) do + if GetInventoryItemLink("player", sid) then + PickupInventoryItem(sid) + PutItemInBackpack() + count = count + 1 + end + end + if count > 0 then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 已脱下 " .. count .. " 件装备") + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 当前没有可脱下的装备") + end + CP:ScheduleEquipUpdate() +end + +-- Internal: save current gear to DB +function CP:SaveGearSetInternal(name) + if not SFramesDB then SFramesDB = {} end + if not SFramesDB.gearSets then SFramesDB.gearSets = {} end + local set = {} + for _, sid in ipairs(GEAR_SLOT_IDS) do + local link = GetInventoryItemLink("player", sid) + if link then + set[sid] = link + end + end + SFramesDB.gearSets[name] = set +end + +-- Save gear set with user-provided name +function CP:SaveGearSet(name) + if not name or name == "" then return end + self:SaveGearSetInternal(name) + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 装备方案 |cffffd100" .. name .. "|r 已保存") +end + +-- Load gear set by name +function CP:LoadGearSet(name) + if not SFramesDB or not SFramesDB.gearSets or not SFramesDB.gearSets[name] then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 找不到装备方案: " .. (name or "nil")) + return + end + local set = SFramesDB.gearSets[name] + local equipped = 0 + + -- Find item in bags by link matching (item:ID portion) + local function FindInBags(targetLink) + if not targetLink then return nil, nil end + local _, _, targetId = string.find(targetLink, "item:(%d+)") + if not targetId then return nil, nil end + for bag = 0, 4 do + local slots = GetContainerNumSlots(bag) + if slots and slots > 0 then + for slot = 1, slots do + local bagLink = GetContainerItemLink(bag, slot) + if bagLink then + local _, _, bagId = string.find(bagLink, "item:(%d+)") + if bagId == targetId then + return bag, slot + end + end + end + end + end + return nil, nil + end + + for _, sid in ipairs(GEAR_SLOT_IDS) do + local targetLink = set[sid] + if targetLink then + -- Check if already wearing this item + local currentLink = GetInventoryItemLink("player", sid) + if currentLink then + local _, _, curId = string.find(currentLink, "item:(%d+)") + local _, _, tarId = string.find(targetLink, "item:(%d+)") + if curId == tarId then + -- Already wearing, skip + else + local bag, slot = FindInBags(targetLink) + if bag and slot then + PickupContainerItem(bag, slot) + PickupInventoryItem(sid) + equipped = equipped + 1 + end + end + else + local bag, slot = FindInBags(targetLink) + if bag and slot then + PickupContainerItem(bag, slot) + PickupInventoryItem(sid) + equipped = equipped + 1 + end + end + end + end + if equipped > 0 then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 已装备 " .. equipped .. " 件 (方案: |cffffd100" .. name .. "|r)") + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 方案 |cffffd100" .. name .. "|r 中的装备已全部穿戴或不在背包中") + end + CP:ScheduleEquipUpdate() +end + +-- Delete gear set +function CP:DeleteGearSet(name) + if not SFramesDB or not SFramesDB.gearSets then return end + SFramesDB.gearSets[name] = nil + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 装备方案 |cffffd100" .. name .. "|r 已删除") +end + +-- Quick equip: load the "_lastStripped" auto-saved set +function CP:QuickEquipLastSet() + if SFramesDB and SFramesDB.gearSets and SFramesDB.gearSets["_lastStripped"] then + self:LoadGearSet("_lastStripped") + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ccff[Nanami-UI]|r 没有最近脱下的装备记录,请先使用一键脱装或保存方案") + end +end + +-- Get sorted list of user-created gear set names +function CP:GetGearSetNames() + local names = {} + if SFramesDB and SFramesDB.gearSets then + for k, _ in pairs(SFramesDB.gearSets) do + if string.sub(k, 1, 1) ~= "_" then + table.insert(names, k) + end + end + end + table.sort(names) + return names +end + +-------------------------------------------------------------------------------- +-- Gear Set Popup UI +-------------------------------------------------------------------------------- +local gearSetPopup + +function CP:ToggleGearSetPopup() + if gearSetPopup and gearSetPopup:IsShown() then + gearSetPopup:Hide() + return + end + self:ShowGearSetPopup() +end + +function CP:ShowGearSetPopup() + if not panel then return end + + local POPUP_W = 200 + local ROW_H = 20 + local PAD = 6 + + if not gearSetPopup then + gearSetPopup = CreateFrame("Frame", "SFramesCPGearSetPopup", panel) + gearSetPopup:SetWidth(POPUP_W) + gearSetPopup:SetFrameStrata("TOOLTIP") + gearSetPopup:SetFrameLevel(200) + SetRoundBackdrop(gearSetPopup, T.bg, T.border) + gearSetPopup:Hide() + gearSetPopup:EnableMouse(true) + + -- Title + local title = MakeFS(gearSetPopup, 10, "CENTER", T.gold) + title:SetPoint("TOP", gearSetPopup, "TOP", 0, -PAD) + title:SetText("装备方案管理") + gearSetPopup.title = title + + -- Save current button + local saveBtn = CreateFrame("Button", NextName("GSave"), gearSetPopup) + saveBtn:SetWidth(POPUP_W - PAD * 2) + saveBtn:SetHeight(ROW_H) + saveBtn:SetPoint("TOP", gearSetPopup, "TOP", 0, -(PAD + 16)) + SetPixelBackdrop(saveBtn, { 0.15, 0.35, 0.15, 0.8 }, T.tabBorder) + local saveTxt = MakeFS(saveBtn, 9, "CENTER", { 0.6, 1, 0.6 }) + saveTxt:SetPoint("CENTER", saveBtn, "CENTER", 0, 0) + saveTxt:SetText("+ 保存当前装备为新方案") + saveBtn.label = saveTxt + saveBtn:SetScript("OnEnter", function() + this:SetBackdropColor(0.2, 0.45, 0.2, 0.9) + this.label:SetTextColor(0.8, 1, 0.8) + end) + saveBtn:SetScript("OnLeave", function() + this:SetBackdropColor(0.15, 0.35, 0.15, 0.8) + this.label:SetTextColor(0.6, 1, 0.6) + end) + saveBtn:SetScript("OnClick", function() + CP:ShowGearSetNameInput() + end) + gearSetPopup.saveBtn = saveBtn + + gearSetPopup.rows = {} + end + + -- Rebuild set list + local names = self:GetGearSetNames() + local numSets = table.getn(names) + + -- Hide old rows + for _, row in ipairs(gearSetPopup.rows) do + row:Hide() + end + + local contentTop = -(PAD + 16 + ROW_H + 4) + + for i = 1, numSets do + local row = gearSetPopup.rows[i] + if not row then + row = CreateFrame("Frame", nil, gearSetPopup) + row:SetWidth(POPUP_W - PAD * 2) + row:SetHeight(ROW_H) + + local bg = row:CreateTexture(nil, "BACKGROUND") + bg:SetTexture("Interface\\Buttons\\WHITE8X8") + bg:SetVertexColor(0.12, 0.12, 0.16, 0.6) + bg:SetAllPoints(row) + row.bg = bg + + local nameText = MakeFS(row, 9, "LEFT", T.nameText) + nameText:SetPoint("LEFT", row, "LEFT", 6, 0) + nameText:SetWidth(POPUP_W - PAD * 2 - 60) + row.nameText = nameText + + -- Load button + local loadBtn = CreateFrame("Button", nil, row) + loadBtn:SetWidth(24) + loadBtn:SetHeight(ROW_H - 4) + loadBtn:SetPoint("RIGHT", row, "RIGHT", -28, 0) + loadBtn:SetFrameLevel(row:GetFrameLevel() + 1) + SetPixelBackdrop(loadBtn, T.tabBg, T.tabBorder) + local loadTxt = MakeFS(loadBtn, 8, "CENTER", { 0.5, 0.8, 1 }) + loadTxt:SetPoint("CENTER", loadBtn, "CENTER", 0, 0) + loadTxt:SetText("穿") + loadBtn.label = loadTxt + loadBtn:SetScript("OnEnter", function() + this:SetBackdropColor(T.tabActiveBg[1], T.tabActiveBg[2], T.tabActiveBg[3], 0.7) + this.label:SetTextColor(0.7, 0.95, 1) + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine("加载此方案", 1, 1, 1) + GameTooltip:Show() + end) + loadBtn:SetScript("OnLeave", function() + this:SetBackdropColor(T.tabBg[1], T.tabBg[2], T.tabBg[3], T.tabBg[4]) + this.label:SetTextColor(0.5, 0.8, 1) + GameTooltip:Hide() + end) + row.loadBtn = loadBtn + + -- Delete button + local delBtn = CreateFrame("Button", nil, row) + delBtn:SetWidth(24) + delBtn:SetHeight(ROW_H - 4) + delBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0) + delBtn:SetFrameLevel(row:GetFrameLevel() + 1) + SetPixelBackdrop(delBtn, { 0.35, 0.12, 0.12, 0.8 }, T.tabBorder) + local delTxt = MakeFS(delBtn, 8, "CENTER", { 1, 0.5, 0.5 }) + delTxt:SetPoint("CENTER", delBtn, "CENTER", 0, 0) + delTxt:SetText("X") + delBtn.label = delTxt + delBtn:SetScript("OnEnter", function() + this:SetBackdropColor(0.5, 0.15, 0.15, 0.9) + this.label:SetTextColor(1, 0.7, 0.7) + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine("删除此方案", 1, 0.3, 0.3) + GameTooltip:Show() + end) + delBtn:SetScript("OnLeave", function() + this:SetBackdropColor(0.35, 0.12, 0.12, 0.8) + this.label:SetTextColor(1, 0.5, 0.5) + GameTooltip:Hide() + end) + row.delBtn = delBtn + + gearSetPopup.rows[i] = row + end + + row:SetPoint("TOPLEFT", gearSetPopup, "TOPLEFT", PAD, contentTop - (i - 1) * (ROW_H + 2)) + row.nameText:SetText(names[i]) + row.setName = names[i] + + row.loadBtn:SetScript("OnClick", function() + CP:LoadGearSet(this:GetParent().setName) + gearSetPopup:Hide() + end) + row.delBtn:SetScript("OnClick", function() + CP:DeleteGearSet(this:GetParent().setName) + CP:ShowGearSetPopup() + end) + + row:Show() + end + + -- Hide extra rows + for i = numSets + 1, table.getn(gearSetPopup.rows) do + gearSetPopup.rows[i]:Hide() + end + + local totalH = PAD + 16 + ROW_H + 4 + numSets * (ROW_H + 2) + PAD + if totalH < 70 then totalH = 70 end + gearSetPopup:SetHeight(totalH) + + -- Position popup to the right of the panel + gearSetPopup:ClearAllPoints() + gearSetPopup:SetPoint("TOPLEFT", panel, "TOPRIGHT", 4, 0) + gearSetPopup:Show() +end + +-------------------------------------------------------------------------------- +-- Gear Set Name Input Dialog +-------------------------------------------------------------------------------- +local gearSetNameInput + +function CP:ShowGearSetNameInput() + if not panel then return end + + if not gearSetNameInput then + local f = CreateFrame("Frame", "SFramesCPGearSetNameInput", UIParent) + f:SetWidth(240) + f:SetHeight(90) + f:SetPoint("CENTER", UIParent, "CENTER", 0, 100) + f:SetFrameStrata("DIALOG") + f:SetFrameLevel(250) + SetRoundBackdrop(f, T.bg, T.border) + f:EnableMouse(true) + f:SetMovable(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function() this:StartMoving() end) + f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + f:Hide() + + local title = MakeFS(f, 10, "CENTER", T.gold) + title:SetPoint("TOP", f, "TOP", 0, -8) + title:SetText("输入方案名称") + + local editBox = CreateFrame("EditBox", "SFramesCPGearSetEditBox", f) + editBox:SetWidth(200) + editBox:SetHeight(22) + editBox:SetPoint("TOP", f, "TOP", 0, -28) + editBox:SetFontObject(ChatFontNormal) + editBox:SetAutoFocus(true) + editBox:SetMaxLetters(30) + SetPixelBackdrop(editBox, { 0.08, 0.08, 0.1, 0.9 }, T.tabBorder) + editBox:SetTextInsets(6, 6, 2, 2) + f.editBox = editBox + + local okBtn = CreateFrame("Button", nil, f) + okBtn:SetWidth(80) + okBtn:SetHeight(20) + okBtn:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 20, 8) + SetPixelBackdrop(okBtn, { 0.15, 0.35, 0.15, 0.8 }, T.tabBorder) + local okTxt = MakeFS(okBtn, 9, "CENTER", { 0.6, 1, 0.6 }) + okTxt:SetPoint("CENTER", okBtn, "CENTER", 0, 0) + okTxt:SetText("保存") + okBtn:SetScript("OnEnter", function() + this:SetBackdropColor(0.2, 0.45, 0.2, 0.9) + end) + okBtn:SetScript("OnLeave", function() + this:SetBackdropColor(0.15, 0.35, 0.15, 0.8) + end) + okBtn:SetScript("OnClick", function() + local name = f.editBox:GetText() + if name and name ~= "" then + CP:SaveGearSet(name) + f:Hide() + if gearSetPopup and gearSetPopup:IsShown() then + CP:ShowGearSetPopup() + end + end + end) + f.okBtn = okBtn + + local cancelBtn = CreateFrame("Button", nil, f) + cancelBtn:SetWidth(80) + cancelBtn:SetHeight(20) + cancelBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -20, 8) + SetPixelBackdrop(cancelBtn, { 0.35, 0.12, 0.12, 0.8 }, T.tabBorder) + local cancelTxt = MakeFS(cancelBtn, 9, "CENTER", { 1, 0.6, 0.6 }) + cancelTxt:SetPoint("CENTER", cancelBtn, "CENTER", 0, 0) + cancelTxt:SetText("取消") + cancelBtn:SetScript("OnEnter", function() + this:SetBackdropColor(0.45, 0.15, 0.15, 0.9) + end) + cancelBtn:SetScript("OnLeave", function() + this:SetBackdropColor(0.35, 0.12, 0.12, 0.8) + end) + cancelBtn:SetScript("OnClick", function() + f:Hide() + end) + f.cancelBtn = cancelBtn + + editBox:SetScript("OnEnterPressed", function() + local name = this:GetText() + if name and name ~= "" then + CP:SaveGearSet(name) + f:Hide() + if gearSetPopup and gearSetPopup:IsShown() then + CP:ShowGearSetPopup() + end + end + end) + editBox:SetScript("OnEscapePressed", function() + f:Hide() + end) + + gearSetNameInput = f + end + + gearSetNameInput.editBox:SetText("") + gearSetNameInput:Show() + gearSetNameInput.editBox:SetFocus() +end + -------------------------------------------------------------------------------- -- Events -------------------------------------------------------------------------------- diff --git a/Chat.lua b/Chat.lua index 0be350a..20317c2 100644 --- a/Chat.lua +++ b/Chat.lua @@ -1502,6 +1502,16 @@ local function EnsureProtectedTabs(db, maxId) tab.kind = "general" tab.locked = true if Trim(tab.name) == "" then tab.name = GENERAL or "General" end + -- General tab: ensure all channel alias groups default to enabled + -- except lfg/lft group which should stay off by default + if type(tab.channelFilters) ~= "table" then tab.channelFilters = {} end + local LFG_GROUP_CANONICAL = ChannelKey("lfg") + for _, group in ipairs(CHANNEL_ALIAS_GROUPS) do + local canonicalKey = ChannelKey(group[1] or "") + if canonicalKey ~= "" and canonicalKey ~= LFG_GROUP_CANONICAL and tab.channelFilters[canonicalKey] == nil then + tab.channelFilters[canonicalKey] = true + end + end elseif i == combatIdx then tab.kind = "combat" tab.locked = true @@ -1524,17 +1534,17 @@ local function EnsureDB() if db.showBorder == nil then db.showBorder = DEFAULTS.showBorder end if db.borderClassColor == nil then db.borderClassColor = DEFAULTS.borderClassColor end if db.showPlayerLevel == nil then db.showPlayerLevel = DEFAULTS.showPlayerLevel end - if type(db.width) ~= "number" then db.width = DEFAULTS.width end - if type(db.height) ~= "number" then db.height = DEFAULTS.height end - if type(db.scale) ~= "number" then db.scale = DEFAULTS.scale end - if type(db.fontSize) ~= "number" then db.fontSize = DEFAULTS.fontSize end - if type(db.sidePadding) ~= "number" then db.sidePadding = DEFAULTS.sidePadding end - if type(db.topPadding) ~= "number" then db.topPadding = DEFAULTS.topPadding end - if type(db.bottomPadding) ~= "number" then db.bottomPadding = DEFAULTS.bottomPadding end - if type(db.bgAlpha) ~= "number" then db.bgAlpha = DEFAULTS.bgAlpha end + if type(db.width) ~= "number" then db.width = tonumber(db.width) or DEFAULTS.width end + if type(db.height) ~= "number" then db.height = tonumber(db.height) or DEFAULTS.height end + if type(db.scale) ~= "number" then db.scale = tonumber(db.scale) or DEFAULTS.scale end + if type(db.fontSize) ~= "number" then db.fontSize = tonumber(db.fontSize) or DEFAULTS.fontSize end + if type(db.sidePadding) ~= "number" then db.sidePadding = tonumber(db.sidePadding) or DEFAULTS.sidePadding end + if type(db.topPadding) ~= "number" then db.topPadding = tonumber(db.topPadding) or DEFAULTS.topPadding end + if type(db.bottomPadding) ~= "number" then db.bottomPadding = tonumber(db.bottomPadding) or DEFAULTS.bottomPadding end + if type(db.bgAlpha) ~= "number" then db.bgAlpha = tonumber(db.bgAlpha) or DEFAULTS.bgAlpha end if type(db.editBoxPosition) ~= "string" then db.editBoxPosition = DEFAULTS.editBoxPosition end - if type(db.editBoxX) ~= "number" then db.editBoxX = DEFAULTS.editBoxX end - if type(db.editBoxY) ~= "number" then db.editBoxY = DEFAULTS.editBoxY end + if type(db.editBoxX) ~= "number" then db.editBoxX = tonumber(db.editBoxX) or DEFAULTS.editBoxX end + if type(db.editBoxY) ~= "number" then db.editBoxY = tonumber(db.editBoxY) or DEFAULTS.editBoxY end if db.translateEnabled == nil then db.translateEnabled = true end if db.chatMonitorEnabled == nil then db.chatMonitorEnabled = true end if type(db.layoutVersion) ~= "number" then db.layoutVersion = 1 end @@ -2076,6 +2086,9 @@ function SFrames.Chat:FindTabIndexById(tabId) end function SFrames.Chat:ShouldAutoTranslateForTab(index, filterKey, channelName) + if SFramesDB and SFramesDB.Chat and SFramesDB.Chat.translateEnabled == false then + return false + end local tab = self:GetTab(index) if not tab then return false end if filterKey == "channel" then @@ -3719,7 +3732,14 @@ function SFrames.Chat:EnsureConfigFrame() end) local filterSection = CreateCfgSection(filtersPage, "消息与频道接收", 0, -108, 584, 380, fontPath) - + + local filterHintText = filterSection:CreateFontString(nil, "OVERLAY") + filterHintText:SetFont(fontPath, 10, "OUTLINE") + filterHintText:SetPoint("TOPRIGHT", filterSection, "TOPRIGHT", -16, -8) + filterHintText:SetJustifyH("RIGHT") + filterHintText:SetText("勾选即等于接收该频道或类型消息") + filterHintText:SetTextColor(0.78, 0.72, 0.58) + CreateCfgButton(filterSection, "全选", 16, -26, 60, 20, function() for _, def in ipairs(FILTER_DEFS) do SFrames.Chat:SetActiveTabFilter(def.key, true) @@ -4130,6 +4150,7 @@ function SFrames.Chat:EnsureConfigFrame() end) self:ShowConfigPage(self.configActivePage or "window") + panel:Hide() end function SFrames.Chat:OpenConfigFrame(pageKey) @@ -6465,6 +6486,8 @@ function SFrames.Chat:Initialize() end translateEvFrame:SetScript("OnEvent", function() if not (SFrames and SFrames.Chat) then return end + -- 翻译总开关关闭时直接跳过,避免无用开销 + if SFramesDB and SFramesDB.Chat and SFramesDB.Chat.translateEnabled == false then return end local filterKey = GetTranslateFilterKeyForEvent(event) if not filterKey then return end local messageText = arg1 @@ -6712,8 +6735,7 @@ function SFrames.Chat:Initialize() if not SFrames.Chat:GetTabChannelFilter(matchedTabIdx, chanName) then return end - local shouldTranslate = SFrames.Chat:GetTabChannelTranslateFilter(matchedTabIdx, chanName) - if shouldTranslate then + if SFrames.Chat:ShouldAutoTranslateForTab(matchedTabIdx, "channel", chanName) then local cleanText = CleanTextForTranslation(text) cleanText = string.gsub(cleanText, "^%[.-%]%s*", "") local _, _, senderName = string.find(cleanText, "^%[([^%]]+)%]:%s*") diff --git a/ConfigUI.lua b/ConfigUI.lua index 6ab2b24..0d65e32 100644 --- a/ConfigUI.lua +++ b/ConfigUI.lua @@ -415,6 +415,7 @@ local function EnsureDB() if SFramesDB.playerShowClassIcon == nil then SFramesDB.playerShowClassIcon = true end if SFramesDB.playerShowPortrait == nil then SFramesDB.playerShowPortrait = true end if type(SFramesDB.playerFrameAlpha) ~= "number" then SFramesDB.playerFrameAlpha = 1 end + if type(SFramesDB.playerBgAlpha) ~= "number" then SFramesDB.playerBgAlpha = 0.9 end if type(SFramesDB.playerNameFontSize) ~= "number" then SFramesDB.playerNameFontSize = 10 end if type(SFramesDB.playerValueFontSize) ~= "number" then SFramesDB.playerValueFontSize = 10 end @@ -427,10 +428,27 @@ local function EnsureDB() if SFramesDB.targetShowClassIcon == nil then SFramesDB.targetShowClassIcon = true end if SFramesDB.targetShowPortrait == nil then SFramesDB.targetShowPortrait = true end if type(SFramesDB.targetFrameAlpha) ~= "number" then SFramesDB.targetFrameAlpha = 1 end + if type(SFramesDB.targetBgAlpha) ~= "number" then SFramesDB.targetBgAlpha = 0.9 end if type(SFramesDB.targetNameFontSize) ~= "number" then SFramesDB.targetNameFontSize = 10 end if type(SFramesDB.targetValueFontSize) ~= "number" then SFramesDB.targetValueFontSize = 10 end if SFramesDB.targetDistanceEnabled == nil then SFramesDB.targetDistanceEnabled = true end if type(SFramesDB.targetDistanceScale) ~= "number" then SFramesDB.targetDistanceScale = 1 end + if type(SFramesDB.targetDistanceFontSize) ~= "number" then SFramesDB.targetDistanceFontSize = 14 end + if type(SFramesDB.fontKey) ~= "string" then SFramesDB.fontKey = "default" end + + -- Focus frame defaults + if SFramesDB.focusEnabled == nil then SFramesDB.focusEnabled = true end + if type(SFramesDB.focusFrameScale) ~= "number" then SFramesDB.focusFrameScale = 0.9 end + if type(SFramesDB.focusFrameWidth) ~= "number" then SFramesDB.focusFrameWidth = 200 end + if type(SFramesDB.focusPortraitWidth) ~= "number" then SFramesDB.focusPortraitWidth = 45 end + if type(SFramesDB.focusHealthHeight) ~= "number" then SFramesDB.focusHealthHeight = 32 end + if type(SFramesDB.focusPowerHeight) ~= "number" then SFramesDB.focusPowerHeight = 10 end + if SFramesDB.focusShowPortrait == nil then SFramesDB.focusShowPortrait = true end + if SFramesDB.focusShowCastBar == nil then SFramesDB.focusShowCastBar = true end + if SFramesDB.focusShowAuras == nil then SFramesDB.focusShowAuras = true end + if type(SFramesDB.focusNameFontSize) ~= "number" then SFramesDB.focusNameFontSize = 11 end + if type(SFramesDB.focusValueFontSize) ~= "number" then SFramesDB.focusValueFontSize = 10 end + if type(SFramesDB.focusBgAlpha) ~= "number" then SFramesDB.focusBgAlpha = 0.9 end if SFramesDB.showPetFrame == nil then SFramesDB.showPetFrame = true end if type(SFramesDB.petFrameScale) ~= "number" then SFramesDB.petFrameScale = 1 end @@ -450,6 +468,7 @@ local function EnsureDB() if type(SFramesDB.partyValueFontSize) ~= "number" then SFramesDB.partyValueFontSize = 10 end if SFramesDB.partyShowBuffs == nil then SFramesDB.partyShowBuffs = true end if SFramesDB.partyShowDebuffs == nil then SFramesDB.partyShowDebuffs = true end + if type(SFramesDB.partyBgAlpha) ~= "number" then SFramesDB.partyBgAlpha = 0.9 end if SFramesDB.raidLayout ~= "horizontal" and SFramesDB.raidLayout ~= "vertical" then SFramesDB.raidLayout = "horizontal" @@ -467,6 +486,7 @@ local function EnsureDB() if SFramesDB.enableRaidFrames == nil then SFramesDB.enableRaidFrames = true end if SFramesDB.raidHealthFormat == nil then SFramesDB.raidHealthFormat = "compact" end if SFramesDB.raidShowGroupLabel == nil then SFramesDB.raidShowGroupLabel = true end + if type(SFramesDB.raidBgAlpha) ~= "number" then SFramesDB.raidBgAlpha = 0.9 end if SFramesDB.charPanelEnable == nil then SFramesDB.charPanelEnable = true end @@ -492,6 +512,7 @@ local function EnsureDB() end if type(SFramesDB.Bags.alpha) ~= "number" then SFramesDB.Bags.alpha = 1 end if type(SFramesDB.Bags.bankAlpha) ~= "number" then SFramesDB.Bags.bankAlpha = 1 end + if type(SFramesDB.Bags.bgAlpha) ~= "number" then SFramesDB.Bags.bgAlpha = 0.95 end if type(SFramesDB.Minimap) ~= "table" then SFramesDB.Minimap = {} end if SFramesDB.Minimap.enabled == nil then SFramesDB.Minimap.enabled = true end @@ -512,8 +533,11 @@ local function EnsureDB() if SFramesDB.Tweaks.superWoW == nil then SFramesDB.Tweaks.superWoW = true end if SFramesDB.Tweaks.turtleCompat == nil then SFramesDB.Tweaks.turtleCompat = true end if SFramesDB.Tweaks.cooldownNumbers == nil then SFramesDB.Tweaks.cooldownNumbers = true end + if SFramesDB.Tweaks.behindIndicator == nil then SFramesDB.Tweaks.behindIndicator = true end + if SFramesDB.Tweaks.combatNotify == nil then SFramesDB.Tweaks.combatNotify = true end if SFramesDB.Tweaks.darkUI == nil then SFramesDB.Tweaks.darkUI = false end if SFramesDB.Tweaks.worldMapWindow == nil then SFramesDB.Tweaks.worldMapWindow = false end + if SFramesDB.Tweaks.mouseoverCast == nil then SFramesDB.Tweaks.mouseoverCast = false end if type(SFramesDB.WorldMap) ~= "table" then SFramesDB.WorldMap = {} end if SFramesDB.WorldMap.enabled == nil then SFramesDB.WorldMap.enabled = true end @@ -539,14 +563,16 @@ local function EnsureDB() if SFramesDB.MinimapBuffs.showTimer == nil then SFramesDB.MinimapBuffs.showTimer = true end if SFramesDB.MinimapBuffs.showDebuffs == nil then SFramesDB.MinimapBuffs.showDebuffs = true end if type(SFramesDB.MinimapBuffs.debuffIconSize) ~= "number" then SFramesDB.MinimapBuffs.debuffIconSize = 30 end + if type(SFramesDB.MinimapBuffs.bgAlpha) ~= "number" then SFramesDB.MinimapBuffs.bgAlpha = 0.92 end if type(SFramesDB.ActionBars) ~= "table" then SFramesDB.ActionBars = {} end local abDef = { enable = true, buttonSize = 36, buttonGap = 2, smallBarSize = 27, scale = 1.0, barCount = 3, showHotkey = true, showMacroName = false, - rangeColoring = true, showPetBar = true, showStanceBar = true, + rangeColoring = true, behindGlow = true, showPetBar = true, showStanceBar = true, showRightBars = true, buttonRounded = false, buttonInnerShadow = false, alpha = 1.0, + bgAlpha = 0.9, } for k, v in pairs(abDef) do if SFramesDB.ActionBars[k] == nil then SFramesDB.ActionBars[k] = v end @@ -557,6 +583,7 @@ local function EnsureDB() if SFramesDB.enableTargetFrame == nil then SFramesDB.enableTargetFrame = true end if SFramesDB.enablePartyFrame == nil then SFramesDB.enablePartyFrame = true end if SFramesDB.enableChat == nil then SFramesDB.enableChat = true end + if type(SFramesDB.chatBgAlpha) ~= "number" then SFramesDB.chatBgAlpha = 0.9 end if type(SFramesDB.Theme) ~= "table" then SFramesDB.Theme = {} end if SFramesDB.Theme.preset == nil then SFramesDB.Theme.preset = "pink" end @@ -564,6 +591,16 @@ local function EnsureDB() if SFramesDB.Theme.iconSet == nil or SFramesDB.Theme.iconSet == "default" then SFramesDB.Theme.iconSet = "icon" end end +local function ApplyBgAlphaToFrame(frame, alpha) + if not frame or not frame.SetBackdropColor then return end + local A = SFrames.ActiveTheme + if A and A.panelBg then + frame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], alpha) + else + frame:SetBackdropColor(0.1, 0.1, 0.1, alpha) + end +end + local function CreateSection(parent, title, x, y, width, height, font) local section = CreateFrame("Frame", NextWidgetName("Section"), parent) section:SetWidth(width) @@ -851,7 +888,7 @@ function SFrames.ConfigUI:BuildUIPage() -- Section 内容从 y=-32 开始(标题24 + 分隔线 + 8px 间距) -- 每个选项行占 26px,描述文字占 16px - local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 990) + local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 1090) local root = uiScroll.child -- ── 初始化向导 ────────────────────────────────────────────── @@ -1079,7 +1116,7 @@ function SFrames.ConfigUI:BuildUIPage() CreateLabel(enhSection, "提示:以上功能需要安装对应的 !Libs 库才能生效。", 14, -172, font, 10, 0.6, 0.6, 0.65) -- ── ShaguTweaks 功能移植 ────────────────────────────────────── - local tweaksSection = CreateSection(root, "ShaguTweaks 功能移植(需 /reload 生效)", 8, -750, 520, 214, font) + local tweaksSection = CreateSection(root, "ShaguTweaks 功能移植(需 /reload 生效)", 8, -750, 520, 260, font) table.insert(controls, CreateCheckBox(tweaksSection, "自动切换姿态/形态", 14, -34, @@ -1107,7 +1144,7 @@ function SFrames.ConfigUI:BuildUIPage() function() return SFramesDB.Tweaks.cooldownNumbers ~= false end, function(checked) SFramesDB.Tweaks.cooldownNumbers = checked end )) - CreateDesc(tweaksSection, "在技能/物品冷却图标上显示剩余时间文字(>2秒)", 36, -142, font, 218) + CreateDesc(tweaksSection, "在技能/物品冷却图标上显示剩余时间文字(2秒以上)", 36, -142, font, 218) table.insert(controls, CreateCheckBox(tweaksSection, "暗色界面风格", 270, -126, @@ -1116,7 +1153,49 @@ function SFrames.ConfigUI:BuildUIPage() )) CreateDesc(tweaksSection, "将整个游戏界面调暗为深色主题(默认关闭)", 292, -142, font, 218) - CreateLabel(tweaksSection, "提示:以上所有选项修改后需要 /reload 才能生效。", 14, -172, font, 10, 0.6, 0.6, 0.65) + table.insert(controls, CreateCheckBox(tweaksSection, + "背后指示器", 14, -172, + function() return SFramesDB.Tweaks.behindIndicator ~= false end, + function(checked) SFramesDB.Tweaks.behindIndicator = checked end + )) + CreateDesc(tweaksSection, "在目标距离旁显示 背后/正面 状态(需安装 UnitXP SP3)", 36, -188, font, 218) + + table.insert(controls, CreateCheckBox(tweaksSection, + "进战斗后台通知", 270, -172, + function() return SFramesDB.Tweaks.combatNotify ~= false end, + function(checked) SFramesDB.Tweaks.combatNotify = checked end + )) + CreateDesc(tweaksSection, "进入战斗时闪烁任务栏图标(需安装 UnitXP SP3)", 292, -188, font, 218) + + table.insert(controls, CreateCheckBox(tweaksSection, + "鼠标指向施法", 14, -218, + function() return SFramesDB.Tweaks.mouseoverCast == true end, + function(checked) SFramesDB.Tweaks.mouseoverCast = checked end + )) + CreateDesc(tweaksSection, "鼠标悬停单位框体时施法直接作用于该单位,不切换目标(需 SuperWoW)", 36, -234, font, 480) + + CreateLabel(tweaksSection, "提示:以上所有选项修改后需要 /reload 才能生效。", 14, -264, font, 10, 0.6, 0.6, 0.65) + + -- ── 聊天背景透明度 ────────────────────────────────────── + local chatBgSection = CreateSection(root, "聊天窗口", 8, -1018, 520, 90, font) + + table.insert(controls, CreateSlider(chatBgSection, "背景透明度", 14, -38, 220, 0, 1.0, 0.05, + function() + local chatDB = SFramesDB and SFramesDB.Chat + return (chatDB and type(chatDB.bgAlpha) == "number") and chatDB.bgAlpha or 0.45 + end, + function(value) + if not SFramesDB.Chat then SFramesDB.Chat = {} end + SFramesDB.Chat.bgAlpha = value + end, + function(v) return string.format("%.0f%%", v * 100) end, + function() + if SFrames.Chat and SFrames.Chat.ApplyConfig then + SFrames.Chat:ApplyConfig() + end + end + )) + CreateDesc(chatBgSection, "调整聊天窗口背景的透明度(仅影响背景,不影响文字)。也可在 /nui chat 中设置", 14, -68, font, 480) uiScroll:UpdateRange() self.uiControls = controls @@ -1129,6 +1208,9 @@ function SFrames.ConfigUI:BuildPlayerPage() local page = self.playerPage local controls = {} + local playerScroll = CreateScrollArea(page, 4, -4, 552, PANEL_HEIGHT - 110, 660) + local root = playerScroll.child + local function RefreshPlayer() if SFrames.Player and SFrames.Player.ApplyConfig then SFrames.Player:ApplyConfig() @@ -1137,7 +1219,7 @@ function SFrames.ConfigUI:BuildPlayerPage() if SFrames.Player and SFrames.Player.UpdateAll then SFrames.Player:UpdateAll() end end - local playerSection = CreateSection(page, "玩家框体", 8, -8, 520, 342, font) + local playerSection = CreateSection(root, "玩家框体", 8, -8, 520, 402, font) table.insert(controls, CreateCheckBox(playerSection, "启用 Nanami 玩家框体(需 /reload)", 12, -28, @@ -1226,12 +1308,49 @@ function SFrames.ConfigUI:BuildPlayerPage() end )) + table.insert(controls, CreateSlider(playerSection, "背景透明度", 14, -288, 150, 0, 1.0, 0.05, + function() return SFramesDB.playerBgAlpha end, + function(value) SFramesDB.playerBgAlpha = value end, + function(v) return string.format("%.0f%%", v * 100) end, + function(value) + if SFrames.Player and SFrames.Player.frame then + local f = SFrames.Player.frame + ApplyBgAlphaToFrame(f, value) + if f.healthBGFrame then ApplyBgAlphaToFrame(f.healthBGFrame, value) end + if f.powerBGFrame then ApplyBgAlphaToFrame(f.powerBGFrame, value) end + if f.portraitBG then ApplyBgAlphaToFrame(f.portraitBG, value) end + end + end + )) + CreateLabel(playerSection, "提示:关闭头像后职业图标移到框体最左侧,框体仅显示血条。", - 14, -299, font, 10, 0.9, 0.9, 0.9) + 14, -355, font, 10, 0.9, 0.9, 0.9) + + -- ── 施法条 ────────────────────────────────────────────────── + local cbSection = CreateSection(root, "施法条", 8, -418, 520, 110, font) + + table.insert(controls, CreateCheckBox(cbSection, + "独立施法条(显示在屏幕下方)", 14, -34, + function() return SFramesDB.castbarStandalone == true end, + function(checked) SFramesDB.castbarStandalone = checked end, + function() + if SFrames.Player and SFrames.Player.ApplyCastbarPosition then + SFrames.Player:ApplyCastbarPosition() + end + end + )) + CreateDesc(cbSection, "关闭时施法条依附玩家框体上方,开启后独立显示在屏幕下方中央", 36, -50, font) + + table.insert(controls, CreateCheckBox(cbSection, + "启用彩虹色施法条", 14, -74, + function() return SFramesDB.castbarRainbow == true end, + function(checked) SFramesDB.castbarRainbow = checked end + )) + CreateDesc(cbSection, "施法时条颜色会随时间流转变化", 36, -90, font) -- ── 宠物框体 ────────────────────────────────────────────────── - local petSection = CreateSection(page, "宠物框体", 8, -358, 520, 98, font) + local petSection = CreateSection(root, "宠物框体", 8, -536, 520, 98, font) table.insert(controls, CreateCheckBox(petSection, "显示宠物框体", 14, -34, @@ -1248,7 +1367,9 @@ function SFrames.ConfigUI:BuildPlayerPage() function(value) if SFrames.Pet and SFrames.Pet.frame then SFrames.Pet.frame:SetScale(value) end end )) + playerScroll:UpdateRange() self.playerControls = controls + self.playerScroll = playerScroll end function SFrames.ConfigUI:BuildTargetPage() @@ -1264,7 +1385,7 @@ function SFrames.ConfigUI:BuildTargetPage() if SFrames.Target and SFrames.Target.UpdateAll then SFrames.Target:UpdateAll() end end - local targetSection = CreateSection(page, "目标框体", 8, -8, 520, 382, font) + local targetSection = CreateSection(page, "目标框体", 8, -8, 520, 460, font) table.insert(controls, CreateCheckBox(targetSection, "启用 Nanami 目标框体(需 /reload)", 12, -28, @@ -1353,8 +1474,23 @@ function SFrames.ConfigUI:BuildTargetPage() end )) + table.insert(controls, CreateSlider(targetSection, "背景透明度", 170, -288, 150, 0, 1.0, 0.05, + function() return SFramesDB.targetBgAlpha end, + function(value) SFramesDB.targetBgAlpha = value end, + function(v) return string.format("%.0f%%", v * 100) end, + function(value) + if SFrames.Target and SFrames.Target.frame then + local f = SFrames.Target.frame + ApplyBgAlphaToFrame(f, value) + if f.healthBGFrame then ApplyBgAlphaToFrame(f.healthBGFrame, value) end + if f.powerBGFrame then ApplyBgAlphaToFrame(f.powerBGFrame, value) end + if f.portraitBG then ApplyBgAlphaToFrame(f.portraitBG, value) end + end + end + )) + table.insert(controls, CreateCheckBox(targetSection, - "启用目标距离文本", 326, -266, + "启用目标距离文本", 326, -326, function() return SFramesDB.targetDistanceEnabled ~= false end, function(checked) SFramesDB.targetDistanceEnabled = checked end, function(checked) @@ -1368,7 +1504,7 @@ function SFrames.ConfigUI:BuildTargetPage() end )) - table.insert(controls, CreateSlider(targetSection, "距离文本缩放", 14, -318, 150, 0.7, 1.8, 0.05, + table.insert(controls, CreateSlider(targetSection, "距离文本缩放", 14, -378, 150, 0.7, 1.8, 0.05, function() return SFramesDB.targetDistanceScale end, function(value) SFramesDB.targetDistanceScale = value end, function(v) return string.format("%.2f", v) end, @@ -1379,9 +1515,112 @@ function SFrames.ConfigUI:BuildTargetPage() end )) + table.insert(controls, CreateSlider(targetSection, "距离文字大小", 200, -378, 150, 8, 24, 1, + function() return SFramesDB.targetDistanceFontSize or 14 end, + function(value) SFramesDB.targetDistanceFontSize = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + function(value) + if SFrames.Target and SFrames.Target.ApplyDistanceScale then + SFrames.Target:ApplyDistanceScale(SFramesDB.targetDistanceScale or 1) + end + end + )) + self.targetControls = controls end +function SFrames.ConfigUI:BuildFocusPage() + local font = SFrames:GetFont() + local page = self.focusPage + local controls = {} + + local focusSection = CreateSection(page, "焦点框体", 8, -8, 520, 380, font) + + local function FocusApply() + if SFrames.Focus and SFrames.Focus.ApplySettings then + SFrames.Focus:ApplySettings() + end + end + + table.insert(controls, CreateCheckBox(focusSection, + "启用焦点框体(需 /reload)", 12, -28, + function() return SFramesDB.focusEnabled ~= false end, + function(checked) SFramesDB.focusEnabled = checked end + )) + + table.insert(controls, CreateSlider(focusSection, "缩放", 14, -72, 150, 0.5, 1.8, 0.05, + function() return SFramesDB.focusFrameScale end, + function(v) SFramesDB.focusFrameScale = v; FocusApply() end, + function(v) return string.format("%.0f%%", v * 100) end + )) + + table.insert(controls, CreateSlider(focusSection, "宽度", 280, -72, 150, 140, 320, 5, + function() return SFramesDB.focusFrameWidth end, + function(v) SFramesDB.focusFrameWidth = v; FocusApply() end, + function(v) return string.format("%.0f", v) end + )) + + table.insert(controls, CreateSlider(focusSection, "血条高度", 14, -126, 150, 16, 60, 1, + function() return SFramesDB.focusHealthHeight end, + function(v) SFramesDB.focusHealthHeight = v; FocusApply() end, + function(v) return string.format("%.0f", v) end + )) + + table.insert(controls, CreateSlider(focusSection, "蓝条高度", 280, -126, 150, 4, 20, 1, + function() return SFramesDB.focusPowerHeight end, + function(v) SFramesDB.focusPowerHeight = v; FocusApply() end, + function(v) return string.format("%.0f", v) end + )) + + table.insert(controls, CreateSlider(focusSection, "头像宽度", 14, -180, 150, 0, 80, 1, + function() return SFramesDB.focusPortraitWidth end, + function(v) SFramesDB.focusPortraitWidth = v; FocusApply() end, + function(v) return string.format("%.0f", v) end + )) + + table.insert(controls, CreateSlider(focusSection, "背景透明度", 280, -180, 150, 0, 1, 0.05, + function() return SFramesDB.focusBgAlpha end, + function(v) SFramesDB.focusBgAlpha = v; FocusApply() end, + function(v) return string.format("%.0f%%", v * 100) end + )) + + table.insert(controls, CreateCheckBox(focusSection, + "显示 3D 头像", 12, -230, + function() return SFramesDB.focusShowPortrait ~= false end, + function(checked) SFramesDB.focusShowPortrait = checked; FocusApply() end + )) + + table.insert(controls, CreateCheckBox(focusSection, + "显示施法条", 270, -230, + function() return SFramesDB.focusShowCastBar ~= false end, + function(checked) SFramesDB.focusShowCastBar = checked; FocusApply() end + )) + + table.insert(controls, CreateCheckBox(focusSection, + "显示 Buff/Debuff", 12, -260, + function() return SFramesDB.focusShowAuras ~= false end, + function(checked) SFramesDB.focusShowAuras = checked; FocusApply() end + )) + + table.insert(controls, CreateSlider(focusSection, "姓名字号", 14, -304, 150, 8, 16, 1, + function() return SFramesDB.focusNameFontSize end, + function(v) SFramesDB.focusNameFontSize = v; FocusApply() end, + function(v) return string.format("%.0f", v) end + )) + + table.insert(controls, CreateSlider(focusSection, "数值字号", 280, -304, 150, 7, 14, 1, + function() return SFramesDB.focusValueFontSize end, + function(v) SFramesDB.focusValueFontSize = v; FocusApply() end, + function(v) return string.format("%.0f", v) end + )) + + CreateLabel(focusSection, "操作: Shift+左键目标框体 = 设为焦点 | 右键目标框体菜单 = 设为/取消焦点", 14, -348, font, 9, 0.65, 0.58, 0.62) + CreateLabel(focusSection, "命令: /nui focus (设焦点) | /nui unfocus (取消) | /nui focustarget (选中焦点)", 14, -364, font, 9, 0.65, 0.58, 0.62) + CreateLabel(focusSection, "提示: 启用/禁用焦点框体需要 /reload,其余设置实时生效。", 14, -384, font, 10, 0.6, 0.6, 0.65) + + self.focusControls = controls +end + function SFrames.ConfigUI:BuildPartyPage() local font = SFrames:GetFont() local page = self.partyPage @@ -1394,10 +1633,10 @@ function SFrames.ConfigUI:BuildPartyPage() if SFrames.Party and SFrames.Party.UpdateAll then SFrames.Party:UpdateAll() end end - local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 320) + local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 480) local root = uiScroll.child - local partySection = CreateSection(root, "小队", 8, -8, 520, 382, font) + local partySection = CreateSection(root, "小队", 8, -8, 520, 460, font) table.insert(controls, CreateCheckBox(partySection, "启用 Nanami 小队框体(需 /reload)", 12, -28, @@ -1497,21 +1736,37 @@ function SFrames.ConfigUI:BuildPartyPage() function() RefreshParty() end )) + table.insert(controls, CreateSlider(partySection, "背景透明度", 170, -320, 150, 0, 1.0, 0.05, + function() return SFramesDB.partyBgAlpha end, + function(value) SFramesDB.partyBgAlpha = value end, + function(v) return string.format("%.0f%%", v * 100) end, + function(value) + if SFrames.Party and SFrames.Party.frames then + for _, f in ipairs(SFrames.Party.frames) do + if f then + if f.pbg then ApplyBgAlphaToFrame(f.pbg, value) end + if f.healthBGFrame then ApplyBgAlphaToFrame(f.healthBGFrame, value) end + end + end + end + end + )) + table.insert(controls, CreateCheckBox(partySection, - "显示小队增益", 170, -318, + "显示小队增益", 14, -380, function() return SFramesDB.partyShowBuffs ~= false end, function(checked) SFramesDB.partyShowBuffs = checked end, function() RefreshParty() end )) table.insert(controls, CreateCheckBox(partySection, - "显示小队减益", 326, -318, + "显示小队减益", 170, -380, function() return SFramesDB.partyShowDebuffs ~= false end, function(checked) SFramesDB.partyShowDebuffs = checked end, function() RefreshParty() end )) - CreateButton(partySection, "小队测试模式", 326, -350, 130, 22, function() + CreateButton(partySection, "小队测试模式", 326, -410, 130, 22, function() if SFrames.Party and SFrames.Party.TestMode then SFrames.Party:TestMode() end end) uiScroll:UpdateRange() @@ -1524,10 +1779,10 @@ function SFrames.ConfigUI:BuildBagPage() local page = self.bagPage local controls = {} - local bagScroll = CreateScrollArea(page, 4, -4, 548, 458, 630) + local bagScroll = CreateScrollArea(page, 4, -4, 548, 458, 700) local root = bagScroll.child - local bagSection = CreateSection(root, "背包", 8, -8, 502, 246, font) + local bagSection = CreateSection(root, "背包", 8, -8, 502, 300, font) table.insert(controls, CreateCheckBox(bagSection, "启用 Nanami 背包(需 /reload)", 12, -28, @@ -1581,13 +1836,22 @@ function SFrames.ConfigUI:BuildBagPage() end )) - CreateButton(bagSection, "打开背包", 16, -196, 220, 24, function() + table.insert(controls, CreateSlider(bagSection, "背包背景透明度", 16, -196, 220, 0, 1.0, 0.05, + function() return SFramesDB.Bags.bgAlpha or 0.95 end, + function(value) SFramesDB.Bags.bgAlpha = value end, + function(value) return string.format("%.0f%%", value * 100) end, + function(value) + if SFramesBagFrame then ApplyBgAlphaToFrame(SFramesBagFrame, value) end + end + )) + + CreateButton(bagSection, "打开背包", 258, -196, 220, 24, function() if SFrames and SFrames.Bags and SFrames.Bags.Container and SFrames.Bags.Container.Open then SFrames.Bags.Container:Open() end end) - local bankSection = CreateSection(root, "银行", 8, -260, 502, 215, font) + local bankSection = CreateSection(root, "银行", 8, -316, 502, 215, font) table.insert(controls, CreateSlider(bankSection, "银行列数", 16, -50, 220, 4, 24, 1, function() return SFramesDB.Bags.bankColumns end, @@ -1649,16 +1913,12 @@ function SFrames.ConfigUI:BuildBagPage() end local function CountDBEntries() - local n = 0 - if NanamiSellPriceDB then - for _ in pairs(NanamiSellPriceDB) do n = n + 1 end - end - return n + return 0 -- No longer using embedded DB file end local dbCount = CountDBEntries() local statusLabel = CreateLabel(priceSection, - string.format("内置: %d 条 | 已学习: %d 条", dbCount, CountCacheEntries()), + string.format("已学习: %d 条 (API模式, 无内置数据库)", CountCacheEntries()), 14, -30, font, 10, 0.8, 0.8, 0.7) local scanResultLabel = CreateLabel(priceSection, "", 14, -104, font, 10, 0.45, 0.9, 0.45) @@ -1679,10 +1939,10 @@ function SFrames.ConfigUI:BuildBagPage() if cache[id] and cache[id] > 0 then return end local price - if NanamiSellPriceDB and NanamiSellPriceDB[id] and NanamiSellPriceDB[id] > 0 then - price = NanamiSellPriceDB[id] - elseif ShaguTweaks and ShaguTweaks.SellValueDB and ShaguTweaks.SellValueDB[id] and ShaguTweaks.SellValueDB[id] > 0 then + if ShaguTweaks and ShaguTweaks.SellValueDB and ShaguTweaks.SellValueDB[id] and ShaguTweaks.SellValueDB[id] > 0 then price = ShaguTweaks.SellValueDB[id] + elseif aux and aux.account_data and aux.account_data.merchant_sell and aux.account_data.merchant_sell[id] and aux.account_data.merchant_sell[id] > 0 then + price = aux.account_data.merchant_sell[id] else local _,_,_,_,_,_,_,_,_,_,sp = GetItemInfo(link) if sp and type(sp) == "number" and sp > 0 then price = sp end @@ -1714,13 +1974,13 @@ function SFrames.ConfigUI:BuildBagPage() TryScan(GetInventoryItemLink("player", slot)) end - statusLabel:SetText(string.format("内置: %d 条 | 已学习: %d 条", dbCount, CountCacheEntries())) + statusLabel:SetText(string.format("已学习: %d 条 (API模式)", CountCacheEntries())) scanResultLabel:SetText(string.format("完成! 检查 %d 件, 新增 %d 条售价", scanned, learned)) end) CreateButton(priceSection, "清空学习缓存", 258, -48, 228, 24, function() if SFramesGlobalDB then SFramesGlobalDB.sellPriceCache = {} end - statusLabel:SetText(string.format("内置: %d 条 | 已学习: 0 条", dbCount)) + statusLabel:SetText("已学习: 0 条 (API模式)") scanResultLabel:SetText("学习缓存已清空") end) @@ -1857,7 +2117,25 @@ function SFrames.ConfigUI:BuildRaidPage() function(v) return tostring(math.floor(v + 0.5)) end, function() RefreshRaid() end )) - + + table.insert(controls, CreateSlider(raidSection, "背景透明度", 326, -336, 130, 0, 1.0, 0.05, + function() return SFramesDB.raidBgAlpha end, + function(value) SFramesDB.raidBgAlpha = value end, + function(v) return string.format("%.0f%%", v * 100) end, + function(value) + if SFrames.Raid and SFrames.Raid.frames then + for i = 1, 40 do + local entry = SFrames.Raid.frames[i] + local f = entry and entry.frame + if f then + if f.healthBGFrame then ApplyBgAlphaToFrame(f.healthBGFrame, value) end + if f.powerBGFrame then ApplyBgAlphaToFrame(f.powerBGFrame, value) end + end + end + end + end + )) + table.insert(controls, CreateCheckBox(raidSection, "血量百分比模式 (关=紧凑数值/-缺口)", 14, -390, function() return SFramesDB.raidHealthFormat == "percent" end, @@ -2112,6 +2390,20 @@ function SFrames.ConfigUI:BuildActionBarPage() end )) + table.insert(controls, CreateSlider(abSection, "背景透明度", 170, -242, 150, 0, 1.0, 0.05, + function() return SFramesDB.ActionBars.bgAlpha or 0.9 end, + function(value) SFramesDB.ActionBars.bgAlpha = value end, + function(v) return string.format("%.0f%%", v * 100) end, + function(value) + if SFrames.ActionBars then + if SFrames.ActionBars.anchor then ApplyBgAlphaToFrame(SFrames.ActionBars.anchor, value) end + if SFrames.ActionBars.rightHolder then ApplyBgAlphaToFrame(SFrames.ActionBars.rightHolder, value) end + if SFrames.ActionBars.stanceHolder then ApplyBgAlphaToFrame(SFrames.ActionBars.stanceHolder, value) end + if SFrames.ActionBars.petHolder then ApplyBgAlphaToFrame(SFrames.ActionBars.petHolder, value) end + end + end + )) + table.insert(controls, CreateCheckBox(abSection, "显示快捷键文字", 12, -300, function() return SFramesDB.ActionBars.showHotkey ~= false end, @@ -2133,79 +2425,85 @@ function SFrames.ConfigUI:BuildActionBarPage() )) table.insert(controls, CreateCheckBox(abSection, - "显示宠物动作条", 12, -356, + "背后技能高亮(在目标背后时高亮背刺等技能)", 12, -356, + function() return SFramesDB.ActionBars.behindGlow ~= false end, + function(checked) SFramesDB.ActionBars.behindGlow = checked end + )) + + table.insert(controls, CreateCheckBox(abSection, + "显示宠物动作条", 12, -384, function() return SFramesDB.ActionBars.showPetBar ~= false end, function(checked) SFramesDB.ActionBars.showPetBar = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "显示姿态栏", 200, -356, + "显示姿态栏", 200, -384, function() return SFramesDB.ActionBars.showStanceBar ~= false end, function(checked) SFramesDB.ActionBars.showStanceBar = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "显示右侧动作条(两列竖向栏)", 12, -384, + "显示右侧动作条(两列竖向栏)", 12, -412, function() return SFramesDB.ActionBars.showRightBars ~= false end, function(checked) SFramesDB.ActionBars.showRightBars = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "始终显示动作条(空格子也显示背景框)", 12, -412, + "始终显示动作条(空格子也显示背景框)", 12, -440, function() return SFramesDB.ActionBars.alwaysShowGrid == true end, function(checked) SFramesDB.ActionBars.alwaysShowGrid = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "按钮圆角", 12, -440, + "按钮圆角", 12, -468, function() return SFramesDB.ActionBars.buttonRounded == true end, function(checked) SFramesDB.ActionBars.buttonRounded = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "按钮内阴影", 200, -440, + "按钮内阴影", 200, -468, function() return SFramesDB.ActionBars.buttonInnerShadow == true end, function(checked) SFramesDB.ActionBars.buttonInnerShadow = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "显示动作条狮鹫(在底部动作条两侧显示装饰狮鹫)", 12, -468, + "显示动作条狮鹫(在底部动作条两侧显示装饰狮鹫)", 12, -496, function() return SFramesDB.ActionBars.hideGryphon == false end, function(checked) SFramesDB.ActionBars.hideGryphon = not checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "狮鹫置于动作条之上(否则在动作条之下)", 12, -496, + "狮鹫置于动作条之上(否则在动作条之下)", 12, -524, function() return SFramesDB.ActionBars.gryphonOnTop == true end, function(checked) SFramesDB.ActionBars.gryphonOnTop = checked end, function() RefreshAB() end )) - table.insert(controls, CreateSlider(abSection, "狮鹫宽度", 14, -550, 150, 24, 200, 2, + table.insert(controls, CreateSlider(abSection, "狮鹫宽度", 14, -578, 150, 24, 200, 2, function() return SFramesDB.ActionBars.gryphonWidth or 64 end, function(value) SFramesDB.ActionBars.gryphonWidth = value end, function(v) return tostring(math.floor(v + 0.5)) end, function() RefreshAB() end )) - table.insert(controls, CreateSlider(abSection, "狮鹫高度", 180, -550, 150, 24, 200, 2, + table.insert(controls, CreateSlider(abSection, "狮鹫高度", 180, -578, 150, 24, 200, 2, function() return SFramesDB.ActionBars.gryphonHeight or 64 end, function(value) SFramesDB.ActionBars.gryphonHeight = value end, function(v) return tostring(math.floor(v + 0.5)) end, function() RefreshAB() end )) - CreateDesc(abSection, "使用布局模式 (/nui layout) 调整狮鹫位置", 14, -612, font, 480) + CreateDesc(abSection, "使用布局模式 (/nui layout) 调整狮鹫位置", 14, -640, font, 480) -- 狮鹫样式选择器(带图例预览) - CreateLabel(abSection, "狮鹫样式:", 14, -664, font, 11, 0.85, 0.75, 0.80) + CreateLabel(abSection, "狮鹫样式:", 14, -692, font, 11, 0.85, 0.75, 0.80) local GRYPHON_STYLES_UI = { { key = "dragonflight", label = "巨龙时代", @@ -2220,7 +2518,7 @@ function SFrames.ConfigUI:BuildActionBarPage() local styleBorders = {} local styleStartX = 14 - local styleY = -684 + local styleY = -712 for idx, style in ipairs(GRYPHON_STYLES_UI) do local xOff = styleStartX + (idx - 1) * 125 @@ -2302,12 +2600,12 @@ function SFrames.ConfigUI:BuildActionBarPage() end end - CreateLabel(abSection, "动作条位置:", 14, -778, font, 11, 0.85, 0.75, 0.80) - CreateDesc(abSection, "使用 /nui layout 或右键聊天框 Nanami 标题进入布局模式调整动作条位置", 14, -798, font, 480) + CreateLabel(abSection, "动作条位置:", 14, -806, font, 11, 0.85, 0.75, 0.80) + CreateDesc(abSection, "使用 /nui layout 或右键聊天框 Nanami 标题进入布局模式调整动作条位置", 14, -826, font, 480) CreateLabel(abSection, "提示:启用/禁用动作条接管需要 /reload 才能生效。", - 14, -818, font, 10, 1, 0.92, 0.38) + 14, -846, font, 10, 1, 0.92, 0.38) uiScroll:UpdateRange() self.actionBarControls = controls @@ -2932,10 +3230,10 @@ function SFrames.ConfigUI:BuildBuffPage() end end - local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 520) + local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 580) local root = uiScroll.child - local buffSection = CreateSection(root, "Buff / Debuff 栏", 8, -8, 520, 420, font) + local buffSection = CreateSection(root, "Buff / Debuff 栏", 8, -8, 520, 480, font) -- Row 1: enable + show timer table.insert(controls, CreateCheckBox(buffSection, @@ -2996,23 +3294,32 @@ function SFrames.ConfigUI:BuildBuffPage() function() RefreshBuffs() end )) + table.insert(controls, CreateSlider(buffSection, "背景透明度", 14, -238, 220, 0, 1.0, 0.05, + function() return SFramesDB.MinimapBuffs.bgAlpha or 0.92 end, + function(value) + SFramesDB.MinimapBuffs.bgAlpha = value + RefreshBuffs() + end, + function(v) return string.format("%.0f%%", v * 100) end + )) + -- Row 5: grow direction + anchor - CreateLabel(buffSection, "增长方向:", 14, -240, font, 11, 0.85, 0.75, 0.80) - CreateButton(buffSection, "向左", 100, -240, 70, 22, function() + CreateLabel(buffSection, "增长方向:", 14, -300, font, 11, 0.85, 0.75, 0.80) + CreateButton(buffSection, "向左", 100, -300, 70, 22, function() SFramesDB.MinimapBuffs.growDirection = "LEFT" RefreshBuffs() end) - CreateButton(buffSection, "向右", 176, -240, 70, 22, function() + CreateButton(buffSection, "向右", 176, -300, 70, 22, function() SFramesDB.MinimapBuffs.growDirection = "RIGHT" RefreshBuffs() end) - CreateLabel(buffSection, "锚点位置:", 270, -240, font, 11, 0.85, 0.75, 0.80) + CreateLabel(buffSection, "锚点位置:", 270, -300, font, 11, 0.85, 0.75, 0.80) local posLabels = { TOPRIGHT = "右上", TOPLEFT = "左上", BOTTOMRIGHT = "右下", BOTTOMLEFT = "左下" } local posKeys = { "TOPRIGHT", "TOPLEFT", "BOTTOMRIGHT", "BOTTOMLEFT" } local posStartX = 356 for _, key in ipairs(posKeys) do - CreateButton(buffSection, posLabels[key], posStartX, -240, 40, 22, function() + CreateButton(buffSection, posLabels[key], posStartX, -300, 40, 22, function() SFramesDB.MinimapBuffs.position = key RefreshBuffs() end) @@ -3020,10 +3327,10 @@ function SFrames.ConfigUI:BuildBuffPage() end -- Row 6: position hint - CreateDesc(buffSection, "使用 /nui layout 进入布局模式调整 Buff 栏位置", 14, -286, font, 480) + CreateDesc(buffSection, "使用 /nui layout 进入布局模式调整 Buff 栏位置", 14, -346, font, 480) -- Row 7: action buttons - CreateButton(buffSection, "重置默认位置", 14, -310, 120, 24, function() + CreateButton(buffSection, "重置默认位置", 14, -370, 120, 24, function() SFramesDB.MinimapBuffs.offsetX = 0 SFramesDB.MinimapBuffs.offsetY = 0 SFramesDB.MinimapBuffs.position = "TOPRIGHT" @@ -3034,7 +3341,7 @@ function SFrames.ConfigUI:BuildBuffPage() end) local simBtn - simBtn = CreateButton(buffSection, "模拟预览", 142, -310, 100, 24, function() + simBtn = CreateButton(buffSection, "模拟预览", 142, -370, 100, 24, function() if not SFrames.MinimapBuffs then return end if SFrames.MinimapBuffs._simulating then SFrames.MinimapBuffs:StopSimulation() @@ -3046,10 +3353,10 @@ function SFrames.ConfigUI:BuildBuffPage() end) -- Row 8: tips - CreateLabel(buffSection, "模拟预览:显示假 Buff / Debuff 以预览布局效果,不影响实际状态。", 14, -344, font, 9, 0.65, 0.58, 0.62) - CreateLabel(buffSection, "Debuff 边框颜色: |cff3399ff魔法|r |cff9900ff诅咒|r |cff996600疾病|r |cff009900毒药|r |cffcc0000物理|r", 14, -360, font, 9, 0.65, 0.58, 0.62) + CreateLabel(buffSection, "模拟预览:显示假 Buff / Debuff 以预览布局效果,不影响实际状态。", 14, -404, font, 9, 0.65, 0.58, 0.62) + CreateLabel(buffSection, "Debuff 边框颜色: |cff3399ff魔法|r |cff9900ff诅咒|r |cff996600疾病|r |cff009900毒药|r |cffcc0000物理|r", 14, -420, font, 9, 0.65, 0.58, 0.62) - CreateLabel(buffSection, "提示:启用/禁用 Buff 栏需要 /reload 才能生效。其他调整实时生效。", 14, -384, font, 10, 0.6, 0.6, 0.65) + CreateLabel(buffSection, "提示:启用/禁用 Buff 栏需要 /reload 才能生效。其他调整实时生效。", 14, -444, font, 10, 0.6, 0.6, 0.65) uiScroll:UpdateRange() self.buffControls = controls @@ -3062,6 +3369,7 @@ function SFrames.ConfigUI:ShowPage(mode) self.uiPage:Hide() self.playerPage:Hide() self.targetPage:Hide() + self.focusPage:Hide() self.bagPage:Hide() self.raidPage:Hide() self.partyPage:Hide() @@ -3072,8 +3380,9 @@ function SFrames.ConfigUI:ShowPage(mode) self.buffPage:Hide() self.personalizePage:Hide() self.themePage:Hide() + self.profilePage:Hide() - local allTabs = { self.uiTab, self.playerTab, self.targetTab, self.partyTab, self.raidTab, self.bagTab, self.charTab, self.actionBarTab, self.keybindsTab, self.minimapTab, self.buffTab, self.personalizeTab, self.themeTab } + local allTabs = { self.uiTab, self.playerTab, self.targetTab, self.focusTab, self.partyTab, self.raidTab, self.bagTab, self.charTab, self.actionBarTab, self.keybindsTab, self.minimapTab, self.buffTab, self.personalizeTab, self.themeTab, self.profileTab } for _, tab in ipairs(allTabs) do tab.sfSoftActive = false tab:Enable() @@ -3096,6 +3405,13 @@ function SFrames.ConfigUI:ShowPage(mode) self.targetTab:RefreshVisual() self.title:SetText("Nanami-UI 设置 - 目标框体") self:RefreshControls(self.targetControls) + elseif mode == "focus" then + self.focusPage:Show() + self.focusTab.sfSoftActive = true + self.focusTab:Disable() + self.focusTab:RefreshVisual() + self.title:SetText("Nanami-UI 设置 - 焦点框体") + self:RefreshControls(self.focusControls) elseif mode == "bags" then self.bagPage:Show() self.bagTab.sfSoftActive = true @@ -3177,6 +3493,14 @@ function SFrames.ConfigUI:ShowPage(mode) self.title:SetText("Nanami-UI 设置 - 主题") self:RefreshControls(self.themeControls) if self.themeScroll and self.themeScroll.Reset then self.themeScroll:Reset() end + elseif mode == "profile" then + self.profilePage:Show() + self.profileTab.sfSoftActive = true + self.profileTab:Disable() + self.profileTab:RefreshVisual() + self.title:SetText("Nanami-UI 设置 - 配置管理") + if self.profileScroll and self.profileScroll.Reset then self.profileScroll:Reset() end + if self.RefreshProfileSummary then self:RefreshProfileSummary() end else self.uiPage:Show() self.uiTab.sfSoftActive = true @@ -3294,10 +3618,19 @@ function SFrames.ConfigUI:EnsureFrame() StyleButton(tabTarget) AddBtnIcon(tabTarget, "search", nil, "left") + local tabFocus = CreateFrame("Button", "SFramesConfigTabFocus", panel, "UIPanelButtonTemplate") + tabFocus:SetWidth(100) + tabFocus:SetHeight(28) + tabFocus:SetPoint("TOP", tabTarget, "BOTTOM", 0, -4) + tabFocus:SetText("焦点框体") + tabFocus:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("focus") end) + StyleButton(tabFocus) + AddBtnIcon(tabFocus, "search", nil, "left") + local tabParty = CreateFrame("Button", "SFramesConfigTabParty", panel, "UIPanelButtonTemplate") tabParty:SetWidth(100) tabParty:SetHeight(28) - tabParty:SetPoint("TOP", tabTarget, "BOTTOM", 0, -4) + tabParty:SetPoint("TOP", tabFocus, "BOTTOM", 0, -4) tabParty:SetText("小队框架") tabParty:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("party") end) StyleButton(tabParty) @@ -3384,6 +3717,15 @@ function SFrames.ConfigUI:EnsureFrame() StyleButton(tabTheme) AddBtnIcon(tabTheme, "star", nil, "left") + local tabProfile = CreateFrame("Button", "SFramesConfigTabProfile", panel, "UIPanelButtonTemplate") + tabProfile:SetWidth(100) + tabProfile:SetHeight(28) + tabProfile:SetPoint("TOP", tabTheme, "BOTTOM", 0, -4) + tabProfile:SetText("配置") + tabProfile:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("profile") end) + StyleButton(tabProfile) + AddBtnIcon(tabProfile, "save", nil, "left") + local content = CreateFrame("Frame", "SFramesConfigContentMain", panel) content:SetWidth(PANEL_WIDTH - 140) content:SetHeight(PANEL_HEIGHT - 60) @@ -3398,6 +3740,9 @@ function SFrames.ConfigUI:EnsureFrame() local targetPage = CreateFrame("Frame", "SFramesConfigTargetPage", content) targetPage:SetAllPoints(content) + local focusPage = CreateFrame("Frame", "SFramesConfigFocusPage", content) + focusPage:SetAllPoints(content) + local bagPage = CreateFrame("Frame", "SFramesConfigBagPage", content) bagPage:SetAllPoints(content) @@ -3428,11 +3773,15 @@ function SFrames.ConfigUI:EnsureFrame() local themePage = CreateFrame("Frame", "SFramesConfigThemePage", content) themePage:SetAllPoints(content) + local profilePage = CreateFrame("Frame", "SFramesConfigProfilePage", content) + profilePage:SetAllPoints(content) + self.frame = panel self.title = title self.uiTab = tabUI self.playerTab = tabPlayer self.targetTab = tabTarget + self.focusTab = tabFocus self.bagTab = tabBags self.raidTab = tabRaid self.partyTab = tabParty @@ -3443,10 +3792,12 @@ function SFrames.ConfigUI:EnsureFrame() self.buffTab = tabBuff self.personalizeTab = tabPersonalize self.themeTab = tabTheme + self.profileTab = tabProfile self.content = content self.uiPage = uiPage self.playerPage = playerPage self.targetPage = targetPage + self.focusPage = focusPage self.bagPage = bagPage self.raidPage = raidPage self.partyPage = partyPage @@ -3457,6 +3808,7 @@ function SFrames.ConfigUI:EnsureFrame() self.buffPage = buffPage self.personalizePage = personalizePage self.themePage = themePage + self.profilePage = profilePage local btnSaveReload = CreateFrame("Button", "SFramesConfigSaveReloadBtn", panel, "UIPanelButtonTemplate") btnSaveReload:SetWidth(100) @@ -3484,7 +3836,7 @@ function SFrames.ConfigUI:BuildPersonalizePage() local page = self.personalizePage local controls = {} - local personalizeScroll = CreateScrollArea(page, 4, -4, 548, 458, 620) + local personalizeScroll = CreateScrollArea(page, 4, -4, 548, 458, 960) local root = personalizeScroll.child -- ── 升级训练师提醒 ────────────────────────────────────────── @@ -3523,8 +3875,24 @@ function SFrames.ConfigUI:BuildPersonalizePage() end) CreateDesc(trainerSection, "点击按钮预览升级提醒效果", 392, -55, font, 100) + -- ── 拾取窗口 ──────────────────────────────────────────────── + local lootOptSection = CreateSection(root, "拾取窗口", 8, -118, 520, 56, font) + + table.insert(controls, CreateCheckBox(lootOptSection, + "拾取窗口跟随鼠标", 14, -30, + function() + if type(SFramesDB.lootDisplay) ~= "table" then return false end + return SFramesDB.lootDisplay.followCursor == true + end, + function(checked) + if type(SFramesDB.lootDisplay) ~= "table" then SFramesDB.lootDisplay = {} end + SFramesDB.lootDisplay.followCursor = checked + end + )) + CreateDesc(lootOptSection, "勾选后拾取窗口显示在鼠标附近,否则显示在固定位置", 36, -46, font) + -- ── 鼠标提示框 ──────────────────────────────────────────────── - local tooltipSection = CreateSection(root, "鼠标提示框", 8, -118, 520, 160, font) + local tooltipSection = CreateSection(root, "鼠标提示框", 8, -184, 520, 180, font) CreateDesc(tooltipSection, "选择游戏提示框的显示位置方式(三选一)", 14, -28, font) local function RefreshTooltipMode() @@ -3563,7 +3931,7 @@ function SFrames.ConfigUI:BuildPersonalizePage() CreateDesc(tooltipSection, "启用后屏幕上会出现绿色锚点,拖动到目标位置后锁定", 292, -62, font) table.insert(controls, CreateSlider(tooltipSection, - "提示框缩放", 14, -100, 200, 0.5, 2.0, 0.05, + "提示框缩放", 14, -116, 200, 0.5, 2.0, 0.05, function() return SFramesDB.tooltipScale or 1.0 end, function(value) SFramesDB.tooltipScale = value @@ -3575,7 +3943,106 @@ function SFrames.ConfigUI:BuildPersonalizePage() )) -- ── AFK 待机动画 ────────────────────────────────────────────── - local afkSection = CreateSection(root, "AFK 待机动画", 8, -288, 520, 146, font) + -- ── 血条材质选择 ────────────────────────────────────────── + local barTexSection = CreateSection(root, "血条材质", 8, -374, 520, 120, font) + + CreateDesc(barTexSection, "选择单位框架中血条和能量条使用的材质。修改后需要 /reload 生效。", 14, -30, font, 490) + + local texBtnStartX = 14 + local texBtnStartY = -50 + local texBtnW = 96 + local texBtnH = 48 + local texBtnGap = 6 + local textures = SFrames.BarTextures or {} + local texBtns = {} + + for idx, entry in ipairs(textures) do + local eKey = entry.key + local eLabel = entry.label + local ePath = entry.path + + local x = texBtnStartX + (idx - 1) * (texBtnW + texBtnGap) + local btn = CreateFrame("Button", NextWidgetName("BarTex"), barTexSection) + btn:SetWidth(texBtnW) + btn:SetHeight(texBtnH) + btn:SetPoint("TOPLEFT", barTexSection, "TOPLEFT", x, texBtnStartY) + + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + btn:SetBackdropColor(0.08, 0.08, 0.10, 0.9) + btn:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + + local preview = CreateFrame("StatusBar", NextWidgetName("BarTexPrev"), btn) + preview:SetPoint("TOPLEFT", btn, "TOPLEFT", 2, -2) + preview:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 14) + preview:SetStatusBarTexture(ePath) + preview:SetStatusBarColor(0.2, 0.85, 0.3, 1) + preview:SetMinMaxValues(0, 1) + preview:SetValue(1) + + local label = btn:CreateFontString(nil, "OVERLAY") + label:SetFont(font, 9, "OUTLINE") + label:SetPoint("BOTTOM", btn, "BOTTOM", 0, 3) + label:SetText(eLabel) + label:SetTextColor(0.8, 0.8, 0.8) + + btn.texKey = eKey + btn.texLabel = eLabel + + local selectBorder = btn:CreateTexture(nil, "OVERLAY") + selectBorder:SetTexture("Interface\\Buttons\\WHITE8X8") + selectBorder:SetPoint("TOPLEFT", btn, "TOPLEFT", -1, 1) + selectBorder:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 1, -1) + selectBorder:SetVertexColor(1, 0.85, 0.4, 0.8) + selectBorder:Hide() + btn.selectBorder = selectBorder + + btn:SetScript("OnClick", function() + SFramesDB.barTexture = this.texKey + for _, b in ipairs(texBtns) do + if b.texKey == this.texKey then + b:SetBackdropBorderColor(1, 0.85, 0.6, 1) + if b.selectBorder then b.selectBorder:Show() end + else + b:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + if b.selectBorder then b.selectBorder:Hide() end + end + end + SFrames:Print("血条材质已切换为: " .. this.texLabel .. " (/reload 生效)") + end) + btn:SetScript("OnEnter", function() + this:SetBackdropBorderColor(0.7, 0.7, 0.8, 1) + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:SetText(this.texLabel) + GameTooltip:AddLine("点击选择此材质", 0.7, 0.7, 0.7) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() + local cur = SFramesDB.barTexture or "default" + if this.texKey == cur then + this:SetBackdropBorderColor(1, 0.85, 0.6, 1) + else + this:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + end + GameTooltip:Hide() + end) + + table.insert(texBtns, btn) + end + + local curTex = SFramesDB.barTexture or "default" + for _, b in ipairs(texBtns) do + if b.texKey == curTex then + b:SetBackdropBorderColor(1, 0.85, 0.6, 1) + if b.selectBorder then b.selectBorder:Show() end + end + end + + local afkSection = CreateSection(root, "AFK 待机动画", 8, -504, 520, 146, font) table.insert(controls, CreateCheckBox(afkSection, "启用 AFK 待机画面", 14, -34, @@ -3606,6 +4073,79 @@ function SFrames.ConfigUI:BuildPersonalizePage() end end) + -- ── 字体选择 ────────────────────────────────────────────────── + local fontSection = CreateSection(root, "全局字体", 8, -660, 520, 160, font) + CreateDesc(fontSection, "选择 UI 全局使用的字体(聊天、框体、系统文字等),需 /reload 完全生效", 14, -28, font, 480) + + local FONT_BTN_W = 150 + local FONT_BTN_H = 22 + local FONT_COLS = 3 + local FONT_X0 = 14 + local FONT_Y0 = -48 + local FONT_GAP_X = 8 + local FONT_GAP_Y = 6 + local fontBtns = {} + + for idx, entry in ipairs(SFrames.FontChoices) do + local col = math.mod(idx - 1, FONT_COLS) + local row = math.floor((idx - 1) / FONT_COLS) + local bx = FONT_X0 + col * (FONT_BTN_W + FONT_GAP_X) + local by = FONT_Y0 - row * (FONT_BTN_H + FONT_GAP_Y) + + local btn = CreateFrame("Button", "SFramesFontBtn" .. idx, fontSection) + btn:SetWidth(FONT_BTN_W) + btn:SetHeight(FONT_BTN_H) + btn:SetPoint("TOPLEFT", fontSection, "TOPLEFT", bx, by) + btn:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 }) + btn:SetBackdropColor(0.12, 0.12, 0.15, 0.9) + btn:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + btn.fontKey = entry.key + + local label = btn:CreateFontString(nil, "OVERLAY") + -- Try to use the actual font for preview; fallback to default if unavailable + local previewOk = pcall(label.SetFont, label, entry.path, 11, "OUTLINE") + if not previewOk then + label:SetFont(font, 11, "OUTLINE") + end + label:SetPoint("CENTER", btn, "CENTER", 0, 0) + label:SetText(entry.label) + label:SetTextColor(0.9, 0.9, 0.9) + + btn:SetScript("OnEnter", function() + if btn.fontKey ~= (SFramesDB.fontKey or "default") then + this:SetBackdropBorderColor(0.8, 0.7, 0.5, 1) + end + end) + btn:SetScript("OnLeave", function() + if btn.fontKey ~= (SFramesDB.fontKey or "default") then + this:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + end + end) + btn:SetScript("OnClick", function() + SFramesDB.fontKey = btn.fontKey + -- Update highlight + for _, b in ipairs(fontBtns) do + if b.fontKey == btn.fontKey then + b:SetBackdropBorderColor(1, 0.85, 0.6, 1) + else + b:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + end + end + end) + + table.insert(fontBtns, btn) + end + + -- Highlight current selection + local curFont = SFramesDB.fontKey or "default" + for _, b in ipairs(fontBtns) do + if b.fontKey == curFont then + b:SetBackdropBorderColor(1, 0.85, 0.6, 1) + end + end + + CreateLabel(fontSection, "提示:切换字体后需要 /reload 才能完全生效。", 14, -136, font, 10, 0.6, 0.6, 0.65) + personalizeScroll:UpdateRange() self.personalizeControls = controls self.personalizeScroll = personalizeScroll @@ -3977,12 +4517,1226 @@ function SFrames.ConfigUI:RefreshIconSetButtons() end end +-------------------------------------------------------------------------------- +-- Profile page: table serializer / deserializer (Lua 5.0 compatible) +-------------------------------------------------------------------------------- + +local PROFILE_PREFIX = "!NUI1!" +local PROFILE_SEP = "\30" + +local P64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +local function P64Encode(src) + if not src or src == "" then return "" end + local out = {} + local len = string.len(src) + local i = 1 + while i <= len do + local a = string.byte(src, i) + local b = (i + 1 <= len) and string.byte(src, i + 1) or 0 + local c = (i + 2 <= len) and string.byte(src, i + 2) or 0 + local remaining = len - i + 1 + local n = a * 65536 + b * 256 + c + table.insert(out, string.sub(P64, math.floor(n / 262144) + 1, math.floor(n / 262144) + 1)) + table.insert(out, string.sub(P64, math.floor(math.mod(n, 262144) / 4096) + 1, math.floor(math.mod(n, 262144) / 4096) + 1)) + if remaining >= 2 then + table.insert(out, string.sub(P64, math.floor(math.mod(n, 4096) / 64) + 1, math.floor(math.mod(n, 4096) / 64) + 1)) + else + table.insert(out, "=") + end + if remaining >= 3 then + table.insert(out, string.sub(P64, math.mod(n, 64) + 1, math.mod(n, 64) + 1)) + else + table.insert(out, "=") + end + i = i + 3 + end + return table.concat(out) +end + +local P64_INV = {} +for i = 1, 64 do + P64_INV[string.sub(P64, i, i)] = i - 1 +end + +local function P64Decode(src) + if not src or src == "" then return "" end + src = string.gsub(src, "%s+", "") + local out = {} + local len = string.len(src) + local i = 1 + while i <= len do + local v1 = P64_INV[string.sub(src, i, i)] or 0 + local v2 = P64_INV[string.sub(src, i + 1, i + 1)] or 0 + local v3 = P64_INV[string.sub(src, i + 2, i + 2)] + local v4 = P64_INV[string.sub(src, i + 3, i + 3)] + local n = v1 * 262144 + v2 * 4096 + (v3 or 0) * 64 + (v4 or 0) + table.insert(out, string.char(math.floor(n / 65536))) + if v3 then + table.insert(out, string.char(math.floor(math.mod(n, 65536) / 256))) + end + if v4 then + table.insert(out, string.char(math.mod(n, 256))) + end + i = i + 4 + end + return table.concat(out) +end + +local function SerializeValue(val, depth) + if depth > 20 then return "nil" end + local t = type(val) + if t == "string" then + local escaped = string.gsub(val, "\\", "\\\\") + escaped = string.gsub(escaped, "\"", "\\\"") + escaped = string.gsub(escaped, "\n", "\\n") + escaped = string.gsub(escaped, "\r", "\\r") + return "\"" .. escaped .. "\"" + elseif t == "number" then + return tostring(val) + elseif t == "boolean" then + return val and "true" or "false" + elseif t == "table" then + local parts = {} + local arrayLen = 0 + for i = 1, 2048 do + if val[i] ~= nil then + arrayLen = i + else + break + end + end + for i = 1, arrayLen do + table.insert(parts, SerializeValue(val[i], depth + 1)) + end + local sorted = {} + for k, v in pairs(val) do + if type(k) == "number" and k >= 1 and k <= arrayLen and math.floor(k) == k then + -- already handled + else + table.insert(sorted, k) + end + end + table.sort(sorted, function(a, b) + local ta, tb = type(a), type(b) + if ta ~= tb then return ta < tb end + return a < b + end) + for _, k in ipairs(sorted) do + local ks + if type(k) == "string" then + ks = "[\"" .. k .. "\"]" + else + ks = "[" .. tostring(k) .. "]" + end + table.insert(parts, ks .. "=" .. SerializeValue(val[k], depth + 1)) + end + return "{" .. table.concat(parts, ",") .. "}" + else + return "nil" + end +end + +local function DeserializeTable(str) + if not str or str == "" then return nil, "空数据" end + local safeStr = string.gsub(str, "[^%w%s%p]", "") + local dangerPatterns = { "function", "loadstring", "dofile", "pcall", "getfenv", "setfenv", "rawget", "rawset", "getmetatable", "setmetatable", "io%.", "os%.", "debug%." } + for _, pat in ipairs(dangerPatterns) do + if string.find(safeStr, pat) then + return nil, "不安全的数据" + end + end + local fn + local loadFunc = loadstring or load + if not loadFunc then return nil, "环境不支持" end + fn = loadFunc("return " .. str) + if not fn then return nil, "解析失败" end + local ok, result = pcall(fn) + if not ok then return nil, "执行失败" end + if type(result) ~= "table" then return nil, "数据格式错误" end + return result +end + +local function DeepCopy(orig) + if type(orig) ~= "table" then return orig end + local copy = {} + for k, v in pairs(orig) do + copy[k] = DeepCopy(v) + end + return copy +end + +local function CollectExportData() + local data = {} + + if SFramesDB then + data.db = DeepCopy(SFramesDB) + end + + if SFramesGlobalDB then + data.global = {} + if SFramesGlobalDB.KeyBindProfiles then + data.global.KeyBindProfiles = DeepCopy(SFramesGlobalDB.KeyBindProfiles) + end + end + + if SFrames.KeyBindManager and SFrames.KeyBindManager.CollectAllBindings then + data.currentBindings = SFrames.KeyBindManager:CollectAllBindings() + end + + -- Nanami-QT + if QuickToolboxDB then + data.qtDB = DeepCopy(QuickToolboxDB) + end + if QuickToolboxSharedDB then + data.qtSharedDB = DeepCopy(QuickToolboxSharedDB) + end + + -- Nanami-Plates + if NanamiPlatesDB then + data.platesDB = DeepCopy(NanamiPlatesDB) + end + + -- Nanami-DPS + if NanamiDPS_DB then + data.dpsDB = DeepCopy(NanamiDPS_DB) + end + + data.exportTime = time and time() or 0 + data.charName = UnitName("player") or "Unknown" + data.version = GetAddOnMetadata and GetAddOnMetadata("Nanami-UI", "Version") or "unknown" + return data +end + +local function ExportProfileString() + local data = CollectExportData() + local raw = SerializeValue(data, 0) + return PROFILE_PREFIX .. P64Encode(raw) +end + +local function ImportProfileString(text) + if not text or text == "" then return nil, "文本为空" end + text = string.gsub(text, "^%s+", "") + text = string.gsub(text, "%s+$", "") + + local prefixLen = string.len(PROFILE_PREFIX) + if string.sub(text, 1, prefixLen) ~= PROFILE_PREFIX then + return nil, "无效的配置字符串(前缀不匹配)" + end + + local encoded = string.sub(text, prefixLen + 1) + local raw = P64Decode(encoded) + if not raw or raw == "" then return nil, "Base64 解码失败" end + + local data, err = DeserializeTable(raw) + if not data then return nil, err end + + return data +end + +local function ApplyImportData(data) + if not data then return false, "无数据" end + local count = 0 + + if data.db and type(data.db) == "table" then + for k, v in pairs(data.db) do + SFramesDB[k] = DeepCopy(v) + count = count + 1 + end + end + + if data.global and type(data.global) == "table" then + if not SFramesGlobalDB then SFramesGlobalDB = {} end + if data.global.KeyBindProfiles then + SFramesGlobalDB.KeyBindProfiles = DeepCopy(data.global.KeyBindProfiles) + end + end + + if data.keyProfiles and type(data.keyProfiles) == "table" then + if not SFramesGlobalDB then SFramesGlobalDB = {} end + SFramesGlobalDB.KeyBindProfiles = DeepCopy(data.keyProfiles) + end + + if data.currentBindings and type(data.currentBindings) == "table" then + if SFrames.KeyBindManager and SFrames.KeyBindManager.ApplyBindings then + SFrames.KeyBindManager:ApplyBindings(data.currentBindings) + end + end + + -- Nanami-QT + if data.qtDB and type(data.qtDB) == "table" then + if not QuickToolboxDB then QuickToolboxDB = {} end + for k, v in pairs(data.qtDB) do + QuickToolboxDB[k] = DeepCopy(v) + end + count = count + 1 + end + if data.qtSharedDB and type(data.qtSharedDB) == "table" then + if not QuickToolboxSharedDB then QuickToolboxSharedDB = {} end + for k, v in pairs(data.qtSharedDB) do + QuickToolboxSharedDB[k] = DeepCopy(v) + end + count = count + 1 + end + + -- Nanami-Plates + if data.platesDB and type(data.platesDB) == "table" then + if not NanamiPlatesDB then NanamiPlatesDB = {} end + for k, v in pairs(data.platesDB) do + NanamiPlatesDB[k] = DeepCopy(v) + end + count = count + 1 + end + + -- Nanami-DPS + if data.dpsDB and type(data.dpsDB) == "table" then + if not NanamiDPS_DB then NanamiDPS_DB = {} end + for k, v in pairs(data.dpsDB) do + NanamiDPS_DB[k] = DeepCopy(v) + end + count = count + 1 + end + + return true, count +end + +local function BoolStr(v, trueStr, falseStr) + if v then return "|cff44ff44" .. (trueStr or "开") .. "|r" end + return "|cffff4444" .. (falseStr or "关") .. "|r" +end + +local function CountTableKeys(t) + if type(t) ~= "table" then return 0 end + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +local function BuildSummaryText() + local lines = {} + local db = SFramesDB or {} + + table.insert(lines, "|cffffcc00角色:|r " .. (UnitName("player") or "Unknown")) + local ver = GetAddOnMetadata and GetAddOnMetadata("Nanami-UI", "Version") or "N/A" + table.insert(lines, "|cffffcc00版本:|r " .. ver) + table.insert(lines, "|cffffcc00布局模式:|r " .. tostring(db.layoutMode or "default")) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 功能模块 ==|r") + table.insert(lines, "单位框架: " .. BoolStr(db.enableUnitFrames ~= false)) + table.insert(lines, "玩家框体: " .. BoolStr(db.enablePlayerFrame ~= false)) + table.insert(lines, "目标框体: " .. BoolStr(db.enableTargetFrame ~= false)) + table.insert(lines, "背包: " .. BoolStr(db.Bags and db.Bags.enable ~= false)) + table.insert(lines, "动作条: " .. BoolStr(db.ActionBars and db.ActionBars.enable ~= false)) + table.insert(lines, "商人: " .. BoolStr(db.enableMerchant ~= false)) + table.insert(lines, "任务UI: " .. BoolStr(db.enableQuestUI ~= false)) + table.insert(lines, "任务日志: " .. BoolStr(db.enableQuestLogSkin ~= false)) + table.insert(lines, "训练师: " .. BoolStr(db.enableTrainer ~= false)) + table.insert(lines, "法术书: " .. BoolStr(db.enableSpellBook ~= false)) + table.insert(lines, "专业技能: " .. BoolStr(db.enableTradeSkill ~= false)) + table.insert(lines, "社交: " .. BoolStr(db.enableSocial ~= false)) + table.insert(lines, "观察: " .. BoolStr(db.enableInspect ~= false)) + table.insert(lines, "飞行地图: " .. BoolStr(db.enableFlightMap ~= false)) + table.insert(lines, "宠物栏: " .. BoolStr(db.enablePetStable ~= false)) + table.insert(lines, "邮件: " .. BoolStr(db.enableMail ~= false)) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 玩家框体 ==|r") + table.insert(lines, "缩放: " .. tostring(db.playerFrameScale or 1)) + table.insert(lines, "宽度: " .. tostring(db.playerFrameWidth or 220)) + table.insert(lines, "头像宽: " .. tostring(db.playerPortraitWidth or 50)) + table.insert(lines, "血条高: " .. tostring(db.playerHealthHeight or 38)) + table.insert(lines, "能量条高: " .. tostring(db.playerPowerHeight or 9)) + table.insert(lines, "头像: " .. BoolStr(db.playerShowPortrait ~= false)) + table.insert(lines, "职业图标: " .. BoolStr(db.playerShowClassIcon ~= false)) + table.insert(lines, "透明度: " .. tostring(db.playerFrameAlpha or 1)) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 目标框体 ==|r") + table.insert(lines, "缩放: " .. tostring(db.targetFrameScale or 1)) + table.insert(lines, "宽度: " .. tostring(db.targetFrameWidth or 220)) + table.insert(lines, "头像宽: " .. tostring(db.targetPortraitWidth or 50)) + table.insert(lines, "血条高: " .. tostring(db.targetHealthHeight or 38)) + table.insert(lines, "距离显示: " .. BoolStr(db.targetDistanceEnabled ~= false)) + table.insert(lines, "距离文字大小: " .. tostring(db.targetDistanceFontSize or 14)) + table.insert(lines, "透明度: " .. tostring(db.targetFrameAlpha or 1)) + table.insert(lines, "全局字体: " .. tostring(db.fontKey or "default")) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 小队框架 ==|r") + table.insert(lines, "布局: " .. tostring(db.partyLayout or "vertical")) + table.insert(lines, "缩放: " .. tostring(db.partyFrameScale or 1)) + table.insert(lines, "宽度: " .. tostring(db.partyFrameWidth or 150)) + table.insert(lines, "高度: " .. tostring(db.partyFrameHeight or 35)) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 团队框架 ==|r") + if db.Raid then + table.insert(lines, "缩放: " .. tostring(db.Raid.scale or 1)) + table.insert(lines, "列数: " .. tostring(db.Raid.columns or 5)) + else + table.insert(lines, "使用默认设置") + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 背包设置 ==|r") + local bags = db.Bags or {} + table.insert(lines, "列数: " .. tostring(bags.columns or 10)) + table.insert(lines, "缩放: " .. tostring(bags.scale or 1)) + table.insert(lines, "间距: " .. tostring(bags.bagSpacing or 0)) + table.insert(lines, "自动卖灰: " .. BoolStr(bags.sellGrey ~= false)) + table.insert(lines, "银行列数: " .. tostring(bags.bankColumns or 12)) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 动作条 ==|r") + if db.ActionBars then + table.insert(lines, "启用: " .. BoolStr(db.ActionBars.enable ~= false)) + table.insert(lines, "缩放: " .. tostring(db.ActionBars.scale or 1)) + else + table.insert(lines, "使用默认设置") + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 聊天设置 ==|r") + local chat = db.Chat or {} + table.insert(lines, "启用: " .. BoolStr(chat.enable ~= false)) + table.insert(lines, "配置项数: " .. CountTableKeys(chat)) + if chat.fontSize then table.insert(lines, "字体大小: " .. tostring(chat.fontSize)) end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00 -- 窗口 --|r") + table.insert(lines, "宽度: " .. tostring(chat.width or "N/A")) + table.insert(lines, "高度: " .. tostring(chat.height or "N/A")) + table.insert(lines, "缩放: " .. tostring(chat.scale or 1)) + table.insert(lines, "背景透明度: " .. tostring(chat.bgAlpha or 0.45)) + table.insert(lines, "边框: " .. BoolStr(chat.showBorder)) + table.insert(lines, "职业色边框: " .. BoolStr(chat.borderClassColor)) + table.insert(lines, "显示玩家等级: " .. BoolStr(chat.showPlayerLevel ~= false)) + table.insert(lines, "输入框位置: " .. tostring(chat.editBoxPosition or "bottom")) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00 -- 标签/过滤 --|r") + local tabCount = (chat.tabs and table.getn(chat.tabs)) or 0 + table.insert(lines, "标签数: " .. tostring(tabCount)) + if chat.tabs then + for i = 1, table.getn(chat.tabs) do + local t = chat.tabs[i] + if t then + local filterCount = t.filters and CountTableKeys(t.filters) or 0 + local chanCount = t.channelFilters and CountTableKeys(t.channelFilters) or 0 + table.insert(lines, " [" .. tostring(i) .. "] " .. tostring(t.name or "?") .. " (过滤:" .. filterCount .. " 频道:" .. chanCount .. ")") + end + end + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00 -- AI翻译 --|r") + table.insert(lines, "翻译引擎: " .. BoolStr(chat.translateEnabled ~= false)) + table.insert(lines, "消息监控: " .. BoolStr(chat.chatMonitorEnabled ~= false)) + if chat.tabs then + for i = 1, table.getn(chat.tabs) do + local t = chat.tabs[i] + if t and t.translateFilters then + local enabledKeys = {} + for k, v in pairs(t.translateFilters) do + if v == true then table.insert(enabledKeys, k) end + end + if table.getn(enabledKeys) > 0 then + table.insert(lines, " [" .. tostring(t.name or i) .. "] 翻译: " .. table.concat(enabledKeys, ",")) + end + end + end + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00 -- 硬核设置 --|r") + table.insert(lines, "全局关闭硬核频道: " .. BoolStr(chat.hcGlobalDisable)) + table.insert(lines, "屏蔽死亡/满级信息: " .. BoolStr(chat.hcDeathDisable)) + table.insert(lines, "最低死亡通报等级: " .. tostring(chat.hcDeathLevelMin or 10)) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 地图设置 ==|r") + local mm = db.Minimap or {} + table.insert(lines, "小地图配置项: " .. CountTableKeys(mm)) + if db.WorldMap then + table.insert(lines, "世界地图配置项: " .. CountTableKeys(db.WorldMap)) + end + if db.MapReveal ~= nil then + table.insert(lines, "地图全亮: " .. BoolStr(db.MapReveal ~= false)) + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== Buff 栏 ==|r") + if db.MinimapBuffs then + table.insert(lines, "配置项数: " .. CountTableKeys(db.MinimapBuffs)) + end + if db.hiddenBuffs then + table.insert(lines, "隐藏的 Buff 数: " .. CountTableKeys(db.hiddenBuffs)) + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 主题 ==|r") + local theme = db.Theme or {} + table.insert(lines, "预设: " .. tostring(theme.preset or "default")) + table.insert(lines, "职业主题: " .. BoolStr(theme.classTheme)) + table.insert(lines, "图标集: " .. tostring(theme.iconSet or "icon")) + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 框架定位 ==|r") + if db.Positions then + local posCount = CountTableKeys(db.Positions) + table.insert(lines, "已保存的框架位置: " .. posCount .. " 个") + for name, _ in pairs(db.Positions) do + table.insert(lines, " - " .. tostring(name)) + end + else + table.insert(lines, "无自定义位置") + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 按键绑定 ==|r") + if SFramesGlobalDB and SFramesGlobalDB.KeyBindProfiles then + local profCount = 0 + for name, _ in pairs(SFramesGlobalDB.KeyBindProfiles) do + profCount = profCount + 1 + table.insert(lines, " 方案: " .. tostring(name)) + end + table.insert(lines, "已保存方案: " .. profCount .. " 个") + else + table.insert(lines, "已保存方案: 0 个") + end + if SFrames.KeyBindManager and SFrames.KeyBindManager.CollectAllBindings then + local binds = SFrames.KeyBindManager:CollectAllBindings() + table.insert(lines, "当前绑定: " .. (binds and table.getn(binds) or 0) .. " 个") + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== 个性化 ==|r") + if db.trainerReminder ~= nil then table.insert(lines, "训练师提醒: " .. BoolStr(db.trainerReminder ~= false)) end + if db.Tweaks then + table.insert(lines, "微调项数: " .. CountTableKeys(db.Tweaks)) + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== Nanami-QT (工具箱) ==|r") + if QuickToolboxDB then + table.insert(lines, "角色配置项: " .. CountTableKeys(QuickToolboxDB)) + else + table.insert(lines, "角色配置: |cff888888未加载|r") + end + if QuickToolboxSharedDB then + table.insert(lines, "全局配置项: " .. CountTableKeys(QuickToolboxSharedDB)) + else + table.insert(lines, "全局配置: |cff888888未加载|r") + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== Nanami-Plates (姓名板) ==|r") + if NanamiPlatesDB then + table.insert(lines, "配置项数: " .. CountTableKeys(NanamiPlatesDB)) + else + table.insert(lines, "配置: |cff888888未加载|r") + end + table.insert(lines, "") + + table.insert(lines, "|cffffcc00== Nanami-DPS (伤害统计) ==|r") + if NanamiDPS_DB then + table.insert(lines, "配置项数: " .. CountTableKeys(NanamiDPS_DB)) + else + table.insert(lines, "配置: |cff888888未加载|r") + end + + return table.concat(lines, "\n") +end + +-------------------------------------------------------------------------------- +-- Profile page: export / import dialogs +-------------------------------------------------------------------------------- + +local profileExportFrame, profileImportFrame + +local function ShowProfileExportDialog() + local text = ExportProfileString() + + if not profileExportFrame then + local font = SFrames:GetFont() + local f = CreateFrame("Frame", "SFramesProfileExport", UIParent) + f:SetWidth(520) + f:SetHeight(380) + f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + f:SetMovable(true) + f:EnableMouse(true) + f:SetClampedToScreen(true) + f:SetToplevel(true) + f:SetFrameStrata("TOOLTIP") + f:SetFrameLevel(200) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function() this:StartMoving() end) + f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + if SFrames.CreateRoundBackdrop then + SFrames:CreateRoundBackdrop(f) + else + EnsureSoftBackdrop(f) + end + tinsert(UISpecialFrames, "SFramesProfileExport") + + local title = f:CreateFontString(nil, "OVERLAY") + title:SetFont(font, 12, "OUTLINE") + title:SetPoint("TOP", f, "TOP", 0, -12) + title:SetText("|cffffcc00导出全部配置|r") + f.title = title + + local desc = f:CreateFontString(nil, "OVERLAY") + desc:SetFont(font, 10, "OUTLINE") + desc:SetPoint("TOP", title, "BOTTOM", 0, -4) + desc:SetText("Ctrl+A 全选,Ctrl+C 复制。将字符串分享给其他人即可导入") + desc:SetTextColor(0.7, 0.7, 0.7) + + local sf = CreateFrame("ScrollFrame", "SFramesProfileExportScroll", f, "UIPanelScrollFrameTemplate") + sf:SetPoint("TOPLEFT", f, "TOPLEFT", 14, -52) + sf:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -34, 44) + + local edit = CreateFrame("EditBox", "SFramesProfileExportEdit", sf) + edit:SetWidth(450) + edit:SetHeight(240) + edit:SetMultiLine(true) + edit:SetFont(font, 9, "OUTLINE") + edit:SetAutoFocus(false) + edit:SetScript("OnEscapePressed", function() f:Hide() end) + sf:SetScrollChild(edit) + f.edit = edit + + local closeBtn = CreateFrame("Button", "SFramesProfileExportClose", f, "UIPanelButtonTemplate") + closeBtn:SetWidth(100) + closeBtn:SetHeight(26) + closeBtn:SetPoint("BOTTOM", f, "BOTTOM", 0, 12) + closeBtn:SetText("关闭") + StyleButton(closeBtn) + closeBtn:SetScript("OnClick", function() f:Hide() end) + + profileExportFrame = f + end + + profileExportFrame.edit:SetText(text) + profileExportFrame:Show() + profileExportFrame:Raise() + profileExportFrame.edit:HighlightText() + profileExportFrame.edit:SetFocus() +end + +local function ShowProfileImportDialog() + if not profileImportFrame then + local font = SFrames:GetFont() + local f = CreateFrame("Frame", "SFramesProfileImport", UIParent) + f:SetWidth(520) + f:SetHeight(420) + f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + f:SetMovable(true) + f:EnableMouse(true) + f:SetClampedToScreen(true) + f:SetToplevel(true) + f:SetFrameStrata("TOOLTIP") + f:SetFrameLevel(200) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function() this:StartMoving() end) + f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + if SFrames.CreateRoundBackdrop then + SFrames:CreateRoundBackdrop(f) + else + EnsureSoftBackdrop(f) + end + tinsert(UISpecialFrames, "SFramesProfileImport") + + local title = f:CreateFontString(nil, "OVERLAY") + title:SetFont(font, 12, "OUTLINE") + title:SetPoint("TOP", f, "TOP", 0, -12) + title:SetText("|cffffcc00导入配置|r") + f.title = title + + local desc = f:CreateFontString(nil, "OVERLAY") + desc:SetFont(font, 10, "OUTLINE") + desc:SetPoint("TOP", title, "BOTTOM", 0, -4) + desc:SetText("将配置字符串粘贴到下方 (Ctrl+V),然后点击导入") + desc:SetTextColor(0.7, 0.7, 0.7) + + local sf = CreateFrame("ScrollFrame", "SFramesProfileImportScroll", f, "UIPanelScrollFrameTemplate") + sf:SetPoint("TOPLEFT", f, "TOPLEFT", 14, -52) + sf:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -34, 78) + + local edit = CreateFrame("EditBox", "SFramesProfileImportEdit", sf) + edit:SetWidth(450) + edit:SetHeight(240) + edit:SetMultiLine(true) + edit:SetFont(font, 9, "OUTLINE") + edit:SetAutoFocus(false) + edit:SetScript("OnEscapePressed", function() f:Hide() end) + sf:SetScrollChild(edit) + f.edit = edit + + local statusLabel = f:CreateFontString(nil, "OVERLAY") + statusLabel:SetFont(font, 10, "OUTLINE") + statusLabel:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 50) + statusLabel:SetWidth(340) + statusLabel:SetJustifyH("LEFT") + statusLabel:SetText("") + f.statusLabel = statusLabel + + local importBtn = CreateFrame("Button", "SFramesProfileImportBtn", f, "UIPanelButtonTemplate") + importBtn:SetWidth(120) + importBtn:SetHeight(26) + importBtn:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 130, 14) + importBtn:SetText("导入并应用") + StyleButton(importBtn) + importBtn:SetScript("OnClick", function() + local inputText = f.edit:GetText() + if not inputText or inputText == "" then + f.statusLabel:SetText("|cffff4444请先粘贴配置数据|r") + return + end + local data, err = ImportProfileString(inputText) + if not data then + f.statusLabel:SetText("|cffff4444错误: " .. (err or "未知") .. "|r") + return + end + local ok, count = ApplyImportData(data) + if ok then + local src = data.charName or "未知" + local ver = data.version or "未知" + f.statusLabel:SetText("|cff44ff44导入成功! 来源: " .. src .. " (v" .. ver .. ")|r") + SFrames:Print("已导入配置 (来源: " .. src .. "),需要 /reload 生效") + else + f.statusLabel:SetText("|cffff4444导入失败|r") + end + end) + + local cancelBtn = CreateFrame("Button", "SFramesProfileImportCancel", f, "UIPanelButtonTemplate") + cancelBtn:SetWidth(100) + cancelBtn:SetHeight(26) + cancelBtn:SetPoint("LEFT", importBtn, "RIGHT", 8, 0) + cancelBtn:SetText("取消") + StyleButton(cancelBtn) + cancelBtn:SetScript("OnClick", function() f:Hide() end) + + profileImportFrame = f + end + + profileImportFrame.edit:SetText("") + profileImportFrame.statusLabel:SetText("") + profileImportFrame:Show() + profileImportFrame:Raise() + profileImportFrame.edit:SetFocus() +end + +-------------------------------------------------------------------------------- +-- Profile page: partial apply dialog +-------------------------------------------------------------------------------- + +local APPLY_CATEGORIES = { + { key = "ui", label = "界面功能开关", keys = {"enableMerchant","enableQuestUI","enableQuestLogSkin","enableTrainer","enableSpellBook","enableTradeSkill","enableSocial","enableInspect","enableFlightMap","enablePetStable","enableMail","enableUnitFrames","enablePlayerFrame","enableTargetFrame","enablePartyFrame","enableChat","showLevel","classColorHealth","smoothBars","fontKey"} }, + { key = "player", label = "玩家框体", keys = {"playerFrameScale","playerFrameWidth","playerPortraitWidth","playerHealthHeight","playerPowerHeight","playerShowClass","playerShowClassIcon","playerShowPortrait","playerFrameAlpha","playerBgAlpha","playerNameFontSize","playerValueFontSize","castbarStandalone","castbarRainbow"} }, + { key = "target", label = "目标框体", keys = {"targetFrameScale","targetFrameWidth","targetPortraitWidth","targetHealthHeight","targetPowerHeight","targetShowClass","targetShowClassIcon","targetShowPortrait","targetFrameAlpha","targetBgAlpha","targetNameFontSize","targetValueFontSize","targetDistanceEnabled","targetDistanceScale","targetDistanceFontSize"} }, + { key = "focus", label = "焦点框体", keys = {"focusEnabled","focusFrameScale","focusFrameWidth","focusPortraitWidth","focusHealthHeight","focusPowerHeight","focusShowPortrait","focusShowCastBar","focusShowAuras","focusNameFontSize","focusValueFontSize","focusBgAlpha"} }, + { key = "party", label = "小队框架", keys = {"partyLayout","partyFrameScale","partyFrameWidth","partyFrameHeight","partyPortraitWidth","partyHealthHeight","partyPowerHeight","partyHorizontalGap","partyVerticalGap","partyNameFontSize","partyValueFontSize","partyShowBuffs","partyShowDebuffs","partyBgAlpha"} }, + { key = "raid", label = "团队框架", keys = {"raidLayout","raidFrameScale","raidFrameWidth","raidFrameHeight","raidHealthHeight","raidHorizontalGap","raidVerticalGap","raidGroupGap","raidNameFontSize","raidValueFontSize","raidShowPower","enableRaidFrames","raidHealthFormat","raidShowGroupLabel","raidBgAlpha"} }, + { key = "bags", label = "背包设置", table_key = "Bags" }, + { key = "actionbar",label = "动作条设置", table_key = "ActionBars" }, + { key = "chat_general", label = "聊天-通用", + chat_keys = {"enable","showBorder","borderClassColor","showPlayerLevel","chatMonitorEnabled","layoutVersion"} }, + { key = "chat_window", label = "聊天-窗口", + chat_keys = {"width","height","scale","fontSize","bgAlpha","sidePadding","topPadding","bottomPadding","editBoxPosition","editBoxX","editBoxY"} }, + { key = "chat_tabs", label = "聊天-标签/过滤", + chat_keys = {"tabs","activeTab","nextTabId"} }, + { key = "chat_translate",label = "聊天-AI翻译", + chat_keys = {"translateEnabled"}, chat_tab_keys = {"translateFilters","channelTranslateFilters"} }, + { key = "chat_hardcore", label = "聊天-硬核设置", + chat_keys = {"hcGlobalDisable","hcDeathDisable","hcDeathLevelMin"} }, + { key = "theme", label = "主题设置", table_key = "Theme" }, + { key = "minimap", label = "地图设置", table_key = "Minimap" }, + { key = "buff", label = "Buff 栏设置", table_key = "MinimapBuffs" }, + { key = "tweaks", label = "微调 / 个性化", table_key = "Tweaks" }, + { key = "positions",label = "界面位置", table_key = "Positions" }, + { key = "qt", label = "Nanami-QT", ext_db = "_qtDB", ext_global = "QuickToolboxDB" }, + { key = "qtshared", label = "QT 全局配置", ext_db = "_qtSharedDB", ext_global = "QuickToolboxSharedDB" }, + { key = "plates", label = "Nanami-Plates", ext_db = "_platesDB", ext_global = "NanamiPlatesDB" }, + { key = "dps", label = "Nanami-DPS", ext_db = "_dpsDB", ext_global = "NanamiDPS_DB" }, +} + +local profileApplyFrame + +local function ShowPartialApplyDialog(profileData, profileName) + if not profileApplyFrame then + local font = SFrames:GetFont() + local f = CreateFrame("Frame", "SFramesProfileApply", UIParent) + f:SetWidth(400) + f:SetHeight(540) + f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + f:SetMovable(true) + f:EnableMouse(true) + f:SetClampedToScreen(true) + f:SetToplevel(true) + f:SetFrameStrata("TOOLTIP") + f:SetFrameLevel(200) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function() this:StartMoving() end) + f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + f:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + f:SetBackdropColor(0.05, 0.05, 0.08, 0.97) + f:SetBackdropBorderColor(0.4, 0.3, 0.5, 0.95) + tinsert(UISpecialFrames, "SFramesProfileApply") + + local title = f:CreateFontString(nil, "OVERLAY") + title:SetFont(font, 12, "OUTLINE") + title:SetPoint("TOP", f, "TOP", 0, -12) + title:SetText("|cffffcc00选择要应用的配置项|r") + f.title = title + + local checks = {} + local startY = -36 + for idx, cat in ipairs(APPLY_CATEGORIES) do + local catKey = cat.key + local catLabel = cat.label + local cb = CreateFrame("CheckButton", NextWidgetName("ApplyCat"), f, "UICheckButtonTemplate") + cb:SetWidth(18) + cb:SetHeight(18) + local col = (idx <= 10) and 0 or 1 + local row = (idx <= 10) and (idx - 1) or (idx - 11) + cb:SetPoint("TOPLEFT", f, "TOPLEFT", 16 + col * 180, startY - row * 24) + cb:SetChecked(1) + cb.sfChecked = true + cb.catKey = catKey + + local text = _G[cb:GetName() .. "Text"] + if text then + text:SetText(catLabel) + text:SetWidth(150) + end + StyleCheckBox(cb) + if cb.sfBox then cb.sfBox:EnableMouse(false) end + if cb.sfApplyState then cb.sfApplyState(true) end + + cb:SetScript("OnClick", function() + local v = this:GetChecked() + local isChecked = (v == 1 or v == true) + this.sfChecked = isChecked + if this.sfApplyState then this.sfApplyState(isChecked) end + end) + + table.insert(checks, cb) + end + f.checks = checks + + local selectAll = CreateFrame("Button", NextWidgetName("Btn"), f, "UIPanelButtonTemplate") + selectAll:SetWidth(80) + selectAll:SetHeight(22) + selectAll:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -440) + selectAll:SetText("全选") + StyleButton(selectAll) + selectAll:SetScript("OnClick", function() + local cks = profileApplyFrame and profileApplyFrame.checks + if cks then + for i = 1, table.getn(cks) do + cks[i]:SetChecked(1) + cks[i].sfChecked = true + if cks[i].sfApplyState then cks[i].sfApplyState(true) end + end + end + end) + + local deselectAll = CreateFrame("Button", NextWidgetName("Btn"), f, "UIPanelButtonTemplate") + deselectAll:SetWidth(80) + deselectAll:SetHeight(22) + deselectAll:SetPoint("LEFT", selectAll, "RIGHT", 4, 0) + deselectAll:SetText("取消全选") + StyleButton(deselectAll) + deselectAll:SetScript("OnClick", function() + local cks = profileApplyFrame and profileApplyFrame.checks + if cks then + for i = 1, table.getn(cks) do + cks[i]:SetChecked(0) + cks[i].sfChecked = false + if cks[i].sfApplyState then cks[i].sfApplyState(false) end + end + end + end) + + local statusLabel = f:CreateFontString(nil, "OVERLAY") + statusLabel:SetFont(font, 10, "OUTLINE") + statusLabel:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 42) + statusLabel:SetWidth(340) + statusLabel:SetJustifyH("LEFT") + statusLabel:SetText("") + f.statusLabel = statusLabel + + local applyBtn = CreateFrame("Button", NextWidgetName("Btn"), f, "UIPanelButtonTemplate") + applyBtn:SetWidth(120) + applyBtn:SetHeight(26) + applyBtn:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 80, 12) + applyBtn:SetText("应用选中项") + StyleButton(applyBtn) + applyBtn:SetScript("OnClick", function() + if not f.profileData then return end + local applied = 0 + for _, cb in ipairs(f.checks) do + if cb:GetChecked() == 1 or cb:GetChecked() == true then + local catKey = cb.catKey + for _, cat in ipairs(APPLY_CATEGORIES) do + if cat.key == catKey then + if cat.ext_db then + -- External addon DB (QT/Plates/DPS) + local srcData = f.profileData[cat.ext_db] + if srcData and type(srcData) == "table" then + local globalName = cat.ext_global + local target = getglobal(globalName) + if not target then + setglobal(globalName, {}) + target = getglobal(globalName) + end + for k, v in pairs(srcData) do + target[k] = v + end + applied = applied + 1 + end + elseif cat.chat_keys then + -- Chat sub-category: read from profileData.Chat + local srcChat = f.profileData.Chat or (f.profileData.db and f.profileData.db.Chat) + if srcChat and type(srcChat) == "table" then + if not SFramesDB.Chat then SFramesDB.Chat = {} end + for _, k in ipairs(cat.chat_keys) do + if srcChat[k] ~= nil then + SFramesDB.Chat[k] = DeepCopy(srcChat[k]) + applied = applied + 1 + end + end + -- chat_tab_keys: copy per-tab sub-fields (e.g. translateFilters) + if cat.chat_tab_keys and srcChat.tabs and SFramesDB.Chat.tabs then + for ti = 1, table.getn(srcChat.tabs) do + local srcTab = srcChat.tabs[ti] + local dstTab = SFramesDB.Chat.tabs[ti] + if srcTab and dstTab then + for _, tk in ipairs(cat.chat_tab_keys) do + if srcTab[tk] ~= nil then + dstTab[tk] = DeepCopy(srcTab[tk]) + end + end + end + end + applied = applied + 1 + end + end + elseif cat.table_key then + if f.profileData[cat.table_key] then + SFramesDB[cat.table_key] = f.profileData[cat.table_key] + applied = applied + 1 + end + elseif cat.keys then + for _, k in ipairs(cat.keys) do + if f.profileData[k] ~= nil then + SFramesDB[k] = f.profileData[k] + applied = applied + 1 + end + end + end + break + end + end + end + end + f.statusLabel:SetText("|cff44ff44已应用 " .. applied .. " 项设置,/reload 生效|r") + SFrames:Print("已部分应用配置(" .. applied .. " 项),需要 /reload 生效") + end) + + local cancelBtn = CreateFrame("Button", NextWidgetName("Btn"), f, "UIPanelButtonTemplate") + cancelBtn:SetWidth(100) + cancelBtn:SetHeight(26) + cancelBtn:SetPoint("LEFT", applyBtn, "RIGHT", 8, 0) + cancelBtn:SetText("取消") + StyleButton(cancelBtn) + cancelBtn:SetScript("OnClick", function() f:Hide() end) + + profileApplyFrame = f + end + + profileApplyFrame.profileData = profileData + profileApplyFrame.title:SetText("|cffffcc00应用配置: " .. (profileName or "未知") .. "|r") + profileApplyFrame.statusLabel:SetText("") + for _, cb in ipairs(profileApplyFrame.checks) do cb:SetChecked(1) end + profileApplyFrame:Show() + profileApplyFrame:Raise() +end + +-------------------------------------------------------------------------------- +-- Profile page: Build +-------------------------------------------------------------------------------- + +function SFrames.ConfigUI:BuildProfilePage() + local font = SFrames:GetFont() + local page = self.profilePage + + local profileScroll = CreateScrollArea(page, 4, -4, 548, 458, 1800) + local root = profileScroll.child + self.profileScroll = profileScroll + + -- ── 角色配置管理 ────────────────────────────────────── + local charSection = CreateSection(root, "角色配置管理", 8, -8, 520, 280, font) + + CreateDesc(charSection, + "每个角色的配置会自动保存。你可以查看同账号下其他角色的配置,并选择应用到当前角色。", + 14, -30, font, 490) + + local charListHolder = CreateFrame("Frame", NextWidgetName("CharList"), charSection) + charListHolder:SetWidth(492) + charListHolder:SetHeight(190) + charListHolder:SetPoint("TOPLEFT", charSection, "TOPLEFT", 14, -50) + + self._charListHolder = charListHolder + self._charListButtons = {} + + local applyBtn = CreateFrame("Button", NextWidgetName("Btn"), charSection, "UIPanelButtonTemplate") + applyBtn:SetWidth(120) + applyBtn:SetHeight(24) + applyBtn:SetPoint("TOPLEFT", charSection, "TOPLEFT", 14, -250) + applyBtn:SetText("应用此配置") + StyleButton(applyBtn) + AddBtnIcon(applyBtn, "save") + applyBtn:SetScript("OnClick", function() + local sel = SFrames.ConfigUI._selectedCharProfile + if not sel then + SFrames:Print("请先选择一个角色配置") + return + end + ShowPartialApplyDialog(sel.data, sel.name .. " (" .. sel.realm .. ")") + end) + + local deleteBtn = CreateFrame("Button", NextWidgetName("Btn"), charSection, "UIPanelButtonTemplate") + deleteBtn:SetWidth(120) + deleteBtn:SetHeight(24) + deleteBtn:SetPoint("LEFT", applyBtn, "RIGHT", 8, 0) + deleteBtn:SetText("删除此配置") + StyleButton(deleteBtn) + AddBtnIcon(deleteBtn, "close") + deleteBtn:SetScript("OnClick", function() + local sel = SFrames.ConfigUI._selectedCharProfile + if not sel then + SFrames:Print("请先选择一个角色配置") + return + end + SFrames:DeleteCharProfile(sel.realm, sel.name) + SFrames:Print("已删除 " .. sel.name .. " (" .. sel.realm .. ") 的配置记录") + SFrames.ConfigUI._selectedCharProfile = nil + SFrames.ConfigUI:RefreshCharList() + end) + + local exportSelBtn = CreateFrame("Button", NextWidgetName("Btn"), charSection, "UIPanelButtonTemplate") + exportSelBtn:SetWidth(120) + exportSelBtn:SetHeight(24) + exportSelBtn:SetPoint("LEFT", deleteBtn, "RIGHT", 8, 0) + exportSelBtn:SetText("导出此配置") + StyleButton(exportSelBtn) + AddBtnIcon(exportSelBtn, "scroll") + exportSelBtn:SetScript("OnClick", function() + local sel = SFrames.ConfigUI._selectedCharProfile + if sel and sel.data then + local cleanDB = {} + local data = { charName = sel.name, version = "profile" } + for k, v in pairs(sel.data) do + if k == "_qtDB" then + data.qtDB = v + elseif k == "_qtSharedDB" then + data.qtSharedDB = v + elseif k == "_platesDB" then + data.platesDB = v + elseif k == "_dpsDB" then + data.dpsDB = v + else + cleanDB[k] = v + end + end + data.db = cleanDB + local raw = SerializeValue(data, 0) + local text = PROFILE_PREFIX .. P64Encode(raw) + if profileExportFrame and profileExportFrame.edit then + profileExportFrame.edit:SetText(text) + profileExportFrame:Show() + profileExportFrame:Raise() + profileExportFrame.edit:HighlightText() + profileExportFrame.edit:SetFocus() + else + ShowProfileExportDialog() + end + else + ShowProfileExportDialog() + end + end) + + -- ── 导入配置 ────────────────────────────────────────── + local importSection = CreateSection(root, "导入配置字符串", 8, -298, 520, 80, font) + + CreateDesc(importSection, + "从其他玩家获取配置字符串后粘贴导入。导入后需要 /reload 生效。", + 14, -30, font, 350) + + local importBtn = CreateFrame("Button", NextWidgetName("Btn"), importSection, "UIPanelButtonTemplate") + importBtn:SetWidth(120) + importBtn:SetHeight(24) + importBtn:SetPoint("TOPLEFT", importSection, "TOPLEFT", 14, -54) + importBtn:SetText("粘贴导入") + StyleButton(importBtn) + AddBtnIcon(importBtn, "scroll") + importBtn:SetScript("OnClick", function() ShowProfileImportDialog() end) + + local exportCurBtn = CreateFrame("Button", NextWidgetName("Btn"), importSection, "UIPanelButtonTemplate") + exportCurBtn:SetWidth(140) + exportCurBtn:SetHeight(24) + exportCurBtn:SetPoint("LEFT", importBtn, "RIGHT", 8, 0) + exportCurBtn:SetText("导出当前配置") + StyleButton(exportCurBtn) + AddBtnIcon(exportCurBtn, "save") + exportCurBtn:SetScript("OnClick", function() ShowProfileExportDialog() end) + + -- ── 配置概览 ────────────────────────────────────────── + local summarySection = CreateSection(root, "当前配置概览", 8, -388, 520, 1140, font) + + local summaryFS = summarySection:CreateFontString(nil, "OVERLAY") + summaryFS:SetFont(font, 10, "OUTLINE") + summaryFS:SetPoint("TOPLEFT", summarySection, "TOPLEFT", 14, -34) + summaryFS:SetWidth(490) + summaryFS:SetJustifyH("LEFT") + summaryFS:SetSpacing(3) + summaryFS:SetText(BuildSummaryText()) + self._profileSummaryFS = summaryFS + + self:RefreshCharList() +end + +function SFrames.ConfigUI:RefreshCharList() + if not self._charListHolder then return end + local holder = self._charListHolder + + if self._charListButtons then + for _, btn in ipairs(self._charListButtons) do + btn:Hide() + end + end + self._charListButtons = {} + self._selectedCharProfile = nil + + local profiles = SFrames:GetAllCharProfiles() + local font = SFrames:GetFont() + local curRealm, curName = SFrames:GetCharKey() + local rowH = 22 + local y = 0 + + table.sort(profiles, function(a, b) + if a.realm ~= b.realm then return a.realm < b.realm end + return a.name < b.name + end) + + for idx, prof in ipairs(profiles) do + local pRealm = prof.realm + local pName = prof.name + local pClass = prof.class + local pLevel = prof.level + local pData = prof.data + local isCurrent = (pRealm == curRealm and pName == curName) + + local btn = CreateFrame("Button", NextWidgetName("CharProf"), holder) + btn:SetWidth(490) + btn:SetHeight(rowH) + btn:SetPoint("TOPLEFT", holder, "TOPLEFT", 0, -y) + + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + + if isCurrent then + btn:SetBackdropColor(0.15, 0.25, 0.15, 0.7) + btn:SetBackdropBorderColor(0.3, 0.5, 0.3, 0.8) + else + btn:SetBackdropColor(0.08, 0.08, 0.10, 0.5) + btn:SetBackdropBorderColor(0.2, 0.2, 0.25, 0.6) + end + + local classColors = { + WARRIOR = {0.78, 0.61, 0.43}, PALADIN = {0.96, 0.55, 0.73}, HUNTER = {0.67, 0.83, 0.45}, + ROGUE = {1, 0.96, 0.41}, PRIEST = {1, 1, 1}, SHAMAN = {0, 0.44, 0.87}, + MAGE = {0.41, 0.80, 0.94}, WARLOCK = {0.58, 0.51, 0.79}, DRUID = {1, 0.49, 0.04}, + } + local cc = classColors[pClass] or {0.8, 0.8, 0.8} + + local nameFS = btn:CreateFontString(nil, "OVERLAY") + nameFS:SetFont(font, 10, "OUTLINE") + nameFS:SetPoint("LEFT", btn, "LEFT", 6, 0) + nameFS:SetText(pName) + nameFS:SetTextColor(cc[1], cc[2], cc[3]) + + local infoFS = btn:CreateFontString(nil, "OVERLAY") + infoFS:SetFont(font, 9, "OUTLINE") + infoFS:SetPoint("LEFT", nameFS, "RIGHT", 8, 0) + local infoText = "Lv" .. pLevel .. " " .. pRealm + if isCurrent then infoText = infoText .. " |cff44ff44(当前角色)|r" end + infoFS:SetText(infoText) + infoFS:SetTextColor(0.6, 0.6, 0.6) + + btn.profileData = { realm = pRealm, name = pName, class = pClass, level = pLevel, data = pData } + + btn:SetScript("OnClick", function() + SFrames.ConfigUI._selectedCharProfile = this.profileData + for _, b in ipairs(SFrames.ConfigUI._charListButtons) do + b:SetBackdropBorderColor(0.2, 0.2, 0.25, 0.6) + end + this:SetBackdropBorderColor(1, 0.85, 0.5, 1) + end) + btn:SetScript("OnEnter", function() + this:SetBackdropColor(0.15, 0.15, 0.18, 0.8) + end) + btn:SetScript("OnLeave", function() + local sel = SFrames.ConfigUI._selectedCharProfile + if sel and sel.realm == this.profileData.realm and sel.name == this.profileData.name then + this:SetBackdropColor(0.18, 0.18, 0.22, 0.8) + elseif this.profileData.realm == curRealm and this.profileData.name == curName then + this:SetBackdropColor(0.15, 0.25, 0.15, 0.7) + else + this:SetBackdropColor(0.08, 0.08, 0.10, 0.5) + end + end) + + table.insert(self._charListButtons, btn) + y = y + rowH + 2 + end + + if table.getn(profiles) == 0 then + local emptyFS = holder:CreateFontString(nil, "OVERLAY") + emptyFS:SetFont(font, 10, "OUTLINE") + emptyFS:SetPoint("TOPLEFT", holder, "TOPLEFT", 4, -4) + emptyFS:SetText("暂无其他角色配置。登录其他角色后自动保存。") + emptyFS:SetTextColor(0.5, 0.5, 0.5) + end +end + +function SFrames.ConfigUI:RefreshProfileSummary() + if self._profileSummaryFS then + self._profileSummaryFS:SetText(BuildSummaryText()) + end + if self._charListHolder then + self:RefreshCharList() + end +end + function SFrames.ConfigUI:EnsurePage(mode) if self._pageBuilt[mode] then return end self._pageBuilt[mode] = true if mode == "ui" then self:BuildUIPage() elseif mode == "player" then self:BuildPlayerPage() elseif mode == "target" then self:BuildTargetPage() + elseif mode == "focus" then self:BuildFocusPage() elseif mode == "party" then self:BuildPartyPage() elseif mode == "bags" then self:BuildBagPage() elseif mode == "raid" then self:BuildRaidPage() @@ -3993,6 +5747,7 @@ function SFrames.ConfigUI:EnsurePage(mode) elseif mode == "buff" then self:BuildBuffPage() elseif mode == "personalize" then self:BuildPersonalizePage() elseif mode == "theme" then self:BuildThemePage() + elseif mode == "profile" then self:BuildProfilePage() end end @@ -4001,7 +5756,7 @@ function SFrames.ConfigUI:Build(mode) self:EnsureFrame() local page = string.lower(mode or "ui") - local validPages = { ui = true, player = true, target = true, bags = true, char = true, party = true, raid = true, actionbar = true, keybinds = true, minimap = true, buff = true, personalize = true, theme = true } + local validPages = { ui = true, player = true, target = true, focus = true, bags = true, char = true, party = true, raid = true, actionbar = true, keybinds = true, minimap = true, buff = true, personalize = true, theme = true, profile = true } if not validPages[page] then page = "ui" end if self.frame:IsShown() and self.activePage == page then diff --git a/Core.lua b/Core.lua index b49b294..ba38fb7 100644 --- a/Core.lua +++ b/Core.lua @@ -47,12 +47,31 @@ SFrames.eventFrame = CreateFrame("Frame", "SFramesEventFrame", UIParent) SFrames.events = {} function SFrames:GetIncomingHeals(unit) + -- Resolve pet unitIds: if "target" points to a pet in our group, use the + -- concrete petN / partypetN id so heal-prediction libraries can match it. + local resolvedUnit = unit + if unit == "target" or unit == "targettarget" then + if UnitExists(unit) and UnitPlayerControlled(unit) and not UnitIsPlayer(unit) then + -- It's a player-controlled non-player unit (pet). Find the real unitId. + if UnitIsUnit(unit, "pet") then + resolvedUnit = "pet" + else + for i = 1, 4 do + if UnitIsUnit(unit, "partypet" .. i) then + resolvedUnit = "partypet" .. i + break + end + end + end + end + end + -- Source 1: ShaguTweaks libpredict if ShaguTweaks and ShaguTweaks.libpredict and ShaguTweaks.libpredict.UnitGetIncomingHeals then local lp = ShaguTweaks.libpredict if lp.UnitGetIncomingHealsBreakdown then local ok, total, mine, others = pcall(function() - return lp:UnitGetIncomingHealsBreakdown(unit, UnitName("player")) + return lp:UnitGetIncomingHealsBreakdown(resolvedUnit, UnitName("player")) end) if ok then return math.max(0, tonumber(total) or 0), @@ -60,7 +79,7 @@ function SFrames:GetIncomingHeals(unit) math.max(0, tonumber(others) or 0) end end - local ok, amount = pcall(function() return lp:UnitGetIncomingHeals(unit) end) + local ok, amount = pcall(function() return lp:UnitGetIncomingHeals(resolvedUnit) end) if ok then amount = math.max(0, tonumber(amount) or 0) return amount, 0, amount @@ -70,7 +89,7 @@ function SFrames:GetIncomingHeals(unit) if AceLibrary and AceLibrary.HasInstance and AceLibrary:HasInstance("HealComm-1.0") then local ok, HC = pcall(function() return AceLibrary("HealComm-1.0") end) if ok and HC and HC.getHeal then - local name = UnitName(unit) + local name = UnitName(resolvedUnit) if name then local total = HC:getHeal(name) or 0 total = math.max(0, tonumber(total) or 0) @@ -124,8 +143,127 @@ end -- Addon Loaded Initializer SFrames:RegisterEvent("PLAYER_LOGIN", function() SFrames:Initialize() + SFrames:SaveCharProfile() end) +SFrames:RegisterEvent("PLAYER_LOGOUT", function() + SFrames:SaveCharProfile() +end) + +function SFrames:GetCharKey() + local name = UnitName("player") or "Unknown" + local realm = GetRealmName and GetRealmName() or "Unknown" + return realm, name +end + +function SFrames:SaveCharProfile() + if not SFramesDB then return end + if not SFramesGlobalDB then SFramesGlobalDB = {} end + if not SFramesGlobalDB.CharProfiles then SFramesGlobalDB.CharProfiles = {} end + + local realm, name = self:GetCharKey() + if not SFramesGlobalDB.CharProfiles[realm] then SFramesGlobalDB.CharProfiles[realm] = {} end + + local snapshot = {} + for k, v in pairs(SFramesDB) do + snapshot[k] = v + end + snapshot._savedAt = time and time() or 0 + snapshot._class = select and select(2, UnitClass("player")) or "UNKNOWN" + if UnitClass then + local _, classEn = UnitClass("player") + snapshot._class = classEn or "UNKNOWN" + end + snapshot._level = UnitLevel and UnitLevel("player") or 0 + + -- Nanami-QT + if QuickToolboxDB then + snapshot._qtDB = QuickToolboxDB + end + if QuickToolboxSharedDB then + snapshot._qtSharedDB = QuickToolboxSharedDB + end + + -- Nanami-Plates + if NanamiPlatesDB then + snapshot._platesDB = NanamiPlatesDB + end + + -- Nanami-DPS + if NanamiDPS_DB then + snapshot._dpsDB = NanamiDPS_DB + end + + SFramesGlobalDB.CharProfiles[realm][name] = snapshot +end + +function SFrames:GetAllCharProfiles() + if not SFramesGlobalDB or not SFramesGlobalDB.CharProfiles then return {} end + local list = {} + for realm, chars in pairs(SFramesGlobalDB.CharProfiles) do + for charName, data in pairs(chars) do + table.insert(list, { + realm = realm, + name = charName, + class = data._class or "UNKNOWN", + level = data._level or 0, + savedAt = data._savedAt or 0, + data = data, + }) + end + end + return list +end + +function SFrames:ApplyCharProfile(profileData) + if not profileData or type(profileData) ~= "table" then return false end + for k, v in pairs(profileData) do + if k ~= "_savedAt" and k ~= "_class" and k ~= "_level" and k ~= "Positions" + and k ~= "_qtDB" and k ~= "_qtSharedDB" and k ~= "_platesDB" and k ~= "_dpsDB" then + SFramesDB[k] = v + end + end + + -- Nanami-QT + if profileData._qtDB and type(profileData._qtDB) == "table" then + if not QuickToolboxDB then QuickToolboxDB = {} end + for k, v in pairs(profileData._qtDB) do + QuickToolboxDB[k] = v + end + end + if profileData._qtSharedDB and type(profileData._qtSharedDB) == "table" then + if not QuickToolboxSharedDB then QuickToolboxSharedDB = {} end + for k, v in pairs(profileData._qtSharedDB) do + QuickToolboxSharedDB[k] = v + end + end + + -- Nanami-Plates + if profileData._platesDB and type(profileData._platesDB) == "table" then + if not NanamiPlatesDB then NanamiPlatesDB = {} end + for k, v in pairs(profileData._platesDB) do + NanamiPlatesDB[k] = v + end + end + + -- Nanami-DPS + if profileData._dpsDB and type(profileData._dpsDB) == "table" then + if not NanamiDPS_DB then NanamiDPS_DB = {} end + for k, v in pairs(profileData._dpsDB) do + NanamiDPS_DB[k] = v + end + end + + return true +end + +function SFrames:DeleteCharProfile(realm, name) + if not SFramesGlobalDB or not SFramesGlobalDB.CharProfiles then return end + if SFramesGlobalDB.CharProfiles[realm] then + SFramesGlobalDB.CharProfiles[realm][name] = nil + end +end + function SFrames:SafeInit(name, initFn) local ok, err = pcall(initFn) if not ok then @@ -208,7 +346,6 @@ function SFrames:DoFullInitialize() deferFrame:SetScript("OnUpdate", function() if idx > table.getn(deferred) then this:SetScript("OnUpdate", nil) - SFrames:Print("所有模块加载完成 =^_^=") return end local batchEnd = idx + batchSize - 1 @@ -421,6 +558,35 @@ function SFrames:InitSlashCommands() else SFrames:Print("StatSummary module unavailable.") end + elseif cmd == "iteminfo" or cmd == "ii" then + -- 打印鼠标悬停物品或指定itemID的GetItemInfo全部返回值 + local itemId = tonumber(args) + local link + if itemId and itemId > 0 then + link = "item:" .. itemId .. ":0:0:0:0:0:0:0" + else + local ttName = GameTooltip:GetName() + local left1 = ttName and _G[ttName .. "TextLeft1"] + local name = left1 and left1:GetText() + if name and name ~= "" and GetItemLinkByName then + link = GetItemLinkByName(name) + end + if not link then + SFrames:Print("用法: /nui iteminfo 或悬停物品后输入 /nui iteminfo") + return + end + end + -- 打印所有返回值(不预设数量,逐个检查) + local results = { GetItemInfo(link) } + SFrames:Print("|cff88ccff=== GetItemInfo 结果 ===|r") + SFrames:Print(" link: " .. tostring(link)) + SFrames:Print(" 返回值数量: " .. table.getn(results)) + for i = 1, table.getn(results) do + SFrames:Print(" [" .. i .. "] " .. tostring(results[i])) + end + if table.getn(results) == 0 then + SFrames:Print(" |cffff4444(无返回值,物品可能不在本地缓存中)|r") + end elseif cmd == "afk" then if SFrames.AFKScreen and SFrames.AFKScreen.Toggle then SFrames.AFKScreen:Toggle() @@ -567,11 +733,13 @@ function SFrames:InitSlashCommands() DEFAULT_CHAT_FRAME:AddMessage(" |cffffd100" .. name .. "|r (用户自定义)") end end + elseif cmd == "profile" then + if SFrames.ConfigUI and SFrames.ConfigUI.Build then SFrames.ConfigUI:Build("profile") end elseif cmd == "config" or cmd == "" then if SFrames.ConfigUI and SFrames.ConfigUI.Build then SFrames.ConfigUI:Build("ui") end else local hex = SFrames.Theme and SFrames.Theme:GetAccentHex() or "ffffb3d9" - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r Commands: /nui, /nui ui, /nui bags, /nui chat, /nui layout, /nui unlock, /nui lock, /nui test, /nui partyh, /nui partyv, /nui focushelp, /nui mapreveal, /nui mapscan, /nui stats, /nui afk, /nui pin, /nui bind, /nui keybinds") + DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r Commands: /nui, /nui ui, /nui bags, /nui chat, /nui layout, /nui unlock, /nui lock, /nui test, /nui partyh, /nui partyv, /nui focushelp, /nui mapreveal, /nui mapscan, /nui stats, /nui afk, /nui pin, /nui bind, /nui keybinds, /nui profile") end end end @@ -633,6 +801,11 @@ function SFrames:HideBlizzardFrames() TargetFrame:Hide() TargetFrame.Show = function() end end + -- 禁用原版目标框体右键菜单 + if TargetFrameDropDown then + TargetFrameDropDown:Hide() + TargetFrameDropDown.initialize = function() end + end if ComboFrame then ComboFrame:UnregisterAllEvents() ComboFrame:Hide() diff --git a/Focus.lua b/Focus.lua index 3e0fe42..937e744 100644 --- a/Focus.lua +++ b/Focus.lua @@ -1,4 +1,31 @@ +-------------------------------------------------------------------------------- +-- Nanami-UI: Focus Frame +-- Complete focus unit frame with health/power/portrait/auras/castbar/distance +-- Supports: native focus > SuperWoW GUID > static (name-only) mode +-------------------------------------------------------------------------------- + SFrames.Focus = {} +local _A = SFrames.ActiveTheme + +local AURA_SIZE = 20 +local AURA_SPACING = 2 +local AURA_ROW_SPACING = 1 + +-------------------------------------------------------------------------------- +-- Focus combat-log cast tracker (name-based, no GUID/unitID needed) +-- Populated by CHAT_MSG_SPELL_* events, consumed by CastbarOnUpdate +-------------------------------------------------------------------------------- +local focusCastTracker = {} -- [casterName] = { spell, startTime, duration, icon, channel } + +-- Pattern matcher for localized global strings (same logic as Target.lua CLMatch) +local function cmatch(str, pattern) + if not str or not pattern then return nil end + local pat = string.gsub(pattern, "%%%d?%$?s", "(.+)") + pat = string.gsub(pat, "%%%d?%$?d", "(%%d+)") + for a, b, c, d in string.gfind(str, pat) do + return a, b, c, d + end +end local function Trim(text) if type(text) ~= "string" then return "" end @@ -7,152 +34,1608 @@ local function Trim(text) return text end +-- Drop cursor item onto a unit (same helper as Target/Party/Raid) +local function TryDropCursorOnUnit(unit) + if not unit or not UnitExists(unit) then return false end + if not CursorHasItem or not CursorHasItem() then return false end + if not DropItemOnUnit then return false end + local ok = pcall(DropItemOnUnit, unit) + if not ok then return false end + return not CursorHasItem() +end + +-- TurtleWoW throws "Unknown unit" for "focus" if not supported. +-- Detect once at load time whether native focus is available. +local NATIVE_FOCUS_OK = false +do + local ok, val = pcall(function() return UnitExists("focus") end) + if ok then NATIVE_FOCUS_OK = true end +end + +local function FocusUnitExists() + if not NATIVE_FOCUS_OK then return false end + local ok, val = pcall(UnitExists, "focus") + return ok and val +end + +-- Simple spell icon lookup (mirrors Target.lua's local GetSpellIcon) +local focusSpellIconCache = {} +local function FocusGetSpellIcon(spellName) + if not spellName then return nil end + if focusSpellIconCache[spellName] then return focusSpellIconCache[spellName] end + local i = 1 + while true do + local name, _, tex = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + if tex then focusSpellIconCache[name] = tex end + i = i + 1 + end + return focusSpellIconCache[spellName] +end + +-------------------------------------------------------------------------------- +-- DB helpers +-------------------------------------------------------------------------------- function SFrames.Focus:EnsureDB() if not SFramesDB then SFramesDB = {} end if not SFramesDB.Focus then SFramesDB.Focus = {} end return SFramesDB.Focus end -function SFrames.Focus:Initialize() - self:EnsureDB() -end - function SFrames.Focus:GetFocusName() - if UnitExists and UnitExists("focus") and UnitName then + if FocusUnitExists() and UnitName then local name = UnitName("focus") - if name and name ~= "" then - return name - end + if name and name ~= "" then return name end end - local db = self:EnsureDB() - if db.name and db.name ~= "" then - return db.name - end + if db.name and db.name ~= "" then return db.name end return nil end -function SFrames.Focus:SetFromTarget() - if not UnitExists or not UnitExists("target") then - return false, "NO_TARGET" +-- Determine the best unitID to query focus data +function SFrames.Focus:GetUnitID() + -- 1) Native focus + if FocusUnitExists() then return "focus" end + + local db = self:EnsureDB() + local focusName = db.name + + -- 2) SuperWoW GUID + if SUPERWOW_VERSION and db.guid and db.guid ~= "" and UnitExists then + local ok, exists = pcall(UnitExists, db.guid) + if ok and exists then return db.guid end end - local name = UnitName and UnitName("target") - if not name or name == "" then - return false, "INVALID_TARGET" + if not focusName or focusName == "" then return nil end + + -- 3) If focus name matches current target, use "target" + if UnitExists("target") then + local tName = UnitName("target") + if tName and tName == focusName then + -- Also try to grab GUID if we didn't have one + if SUPERWOW_VERSION and (not db.guid or db.guid == "") and UnitGUID then + local ok, g = pcall(UnitGUID, "target") + if ok and g then db.guid = g end + end + return "target" + end end + -- 4) Check party/raid members by name + local n = GetNumPartyMembers and GetNumPartyMembers() or 0 + for i = 1, n do + local pUnit = "party" .. i + if UnitExists(pUnit) and UnitName(pUnit) == focusName then return pUnit end + end + local r = GetNumRaidMembers and GetNumRaidMembers() or 0 + for i = 1, r do + local rUnit = "raid" .. i + if UnitExists(rUnit) and UnitName(rUnit) == focusName then return rUnit end + end + + -- 5) Scan indirect unitIDs: targettarget, party targets, etc. + local scanUnits = { "targettarget" } + for i = 1, (n > 0 and n or 0) do + table.insert(scanUnits, "party" .. i .. "target") + end + for _, u in ipairs(scanUnits) do + local ok, exists = pcall(UnitExists, u) + if ok and exists then + local ok2, uName = pcall(UnitName, u) + if ok2 and uName == focusName then return u end + end + end + + return nil +end + +-------------------------------------------------------------------------------- +-- Set / Clear / Target / Cast (data logic) +-------------------------------------------------------------------------------- +-- STUB: SetFromTarget +function SFrames.Focus:SetFromTarget() + return self:SetFromUnit("target") +end + +-- Set focus from any unitID (target, party1, raid3, etc.) +function SFrames.Focus:SetFromUnit(unit) + if not unit or not UnitExists or not UnitExists(unit) then return false, "NO_UNIT" end + local name = UnitName and UnitName(unit) + if not name or name == "" then return false, "INVALID_UNIT" end + local db = self:EnsureDB() db.name = name - db.level = UnitLevel and UnitLevel("target") or nil - local _, classToken = UnitClass and UnitClass("target") + db.level = UnitLevel and UnitLevel(unit) or nil + local classToken + if UnitClass then + _, classToken = UnitClass(unit) + end db.class = classToken - local usedNative = false - if FocusUnit then - local ok = pcall(function() FocusUnit("target") end) - if not ok then - ok = pcall(function() FocusUnit() end) + -- Get GUID with pcall protection + db.guid = nil + if UnitGUID then + local ok, g = pcall(UnitGUID, unit) + if ok and g then + db.guid = g + if SFrames.guidToName then + SFrames.guidToName[g] = name + end end - if ok and UnitExists and UnitExists("focus") then - usedNative = true + end + -- If UnitGUID failed, try reverse lookup from guidToName + if not db.guid and SFrames.guidToName then + for guid, gname in pairs(SFrames.guidToName) do + if gname == name then + db.guid = guid + break + end end end + -- Try to set native focus + local usedNative = false + if FocusUnit then + -- FocusUnit() sets current target as focus; if unit is not target, try FocusUnit(unit) + pcall(FocusUnit, unit) + if not FocusUnitExists() then + pcall(FocusUnit) + end + if FocusUnitExists() then usedNative = true end + end + + self:OnFocusChanged() return true, name, usedNative end function SFrames.Focus:Clear() - if ClearFocus then - pcall(ClearFocus) - end - + if ClearFocus then pcall(ClearFocus) end local db = self:EnsureDB() db.name = nil db.level = nil db.class = nil - + db.guid = nil + self:OnFocusChanged() return true end function SFrames.Focus:Target() - if UnitExists and UnitExists("focus") then + if FocusUnitExists() then TargetUnit("focus") return true, "NATIVE" end + local focusName = self:GetFocusName() + if not focusName then return false, "NO_FOCUS" end - local name = self:GetFocusName() - if not name then - return false, "NO_FOCUS" + -- SuperWoW GUID + local db = self:EnsureDB() + if SUPERWOW_VERSION and db.guid and db.guid ~= "" then + local ok = pcall(TargetUnit, db.guid) + if ok and UnitExists("target") then return true, "GUID" end end if TargetByName then - TargetByName(name, true) - else - return false, "NO_TARGETBYNAME" - end - - if UnitExists and UnitExists("target") and UnitName and UnitName("target") == name then - return true, "NAME" + TargetByName(focusName, true) + if UnitExists("target") and UnitName("target") == focusName then + return true, "NAME" + end end return false, "NOT_FOUND" end function SFrames.Focus:Cast(spellName) local spell = Trim(spellName) - if spell == "" then - return false, "NO_SPELL" + if spell == "" then return false, "NO_SPELL" end + + -- Best: native focus + SuperWoW CastSpellByName(spell, unit) + if FocusUnitExists() and SUPERWOW_VERSION then + local ok = pcall(CastSpellByName, spell, "focus") + if ok then + if SpellIsTargeting and SpellIsTargeting() then SpellTargetUnit("focus") end + if SpellIsTargeting and SpellIsTargeting() then SpellStopTargeting(); return false, "BAD_TARGET" end + return true, "SUPERWOW_NATIVE" + end end - if UnitExists and UnitExists("focus") then + -- Native focus without SuperWoW + if FocusUnitExists() then CastSpellByName(spell) - if SpellIsTargeting and SpellIsTargeting() then - SpellTargetUnit("focus") - end - if SpellIsTargeting and SpellIsTargeting() then - SpellStopTargeting() - return false, "BAD_TARGET" - end + if SpellIsTargeting and SpellIsTargeting() then SpellTargetUnit("focus") end + if SpellIsTargeting and SpellIsTargeting() then SpellStopTargeting(); return false, "BAD_TARGET" end return true, "NATIVE" end - local focusName = self:GetFocusName() - if not focusName then - return false, "NO_FOCUS" + -- Fallback: GUID + local db = self:EnsureDB() + if SUPERWOW_VERSION and db.guid and db.guid ~= "" then + local ok = pcall(CastSpellByName, spell, db.guid) + if ok then + if SpellIsTargeting and SpellIsTargeting() then SpellTargetUnit(db.guid) end + if SpellIsTargeting and SpellIsTargeting() then SpellStopTargeting(); return false, "BAD_TARGET" end + return true, "SUPERWOW_GUID" + end end + -- Last resort: target-switch + local focusName = self:GetFocusName() + if not focusName then return false, "NO_FOCUS" end + local hadTarget = UnitExists and UnitExists("target") local prevName = hadTarget and UnitName and UnitName("target") or nil - local onFocus = false + if hadTarget and prevName == focusName then onFocus = true elseif TargetByName then TargetByName(focusName, true) - if UnitExists and UnitExists("target") and UnitName and UnitName("target") == focusName then - onFocus = true - end - end - - if not onFocus then - return false, "FOCUS_NOT_FOUND" + if UnitExists("target") and UnitName("target") == focusName then onFocus = true end end + if not onFocus then return false, "FOCUS_NOT_FOUND" end CastSpellByName(spell) - if SpellIsTargeting and SpellIsTargeting() then - SpellTargetUnit("target") - end + if SpellIsTargeting and SpellIsTargeting() then SpellTargetUnit("target") end if SpellIsTargeting and SpellIsTargeting() then SpellStopTargeting() - if hadTarget and prevName and prevName ~= focusName and TargetLastTarget then - TargetLastTarget() - end + if hadTarget and prevName and prevName ~= focusName and TargetLastTarget then TargetLastTarget() end return false, "BAD_TARGET" end - - if hadTarget and prevName and prevName ~= focusName and TargetLastTarget then - TargetLastTarget() - end + if hadTarget and prevName and prevName ~= focusName and TargetLastTarget then TargetLastTarget() end return true, "NAME" end +-------------------------------------------------------------------------------- +-- Frame Creation +-------------------------------------------------------------------------------- +-- STUB: CreateFocusFrame +function SFrames.Focus:CreateFocusFrame() + if SFramesDB and SFramesDB.focusEnabled == false then return end + + local width = tonumber(SFramesDB and SFramesDB.focusFrameWidth) or 200 + local pWidth = tonumber(SFramesDB and SFramesDB.focusPortraitWidth) or 45 + local hHeight = tonumber(SFramesDB and SFramesDB.focusHealthHeight) or 32 + local pHeight = tonumber(SFramesDB and SFramesDB.focusPowerHeight) or 10 + local totalH = hHeight + pHeight + 3 + local scale = tonumber(SFramesDB and SFramesDB.focusFrameScale) or 0.9 + local bgAlpha = tonumber(SFramesDB and SFramesDB.focusBgAlpha) or 0.9 + local nameFontSize = tonumber(SFramesDB and SFramesDB.focusNameFontSize) or 11 + local valueFontSize = tonumber(SFramesDB and SFramesDB.focusValueFontSize) or 10 + + local f = CreateFrame("Button", "SFramesFocusFrame", UIParent) + f:SetWidth(width) + f:SetHeight(totalH) + f:SetScale(scale) + + if SFramesDB and SFramesDB.Positions and SFramesDB.Positions["FocusFrame"] then + local pos = SFramesDB.Positions["FocusFrame"] + -- Validate: if GetTop would be near 0 or negative, position is bad + f:SetPoint(pos.point or "LEFT", UIParent, pos.relativePoint or "LEFT", pos.xOfs or 250, pos.yOfs or 0) + -- After setting, check if visible on screen + local top = f:GetTop() + local left = f:GetLeft() + if not top or not left or top < 50 or left < 0 then + -- Bad position, reset + f:ClearAllPoints() + f:SetPoint("LEFT", UIParent, "LEFT", 250, 0) + SFramesDB.Positions["FocusFrame"] = nil + end + else + f:SetPoint("LEFT", UIParent, "LEFT", 250, 0) + end + + f:SetMovable(true) + f:EnableMouse(true) + f:RegisterForClicks("LeftButtonUp", "RightButtonUp") + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function() if IsAltKeyDown() or SFrames.isUnlocked then this:StartMoving() end end) + f:SetScript("OnDragStop", function() + this:StopMovingOrSizing() + if not SFramesDB then SFramesDB = {} end + if not SFramesDB.Positions then SFramesDB.Positions = {} end + local point, _, relativePoint, xOfs, yOfs = this:GetPoint() + SFramesDB.Positions["FocusFrame"] = { point = point, relativePoint = relativePoint, xOfs = xOfs, yOfs = yOfs } + end) + + f:SetScript("OnClick", function() + local uid = SFrames.Focus:GetUnitID() + if arg1 == "LeftButton" then + -- User clicked the focus frame, don't restore target on leave + this._focusSwappedTarget = false + -- 物品拖拽到焦点 + if uid and TryDropCursorOnUnit(uid) then return end + if uid then + if SpellIsTargeting and SpellIsTargeting() then + SpellTargetUnit(uid) + else + TargetUnit(uid) + end + else + SFrames.Focus:Target() + end + elseif arg1 == "RightButton" then + if SpellIsTargeting and SpellIsTargeting() then + SpellStopTargeting() + return + end + -- 有可查询的 unitID 时弹出右键菜单,否则清除焦点 + if uid and UnitExists(uid) then + if not SFrames.Focus.dropDown then + SFrames.Focus.dropDown = CreateFrame("Frame", "SFramesFocusDropDown", UIParent, "UIDropDownMenuTemplate") + SFrames.Focus.dropDown.displayMode = "MENU" + SFrames.Focus.dropDown.initialize = function() + local dd = SFrames.Focus.dropDown + local unit = dd.focusUID + local name = dd.focusName + if not name then return end + + local info = {} + info.text = "|cff88ccff[焦点]|r " .. name + info.isTitle = 1 + info.notCheckable = 1 + UIDropDownMenu_AddButton(info) + + if unit and UnitIsPlayer(unit) and UnitIsFriend("player", unit) and not UnitIsUnit(unit, "player") then + -- 悄悄话 + info = {} + info.text = "悄悄话" + info.notCheckable = 1 + info.func = function() ChatFrame_SendTell(name) end + UIDropDownMenu_AddButton(info) + + -- 观察 + info = {} + info.text = "观察" + info.notCheckable = 1 + info.func = function() + local u = SFrames.Focus:GetUnitID() + if u then InspectUnit(u) end + end + UIDropDownMenu_AddButton(info) + + -- 交易 + info = {} + info.text = "交易" + info.notCheckable = 1 + info.func = function() + local u = SFrames.Focus:GetUnitID() + if u then InitiateTrade(u) end + end + UIDropDownMenu_AddButton(info) + + -- 邀请组队 + local inParty = UnitInParty(unit) + if not inParty then + info = {} + info.text = "邀请组队" + info.notCheckable = 1 + info.func = function() InviteByName(name) end + UIDropDownMenu_AddButton(info) + end + + -- 跟随 + info = {} + info.text = "跟随" + info.notCheckable = 1 + info.func = function() + local u = SFrames.Focus:GetUnitID() + if u then FollowUnit(u) end + end + UIDropDownMenu_AddButton(info) + + -- 决斗 + if not inParty then + info = {} + info.text = "决斗" + info.notCheckable = 1 + info.func = function() + local u = SFrames.Focus:GetUnitID() + if u then StartDuel(u) end + end + UIDropDownMenu_AddButton(info) + end + end + + -- 取消焦点(始终显示) + info = {} + info.text = "取消焦点" + info.notCheckable = 1 + info.func = function() SFrames.Focus:Clear() end + UIDropDownMenu_AddButton(info) + end + end + SFrames.Focus.dropDown.focusUID = uid + SFrames.Focus.dropDown.focusName = UnitName(uid) or SFrames.Focus:GetFocusName() + ToggleDropDownMenu(1, nil, SFrames.Focus.dropDown, "cursor") + else + SFrames.Focus:Clear() + end + end + end) + + -- 物品拖拽释放到焦点框体 + f:SetScript("OnReceiveDrag", function() + local uid = SFrames.Focus:GetUnitID() + if uid and TryDropCursorOnUnit(uid) then return end + if uid and SpellIsTargeting and SpellIsTargeting() then + SpellTargetUnit(uid) + end + end) + + -- Track whether we did a temporary target-switch for mouseover casting + f._focusSwappedTarget = false + f._focusPrevTargetName = nil + + f:SetScript("OnEnter", function() + local uid = SFrames.Focus:GetUnitID() + if uid then + this.unit = uid + if SetMouseoverUnit then SetMouseoverUnit(uid) end + GameTooltip_SetDefaultAnchor(GameTooltip, this) + GameTooltip:SetUnit(uid) + GameTooltip:AddLine("|cff999999按住 Alt + 左键拖动|r", 0.6, 0.6, 0.6) + GameTooltip:Show() + else + -- No valid unitID — try temporary target switch for mouseover casting + local focusName = SFrames.Focus:GetFocusName() + if focusName and TargetByName then + this._focusPrevTargetName = UnitExists("target") and UnitName("target") or nil + this._focusSwappedTarget = false + -- Only switch if current target is not already the focus + if not (UnitExists("target") and UnitName("target") == focusName) then + TargetByName(focusName, true) + if UnitExists("target") and UnitName("target") == focusName then + this._focusSwappedTarget = true + end + end + -- Now "target" should be our focus + if UnitExists("target") and UnitName("target") == focusName then + this.unit = "target" + if SetMouseoverUnit then SetMouseoverUnit("target") end + GameTooltip_SetDefaultAnchor(GameTooltip, this) + GameTooltip:SetUnit("target") + GameTooltip:AddLine("|cff999999按住 Alt + 左键拖动|r", 0.6, 0.6, 0.6) + GameTooltip:Show() + else + this.unit = nil + GameTooltip_SetDefaultAnchor(GameTooltip, this) + GameTooltip:SetText("焦点: " .. focusName) + GameTooltip:AddLine("|cff999999按住 Alt + 左键拖动|r", 0.6, 0.6, 0.6) + GameTooltip:Show() + end + else + GameTooltip_SetDefaultAnchor(GameTooltip, this) + GameTooltip:SetText("|cff999999按住 Alt + 左键拖动|r") + GameTooltip:Show() + end + end + end) + f:SetScript("OnLeave", function() + if SetMouseoverUnit then SetMouseoverUnit() end + GameTooltip:Hide() + -- Restore previous target if we swapped + if this._focusSwappedTarget then + this._focusSwappedTarget = false + if this._focusPrevTargetName and this._focusPrevTargetName ~= "" then + TargetByName(this._focusPrevTargetName, true) + else + ClearTarget() + end + end + this._focusPrevTargetName = nil + this.unit = nil + end) + + SFrames:CreateUnitBackdrop(f) + + -- Portrait (right side) — EnableMouse(false) so clicks pass through to main Button + local showPortrait = not (SFramesDB and SFramesDB.focusShowPortrait == false) + f.portrait = CreateFrame("PlayerModel", nil, f) + f.portrait:SetWidth(pWidth) + f.portrait:SetHeight(totalH - 2) + f.portrait:SetPoint("RIGHT", f, "RIGHT", -1, 0) + f.portrait:EnableMouse(false) + + local pbg = CreateFrame("Frame", nil, f) + pbg:SetPoint("TOPLEFT", f.portrait, "TOPLEFT", -1, 0) + pbg:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0) + pbg:SetFrameLevel(f:GetFrameLevel()) + SFrames:CreateUnitBackdrop(pbg) + f.portraitBG = pbg + pbg:EnableMouse(false) + + if not showPortrait then + f.portrait:Hide() + pbg:Hide() + end + + -- Health bar + f.health = SFrames:CreateStatusBar(f, "SFramesFocusHealth") + f.health:EnableMouse(false) + if showPortrait then + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + f.health:SetPoint("TOPRIGHT", f.portrait, "TOPLEFT", -1, 0) + else + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, 0) + end + f.health:SetHeight(hHeight) + + local hbg = CreateFrame("Frame", nil, f) + hbg:SetPoint("TOPLEFT", f.health, "TOPLEFT", -1, 1) + hbg:SetPoint("BOTTOMRIGHT", f.health, "BOTTOMRIGHT", 1, -1) + hbg:SetFrameLevel(f:GetFrameLevel() - 1) + SFrames:CreateUnitBackdrop(hbg) + f.healthBGFrame = hbg + hbg:EnableMouse(false) + + f.health.bg = f.health:CreateTexture(nil, "BACKGROUND") + f.health.bg:SetAllPoints() + f.health.bg:SetTexture(SFrames:GetTexture()) + f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) + + -- Power bar + f.power = SFrames:CreateStatusBar(f, "SFramesFocusPower") + f.power:EnableMouse(false) + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -1) + if showPortrait then + f.power:SetPoint("BOTTOMRIGHT", f.portrait, "BOTTOMLEFT", -1, 0) + else + f.power:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1) + end + + local powerbg = CreateFrame("Frame", nil, f) + powerbg:SetPoint("TOPLEFT", f.power, "TOPLEFT", -1, 1) + powerbg:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 1, -1) + powerbg:SetFrameLevel(f:GetFrameLevel() - 1) + SFrames:CreateUnitBackdrop(powerbg) + f.powerBGFrame = powerbg + powerbg:EnableMouse(false) + + f.power.bg = f.power:CreateTexture(nil, "BACKGROUND") + f.power.bg:SetAllPoints() + f.power.bg:SetTexture(SFrames:GetTexture()) + f.power.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) + + -- Class icon + f.classIcon = SFrames:CreateClassIcon(f, 14) + f.classIcon.overlay:SetPoint("CENTER", f.portrait, "TOPRIGHT", 0, 0) + f.classIcon.overlay:EnableMouse(false) + + -- Texts + f.nameText = SFrames:CreateFontString(f.health, nameFontSize, "LEFT") + f.nameText:SetPoint("LEFT", f.health, "LEFT", 4, 0) + f.nameText:SetShadowColor(0, 0, 0, 1) + f.nameText:SetShadowOffset(1, -1) + + f.healthText = SFrames:CreateFontString(f.health, valueFontSize, "RIGHT") + f.healthText:SetPoint("RIGHT", f.health, "RIGHT", -4, 0) + f.healthText:SetShadowColor(0, 0, 0, 1) + f.healthText:SetShadowOffset(1, -1) + + f.powerText = SFrames:CreateFontString(f.power, valueFontSize - 1, "RIGHT") + f.powerText:SetPoint("RIGHT", f.power, "RIGHT", -4, 0) + f.powerText:SetShadowColor(0, 0, 0, 1) + f.powerText:SetShadowOffset(1, -1) + + -- Raid icon + local raidIconSize = 18 + local raidIconOvr = CreateFrame("Frame", nil, f) + raidIconOvr:SetFrameLevel((f:GetFrameLevel() or 0) + 5) + raidIconOvr:SetWidth(raidIconSize) + raidIconOvr:SetHeight(raidIconSize) + raidIconOvr:SetPoint("CENTER", f.health, "TOP", 0, 0) + raidIconOvr:EnableMouse(false) + f.raidIcon = raidIconOvr:CreateTexture(nil, "OVERLAY") + f.raidIcon:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") + f.raidIcon:SetAllPoints(raidIconOvr) + f.raidIcon:Hide() + f.raidIconOverlay = raidIconOvr + + -- "焦点" label (small text at top-left corner) + f.focusLabel = SFrames:CreateFontString(f, 9, "LEFT") + f.focusLabel:SetPoint("BOTTOMLEFT", f, "TOPLEFT", 2, 2) + f.focusLabel:SetText("|cff88ccff焦点|r") + f.focusLabel:SetShadowColor(0, 0, 0, 1) + f.focusLabel:SetShadowOffset(1, -1) + + self.frame = f + f:Hide() + + self:CreateAuras() + self:CreateCastbar() +end + +-- STUB: CreateAuras +function SFrames.Focus:CreateAuras() + if not self.frame then return end + if SFramesDB and SFramesDB.focusShowAuras == false then return end + + self.frame.buffs = {} + self.frame.debuffs = {} + + for i = 1, 8 do + local b = CreateFrame("Button", "SFramesFocusBuff"..i, self.frame) + b:SetWidth(AURA_SIZE) + b:SetHeight(AURA_SIZE) + SFrames:CreateUnitBackdrop(b) + b.icon = b:CreateTexture(nil, "ARTWORK") + b.icon:SetAllPoints() + b.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) + b.cdText = SFrames:CreateFontString(b, 8, "CENTER") + b.cdText:SetPoint("BOTTOM", b, "BOTTOM", 0, 1) + b.cdText:SetTextColor(1, 0.82, 0) + b.cdText:SetShadowColor(0, 0, 0, 1) + b.cdText:SetShadowOffset(1, -1) + b:SetScript("OnEnter", function() + local uid = SFrames.Focus:GetUnitID() + if uid then + GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT") + GameTooltip:SetUnitBuff(uid, this:GetID()) + end + end) + b:SetScript("OnLeave", function() GameTooltip:Hide() end) + if i == 1 then + b:SetPoint("TOPLEFT", self.frame, "BOTTOMLEFT", 0, -1) + else + b:SetPoint("LEFT", self.frame.buffs[i-1], "RIGHT", AURA_SPACING, 0) + end + b:Hide() + self.frame.buffs[i] = b + end + + for i = 1, 8 do + local b = CreateFrame("Button", "SFramesFocusDebuff"..i, self.frame) + b:SetWidth(AURA_SIZE) + b:SetHeight(AURA_SIZE) + SFrames:CreateUnitBackdrop(b) + b.icon = b:CreateTexture(nil, "ARTWORK") + b.icon:SetAllPoints() + b.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) + b.cdText = SFrames:CreateFontString(b, 8, "CENTER") + b.cdText:SetPoint("BOTTOM", b, "BOTTOM", 0, 1) + b.cdText:SetTextColor(1, 0.82, 0) + b.cdText:SetShadowColor(0, 0, 0, 1) + b.cdText:SetShadowOffset(1, -1) + b.border = b:CreateTexture(nil, "OVERLAY") + b.border:SetPoint("TOPLEFT", -1, 1) + b.border:SetPoint("BOTTOMRIGHT", 1, -1) + b.border:SetTexture("Interface\\Buttons\\UI-Debuff-Overlays") + b.border:SetTexCoord(0.296875, 0.5703125, 0, 0.515625) + b:SetScript("OnEnter", function() + local uid = SFrames.Focus:GetUnitID() + if uid then + GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT") + GameTooltip:SetUnitDebuff(uid, this:GetID()) + end + end) + b:SetScript("OnLeave", function() GameTooltip:Hide() end) + if i == 1 then + b:SetPoint("TOPLEFT", self.frame.buffs[1], "BOTTOMLEFT", 0, -AURA_ROW_SPACING) + else + b:SetPoint("LEFT", self.frame.debuffs[i-1], "RIGHT", AURA_SPACING, 0) + end + b:Hide() + self.frame.debuffs[i] = b + end +end + +-- STUB: CreateCastbar +function SFrames.Focus:CreateCastbar() + if not self.frame then return end + if SFramesDB and SFramesDB.focusShowCastBar == false then return end + + local cbH = 12 + local cb = SFrames:CreateStatusBar(self.frame, "SFramesFocusCastbar") + cb:SetHeight(cbH) + cb:SetPoint("BOTTOMLEFT", self.frame, "TOPLEFT", 0, 6) + local showPortrait = not (SFramesDB and SFramesDB.focusShowPortrait == false) + if showPortrait then + cb:SetPoint("BOTTOMRIGHT", self.frame.portrait, "TOPRIGHT", -(cbH + 6), 6) + else + cb:SetPoint("BOTTOMRIGHT", self.frame, "TOPRIGHT", -(cbH + 6), 6) + end + + local cbbg = CreateFrame("Frame", nil, self.frame) + cbbg:SetPoint("TOPLEFT", cb, "TOPLEFT", -1, 1) + cbbg:SetPoint("BOTTOMRIGHT", cb, "BOTTOMRIGHT", 1, -1) + cbbg:SetFrameLevel(cb:GetFrameLevel() - 1) + SFrames:CreateUnitBackdrop(cbbg) + + cb.bg = cb:CreateTexture(nil, "BACKGROUND") + cb.bg:SetAllPoints() + cb.bg:SetTexture(SFrames:GetTexture()) + cb.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) + cb:SetStatusBarColor(1, 0.7, 0) + + cb.text = SFrames:CreateFontString(cb, 9, "LEFT") + cb.text:SetPoint("LEFT", cb, "LEFT", 4, 0) + + cb.icon = cb:CreateTexture(nil, "ARTWORK") + cb.icon:SetWidth(cbH + 2) + cb.icon:SetHeight(cbH + 2) + cb.icon:SetPoint("LEFT", cb, "RIGHT", 4, 0) + cb.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) + + local ibg = CreateFrame("Frame", nil, self.frame) + ibg:SetPoint("TOPLEFT", cb.icon, "TOPLEFT", -1, 1) + ibg:SetPoint("BOTTOMRIGHT", cb.icon, "BOTTOMRIGHT", 1, -1) + ibg:SetFrameLevel(cb:GetFrameLevel() - 1) + SFrames:CreateUnitBackdrop(ibg) + + cb:Hide(); cbbg:Hide(); cb.icon:Hide(); ibg:Hide() + + self.frame.castbar = cb + self.frame.castbar.cbbg = cbbg + self.frame.castbar.ibg = ibg + + self.frame.castbarUpdater = CreateFrame("Frame", "SFramesFocusCastbarUpdater", UIParent) + self.frame.castbarUpdater:SetScript("OnUpdate", function() SFrames.Focus:CastbarOnUpdate() end) +end + +-------------------------------------------------------------------------------- +-- Data Update +-------------------------------------------------------------------------------- +-- STUB: UpdateAll +function SFrames.Focus:UpdateAll() + local uid = self:GetUnitID() + if not uid then + -- Static mode: show name only + local db = self:EnsureDB() + if db.name then + self.frame.nameText:SetText(db.name or "") + if db.class and SFrames.Config.colors.class[db.class] then + local c = SFrames.Config.colors.class[db.class] + self.frame.nameText:SetTextColor(c.r, c.g, c.b) + self.frame.health:SetStatusBarColor(c.r, c.g, c.b) + else + self.frame.nameText:SetTextColor(0.7, 0.7, 0.7) + self.frame.health:SetStatusBarColor(0.4, 0.4, 0.4) + end + self.frame.health:SetMinMaxValues(0, 1) + self.frame.health:SetValue(1) + self.frame.healthText:SetText("") + self.frame.power:SetMinMaxValues(0, 1) + self.frame.power:SetValue(0) + self.frame.powerText:SetText("") + if self.frame.portrait then self.frame.portrait:Hide() end + if self.frame.classIcon then self.frame.classIcon:Hide(); if self.frame.classIcon.overlay then self.frame.classIcon.overlay:Hide() end end + self.frame.raidIcon:Hide() + self:HideAuras() + end + return + end + + self:UpdateHealth() + self:UpdatePowerType() + self:UpdatePower() + self:UpdateRaidIcon() + self:UpdateAuras() + + local showPortrait = not (SFramesDB and SFramesDB.focusShowPortrait == false) + if showPortrait and self.frame.portrait then + -- Only reset portrait model on first load / focus change (not every update) + if not self.frame._lastPortraitUID or self.frame._lastPortraitUID ~= uid then + self.frame.portrait:SetUnit(uid) + self.frame.portrait:SetCamera(0) + self.frame.portrait:Hide() + self.frame.portrait:Show() + self.frame.portrait:SetPosition(-1.0, 0, 0) + self.frame._lastPortraitUID = uid + end + end + + local name = UnitName(uid) or "" + local level = UnitLevel(uid) + local levelText = level + + local function RGBToHex(r, g, b) + return string.format("|cff%02x%02x%02x", r*255, g*255, b*255) + end + + local function GetLevelDiffColor(targetLevel) + local playerLevel = UnitLevel("player") + if targetLevel == -1 then return 1, 0, 0 end + local diff = targetLevel - playerLevel + if diff >= 5 then return 1, 0.1, 0.1 + elseif diff >= 3 then return 1, 0.5, 0.25 + elseif diff >= -2 then return 1, 1, 0 + elseif -diff <= (GetQuestGreenRange and GetQuestGreenRange() or 5) then return 0.25, 0.75, 0.25 + else return 0.5, 0.5, 0.5 end + end + + local levelColor = RGBToHex(1, 1, 1) + if level == -1 then + levelText = "??" + levelColor = RGBToHex(1, 0, 0) + elseif level then + local r, g, b = GetLevelDiffColor(level) + levelColor = RGBToHex(r, g, b) + end + + local formattedLevel = "" + if level and not (SFramesDB and SFramesDB.showLevel == false) then + formattedLevel = levelColor .. tostring(levelText) .. "|r " + end + + -- Class icon + if UnitIsPlayer(uid) then + local _, tClass = UnitClass(uid) + if tClass and SFrames.SetClassIcon then + SFrames:SetClassIcon(self.frame.classIcon, tClass) + end + else + if self.frame.classIcon then self.frame.classIcon:Hide(); if self.frame.classIcon.overlay then self.frame.classIcon.overlay:Hide() end end + end + + -- Color by class or reaction + local useClassColor = not (SFramesDB and SFramesDB.classColorHealth == false) + if UnitIsPlayer(uid) and useClassColor then + local _, class = UnitClass(uid) + if class and SFrames.Config.colors.class[class] then + local color = SFrames.Config.colors.class[class] + self.frame.health:SetStatusBarColor(color.r, color.g, color.b) + self.frame.nameText:SetText(formattedLevel .. name) + self.frame.nameText:SetTextColor(color.r, color.g, color.b) + else + self.frame.health:SetStatusBarColor(0, 1, 0) + self.frame.nameText:SetText(formattedLevel .. name) + self.frame.nameText:SetTextColor(1, 1, 1) + end + else + local r, g, b = 0.85, 0.77, 0.36 + if UnitIsEnemy("player", uid) then + r, g, b = 0.78, 0.25, 0.25 + elseif UnitIsFriend("player", uid) then + r, g, b = 0.33, 0.59, 0.33 + end + self.frame.health:SetStatusBarColor(r, g, b) + self.frame.nameText:SetText(formattedLevel .. name) + self.frame.nameText:SetTextColor(r, g, b) + end +end + +function SFrames.Focus:HideAuras() + if not self.frame then return end + if self.frame.buffs then + for i = 1, 8 do if self.frame.buffs[i] then self.frame.buffs[i]:Hide() end end + end + if self.frame.debuffs then + for i = 1, 8 do if self.frame.debuffs[i] then self.frame.debuffs[i]:Hide() end end + end +end + +-- STUB: UpdateHealth +function SFrames.Focus:UpdateHealth() + local uid = self:GetUnitID() + if not uid or not self.frame then return end + local hp = UnitHealth(uid) + local maxHp = UnitHealthMax(uid) + self.frame.health:SetMinMaxValues(0, maxHp) + self.frame.health:SetValue(hp) + if maxHp > 0 then + local pct = math.floor(hp / maxHp * 100) + self.frame.healthText:SetText(hp .. " / " .. maxHp .. " (" .. pct .. "%)") + else + self.frame.healthText:SetText("") + end +end + +-- STUB: UpdatePowerType +function SFrames.Focus:UpdatePowerType() + local uid = self:GetUnitID() + if not uid or not self.frame then return end + local powerType = UnitPowerType(uid) + local color = SFrames.Config.colors.power[powerType] + if color then + self.frame.power:SetStatusBarColor(color.r, color.g, color.b) + else + self.frame.power:SetStatusBarColor(0, 0, 1) + end +end + +-- STUB: UpdatePower +function SFrames.Focus:UpdatePower() + local uid = self:GetUnitID() + if not uid or not self.frame then return end + local power = UnitMana(uid) + local maxPower = UnitManaMax(uid) + self.frame.power:SetMinMaxValues(0, maxPower) + self.frame.power:SetValue(power) + if maxPower > 0 then + self.frame.powerText:SetText(power .. " / " .. maxPower) + else + self.frame.powerText:SetText("") + end +end + +-- STUB: UpdateRaidIcon +function SFrames.Focus:UpdateRaidIcon() + local uid = self:GetUnitID() + if not uid or not self.frame then self.frame.raidIcon:Hide(); return end + local index = GetRaidTargetIndex(uid) + if index then + local col = math.mod(index - 1, 4) + local row = math.floor((index - 1) / 4) + self.frame.raidIcon:SetTexCoord(col * 0.25, (col + 1) * 0.25, row * 0.25, (row + 1) * 0.25) + self.frame.raidIcon:Show() + else + self.frame.raidIcon:Hide() + end +end + +-- STUB: UpdateAuras +function SFrames.Focus:UpdateAuras() + local uid = self:GetUnitID() + if not uid or not self.frame or not self.frame.buffs then self:HideAuras(); return end + + for i = 1, 8 do + local texture = UnitBuff(uid, i) + local b = self.frame.buffs[i] + if b then + b:SetID(i) + if texture then + b.icon:SetTexture(texture) + b:Show() + else + b:Hide() + end + b.cdText:SetText("") + end + end + + for i = 1, 8 do + local texture, count, dtype = UnitDebuff(uid, i) + local b = self.frame.debuffs[i] + if b then + b:SetID(i) + if texture then + b.icon:SetTexture(texture) + if b.border then + if dtype == "Magic" then b.border:SetVertexColor(0.2, 0.6, 1) + elseif dtype == "Curse" then b.border:SetVertexColor(0.6, 0, 1) + elseif dtype == "Disease" then b.border:SetVertexColor(0.6, 0.4, 0) + elseif dtype == "Poison" then b.border:SetVertexColor(0, 0.6, 0) + else b.border:SetVertexColor(0.8, 0, 0) end + end + if count and count > 1 then + b.cdText:SetText(count) + else + b.cdText:SetText("") + end + b:Show() + else + b:Hide() + end + end + end +end + +-- STUB: CastbarOnUpdate +function SFrames.Focus:CastbarOnUpdate() + if not self.frame or not self.frame.castbar then return end + local cb = self.frame.castbar + local uid = self:GetUnitID() + local focusName = self:GetFocusName() + + if not focusName then + if cb:IsShown() then cb:Hide(); cb.cbbg:Hide(); cb.icon:Hide(); cb.ibg:Hide() end + return + end + + local spell, texture, startTime, endTime, channel + + -- 1) Native UnitCastingInfo / UnitChannelInfo (if available) + if uid then + local _UCI = UnitCastingInfo or (CastingInfo and function(u) return CastingInfo(u) end) + local _UCH = UnitChannelInfo or (ChannelInfo and function(u) return ChannelInfo(u) end) + if _UCI then + local ok, cSpell, _, _, cIcon, cStart, cEnd = pcall(_UCI, uid) + if ok and cSpell and cStart then + spell, texture = cSpell, cIcon + startTime, endTime, channel = cStart / 1000, cEnd / 1000, false + end + end + if not spell and _UCH then + local ok, cSpell, _, _, cIcon, cStart, cEnd = pcall(_UCH, uid) + if ok and cSpell and cStart then + spell, texture = cSpell, cIcon + startTime, endTime, channel = cStart / 1000, cEnd / 1000, true + end + end + end + + -- 2) SFrames.castdb via stored GUID (SuperWoW UNIT_CASTEVENT) + if not spell and SFrames.castdb then + local db = self:EnsureDB() + local guid = db.guid + if guid and SFrames.castdb[guid] then + local data = SFrames.castdb[guid] + if data.cast and data.start and data.casttime then + spell = data.cast + texture = data.icon + startTime = data.start + endTime = data.start + data.casttime / 1000 + channel = data.channel + end + end + end + + -- 3) SFrames.castByName: name-based UNIT_CASTEVENT data (works out of combat!) + if not spell and SFrames.castByName and focusName and SFrames.castByName[focusName] then + local data = SFrames.castByName[focusName] + if data.cast and data.start and data.casttime then + local dur = data.casttime / 1000 + local elapsed = GetTime() - data.start + if elapsed <= dur + 0.5 then + spell = data.cast + texture = data.icon + startTime = data.start + endTime = data.start + dur + channel = data.channel + else + SFrames.castByName[focusName] = nil + end + end + end + + -- 4) focusCastTracker: combat-log based tracker (in-combat only) + if not spell and focusCastTracker[focusName] then + local entry = focusCastTracker[focusName] + local now = GetTime() + local elapsed = now - entry.startTime + if elapsed <= entry.duration + 0.5 then + spell = entry.spell + texture = entry.icon + startTime = entry.startTime + endTime = entry.startTime + entry.duration + channel = entry.channel or false + else + focusCastTracker[focusName] = nil + end + end + + -- 5) self.clCast fallback (UNIT_CASTEVENT direct capture via GUID) + if not spell and self.clCast then + local now = GetTime() + local elapsed = now - self.clCast.startTime + if elapsed <= self.clCast.duration + 0.5 then + spell = self.clCast.spell + texture = self.clCast.icon + startTime = self.clCast.startTime + endTime = self.clCast.startTime + self.clCast.duration + channel = self.clCast.channel + else + self.clCast = nil + end + end + + if not spell or not startTime or not endTime then + if cb:IsShown() then cb:Hide(); cb.cbbg:Hide(); cb.icon:Hide(); cb.ibg:Hide() end + return + end + + local now = GetTime() + local duration = endTime - startTime + if duration <= 0 then + cb:Hide(); cb.cbbg:Hide(); cb.icon:Hide(); cb.ibg:Hide() + return + end + + local elapsed = now - startTime + if elapsed > duration + 0.5 then + cb:Hide(); cb.cbbg:Hide(); cb.icon:Hide(); cb.ibg:Hide() + return + end + + cb:SetMinMaxValues(0, duration) + if channel then + cb:SetValue(duration - elapsed) + cb:SetStatusBarColor(0.3, 0.7, 1) + else + cb:SetValue(elapsed) + cb:SetStatusBarColor(1, 0.7, 0) + end + + cb.text:SetText(spell or "") + if texture then + cb.icon:SetTexture(texture) + cb.icon:Show() + cb.ibg:Show() + else + cb.icon:Hide() + cb.ibg:Hide() + end + cb:Show() + cb.cbbg:Show() +end + +-- STUB: OnFocusChanged +function SFrames.Focus:OnFocusChanged() + if not self.frame then + if DEFAULT_CHAT_FRAME then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Focus] frame is nil!|r") end + return + end + local name = self:GetFocusName() + if name then + self.frame:Show() + self.frame._lastPortraitUID = nil -- Force portrait refresh on focus change + self:UpdateAll() + else + self.frame:Hide() + if self.frame.castbar then + self.frame.castbar:Hide() + self.frame.castbar.cbbg:Hide() + self.frame.castbar.icon:Hide() + self.frame.castbar.ibg:Hide() + end + end +end + +-------------------------------------------------------------------------------- +-- Live-apply settings (no reload needed) +-------------------------------------------------------------------------------- +function SFrames.Focus:ApplySettings() + local f = self.frame + if not f then return end + + local width = tonumber(SFramesDB and SFramesDB.focusFrameWidth) or 200 + local pWidth = tonumber(SFramesDB and SFramesDB.focusPortraitWidth) or 45 + local hHeight = tonumber(SFramesDB and SFramesDB.focusHealthHeight) or 32 + local pHeight = tonumber(SFramesDB and SFramesDB.focusPowerHeight) or 10 + local totalH = hHeight + pHeight + 3 + local scale = tonumber(SFramesDB and SFramesDB.focusFrameScale) or 0.9 + local bgAlpha = tonumber(SFramesDB and SFramesDB.focusBgAlpha) or 0.9 + local nameFontSize = tonumber(SFramesDB and SFramesDB.focusNameFontSize) or 11 + local valueFontSize = tonumber(SFramesDB and SFramesDB.focusValueFontSize) or 10 + local showPortrait = not (SFramesDB and SFramesDB.focusShowPortrait == false) + local showCastBar = not (SFramesDB and SFramesDB.focusShowCastBar == false) + local showAuras = not (SFramesDB and SFramesDB.focusShowAuras == false) + + -- Main frame size & scale + f:SetWidth(width) + f:SetHeight(totalH) + f:SetScale(scale) + + -- Background alpha + if f.SetBackdropColor then + local r, g, b = 0, 0, 0 + if f.GetBackdropColor then r, g, b = f:GetBackdropColor() end + f:SetBackdropColor(r, g, b, bgAlpha) + end + + -- Portrait + if f.portrait then + f.portrait:SetWidth(pWidth) + f.portrait:SetHeight(totalH - 2) + if showPortrait then + f.portrait:Show() + if f.portraitBG then f.portraitBG:Show() end + else + f.portrait:Hide() + if f.portraitBG then f.portraitBG:Hide() end + end + end + + -- Health bar anchors + if f.health then + f.health:ClearAllPoints() + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + if showPortrait then + f.health:SetPoint("TOPRIGHT", f.portrait, "TOPLEFT", -1, 0) + else + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, 0) + end + f.health:SetHeight(hHeight) + end + + -- Power bar anchors + if f.power then + f.power:ClearAllPoints() + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -1) + if showPortrait then + f.power:SetPoint("BOTTOMRIGHT", f.portrait, "BOTTOMLEFT", -1, 0) + else + f.power:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1) + end + end + + -- Castbar anchors + if f.castbar then + f.castbar:ClearAllPoints() + local cbH = 12 + f.castbar:SetHeight(cbH) + f.castbar:SetPoint("BOTTOMLEFT", f, "TOPLEFT", 0, 6) + if showPortrait then + f.castbar:SetPoint("BOTTOMRIGHT", f.portrait, "TOPRIGHT", -(cbH + 6), 6) + else + f.castbar:SetPoint("BOTTOMRIGHT", f, "TOPRIGHT", -(cbH + 6), 6) + end + if not showCastBar then + f.castbar:Hide() + if f.castbar.cbbg then f.castbar.cbbg:Hide() end + if f.castbar.icon then f.castbar.icon:Hide() end + if f.castbar.ibg then f.castbar.ibg:Hide() end + end + end + + -- Font sizes + if f.nameText then + local font, _, flags = f.nameText:GetFont() + if font then f.nameText:SetFont(font, nameFontSize, flags) end + end + if f.healthText then + local font, _, flags = f.healthText:GetFont() + if font then f.healthText:SetFont(font, valueFontSize, flags) end + end + if f.powerText then + local font, _, flags = f.powerText:GetFont() + if font then f.powerText:SetFont(font, valueFontSize - 1, flags) end + end + + -- Auras visibility + if not showAuras then + self:HideAuras() + elseif self:GetFocusName() then + self:UpdateAuras() + end + + -- Force portrait refresh + f._lastPortraitUID = nil + if self:GetFocusName() then + self:UpdateAll() + end +end + +-------------------------------------------------------------------------------- +-- Initialize +-------------------------------------------------------------------------------- +function SFrames.Focus:Initialize() + self:EnsureDB() + + local ok, err = pcall(function() SFrames.Focus:CreateFocusFrame() end) + if not ok then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444Nanami-UI: FocusFrame create failed: " .. tostring(err) .. "|r") + end + if not self.frame then return end + + local focusSelf = self + + -- Event frame for focus-specific events + local ef = CreateFrame("Frame", "SFramesFocusEvents", UIParent) + + -- Native focus events (TurtleWoW may not support these) + pcall(ef.RegisterEvent, ef, "PLAYER_FOCUS_CHANGED") + pcall(ef.RegisterEvent, ef, "UNIT_CASTEVENT") + ef:RegisterEvent("PLAYER_TARGET_CHANGED") + ef:RegisterEvent("UNIT_HEALTH") + ef:RegisterEvent("UNIT_MANA") + ef:RegisterEvent("UNIT_ENERGY") + ef:RegisterEvent("UNIT_RAGE") + ef:RegisterEvent("UNIT_MAXHEALTH") + ef:RegisterEvent("UNIT_MAXMANA") + ef:RegisterEvent("UNIT_MAXENERGY") + ef:RegisterEvent("UNIT_MAXRAGE") + ef:RegisterEvent("UNIT_DISPLAYPOWER") + ef:RegisterEvent("UNIT_AURA") + ef:RegisterEvent("UNIT_PORTRAIT_UPDATE") + ef:RegisterEvent("UNIT_TARGET") + ef:RegisterEvent("RAID_TARGET_UPDATE") + -- Combat log events for castbar detection (non-SuperWoW fallback) + local CL_EVENTS = { + "CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF", + "CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF", + "CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF", + "CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE", + "CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF", + "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE", + "CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF", + "CHAT_MSG_SPELL_PARTY_DAMAGE", + "CHAT_MSG_SPELL_PARTY_BUFF", + "CHAT_MSG_SPELL_SELF_DAMAGE", + "CHAT_MSG_SPELL_SELF_BUFF", + "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE", + } + for _, evt in ipairs(CL_EVENTS) do + pcall(ef.RegisterEvent, ef, evt) + end + + ef:SetScript("OnEvent", function() + if event == "PLAYER_FOCUS_CHANGED" then + focusSelf:OnFocusChanged() + return + end + if event == "RAID_TARGET_UPDATE" then + if focusSelf:GetFocusName() then focusSelf:UpdateRaidIcon() end + return + end + + -- When target changes, if new target is our focus, do a full refresh + if event == "PLAYER_TARGET_CHANGED" then + local focusName = focusSelf:GetFocusName() + if focusName and UnitExists("target") and UnitName("target") == focusName then + focusSelf.frame._lastPortraitUID = nil + focusSelf:UpdateAll() + -- Try to grab GUID while we have target + if UnitGUID then + local db = focusSelf:EnsureDB() + local ok, g = pcall(UnitGUID, "target") + if ok and g then + db.guid = g + if SFrames.guidToName then SFrames.guidToName[g] = focusName end + end + end + end + return + end + + -- When any unit changes target, check if the new target is our focus + if event == "UNIT_TARGET" then + local focusName = focusSelf:GetFocusName() + if focusName and arg1 then + local tgtUnit = arg1 .. "target" + local ok, exists = pcall(UnitExists, tgtUnit) + if ok and exists then + local ok2, tName = pcall(UnitName, tgtUnit) + if ok2 and tName == focusName then + focusSelf:UpdateAll() + end + end + end + return + end + + -- UNIT_CASTEVENT (SuperWoW): only use if we have a stored GUID + if event == "UNIT_CASTEVENT" then + local db = focusSelf:EnsureDB() + if db.guid and db.guid ~= "" and arg1 == db.guid then + if arg3 == "START" or arg3 == "CAST" or arg3 == "CHANNEL" then + -- castdb is already updated by Tweaks.lua, nothing extra needed + -- But also write clCast as backup + local spellName, icon + if SpellInfo and arg4 then + local ok, s, _, ic = pcall(SpellInfo, arg4) + if ok then spellName = s; icon = ic end + end + spellName = spellName or "Casting" + icon = icon or FocusGetSpellIcon(spellName) or "Interface\\Icons\\INV_Misc_QuestionMark" + focusSelf.clCast = { + spell = spellName, + startTime = GetTime(), + duration = (arg5 or 2000) / 1000, + icon = icon, + channel = (arg3 == "CHANNEL"), + } + elseif arg3 == "FAIL" then + focusSelf.clCast = nil + end + end + return + end + + -- Combat log events: fill focusCastTracker by name + if arg1 and string.find(event, "CHAT_MSG_SPELL") then + local msg = arg1 + + -- Cast start detection (localized) + local caster, spell + local castStart = SPELLCASTOTHERSTART or "%s begins to cast %s." + caster, spell = cmatch(msg, castStart) + if not caster then + local perfStart = SPELLPERFORMOTHERSTART or "%s begins to perform %s." + caster, spell = cmatch(msg, perfStart) + end + if caster and spell then + local icon = FocusGetSpellIcon(spell) or "Interface\\Icons\\INV_Misc_QuestionMark" + focusCastTracker[caster] = { + spell = spell, + startTime = GetTime(), + duration = 2.0, + icon = icon, + channel = false, + } + return + end + + -- Cast interrupt / fail detection (localized) + local interrupted = false + -- English fallback patterns + for u in string.gfind(msg, "(.+)'s .+ is interrupted%.") do + if focusCastTracker[u] then focusCastTracker[u] = nil; interrupted = true end + end + if not interrupted then + for u in string.gfind(msg, "(.+)'s .+ fails%.") do + if focusCastTracker[u] then focusCastTracker[u] = nil; interrupted = true end + end + end + -- Localized patterns + if not interrupted and SPELLINTERRUPTOTHEROTHER then + local a = cmatch(msg, SPELLINTERRUPTOTHEROTHER) + if a and focusCastTracker[a] then focusCastTracker[a] = nil end + end + if not interrupted and SPELLFAILCASTOTHER then + local a = cmatch(msg, SPELLFAILCASTOTHER) + if a and focusCastTracker[a] then focusCastTracker[a] = nil end + end + if not interrupted and SPELLFAILPERFORMOTHER then + local a = cmatch(msg, SPELLFAILPERFORMOTHER) + if a and focusCastTracker[a] then focusCastTracker[a] = nil end + end + return + end + + -- Unit events: check if it's our focus by name matching + local focusName2 = focusSelf:GetFocusName() + if not focusName2 then return end + + local isOurFocus = false + if arg1 == "focus" and FocusUnitExists() then + isOurFocus = true + elseif arg1 and focusName2 then + local ok, eName = pcall(UnitName, arg1) + if ok and eName and eName == focusName2 then + isOurFocus = true + end + end + + if isOurFocus then + local evtUID = arg1 + if event == "UNIT_HEALTH" or event == "UNIT_MAXHEALTH" then + if evtUID and focusSelf.frame then + local hp = UnitHealth(evtUID) + local maxHp = UnitHealthMax(evtUID) + focusSelf.frame.health:SetMinMaxValues(0, maxHp) + focusSelf.frame.health:SetValue(hp) + if maxHp > 0 then + local pct = math.floor(hp / maxHp * 100) + focusSelf.frame.healthText:SetText(hp .. " / " .. maxHp .. " (" .. pct .. "%)") + else + focusSelf.frame.healthText:SetText("") + end + end + elseif event == "UNIT_MANA" or event == "UNIT_MAXMANA" + or event == "UNIT_ENERGY" or event == "UNIT_MAXENERGY" + or event == "UNIT_RAGE" or event == "UNIT_MAXRAGE" then + if evtUID and focusSelf.frame then + local power = UnitMana(evtUID) + local maxPower = UnitManaMax(evtUID) + focusSelf.frame.power:SetMinMaxValues(0, maxPower) + focusSelf.frame.power:SetValue(power) + if maxPower > 0 then + focusSelf.frame.powerText:SetText(power .. " / " .. maxPower) + else + focusSelf.frame.powerText:SetText("") + end + end + elseif event == "UNIT_DISPLAYPOWER" then + if evtUID and focusSelf.frame then + local powerType = UnitPowerType(evtUID) + local color = SFrames.Config.colors.power[powerType] + if color then + focusSelf.frame.power:SetStatusBarColor(color.r, color.g, color.b) + else + focusSelf.frame.power:SetStatusBarColor(0, 0, 1) + end + local power = UnitMana(evtUID) + local maxPower = UnitManaMax(evtUID) + focusSelf.frame.power:SetMinMaxValues(0, maxPower) + focusSelf.frame.power:SetValue(power) + if maxPower > 0 then + focusSelf.frame.powerText:SetText(power .. " / " .. maxPower) + else + focusSelf.frame.powerText:SetText("") + end + end + elseif event == "UNIT_AURA" then + focusSelf:UpdateAuras() + elseif event == "UNIT_PORTRAIT_UPDATE" then + local showPortrait = not (SFramesDB and SFramesDB.focusShowPortrait == false) + if showPortrait and focusSelf.frame.portrait and evtUID then + focusSelf.frame._lastPortraitUID = nil + focusSelf.frame.portrait:SetUnit(evtUID) + focusSelf.frame.portrait:SetCamera(0) + focusSelf.frame.portrait:SetPosition(-1.0, 0, 0) + focusSelf.frame._lastPortraitUID = evtUID + end + end + end + end) + + -- Polling for non-native focus (SuperWoW GUID or static) + -- Also handles periodic refresh for native focus + ef.pollTimer = 0 + ef.lastUID = nil -- Track last unitID to avoid portrait flicker + ef:SetScript("OnUpdate", function() + ef.pollTimer = ef.pollTimer + (arg1 or 0) + if ef.pollTimer < 0.25 then return end + ef.pollTimer = 0 + + local name = focusSelf:GetFocusName() + if not name then + if focusSelf.frame:IsShown() then focusSelf.frame:Hide() end + ef.lastUID = nil + return + end + + if not focusSelf.frame:IsShown() then + focusSelf.frame:Show() + end + + -- Re-scan for a valid unitID every poll cycle + -- This catches cases where focus becomes target/party/raid dynamically + local uid = focusSelf:GetUnitID() + if uid then + focusSelf:UpdateHealth() + focusSelf:UpdatePowerType() + focusSelf:UpdatePower() + focusSelf:UpdateAuras() + focusSelf:UpdateRaidIcon() + + -- Only refresh portrait when unitID changes (prevents 3D model flicker) + local showPortrait = not (SFramesDB and SFramesDB.focusShowPortrait == false) + if showPortrait and focusSelf.frame.portrait then + if uid ~= ef.lastUID then + focusSelf.frame.portrait:SetUnit(uid) + focusSelf.frame.portrait:SetCamera(0) + focusSelf.frame.portrait:SetPosition(-1.0, 0, 0) + focusSelf.frame.portrait:Show() + ef.lastUID = uid + end + end + else + ef.lastUID = nil + end + end) + + -- Register mover + if SFrames.Movers and SFrames.Movers.RegisterMover then + if SFramesTargetFrame then + SFrames.Movers:RegisterMover("FocusFrame", self.frame, "焦点", + "TOPLEFT", "SFramesTargetFrame", "BOTTOMLEFT", 0, -10) + else + SFrames.Movers:RegisterMover("FocusFrame", self.frame, "焦点", + "LEFT", "UIParent", "LEFT", 250, 0) + end + end + + -- If focus already set (e.g. after /reload), show it + self:OnFocusChanged() + + -- Hook WorldFrame for Shift+LeftClick to set focus from 3D world + local origWorldFrameOnMouseDown = WorldFrame:GetScript("OnMouseDown") + WorldFrame:SetScript("OnMouseDown", function() + if arg1 == "LeftButton" and IsShiftKeyDown() then + if UnitExists("mouseover") then + pcall(SFrames.Focus.SetFromUnit, SFrames.Focus, "mouseover") + return + end + end + if origWorldFrameOnMouseDown then + origWorldFrameOnMouseDown() + end + end) +end diff --git a/LootDisplay.lua b/LootDisplay.lua index 8369f89..c03b088 100644 --- a/LootDisplay.lua +++ b/LootDisplay.lua @@ -96,8 +96,12 @@ local function GetDB() alertEnable = true, alertFadeDelay = ALERT_HOLD, scale = 1.0, + followCursor = false, } end + if SFramesDB.lootDisplay.followCursor == nil then + SFramesDB.lootDisplay.followCursor = false + end return SFramesDB.lootDisplay end @@ -585,21 +589,30 @@ local function UpdateLootFrame() ShowLootPage() - if not lootFrame._posApplied then - local hasSaved = false - if SFrames.Movers and SFrames.Movers.ApplyPosition then - hasSaved = SFrames.Movers:ApplyPosition("LootFrame", lootFrame, - "TOPLEFT", "UIParent", "TOPLEFT", 50, -200) - end - if hasSaved then - lootFrame._posApplied = true - end - end - if not lootFrame._posApplied then + if db.followCursor then + -- 跟随鼠标:每次打开都定位到鼠标附近 local cx, cy = GetCursorPosition() local uiS = UIParent:GetEffectiveScale() lootFrame:ClearAllPoints() lootFrame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cx / uiS - 30, cy / uiS + 16) + else + -- 固定位置:使用保存的位置或默认位置 + if not lootFrame._posApplied then + local hasSaved = false + if SFrames.Movers and SFrames.Movers.ApplyPosition then + hasSaved = SFrames.Movers:ApplyPosition("LootFrame", lootFrame, + "TOPLEFT", "UIParent", "TOPLEFT", 50, -200) + end + if hasSaved then + lootFrame._posApplied = true + end + end + if not lootFrame._posApplied then + local cx, cy = GetCursorPosition() + local uiS = UIParent:GetEffectiveScale() + lootFrame:ClearAllPoints() + lootFrame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cx / uiS - 30, cy / uiS + 16) + end end lootFrame:Show() diff --git a/Mail.lua b/Mail.lua index 36db9a0..7e4ea99 100644 --- a/Mail.lua +++ b/Mail.lua @@ -10,6 +10,9 @@ SFrames = SFrames or {} SFrames.Mail = {} local ML = SFrames.Mail SFramesDB = SFramesDB or {} +if SFramesDB.mailContacts == nil then + SFramesDB.mailContacts = {} +end -- Save original Blizzard functions BEFORE other addons (TurtleMail etc.) hook them. -- File-level locals are captured at load time, which is before later-alphabetical addons. @@ -219,6 +222,8 @@ local function CreateActionBtn(parent, text, w) end end) + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + return btn end @@ -1223,13 +1228,178 @@ local function BuildSendPanel() local labelW = 50 local ebW = L.W - L.PAD * 2 - labelW - 6 local toLabel = sp:CreateFontString(nil, "OVERLAY") - toLabel:SetFont(font, 11, "OUTLINE"); toLabel:SetPoint("TOPLEFT", sp, "TOPLEFT", L.PAD, -6) + toLabel:SetFont(font, 11, "OUTLINE"); toLabel:SetPoint("TOPLEFT", sp, "TOPLEFT", L.PAD, -32) toLabel:SetWidth(labelW); toLabel:SetJustifyH("RIGHT") toLabel:SetText("收件人:"); toLabel:SetTextColor(T.labelText[1], T.labelText[2], T.labelText[3]) local toEB = CreateStyledEditBox(sp, ebW, 22) toEB:SetPoint("LEFT", toLabel, "RIGHT", 6, 0) f.toEditBox = toEB + -- Contact buttons + local addContactBtn = CreateActionBtn(sp, "添加", 30) + addContactBtn:SetHeight(22); addContactBtn:SetPoint("TOPRIGHT", sp, "TOPRIGHT", -L.PAD, -6) + addContactBtn:SetScript("OnClick", function() + local name = f.toEditBox:GetText() + if name and name ~= "" then + if not SFramesDB then + SFramesDB = {} + end + if not SFramesDB.mailContacts then + SFramesDB.mailContacts = {} + end + SFramesDB.mailContacts[name] = true + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88[Nanami-Mail]|r 已添加常用联系人: " .. name) + end + end) + + -- Contact management frame + local contactFrame + local function CreateContactFrame() + if contactFrame then return end + + contactFrame = CreateFrame("Frame", "SFramesMailContactFrame", UIParent) + if not contactFrame then + DEFAULT_CHAT_FRAME:AddMessage("|cFFFF6666[Nanami-Mail]|r 创建联系人管理窗口失败") + return + end + contactFrame:SetWidth(250) + contactFrame:SetHeight(300) + contactFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + contactFrame:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 } + }) + contactFrame:SetBackdropColor(0.1, 0.1, 0.1, 0.95) + contactFrame:SetBackdropBorderColor(0.5, 0.5, 0.5, 1) + contactFrame:SetFrameStrata("DIALOG") + contactFrame:SetMovable(true) + contactFrame:EnableMouse(true) + contactFrame:RegisterForDrag("LeftButton") + contactFrame:SetScript("OnDragStart", function() this:StartMoving() end) + contactFrame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + contactFrame:Hide() + + -- Title + local title = contactFrame:CreateFontString(nil, "OVERLAY") + title:SetFont(GetFont(), 12, "OUTLINE") + title:SetPoint("TOP", contactFrame, "TOP", 0, -10) + title:SetText("|cFFFFCC00常用联系人管理|r") + + -- Close button + local closeBtn = CreateActionBtn(contactFrame, "X", 20) + closeBtn:SetHeight(20) + closeBtn:SetPoint("TOPRIGHT", contactFrame, "TOPRIGHT", -5, -5) + closeBtn:SetScript("OnClick", function() contactFrame:Hide() end) + + -- Scroll frame + local scrollFrame = CreateFrame("ScrollFrame", nil, contactFrame) + if not scrollFrame then + DEFAULT_CHAT_FRAME:AddMessage("|cFFFF6666[Nanami-Mail]|r 创建滚动框架失败") + return + end + scrollFrame:SetWidth(230) + scrollFrame:SetHeight(240) + scrollFrame:SetPoint("TOPLEFT", contactFrame, "TOPLEFT", 10, -30) + + local scrollChild = CreateFrame("Frame", nil, scrollFrame) + if not scrollChild then + DEFAULT_CHAT_FRAME:AddMessage("|cFFFF6666[Nanami-Mail]|r 创建滚动子框架失败") + return + end + scrollChild:SetWidth(210) + scrollChild:SetHeight(1) + scrollFrame:SetScrollChild(scrollChild) + + -- Refresh function + contactFrame.Refresh = function() + if not SFramesDB then SFramesDB = {} end + if not SFramesDB.mailContacts then SFramesDB.mailContacts = {} end + + -- Clear existing buttons + for _, child in ipairs({scrollChild:GetChildren()}) do + child:Hide() + child:SetParent(nil) + child = nil + end + + -- Create contact buttons + local contacts = SFramesDB.mailContacts + local y = 0 + for name in pairs(contacts) do + local contactBtn = CreateActionBtn(scrollChild, name, 180) + contactBtn:SetHeight(20) + contactBtn:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, -y) + contactBtn.contactName = name + contactBtn:SetScript("OnClick", function() + if this.contactName and f.toEditBox then + f.toEditBox:SetText(this.contactName) + contactFrame:Hide() + end + end) + + local deleteBtn = CreateActionBtn(scrollChild, "×", 20) + deleteBtn:SetHeight(20) + deleteBtn:SetPoint("LEFT", contactBtn, "RIGHT", 5, 0) + deleteBtn.contactName = name + deleteBtn:SetScript("OnClick", function() + if this.contactName then + SFramesDB.mailContacts[this.contactName] = nil + contactFrame:Refresh() + end + end) + + y = y + 25 + end + + scrollChild:SetHeight(math.max(1, y)) + + -- No contacts message + if not next(contacts) then + local noContacts = scrollChild:CreateFontString(nil, "OVERLAY") + noContacts:SetFont(GetFont(), 11, "OUTLINE") + noContacts:SetPoint("TOP", scrollChild, "TOP", 0, -10) + noContacts:SetText("|cFF999999常用联系人列表为空|r") + end + end + + -- Add contact section + local addLabel = contactFrame:CreateFontString(nil, "OVERLAY") + addLabel:SetFont(GetFont(), 11, "OUTLINE") + addLabel:SetPoint("BOTTOMLEFT", contactFrame, "BOTTOMLEFT", 10, 40) + addLabel:SetText("添加新联系人:") + + local addEditBox = CreateStyledEditBox(contactFrame, 150, 20) + addEditBox:SetPoint("BOTTOMLEFT", contactFrame, "BOTTOMLEFT", 10, 15) + + local addBtn = CreateActionBtn(contactFrame, "添加", 60) + addBtn:SetHeight(20) + addBtn:SetPoint("BOTTOMRIGHT", contactFrame, "BOTTOMRIGHT", -10, 15) + addBtn:SetScript("OnClick", function() + local name = addEditBox:GetText() + if name and name ~= "" then + if not SFramesDB then SFramesDB = {} end + if not SFramesDB.mailContacts then SFramesDB.mailContacts = {} end + SFramesDB.mailContacts[name] = true + addEditBox:SetText("") + contactFrame:Refresh() + end + end) + end + + local manageContactBtn = CreateActionBtn(sp, "管理", 30) + manageContactBtn:SetHeight(22); manageContactBtn:SetPoint("RIGHT", addContactBtn, "LEFT", -4, 0) + manageContactBtn:SetScript("OnClick", function() + CreateContactFrame() + if contactFrame then + contactFrame:Refresh() + contactFrame:Show() + else + DEFAULT_CHAT_FRAME:AddMessage("|cFFFF6666[Nanami-Mail]|r 无法创建联系人管理窗口") + end + end) + -- Autocomplete dropdown for recipient local AC_MAX = 8 local acBox = CreateFrame("Frame", "SFramesMailAutoComplete", f) @@ -1297,6 +1467,13 @@ local function BuildSendPanel() seen[name] = true end + -- Add mail contacts first + if SFramesDB and SFramesDB.mailContacts then + for name in pairs(SFramesDB.mailContacts) do + addName(name, "contact") + end + end + for fi = 1, GetNumFriends() do local name = GetFriendInfo(fi) addName(name, "friend") @@ -1318,7 +1495,9 @@ local function BuildSendPanel() local r = results[ai] local col = T.whoColor local tag = "" - if r.source == "friend" then + if r.source == "contact" then + col = { 1, 1, 0.3 }; tag = " |cFFFFCC00[常用]|r" + elseif r.source == "friend" then col = T.friendColor; tag = " |cFF66FF88[好友]|r" elseif r.source == "guild" then col = T.guildColor; tag = " |cFF66CCFF[公会]|r" @@ -1343,7 +1522,7 @@ local function BuildSendPanel() -- Subject (second line, aligned with recipient) local subLabel = sp:CreateFontString(nil, "OVERLAY") - subLabel:SetFont(font, 11, "OUTLINE"); subLabel:SetPoint("TOPLEFT", sp, "TOPLEFT", L.PAD, -32) + subLabel:SetFont(font, 11, "OUTLINE"); subLabel:SetPoint("TOPLEFT", sp, "TOPLEFT", L.PAD, -60) subLabel:SetWidth(labelW); subLabel:SetJustifyH("RIGHT") subLabel:SetText("主题:"); subLabel:SetTextColor(T.labelText[1], T.labelText[2], T.labelText[3]) local subEB = CreateStyledEditBox(sp, ebW, 22) @@ -1355,7 +1534,7 @@ local function BuildSendPanel() -- Body local bodyLabel = sp:CreateFontString(nil, "OVERLAY") - bodyLabel:SetFont(font, 11, "OUTLINE"); bodyLabel:SetPoint("TOPLEFT", sp, "TOPLEFT", L.PAD, -58) + bodyLabel:SetFont(font, 11, "OUTLINE"); bodyLabel:SetPoint("TOPLEFT", sp, "TOPLEFT", L.PAD, -88) bodyLabel:SetText("正文:"); bodyLabel:SetTextColor(T.labelText[1], T.labelText[2], T.labelText[3]) local bsf = CreateFrame("ScrollFrame", "SFramesMailBodyScroll", sp, "UIPanelScrollFrameTemplate") @@ -1620,6 +1799,54 @@ function ML:ShowSendPanel() ML:UpdateSendPanel() end +-------------------------------------------------------------------------------- +-- Slash commands for mail contacts +-------------------------------------------------------------------------------- +SLASH_MAILCONTACT1 = "/mailcontact" +SLASH_MAILCONTACT2 = "/mailcontacts" +SlashCmdList["MAILCONTACT"] = function(msg) + if not SFramesDB then + SFramesDB = {} + end + if not SFramesDB.mailContacts then + SFramesDB.mailContacts = {} + end + + local cmd, arg = string.match(msg, "^(%S*)%s*(.-)$") + cmd = string.lower(cmd) + + if cmd == "add" and arg ~= "" then + SFramesDB.mailContacts[arg] = true + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88[Nanami-Mail]|r 已添加常用联系人: " .. arg) + elseif cmd == "remove" and arg ~= "" then + if SFramesDB.mailContacts[arg] then + SFramesDB.mailContacts[arg] = nil + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88[Nanami-Mail]|r 已删除常用联系人: " .. arg) + else + DEFAULT_CHAT_FRAME:AddMessage("|cFFFF6666[Nanami-Mail]|r 常用联系人中不存在: " .. arg) + end + elseif cmd == "list" then + local contacts = SFramesDB.mailContacts + if next(contacts) then + DEFAULT_CHAT_FRAME:AddMessage("|cFFFFCC00[Nanami-Mail]|r 常用联系人列表:") + for name in pairs(contacts) do + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88- |r" .. name) + end + else + DEFAULT_CHAT_FRAME:AddMessage("|cFFFFCC00[Nanami-Mail]|r 常用联系人列表为空") + end + elseif cmd == "clear" then + SFramesDB.mailContacts = {} + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88[Nanami-Mail]|r 已清空常用联系人列表") + else + DEFAULT_CHAT_FRAME:AddMessage("|cFFFFCC00[Nanami-Mail]|r 常用联系人命令:") + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88/mailcontact add |r - 添加常用联系人") + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88/mailcontact remove |r - 删除常用联系人") + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88/mailcontact list|r - 查看常用联系人列表") + DEFAULT_CHAT_FRAME:AddMessage("|cFF66FF88/mailcontact clear|r - 清空常用联系人列表") + end +end + -------------------------------------------------------------------------------- -- Bootstrap -------------------------------------------------------------------------------- diff --git a/MapIcons.lua b/MapIcons.lua index 09d56db..d776770 100644 --- a/MapIcons.lua +++ b/MapIcons.lua @@ -333,5 +333,4 @@ function MI:Initialize() UpdateMinimapDots() end) - SFrames:Print("地图职业图标模块已加载") end diff --git a/Media.lua b/Media.lua index d4ef9f5..8105309 100644 --- a/Media.lua +++ b/Media.lua @@ -10,6 +10,23 @@ SFrames.Media = { fontOutline = "OUTLINE", } +-- Built-in font choices (includes Nanami-Plates bundled fonts) +SFrames.FontChoices = { + { key = "default", label = "默认 (ARIALN)", path = "Fonts\\ARIALN.TTF" }, + { key = "frizqt", label = "FRIZQT", path = "Fonts\\FRIZQT__.TTF" }, + { key = "morpheus", label = "Morpheus", path = "Fonts\\MORPHEUS.TTF" }, + { key = "skurri", label = "Skurri", path = "Fonts\\SKURRI.TTF" }, + { key = "np_handwrite", label = "手写DF+DB", path = "Interface\\AddOns\\Nanami-Plates\\fonts\\手写DF+DB.ttf" }, + { key = "np_pangwa", label = "胖娃方正英文数字", path = "Interface\\AddOns\\Nanami-Plates\\fonts\\胖娃方正英文数字.ttf" }, + { key = "np_yahei_star", label = "雅黑+一星球", path = "Interface\\AddOns\\Nanami-Plates\\fonts\\雅黑+一星球.ttf" }, + { key = "np_yahei_hand", label = "雅黑手写英文", path = "Interface\\AddOns\\Nanami-Plates\\fonts\\雅黑手写英文.ttf" }, +} + +SFrames._fontLookup = {} +for _, entry in ipairs(SFrames.FontChoices) do + SFrames._fontLookup[entry.key] = entry.path +end + function SFrames:GetSharedMedia() if LibStub then local ok, LSM = pcall(function() return LibStub("LibSharedMedia-3.0", true) end) @@ -18,8 +35,24 @@ function SFrames:GetSharedMedia() return nil end +SFrames.BarTextures = { + { key = "default", label = "默认", path = "Interface\\TargetingFrame\\UI-StatusBar" }, + { key = "bar_elvui", label = "ElvUI", path = "Interface\\AddOns\\Nanami-UI\\img\\bar\\bar_elvui.tga" }, + { key = "bar_gradient", label = "渐变", path = "Interface\\AddOns\\Nanami-UI\\img\\bar\\bar_gradient.tga" }, + { key = "bar_tukui", label = "TukUI", path = "Interface\\AddOns\\Nanami-UI\\img\\bar\\bar_tukui.tga" }, + { key = "flat", label = "纯色", path = "Interface\\Buttons\\WHITE8X8" }, +} + +SFrames._barTextureLookup = {} +for _, entry in ipairs(SFrames.BarTextures) do + SFrames._barTextureLookup[entry.key] = entry.path +end + function SFrames:GetTexture() if SFramesDB and SFramesDB.barTexture then + local builtin = self._barTextureLookup[SFramesDB.barTexture] + if builtin then return builtin end + local LSM = self:GetSharedMedia() if LSM then local path = LSM:Fetch("statusbar", SFramesDB.barTexture, true) @@ -30,6 +63,12 @@ function SFrames:GetTexture() end function SFrames:GetFont() + -- 1. Check built-in font key + if SFramesDB and SFramesDB.fontKey then + local builtin = self._fontLookup[SFramesDB.fontKey] + if builtin then return builtin end + end + -- 2. Fallback: LibSharedMedia font name if SFramesDB and SFramesDB.fontName then local LSM = self:GetSharedMedia() if LSM then diff --git a/MinimapBuffs.lua b/MinimapBuffs.lua index 1ade096..e9800bb 100644 --- a/MinimapBuffs.lua +++ b/MinimapBuffs.lua @@ -109,7 +109,8 @@ end local function ApplySlotBackdrop(btn, isBuff) btn:SetBackdrop(ROUND_BACKDROP) - btn:SetBackdropColor(0.06, 0.06, 0.08, 0.92) + local bgA = (SFramesDB and SFramesDB.MinimapBuffs and type(SFramesDB.MinimapBuffs.bgAlpha) == "number") and SFramesDB.MinimapBuffs.bgAlpha or 0.92 + btn:SetBackdropColor(0.06, 0.06, 0.08, bgA) if isBuff then btn:SetBackdropBorderColor(0.25, 0.25, 0.30, 1) else diff --git a/Nanami-UI.toc b/Nanami-UI.toc index dee0a8d..f7b0af5 100644 --- a/Nanami-UI.toc +++ b/Nanami-UI.toc @@ -35,7 +35,6 @@ Units\ToT.lua Units\Party.lua TalentDefaultDB.lua Units\TalentTree.lua -SellPriceDB.lua GearScore.lua Tooltip.lua Units\Raid.lua diff --git a/QuestUI.lua b/QuestUI.lua index 443f917..6719c9b 100644 --- a/QuestUI.lua +++ b/QuestUI.lua @@ -143,6 +143,7 @@ local function ForwardScrollWheel(frame) local p = this:GetParent() while p do if p:GetObjectType() == "ScrollFrame" then + if p.UpdateScrollChildRect then p:UpdateScrollChildRect() end local cur = p:GetVerticalScroll() local maxVal = p:GetVerticalScrollRange() if arg1 > 0 then @@ -208,8 +209,16 @@ local function CreateScrollArea(parent, name) content:SetHeight(1) scroll:SetScrollChild(content) + -- Hook content:SetHeight to auto-refresh scroll range + local origSetHeight = content.SetHeight + content.SetHeight = function(self, h) + origSetHeight(self, h) + if scroll.UpdateScrollChildRect then scroll:UpdateScrollChildRect() end + end + scroll:EnableMouseWheel(true) scroll:SetScript("OnMouseWheel", function() + if this.UpdateScrollChildRect then this:UpdateScrollChildRect() end local cur = this:GetVerticalScroll() local maxVal = this:GetVerticalScrollRange() if arg1 > 0 then @@ -705,6 +714,9 @@ local function ShowPage(name) if pages[name] then pages[name]:Show() if pages[name].scroll then + if pages[name].scroll.UpdateScrollChildRect then + pages[name].scroll:UpdateScrollChildRect() + end pages[name].scroll:SetVerticalScroll(0) end end @@ -1555,6 +1567,19 @@ function QUI:Initialize() end end end + -- Deferred scroll refresh: re-layout after 1 frame so FontString heights are valid + if MainFrame._deferredPage then + MainFrame._deferredTick = (MainFrame._deferredTick or 0) + 1 + if MainFrame._deferredTick >= 2 then + local pg = pages[MainFrame._deferredPage] + if pg and pg.Update then pg.Update() end + if pg and pg.scroll and pg.scroll.UpdateScrollChildRect then + pg.scroll:UpdateScrollChildRect() + end + MainFrame._deferredPage = nil + MainFrame._deferredTick = nil + end + end end) MainFrame:Hide() @@ -1578,6 +1603,7 @@ function QUI:Initialize() pages.gossip.Update() UpdateBottomButtons() MainFrame:Show() + MainFrame._deferredPage = "gossip"; MainFrame._deferredTick = 0 elseif event == "GOSSIP_CLOSED" then if currentPage == "gossip" then @@ -1593,6 +1619,7 @@ function QUI:Initialize() pages.greeting.Update() UpdateBottomButtons() MainFrame:Show() + MainFrame._deferredPage = "greeting"; MainFrame._deferredTick = 0 elseif event == "QUEST_DETAIL" then pendingClose = false @@ -1601,6 +1628,7 @@ function QUI:Initialize() pages.detail.Update() UpdateBottomButtons() MainFrame:Show() + MainFrame._deferredPage = "detail"; MainFrame._deferredTick = 0 elseif event == "QUEST_PROGRESS" then pendingClose = false @@ -1609,6 +1637,7 @@ function QUI:Initialize() pages.progress.Update() UpdateBottomButtons() MainFrame:Show() + MainFrame._deferredPage = "progress"; MainFrame._deferredTick = 0 elseif event == "QUEST_COMPLETE" then pendingClose = false @@ -1618,6 +1647,7 @@ function QUI:Initialize() pages.complete.Update() UpdateBottomButtons() MainFrame:Show() + MainFrame._deferredPage = "complete"; MainFrame._deferredTick = 0 elseif event == "QUEST_FINISHED" then if previousPage then diff --git a/SellPriceDB.lua b/SellPriceDB.lua index 6b5fefc..c4a75a2 100644 --- a/SellPriceDB.lua +++ b/SellPriceDB.lua @@ -1,4 +1,4 @@ --- Nanami-UI Embedded Sell Price Database +-- Nanami-UI Embedded Sell Price Database -- Merged: ShaguTweaks Turtle WoW (priority) + Informant + SellValue wowhead data -- [itemID] = vendorSellPrice (in copper) -- Total entries: 23853 diff --git a/SpellBookUI.lua b/SpellBookUI.lua index cce98fe..6342169 100644 --- a/SpellBookUI.lua +++ b/SpellBookUI.lua @@ -832,6 +832,19 @@ local function BuildOptions(f, pageY) function() return SFramesDB.spellBookAutoReplace == true end, function(v) SFramesDB.spellBookAutoReplace = v end ) + + f.optMouseover = MakeCheckOption(f, "鼠标指向施法", L.SIDE_PAD + 280, optY, + function() + return SFramesDB.Tweaks and SFramesDB.Tweaks.mouseoverCast == true + end, + function(v) + if not SFramesDB.Tweaks then SFramesDB.Tweaks = {} end + SFramesDB.Tweaks.mouseoverCast = v + if SFrames.Tweaks and SFrames.Tweaks.SetMouseoverCast then + SFrames.Tweaks:SetMouseoverCast(v) + end + end + ) end -------------------------------------------------------------------------------- diff --git a/StatSummary.lua b/StatSummary.lua index 122fe59..7514aeb 100644 --- a/StatSummary.lua +++ b/StatSummary.lua @@ -357,7 +357,7 @@ local function BuildPanel() f:SetWidth(PANEL_W) f:SetHeight(PANEL_H) f:SetPoint("CENTER", UIParent, "CENTER", 200, 0) - f:SetFrameStrata("DIALOG") + f:SetFrameStrata("HIGH") f:SetFrameLevel(100) f:EnableMouse(true) f:SetMovable(true) diff --git a/Tooltip.lua b/Tooltip.lua index 76b62c8..c6930cc 100644 --- a/Tooltip.lua +++ b/Tooltip.lua @@ -682,6 +682,41 @@ function SFrames.FloatingTooltip:FormatLines(tooltip) end end + -------------------------------------------------------------------------- + -- Armor & Resistances for non-player units + -------------------------------------------------------------------------- + if not UnitIsPlayer(unit) and UnitExists(unit) then + local armor = UnitResistance(unit, 0) or 0 + if armor > 0 then + tooltip:AddLine(" ") + tooltip:AddLine(string.format("护甲: %d", armor), 0.78, 0.61, 0.43) + end + + local resInfo = { + { 2, "火焰", 1.0, 0.3, 0.3 }, + { 3, "自然", 0.3, 1.0, 0.3 }, + { 4, "冰霜", 0.3, 0.3, 1.0 }, + { 5, "暗影", 0.6, 0.2, 0.9 }, + { 6, "奥术", 1.0, 1.0, 1.0 }, + { 1, "神圣", 1.0, 0.9, 0.5 }, + } + local hasRes = false + local resLine = "" + for i = 1, table.getn(resInfo) do + local ri = resInfo[i] + local val = UnitResistance(unit, ri[1]) or 0 + if val > 0 then + if hasRes then resLine = resLine .. " " end + resLine = resLine .. ri[2] .. ":" .. val + hasRes = true + end + end + if hasRes then + if armor <= 0 then tooltip:AddLine(" ") end + tooltip:AddLine("抗性: " .. resLine, 0.7, 0.7, 0.75) + end + end + -------------------------------------------------------------------------- -- Target of mouseover (all units) -------------------------------------------------------------------------- @@ -817,7 +852,7 @@ local function IC_AppendItemLevel(tooltip, link) end -------------------------------------------------------------------------------- --- Sell Price Infrastructure +-- Sell Price Infrastructure (GetItemInfo API + runtime cache) -------------------------------------------------------------------------------- local function IC_GetItemIdFromLink(link) if not link then return nil end @@ -835,26 +870,44 @@ end local function IC_GetSellPrice(itemId) if not itemId then return nil end - if NanamiSellPriceDB then - local price = NanamiSellPriceDB[itemId] - if price and price > 0 then return price end - end - if ShaguTweaks and ShaguTweaks.SellValueDB then - local price = ShaguTweaks.SellValueDB[itemId] - if price and price > 0 then return price end - end + -- Runtime cache (persisted in SavedVariables) if SFramesGlobalDB and SFramesGlobalDB.sellPriceCache then local price = SFramesGlobalDB.sellPriceCache[itemId] if price and price > 0 then return price end end + -- Fallback: aux addon + if aux and aux.account_data and aux.account_data.merchant_sell then + local price = aux.account_data.merchant_sell[itemId] + if price and price > 0 then return price end + end + -- Fallback: ShaguTweaks + if ShaguTweaks and ShaguTweaks.SellValueDB then + local price = ShaguTweaks.SellValueDB[itemId] + if price and price > 0 then return price end + end return nil end -local function IC_TryGetItemInfoSellPrice(link) +--------------------------------------------------------------------------- +-- GetItemInfo API: query sell price and learn/cache it +-- Returns: sellPrice (copper) or nil +--------------------------------------------------------------------------- +local function IC_QueryAndLearnPrice(link) if not link then return nil end - local r1,r2,r3,r4,r5,r6,r7,r8,r9,r10,r11 = GetItemInfo(link) - if r11 and type(r11) == "number" and r11 > 0 then - return r11 + local itemId = IC_GetItemIdFromLink(link) + -- Check cache first + local cached = IC_GetSellPrice(itemId) + if cached then return cached end + -- Query via GetItemInfo (11th return = sellPrice) + local name, _, quality, ilvl, minLvl, itemType, subType, stackCount, equipLoc, texture, sellPrice = GetItemInfo(link) + if name then + DEFAULT_CHAT_FRAME:AddMessage( + "|cff88ccff[Nanami-SellPrice]|r GetItemInfo(" .. (itemId or "?") .. ") = " + .. (name or "nil") .. ", sellPrice=" .. tostring(sellPrice or "nil")) + end + if sellPrice and type(sellPrice) == "number" and sellPrice > 0 then + if itemId then IC_CacheSellPrice(itemId, sellPrice) end + return sellPrice end return nil end @@ -880,12 +933,7 @@ local function IC_AddSellPrice(tooltip, link, count) if not link then return end if tooltip._nanamiSellPriceAdded then return end if MerchantFrame and MerchantFrame:IsShown() then return end - local itemId = IC_GetItemIdFromLink(link) - local price = IC_GetSellPrice(itemId) - if not price then - price = IC_TryGetItemInfoSellPrice(link) - if price and itemId then IC_CacheSellPrice(itemId, price) end - end + local price = IC_QueryAndLearnPrice(link) if price and price > 0 then count = count or 1 if count < 1 then count = 1 end @@ -954,7 +1002,7 @@ function IC:HookTooltips() orig_SetTooltipMoney(frame, money, a1, a2, a3) end if IC_PendingBagLink and money and money > 0 then - if frame == GameTooltip or frame == SFramesScanTooltip then + if frame == GameTooltip then local itemId = IC_GetItemIdFromLink(IC_PendingBagLink) local count = IC_PendingBagCount or 1 if count < 1 then count = 1 end @@ -966,7 +1014,8 @@ function IC:HookTooltips() end --------------------------------------------------------------------------- - -- MERCHANT_SHOW: proactively scan all bag items to cache sell prices. + -- MERCHANT_SHOW: proactively scan all bag items to learn sell prices + -- via GetItemInfo API and cache them. --------------------------------------------------------------------------- local scanFrame = CreateFrame("Frame") scanFrame:RegisterEvent("MERCHANT_SHOW") @@ -976,26 +1025,7 @@ function IC:HookTooltips() for slot = 1, numSlots do local link = GetContainerItemLink(bag, slot) if link then - local itemId = IC_GetItemIdFromLink(link) - if itemId and not IC_GetSellPrice(itemId) then - local infoPrice = IC_TryGetItemInfoSellPrice(link) - if infoPrice then - IC_CacheSellPrice(itemId, infoPrice) - elseif SFramesScanTooltip then - local _, cnt = GetContainerItemInfo(bag, slot) - cnt = cnt or 1 - IC_PendingBagLink = link - IC_PendingBagCount = cnt - pcall(function() - SFramesScanTooltip:SetOwner(UIParent, "ANCHOR_NONE") - SFramesScanTooltip:ClearLines() - SFramesScanTooltip:SetBagItem(bag, slot) - SFramesScanTooltip:Hide() - end) - IC_PendingBagLink = nil - IC_PendingBagCount = nil - end - end + IC_QueryAndLearnPrice(link) end end end @@ -1248,11 +1278,7 @@ function IC:HookTooltips() if itemStr then ItemRefTooltip._nanamiSellPriceAdded = nil local itemId = IC_GetItemIdFromLink(itemStr) - local price = IC_GetSellPrice(itemId) - if not price then - price = IC_TryGetItemInfoSellPrice(itemStr) - if price and itemId then IC_CacheSellPrice(itemId, price) end - end + local price = IC_QueryAndLearnPrice(itemStr) if price and price > 0 and not ItemRefTooltip.hasMoney then SetTooltipMoney(ItemRefTooltip, price) ItemRefTooltip:Show() diff --git a/TrainerUI.lua b/TrainerUI.lua index f282704..45ebcb8 100644 --- a/TrainerUI.lua +++ b/TrainerUI.lua @@ -37,18 +37,18 @@ local function ColorToQuality(r, g, b) end -------------------------------------------------------------------------------- --- Layout +-- Layout (Left-Right structure like TradeSkill frame) -------------------------------------------------------------------------------- -local FRAME_W = 380 -local FRAME_H = 540 +local FRAME_W = 620 +local FRAME_H = 480 local HEADER_H = 34 -local SIDE_PAD = 14 -local CONTENT_W = FRAME_W - SIDE_PAD * 2 +local SIDE_PAD = 10 local FILTER_H = 28 -local LIST_ROW_H = 32 +local LIST_W = 320 +local DETAIL_W = FRAME_W - LIST_W - SIDE_PAD * 3 +local LIST_ROW_H = 28 local CAT_ROW_H = 20 -local DETAIL_H = 160 -local BOTTOM_H = 52 +local BOTTOM_H = 48 local SCROLL_STEP = 40 local MAX_ROWS = 60 @@ -61,6 +61,7 @@ local currentFilter = "all" local displayList = {} local rowButtons = {} local collapsedCats = {} +local isTradeskillTrainerCached = false -- Cache to avoid repeated API calls local function HideBlizzardTrainer() if not ClassTrainerFrame then return end ClassTrainerFrame:SetScript("OnHide", function() end) @@ -152,6 +153,58 @@ local function IsServiceHeader(index) return false end +-------------------------------------------------------------------------------- +-- Enhanced category detection for better filtering +-- Trust Blizzard API for class trainers (considers spell rank requirements) +-- Only do extra verification for tradeskill trainers +-------------------------------------------------------------------------------- +local function GetVerifiedCategory(index) + local name, _, category = GetTrainerServiceInfo(index) + if not name then return nil end + + -- "used" is always reliable - player already knows this skill + if category == "used" then + return "used" + end + + -- "unavailable" from API should be trusted - it considers: + -- - Level requirements + -- - Skill rank prerequisites (e.g., need Fireball Rank 1 before Rank 2) + -- - Profession skill requirements + if category == "unavailable" then + return "unavailable" + end + + -- For "available", do extra verification only for tradeskill trainers + -- Class trainers' "available" is already accurate + if category == "available" then + -- Additional check for tradeskill trainers (use cached value) + if isTradeskillTrainerCached then + local playerLevel = UnitLevel("player") or 1 + + -- Check level requirement + if GetTrainerServiceLevelReq then + local ok, levelReq = pcall(GetTrainerServiceLevelReq, index) + if ok and levelReq and levelReq > 0 and playerLevel < levelReq then + return "unavailable" + end + end + + -- Check skill requirement + if GetTrainerServiceSkillReq then + local ok, skillName, skillRank, hasReq = pcall(GetTrainerServiceSkillReq, index) + if ok and skillName and skillName ~= "" and not hasReq then + return "unavailable" + end + end + end + return "available" + end + + -- Fallback: unknown category, treat as unavailable + return category or "unavailable" +end + local scanTip = nil local function GetServiceTooltipInfo(index) @@ -350,7 +403,7 @@ end -------------------------------------------------------------------------------- local function CreateListRow(parent, idx) local row = CreateFrame("Button", nil, parent) - row:SetWidth(CONTENT_W) + row:SetWidth(LIST_W - 30) -- Account for scrollbar row:SetHeight(LIST_ROW_H) local iconFrame = CreateFrame("Frame", nil, row) @@ -365,6 +418,7 @@ local function CreateListRow(parent, idx) }) iconFrame:SetBackdropColor(T.slotBg[1], T.slotBg[2], T.slotBg[3], T.slotBg[4]) iconFrame:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + iconFrame:EnableMouse(false) -- Don't block mouse events row.iconFrame = iconFrame local icon = iconFrame:CreateTexture(nil, "ARTWORK") @@ -384,16 +438,17 @@ local function CreateListRow(parent, idx) row.qualGlow = qualGlow local nameFS = row:CreateFontString(nil, "OVERLAY") - nameFS:SetFont(GetFont(), 12, "OUTLINE") - nameFS:SetPoint("LEFT", iconFrame, "RIGHT", 6, 2) + nameFS:SetFont(GetFont(), 11, "OUTLINE") + nameFS:SetPoint("LEFT", iconFrame, "RIGHT", 6, 5) nameFS:SetPoint("RIGHT", row, "RIGHT", -90, 0) nameFS:SetJustifyH("LEFT") nameFS:SetTextColor(T.nameText[1], T.nameText[2], T.nameText[3]) row.nameFS = nameFS local subFS = row:CreateFontString(nil, "OVERLAY") - subFS:SetFont(GetFont(), 10, "OUTLINE") - subFS:SetPoint("TOPLEFT", nameFS, "BOTTOMLEFT", 0, -1) + subFS:SetFont(GetFont(), 9, "OUTLINE") + subFS:SetPoint("LEFT", iconFrame, "RIGHT", 6, -6) + subFS:SetPoint("RIGHT", row, "RIGHT", -90, 0) subFS:SetJustifyH("LEFT") subFS:SetTextColor(T.dimText[1], T.dimText[2], T.dimText[3]) row.subFS = subFS @@ -414,6 +469,13 @@ local function CreateListRow(parent, idx) costMoney:UnregisterAllEvents() costMoney:SetScript("OnEvent", nil) costMoney:SetScript("OnShow", nil) + costMoney:EnableMouse(false) -- Don't block mouse events + -- Disable mouse on child buttons too + local moneyName = "SFTRow" .. idx .. "Money" + for _, suffix in ipairs({"GoldButton", "SilverButton", "CopperButton"}) do + local child = _G[moneyName .. suffix] + if child and child.EnableMouse then child:EnableMouse(false) end + end costMoney.moneyType = nil costMoney.hasPickup = nil costMoney.small = 1 @@ -542,12 +604,18 @@ local function CreateListRow(parent, idx) self.icon:SetVertexColor(T.passive[1], T.passive[2], T.passive[3]) end - local quality = GetCachedServiceQuality(svc.index) - local qc = QUALITY_COLORS[quality] - if qc and quality and quality >= 2 then - self.qualGlow:SetVertexColor(qc[1], qc[2], qc[3]) - self.qualGlow:Show() - self.iconFrame:SetBackdropBorderColor(qc[1], qc[2], qc[3], 1) + -- Skip quality scan for tradeskill trainers (performance optimization) + if not isTradeskillTrainerCached then + local quality = GetCachedServiceQuality(svc.index) + local qc = QUALITY_COLORS[quality] + if qc and quality and quality >= 2 then + self.qualGlow:SetVertexColor(qc[1], qc[2], qc[3]) + self.qualGlow:Show() + self.iconFrame:SetBackdropBorderColor(qc[1], qc[2], qc[3], 1) + else + self.qualGlow:Hide() + self.iconFrame:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + end else self.qualGlow:Hide() self.iconFrame:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) @@ -574,6 +642,21 @@ local function CreateListRow(parent, idx) self:Hide() end + -- Enable mouse wheel scrolling on rows to pass through to scrollbar + row:EnableMouseWheel(true) + row:SetScript("OnMouseWheel", function() + local scrollBar = MainFrame and MainFrame.scrollBar + if scrollBar then + local cur = scrollBar:GetValue() + local min, max = scrollBar:GetMinMaxValues() + if arg1 > 0 then + scrollBar:SetValue(math.max(min, cur - SCROLL_STEP)) + else + scrollBar:SetValue(math.min(max, cur + SCROLL_STEP)) + end + end + end) + return row end @@ -586,8 +669,8 @@ local function BuildDisplayList() if numServices == 0 then return end local currentCat = nil - local catServices = {} - local categories = {} + -- Each category stores three buckets: available, used, unavailable + local catBuckets = {} local catOrder = {} for i = 1, numServices do @@ -596,30 +679,28 @@ local function BuildDisplayList() local isHdr = IsServiceHeader(i) if isHdr then currentCat = name - if not catServices[name] then - catServices[name] = {} + if not catBuckets[name] then + catBuckets[name] = { available = {}, used = {}, unavailable = {} } table.insert(catOrder, name) end else if not currentCat then currentCat = "技能" - if not catServices[currentCat] then - catServices[currentCat] = {} + if not catBuckets[currentCat] then + catBuckets[currentCat] = { available = {}, used = {}, unavailable = {} } table.insert(catOrder, currentCat) end end - local show = false - if currentFilter == "all" then - show = true - elseif currentFilter == (category or "") then - show = true - end + -- Use verified category for more accurate filtering + local cat = GetVerifiedCategory(i) or "unavailable" + local show = (currentFilter == "all") or (currentFilter == cat) if show then - table.insert(catServices[currentCat], { + local bucket = catBuckets[currentCat][cat] or catBuckets[currentCat]["unavailable"] + table.insert(bucket, { index = i, name = name, subText = subText or "", - category = category or "unavailable", + category = cat, }) end end @@ -629,8 +710,9 @@ local function BuildDisplayList() local hasCats = table.getn(catOrder) > 1 for _, catName in ipairs(catOrder) do - local svcs = catServices[catName] - if table.getn(svcs) > 0 then + local buckets = catBuckets[catName] + local totalCount = table.getn(buckets.available) + table.getn(buckets.used) + table.getn(buckets.unavailable) + if totalCount > 0 then if hasCats then table.insert(displayList, { type = "header", @@ -639,23 +721,19 @@ local function BuildDisplayList() }) end if not collapsedCats[catName] then - for _, svc in ipairs(svcs) do - table.insert(displayList, { - type = "service", - data = svc, - }) + -- available → used → unavailable + for _, svc in ipairs(buckets.available) do + table.insert(displayList, { type = "service", data = svc }) + end + for _, svc in ipairs(buckets.used) do + table.insert(displayList, { type = "service", data = svc }) + end + for _, svc in ipairs(buckets.unavailable) do + table.insert(displayList, { type = "service", data = svc }) end end end end - - if table.getn(catOrder) <= 1 and table.getn(displayList) == 0 then - local allCat = catOrder[1] or "技能" - local svcs = catServices[allCat] or {} - for _, svc in ipairs(svcs) do - table.insert(displayList, { type = "service", data = svc }) - end - end end -------------------------------------------------------------------------------- @@ -712,6 +790,18 @@ local function UpdateList() end content:SetHeight(math.max(1, y)) + + -- Update scrollbar range + if MainFrame.scrollBar then + local visibleHeight = MainFrame.listScroll:GetHeight() or 1 + local maxScroll = math.max(0, y - visibleHeight) + MainFrame.scrollBar:SetMinMaxValues(0, maxScroll) + if maxScroll <= 0 then + MainFrame.scrollBar:Hide() + else + MainFrame.scrollBar:Show() + end + end end local function UpdateDetail() @@ -734,7 +824,8 @@ local function UpdateDetail() return end - local name, subText, category = GetTrainerServiceInfo(selectedIndex) + local name, subText, _ = GetTrainerServiceInfo(selectedIndex) + local category = GetVerifiedCategory(selectedIndex) local iconTex = GetTrainerServiceIcon and GetTrainerServiceIcon(selectedIndex) local ok, cost = pcall(GetTrainerServiceCost, selectedIndex) if not ok then cost = 0 end @@ -904,57 +995,133 @@ function TUI:Initialize() headerSep:SetPoint("TOPRIGHT", MainFrame, "TOPRIGHT", -6, -HEADER_H) headerSep:SetVertexColor(T.divider[1], T.divider[2], T.divider[3], T.divider[4]) - -- Filter bar + -- Filter bar (above left list) local filterBar = CreateFrame("Frame", nil, MainFrame) filterBar:SetPoint("TOPLEFT", MainFrame, "TOPLEFT", SIDE_PAD, -(HEADER_H + 4)) - filterBar:SetPoint("TOPRIGHT", MainFrame, "TOPRIGHT", -SIDE_PAD, -(HEADER_H + 4)) + filterBar:SetWidth(LIST_W) filterBar:SetHeight(FILTER_H) - local fAll = CreateFilterBtn(filterBar, "全部", 52) - fAll:SetPoint("LEFT", filterBar, "LEFT", 0, 0) - fAll:SetScript("OnClick", function() currentFilter = "all"; FullUpdate() end) - MainFrame.filterAll = fAll - - local fAvail = CreateFilterBtn(filterBar, "可学习", 60) - fAvail:SetPoint("LEFT", fAll, "RIGHT", 4, 0) + local fAvail = CreateFilterBtn(filterBar, "可学习", 55) + fAvail:SetPoint("LEFT", filterBar, "LEFT", 0, 0) fAvail:SetScript("OnClick", function() currentFilter = "available"; FullUpdate() end) MainFrame.filterAvail = fAvail - local fUnavail = CreateFilterBtn(filterBar, "不可学", 60) - fUnavail:SetPoint("LEFT", fAvail, "RIGHT", 4, 0) + local fUnavail = CreateFilterBtn(filterBar, "不可学", 55) + fUnavail:SetPoint("LEFT", fAvail, "RIGHT", 2, 0) fUnavail:SetScript("OnClick", function() currentFilter = "unavailable"; FullUpdate() end) MainFrame.filterUnavail = fUnavail - local fUsed = CreateFilterBtn(filterBar, "已学会", 60) - fUsed:SetPoint("LEFT", fUnavail, "RIGHT", 4, 0) + local fUsed = CreateFilterBtn(filterBar, "已学会", 55) + fUsed:SetPoint("LEFT", fUnavail, "RIGHT", 2, 0) fUsed:SetScript("OnClick", function() currentFilter = "used"; FullUpdate() end) MainFrame.filterUsed = fUsed - -- Scrollable list area - local listTop = HEADER_H + FILTER_H + 8 - local listBottom = DETAIL_H + BOTTOM_H + 8 + local fAll = CreateFilterBtn(filterBar, "全部", 45) + fAll:SetPoint("LEFT", fUsed, "RIGHT", 2, 0) + fAll:SetScript("OnClick", function() currentFilter = "all"; FullUpdate() end) + MainFrame.filterAll = fAll - local listScroll = CreateFrame("ScrollFrame", "SFramesTrainerListScroll", MainFrame) - listScroll:SetPoint("TOPLEFT", MainFrame, "TOPLEFT", SIDE_PAD, -listTop) - listScroll:SetPoint("BOTTOMRIGHT", MainFrame, "BOTTOMRIGHT", -SIDE_PAD, listBottom) + -- Left side: Scrollable list area + local listTop = HEADER_H + FILTER_H + 8 + + local listBg = CreateFrame("Frame", nil, MainFrame) + listBg:SetPoint("TOPLEFT", MainFrame, "TOPLEFT", SIDE_PAD, -listTop) + listBg:SetPoint("BOTTOMLEFT", MainFrame, "BOTTOMLEFT", SIDE_PAD, BOTTOM_H + 4) + listBg:SetWidth(LIST_W) + listBg:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 2, right = 2, top = 2, bottom = 2 }, + }) + listBg:SetBackdropColor(0, 0, 0, 0.3) + listBg:SetBackdropBorderColor(T.panelBorder[1], T.panelBorder[2], T.panelBorder[3], 0.5) + + -- Scrollbar + local scrollBar = CreateFrame("Slider", "SFramesTrainerScrollBar", listBg) + scrollBar:SetWidth(16) + scrollBar:SetPoint("TOPRIGHT", listBg, "TOPRIGHT", -4, -4) + scrollBar:SetPoint("BOTTOMRIGHT", listBg, "BOTTOMRIGHT", -4, 4) + scrollBar:SetOrientation("VERTICAL") + scrollBar:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob") + scrollBar:SetMinMaxValues(0, 1) + scrollBar:SetValue(0) + scrollBar:SetValueStep(SCROLL_STEP) + scrollBar:EnableMouseWheel(true) + + local scrollBg = scrollBar:CreateTexture(nil, "BACKGROUND") + scrollBg:SetAllPoints() + scrollBg:SetTexture(0, 0, 0, 0.3) + + local scrollUp = CreateFrame("Button", nil, scrollBar) + scrollUp:SetWidth(16); scrollUp:SetHeight(16) + scrollUp:SetPoint("BOTTOM", scrollBar, "TOP", 0, 0) + scrollUp:SetNormalTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Up") + scrollUp:SetPushedTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Down") + scrollUp:SetDisabledTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Disabled") + scrollUp:SetHighlightTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Highlight") + scrollUp:SetScript("OnClick", function() + local val = scrollBar:GetValue() + scrollBar:SetValue(val - SCROLL_STEP) + end) + + local scrollDown = CreateFrame("Button", nil, scrollBar) + scrollDown:SetWidth(16); scrollDown:SetHeight(16) + scrollDown:SetPoint("TOP", scrollBar, "BOTTOM", 0, 0) + scrollDown:SetNormalTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Up") + scrollDown:SetPushedTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Down") + scrollDown:SetDisabledTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Disabled") + scrollDown:SetHighlightTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Highlight") + scrollDown:SetScript("OnClick", function() + local val = scrollBar:GetValue() + scrollBar:SetValue(val + SCROLL_STEP) + end) + + local listScroll = CreateFrame("ScrollFrame", "SFramesTrainerListScroll", listBg) + listScroll:SetPoint("TOPLEFT", listBg, "TOPLEFT", 4, -4) + listScroll:SetPoint("BOTTOMRIGHT", scrollBar, "BOTTOMLEFT", -2, 0) local listContent = CreateFrame("Frame", "SFramesTrainerListContent", listScroll) - listContent:SetWidth(CONTENT_W) + listContent:SetWidth(LIST_W - 30) listContent:SetHeight(1) listScroll:SetScrollChild(listContent) - listScroll:EnableMouseWheel(true) - listScroll:SetScript("OnMouseWheel", function() - local cur = this:GetVerticalScroll() - local maxVal = this:GetVerticalScrollRange() + -- Sync scrollbar with scroll frame + scrollBar:SetScript("OnValueChanged", function() + listScroll:SetVerticalScroll(this:GetValue()) + end) + + scrollBar:SetScript("OnMouseWheel", function() + local cur = this:GetValue() + local min, max = this:GetMinMaxValues() if arg1 > 0 then - this:SetVerticalScroll(math.max(0, cur - SCROLL_STEP)) + this:SetValue(math.max(min, cur - SCROLL_STEP)) else - this:SetVerticalScroll(math.min(maxVal, cur + SCROLL_STEP)) + this:SetValue(math.min(max, cur + SCROLL_STEP)) end end) + + -- Mouse wheel on list area + local function OnListMouseWheel() + local cur = scrollBar:GetValue() + local min, max = scrollBar:GetMinMaxValues() + if arg1 > 0 then + scrollBar:SetValue(math.max(min, cur - SCROLL_STEP)) + else + scrollBar:SetValue(math.min(max, cur + SCROLL_STEP)) + end + end + + listBg:EnableMouseWheel(true) + listBg:SetScript("OnMouseWheel", OnListMouseWheel) + + listScroll:EnableMouseWheel(true) + listScroll:SetScript("OnMouseWheel", OnListMouseWheel) + listScroll.content = listContent MainFrame.listScroll = listScroll + MainFrame.listBg = listBg + MainFrame.scrollBar = scrollBar for i = 1, MAX_ROWS do local row = CreateListRow(listContent, i) @@ -968,24 +1135,24 @@ function TUI:Initialize() rowButtons[i] = row end - -- Detail separator - local detailSep = MainFrame:CreateTexture(nil, "ARTWORK") - detailSep:SetTexture("Interface\\Buttons\\WHITE8X8") - detailSep:SetHeight(1) - detailSep:SetPoint("BOTTOMLEFT", MainFrame, "BOTTOMLEFT", 6, DETAIL_H + BOTTOM_H + 4) - detailSep:SetPoint("BOTTOMRIGHT", MainFrame, "BOTTOMRIGHT", -6, DETAIL_H + BOTTOM_H + 4) - detailSep:SetVertexColor(T.divider[1], T.divider[2], T.divider[3], T.divider[4]) + -- Right side: Detail panel + local detailPanel = CreateFrame("Frame", nil, MainFrame) + detailPanel:SetPoint("TOPLEFT", MainFrame, "TOPLEFT", SIDE_PAD + LIST_W + SIDE_PAD, -listTop) + detailPanel:SetPoint("BOTTOMRIGHT", MainFrame, "BOTTOMRIGHT", -SIDE_PAD, BOTTOM_H + 4) + detailPanel:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 2, right = 2, top = 2, bottom = 2 }, + }) + detailPanel:SetBackdropColor(0, 0, 0, 0.3) + detailPanel:SetBackdropBorderColor(T.panelBorder[1], T.panelBorder[2], T.panelBorder[3], 0.5) + MainFrame.detail = detailPanel - -- Detail area - local detail = CreateFrame("Frame", nil, MainFrame) - detail:SetPoint("BOTTOMLEFT", MainFrame, "BOTTOMLEFT", SIDE_PAD, BOTTOM_H + 4) - detail:SetPoint("BOTTOMRIGHT", MainFrame, "BOTTOMRIGHT", -SIDE_PAD, BOTTOM_H + 4) - detail:SetHeight(DETAIL_H) - MainFrame.detail = detail - - local dIconFrame = CreateFrame("Frame", nil, detail) - dIconFrame:SetWidth(42); dIconFrame:SetHeight(42) - dIconFrame:SetPoint("TOPLEFT", detail, "TOPLEFT", 2, -6) + -- Icon in detail panel + local dIconFrame = CreateFrame("Frame", nil, detailPanel) + dIconFrame:SetWidth(48); dIconFrame:SetHeight(48) + dIconFrame:SetPoint("TOPLEFT", detailPanel, "TOPLEFT", 10, -10) dIconFrame:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", @@ -995,55 +1162,61 @@ function TUI:Initialize() dIconFrame:SetBackdropColor(T.slotBg[1], T.slotBg[2], T.slotBg[3], T.slotBg[4]) dIconFrame:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) dIconFrame:Hide() - detail.iconFrame = dIconFrame + detailPanel.iconFrame = dIconFrame local dIcon = dIconFrame:CreateTexture(nil, "ARTWORK") dIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) dIcon:SetPoint("TOPLEFT", dIconFrame, "TOPLEFT", 3, -3) dIcon:SetPoint("BOTTOMRIGHT", dIconFrame, "BOTTOMRIGHT", -3, 3) - detail.icon = dIcon + detailPanel.icon = dIcon - local dNameFS = detail:CreateFontString(nil, "OVERLAY") - dNameFS:SetFont(GetFont(), 13, "OUTLINE") - dNameFS:SetPoint("TOPLEFT", dIconFrame, "TOPRIGHT", 8, -2) - dNameFS:SetPoint("RIGHT", detail, "RIGHT", -4, 0) + -- Name next to icon + local dNameFS = detailPanel:CreateFontString(nil, "OVERLAY") + dNameFS:SetFont(GetFont(), 14, "OUTLINE") + dNameFS:SetPoint("TOPLEFT", dIconFrame, "TOPRIGHT", 8, -4) + dNameFS:SetPoint("RIGHT", detailPanel, "RIGHT", -10, 0) dNameFS:SetJustifyH("LEFT") dNameFS:SetTextColor(T.nameText[1], T.nameText[2], T.nameText[3]) - detail.nameFS = dNameFS + detailPanel.nameFS = dNameFS - local dSubFS = detail:CreateFontString(nil, "OVERLAY") + -- Subtext (rank) + local dSubFS = detailPanel:CreateFontString(nil, "OVERLAY") dSubFS:SetFont(GetFont(), 11, "OUTLINE") dSubFS:SetPoint("TOPLEFT", dNameFS, "BOTTOMLEFT", 0, -2) dSubFS:SetJustifyH("LEFT") dSubFS:SetTextColor(T.dimText[1], T.dimText[2], T.dimText[3]) - detail.subFS = dSubFS + detailPanel.subFS = dSubFS - local dReqFS = detail:CreateFontString(nil, "OVERLAY") + -- Requirements + local dReqFS = detailPanel:CreateFontString(nil, "OVERLAY") dReqFS:SetFont(GetFont(), 11, "OUTLINE") - dReqFS:SetPoint("TOPLEFT", dIconFrame, "BOTTOMLEFT", 0, -6) - dReqFS:SetPoint("RIGHT", detail, "RIGHT", -4, 0) + dReqFS:SetPoint("TOPLEFT", dIconFrame, "BOTTOMLEFT", 0, -10) + dReqFS:SetPoint("RIGHT", detailPanel, "RIGHT", -10, 0) dReqFS:SetJustifyH("LEFT") - detail.reqFS = dReqFS + detailPanel.reqFS = dReqFS - local dInfoFS = detail:CreateFontString(nil, "OVERLAY") + -- Spell info + local dInfoFS = detailPanel:CreateFontString(nil, "OVERLAY") dInfoFS:SetFont(GetFont(), 11) - dInfoFS:SetPoint("TOPLEFT", dReqFS, "BOTTOMLEFT", 0, -4) - dInfoFS:SetPoint("RIGHT", detail, "RIGHT", -4, 0) + dInfoFS:SetPoint("TOPLEFT", dReqFS, "BOTTOMLEFT", 0, -6) + dInfoFS:SetPoint("RIGHT", detailPanel, "RIGHT", -10, 0) dInfoFS:SetJustifyH("LEFT") dInfoFS:SetTextColor(T.bodyText[1], T.bodyText[2], T.bodyText[3]) - detail.infoFS = dInfoFS + detailPanel.infoFS = dInfoFS - local descDivider = detail:CreateTexture(nil, "ARTWORK") + -- Divider line + local descDivider = detailPanel:CreateTexture(nil, "ARTWORK") descDivider:SetTexture("Interface\\Buttons\\WHITE8X8") descDivider:SetHeight(1) - descDivider:SetPoint("TOPLEFT", dInfoFS, "BOTTOMLEFT", 0, -5) - descDivider:SetPoint("RIGHT", detail, "RIGHT", -4, 0) - descDivider:SetVertexColor(T.divider[1], T.divider[2], T.divider[3], 0.3) - detail.descDivider = descDivider + descDivider:SetPoint("TOPLEFT", dInfoFS, "BOTTOMLEFT", 0, -8) + descDivider:SetPoint("RIGHT", detailPanel, "RIGHT", -10, 0) + descDivider:SetVertexColor(T.divider[1], T.divider[2], T.divider[3], 0.5) + detailPanel.descDivider = descDivider - local descScroll = CreateFrame("ScrollFrame", nil, detail) - descScroll:SetPoint("TOPLEFT", descDivider, "BOTTOMLEFT", 0, -4) - descScroll:SetPoint("BOTTOMRIGHT", detail, "BOTTOMRIGHT", -4, 2) + -- Description scroll area + local descScroll = CreateFrame("ScrollFrame", nil, detailPanel) + descScroll:SetPoint("TOPLEFT", descDivider, "BOTTOMLEFT", 0, -6) + descScroll:SetPoint("BOTTOMRIGHT", detailPanel, "BOTTOMRIGHT", -10, 8) descScroll:EnableMouseWheel(true) descScroll:SetScript("OnMouseWheel", function() local cur = this:GetVerticalScroll() @@ -1054,20 +1227,20 @@ function TUI:Initialize() this:SetVerticalScroll(math.min(maxVal, cur + 14)) end end) - detail.descScroll = descScroll + detailPanel.descScroll = descScroll local descContent = CreateFrame("Frame", nil, descScroll) - descContent:SetWidth(CONTENT_W - 8) + descContent:SetWidth(DETAIL_W - 20) descContent:SetHeight(1) descScroll:SetScrollChild(descContent) local dDescFS = descContent:CreateFontString(nil, "OVERLAY") dDescFS:SetFont(GetFont(), 11) dDescFS:SetPoint("TOPLEFT", descContent, "TOPLEFT", 0, 0) - dDescFS:SetWidth(CONTENT_W - 8) + dDescFS:SetWidth(DETAIL_W - 20) dDescFS:SetJustifyH("LEFT") dDescFS:SetTextColor(T.bodyText[1], T.bodyText[2], T.bodyText[3]) - detail.descFS = dDescFS + detailPanel.descFS = dDescFS -- Bottom bar local bottomSep = MainFrame:CreateTexture(nil, "ARTWORK") @@ -1125,8 +1298,9 @@ function TUI:Initialize() local numServices = GetNumTrainerServices and GetNumTrainerServices() or 0 local gold = GetMoney() for i = 1, numServices do - local name, _, category = GetTrainerServiceInfo(i) - if name and category == "available" and not IsServiceHeader(i) then + local name = GetTrainerServiceInfo(i) + local cat = GetVerifiedCategory(i) + if name and cat == "available" and not IsServiceHeader(i) then local ok, cost = pcall(GetTrainerServiceCost, i) if ok and cost and gold >= cost then pcall(BuyTrainerService, i) @@ -1192,11 +1366,15 @@ function TUI:Initialize() end selectedIndex = nil - currentFilter = "all" + currentFilter = "available" collapsedCats = {} qualityCache = {} + + -- Cache tradeskill trainer status once (avoid repeated API calls) + isTradeskillTrainerCached = IsTradeskillTrainer and IsTradeskillTrainer() or false + local npcName = UnitName("npc") or "训练师" - if IsTradeskillTrainer and IsTradeskillTrainer() then + if isTradeskillTrainerCached then npcName = npcName .. " - 专业训练" end MainFrame.npcNameFS:SetText(npcName) diff --git a/Tweaks.lua b/Tweaks.lua index 124ae8a..ea4715e 100644 --- a/Tweaks.lua +++ b/Tweaks.lua @@ -7,18 +7,21 @@ -- 5. Dark UI - darken the entire interface -- 6. WorldMap Window - turn fullscreen map into a movable/scalable window -- 7. Hunter Aspect Guard - cancel Cheetah/Pack when taking damage in combat (avoid OOC false positives) +-- 8. Mouseover Cast - cast on mouseover unit without changing target -------------------------------------------------------------------------------- SFrames.Tweaks = SFrames.Tweaks or {} local Tweaks = SFrames.Tweaks SFrames.castdb = SFrames.castdb or {} +SFrames.guidToName = SFrames.guidToName or {} +SFrames.castByName = SFrames.castByName or {} -- [unitName] = { spell, start, casttime, icon, channel } local function GetTweaksCfg() if not SFramesDB or type(SFramesDB.Tweaks) ~= "table" then return { autoStance = true, superWoW = true, turtleCompat = true, cooldownNumbers = true, darkUI = false, worldMapWindow = false, - hunterAspectGuard = true } + hunterAspectGuard = true, mouseoverCast = false } end return SFramesDB.Tweaks end @@ -104,6 +107,49 @@ local function InitSuperWoW() castdb[guid].icon = icon castdb[guid].channel = (arg3 == "CHANNEL") or false + -- Build GUID -> name mapping and castByName + -- Try UnitName(guid) directly (SuperWoW allows GUID as unitID) + local castName + local ok_n, gname = pcall(UnitName, guid) + if ok_n and gname and gname ~= "" and gname ~= UNKNOWN then + castName = gname + SFrames.guidToName[guid] = gname + end + -- Fallback: scan known unitIDs with UnitGUID + if not castName and UnitGUID then + local scanUnits = {"target","targettarget","party1","party2","party3","party4", + "party1target","party2target","party3target","party4target"} + local rn = GetNumRaidMembers and GetNumRaidMembers() or 0 + for ri = 1, rn do + table.insert(scanUnits, "raid"..ri) + table.insert(scanUnits, "raid"..ri.."target") + end + for _, u in ipairs(scanUnits) do + local ok1, exists = pcall(UnitExists, u) + if ok1 and exists then + local ok2, ug = pcall(UnitGUID, u) + if ok2 and ug and ug == guid then + local ok3, uname = pcall(UnitName, u) + if ok3 and uname then + castName = uname + SFrames.guidToName[guid] = uname + end + break + end + end + end + end + -- Write castByName for name-based consumers (Focus castbar) + if castName then + SFrames.castByName[castName] = { + cast = spell, + start = GetTime(), + casttime = arg5, + icon = icon, + channel = (arg3 == "CHANNEL") or false, + } + end + SFrames.superwow_active = true elseif arg3 == "FAIL" then local guid = arg1 @@ -115,6 +161,15 @@ local function InitSuperWoW() castdb[guid].icon = nil castdb[guid].channel = nil end + -- Clear castByName + local failName = SFrames.guidToName[guid] + if not failName then + local ok_n, gname = pcall(UnitName, guid) + if ok_n and gname and gname ~= "" and gname ~= UNKNOWN then failName = gname end + end + if failName and SFrames.castByName[failName] then + SFrames.castByName[failName] = nil + end end end) end @@ -1074,6 +1129,185 @@ local function InitHunterAspectGuard() end) end +-------------------------------------------------------------------------------- +-- Combat Background Notify +-- Flash the Windows taskbar icon when entering combat (UnitXP SP3 required). +-------------------------------------------------------------------------------- +local function InitCombatNotify() + if not (type(UnitXP) == "function" and pcall(UnitXP, "nop", "nop")) then return end + local f = CreateFrame("Frame", "NanamiCombatNotify") + f:RegisterEvent("PLAYER_REGEN_DISABLED") + f:SetScript("OnEvent", function() + pcall(UnitXP, "notify", "taskbarIcon") + end) +end + +-------------------------------------------------------------------------------- +-- Mouseover Cast +-- When enabled, action bar presses and CastSpellByName calls will target the +-- unit under the mouse cursor without changing current target. +-- +-- Strategy: +-- UseAction hook: temporarily TargetUnit(moUnit) before the real UseAction, +-- then restore previous target afterwards. This preserves the hardware +-- event callstack so the client doesn't reject the action. +-- CastSpellByName hook (SuperWoW): pass moUnit as 2nd arg directly. +-- CastSpellByName hook (no SuperWoW): same target-swap trick. +-------------------------------------------------------------------------------- +local mouseoverCastEnabled = false +local origUseAction = nil +local origCastSpellByName = nil +local inMouseoverAction = false -- re-entrancy guard + +local function GetMouseoverUnit() + local focus = GetMouseFocus and GetMouseFocus() + if focus then + if focus.unit and UnitExists(focus.unit) then + return focus.unit + end + local parent = focus:GetParent() + if parent and parent.unit and UnitExists(parent.unit) then + return parent.unit + end + end + if UnitExists("mouseover") then + return "mouseover" + end + return nil +end + +local function MouseoverUseAction(action, cursor, onSelf) + -- Don't interfere: picking up action, or re-entrant call + if cursor == 1 or inMouseoverAction then + return origUseAction(action, cursor, onSelf) + end + + local moUnit = GetMouseoverUnit() + if not moUnit then + return origUseAction(action, cursor, onSelf) + end + + -- Skip if mouseover IS current target (no swap needed) + if UnitIsUnit and UnitExists("target") and UnitIsUnit(moUnit, "target") then + return origUseAction(action, cursor, onSelf) + end + + -- Remember current target state + local hadTarget = UnitExists("target") + local prevTargetName = hadTarget and UnitName("target") or nil + + -- Temporarily target the mouseover unit + inMouseoverAction = true + TargetUnit(moUnit) + + -- Execute the real UseAction on the now-targeted mouseover unit + origUseAction(action, cursor, onSelf) + + -- Handle ground-targeted spells (Blizzard, Flamestrike, etc.) + if SpellIsTargeting and SpellIsTargeting() then + SpellTargetUnit(moUnit) + end + if SpellIsTargeting and SpellIsTargeting() then + SpellStopTargeting() + end + + -- Restore previous target + if hadTarget and prevTargetName then + -- Target back the previous unit + TargetLastTarget() + -- Verify restoration worked + if not UnitExists("target") or UnitName("target") ~= prevTargetName then + -- TargetLastTarget failed, try by name + TargetByName(prevTargetName, true) + end + else + -- Had no target before, clear + ClearTarget() + end + + inMouseoverAction = false +end + +local function MouseoverCastSpellByName(spell, arg2) + -- Already has explicit target arg, pass through + if arg2 then + return origCastSpellByName(spell, arg2) + end + + -- Re-entrancy guard (e.g. called from within MouseoverUseAction's chain) + if inMouseoverAction then + return origCastSpellByName(spell) + end + + local moUnit = GetMouseoverUnit() + if not moUnit then + return origCastSpellByName(spell) + end + + -- SuperWoW: direct unit parameter, no target swap needed + if SUPERWOW_VERSION then + origCastSpellByName(spell, moUnit) + if SpellIsTargeting and SpellIsTargeting() then + SpellTargetUnit(moUnit) + end + if SpellIsTargeting and SpellIsTargeting() then + SpellStopTargeting() + end + return + end + + -- No SuperWoW: target-swap + local hadTarget = UnitExists("target") + local prevTargetName = hadTarget and UnitName("target") or nil + + if not (hadTarget and UnitIsUnit and UnitIsUnit(moUnit, "target")) then + TargetUnit(moUnit) + end + + origCastSpellByName(spell) + + if SpellIsTargeting and SpellIsTargeting() then + SpellTargetUnit("target") + end + if SpellIsTargeting and SpellIsTargeting() then + SpellStopTargeting() + end + + if hadTarget and prevTargetName then + TargetLastTarget() + if not UnitExists("target") or UnitName("target") ~= prevTargetName then + TargetByName(prevTargetName, true) + end + elseif not hadTarget then + ClearTarget() + end +end + +local function InitMouseoverCast() + if UseAction then + origUseAction = UseAction + UseAction = MouseoverUseAction + end + if CastSpellByName then + origCastSpellByName = CastSpellByName + CastSpellByName = MouseoverCastSpellByName + end + mouseoverCastEnabled = true + DEFAULT_CHAT_FRAME:AddMessage("|cff88ccff[Nanami] 鼠标指向施法已启用" .. + (SUPERWOW_VERSION and "(SuperWoW 模式)" or "(目标切换模式)") .. "|r") +end + +function Tweaks:SetMouseoverCast(enabled) + if enabled and not mouseoverCastEnabled then + InitMouseoverCast() + elseif not enabled and mouseoverCastEnabled then + if origUseAction then UseAction = origUseAction end + if origCastSpellByName then CastSpellByName = origCastSpellByName end + mouseoverCastEnabled = false + DEFAULT_CHAT_FRAME:AddMessage("|cff88ccff[Nanami] 鼠标指向施法已关闭|r") + end +end + -------------------------------------------------------------------------------- -- Module API -------------------------------------------------------------------------------- @@ -1131,6 +1365,20 @@ function Tweaks:Initialize() end end + if cfg.combatNotify ~= false then + local ok, err = pcall(InitCombatNotify) + if not ok then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444Nanami-UI: CombatNotify init failed: " .. tostring(err) .. "|r") + end + end + + if cfg.mouseoverCast then + local ok, err = pcall(InitMouseoverCast) + if not ok then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444Nanami-UI: MouseoverCast init failed: " .. tostring(err) .. "|r") + end + end + if cfg.darkUI then local ok, err = pcall(InitDarkUI) if not ok then diff --git a/Units/Party.lua b/Units/Party.lua index f300318..5b458b8 100644 --- a/Units/Party.lua +++ b/Units/Party.lua @@ -129,6 +129,17 @@ function SFrames.Party:ApplyFrameStyle(frame, metrics) frame.healthBGFrame:SetPoint("BOTTOMRIGHT", frame.health, "BOTTOMRIGHT", 1, -1) end + local bgA = (SFramesDB and type(SFramesDB.partyBgAlpha) == "number") and SFramesDB.partyBgAlpha or 0.9 + local A = SFrames.ActiveTheme + if A and A.panelBg and bgA < 0.89 then + if frame.pbg and frame.pbg.SetBackdropColor then + frame.pbg:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], bgA) + end + if frame.healthBGFrame and frame.healthBGFrame.SetBackdropColor then + frame.healthBGFrame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], bgA) + end + end + if frame.power then frame.power:ClearAllPoints() frame.power:SetPoint("TOPLEFT", frame.health, "BOTTOMLEFT", 0, -1) @@ -302,6 +313,10 @@ function SFrames.Party:Initialize() end) f:SetScript("OnClick", function() if arg1 == "LeftButton" then + if IsShiftKeyDown() and SFrames.Focus and SFrames.Focus.SetFromUnit then + pcall(SFrames.Focus.SetFromUnit, SFrames.Focus, this.unit) + return + end if TryDropCursorOnUnit(this.unit) then return end @@ -324,10 +339,12 @@ function SFrames.Party:Initialize() end end) f:SetScript("OnEnter", function() + if SetMouseoverUnit then SetMouseoverUnit(this.unit) end GameTooltip_SetDefaultAnchor(GameTooltip, this) GameTooltip:SetUnit(this.unit) end) f:SetScript("OnLeave", function() + if SetMouseoverUnit then SetMouseoverUnit() end GameTooltip:Hide() end) f.unit = unit @@ -376,13 +393,21 @@ function SFrames.Party:Initialize() f.health.healPredMine = f.health:CreateTexture(nil, "OVERLAY") f.health.healPredMine:SetTexture(SFrames:GetTexture()) f.health.healPredMine:SetVertexColor(0.4, 1.0, 0.55, 0.78) + f.health.healPredMine:SetDrawLayer("OVERLAY", 7) f.health.healPredMine:Hide() f.health.healPredOther = f.health:CreateTexture(nil, "OVERLAY") f.health.healPredOther:SetTexture(SFrames:GetTexture()) f.health.healPredOther:SetVertexColor(0.2, 0.9, 0.35, 0.5) + f.health.healPredOther:SetDrawLayer("OVERLAY", 7) f.health.healPredOther:Hide() + f.health.healPredOver = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOver:SetTexture(SFrames:GetTexture()) + f.health.healPredOver:SetVertexColor(1.0, 0.3, 0.3, 0.6) + f.health.healPredOver:SetDrawLayer("OVERLAY", 7) + f.health.healPredOver:Hide() + -- Power Bar f.power = SFrames:CreateStatusBar(f, "SFramesPartyFrame"..i.."Power") f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -1) @@ -470,10 +495,14 @@ function SFrames.Party:Initialize() pf:RegisterForClicks("LeftButtonUp", "RightButtonUp") pf:SetScript("OnClick", function() TargetUnit(this.unit) end) pf:SetScript("OnEnter", function() + if SetMouseoverUnit then SetMouseoverUnit(this.unit) end GameTooltip_SetDefaultAnchor(GameTooltip, this) GameTooltip:SetUnit(this.unit) end) - pf:SetScript("OnLeave", function() GameTooltip:Hide() end) + pf:SetScript("OnLeave", function() + if SetMouseoverUnit then SetMouseoverUnit() end + GameTooltip:Hide() + end) pf:Hide() f.petFrame = pf @@ -882,14 +911,16 @@ function SFrames.Party:UpdateHealPrediction(unit) local data = self:GetFrameByUnit(unit) if not data then return end local f = data.frame - if not (f.health and f.health.healPredMine and f.health.healPredOther) then return end + if not (f.health and f.health.healPredMine and f.health.healPredOther and f.health.healPredOver) then return end local predMine = f.health.healPredMine local predOther = f.health.healPredOther + local predOver = f.health.healPredOver local function HidePredictions() predMine:Hide() predOther:Hide() + predOver:Hide() end if not UnitExists(unit) or not UnitIsConnected(unit) then @@ -899,14 +930,39 @@ function SFrames.Party:UpdateHealPrediction(unit) local hp = UnitHealth(unit) or 0 local maxHp = UnitHealthMax(unit) or 0 - if maxHp <= 0 or hp >= maxHp then + + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, realHp = pcall(UnitHealth, unit) + if ok2 then + hp = realHp or hp + end + local ok3, realMaxHp = pcall(UnitHealthMax, unit) + if ok3 then + maxHp = realMaxHp or maxHp + end + end + end + + if maxHp <= 0 then HidePredictions() return end local _, mineIncoming, othersIncoming = GetIncomingHeals(unit) + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, _, realMine, realOther = pcall(GetIncomingHeals, unit) + if ok2 then + mineIncoming = realMine or mineIncoming + othersIncoming = realOther or othersIncoming + end + end + end local missing = maxHp - hp - if missing <= 0 then + if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end @@ -914,29 +970,30 @@ function SFrames.Party:UpdateHealPrediction(unit) local mineShown = math.min(math.max(0, mineIncoming), missing) local remaining = missing - mineShown local otherShown = math.min(math.max(0, othersIncoming), remaining) - if mineShown <= 0 and otherShown <= 0 then + if mineIncoming <= 0 and othersIncoming <= 0 then HidePredictions() return end - local barWidth = f.health:GetWidth() or 0 + local barWidth = f:GetWidth() - (f.portrait:GetWidth() + 4) if barWidth <= 0 then HidePredictions() return end - local currentWidth = math.floor((hp / maxHp) * barWidth + 0.5) + local currentWidth = (hp / maxHp) * barWidth if currentWidth < 0 then currentWidth = 0 end if currentWidth > barWidth then currentWidth = barWidth end local availableWidth = barWidth - currentWidth - if availableWidth <= 0 then - HidePredictions() - return - end + if availableWidth < 0 then availableWidth = 0 end - local mineWidth = math.floor((mineShown / maxHp) * barWidth + 0.5) - local otherWidth = math.floor((otherShown / maxHp) * barWidth + 0.5) + local mineWidth = 0 + local otherWidth = 0 + if missing > 0 then + mineWidth = (mineShown / missing) * availableWidth + otherWidth = (otherShown / missing) * availableWidth + end if mineWidth < 0 then mineWidth = 0 end if otherWidth < 0 then otherWidth = 0 end if mineWidth > availableWidth then mineWidth = availableWidth end @@ -949,6 +1006,7 @@ function SFrames.Party:UpdateHealPrediction(unit) predMine:SetPoint("TOPLEFT", f.health, "TOPLEFT", currentWidth, 0) predMine:SetPoint("BOTTOMLEFT", f.health, "BOTTOMLEFT", currentWidth, 0) predMine:SetWidth(mineWidth) + predMine:SetHeight(f.health:GetHeight()) predMine:Show() else predMine:Hide() @@ -959,10 +1017,29 @@ function SFrames.Party:UpdateHealPrediction(unit) predOther:SetPoint("TOPLEFT", f.health, "TOPLEFT", currentWidth + mineWidth, 0) predOther:SetPoint("BOTTOMLEFT", f.health, "BOTTOMLEFT", currentWidth + mineWidth, 0) predOther:SetWidth(otherWidth) + predOther:SetHeight(f.health:GetHeight()) predOther:Show() else predOther:Hide() end + + local totalIncoming = mineIncoming + othersIncoming + local overHeal = totalIncoming - missing + if overHeal > 0 then + local overWidth = (overHeal / maxHp) * barWidth + if overWidth > 0 then + predOver:ClearAllPoints() + predOver:SetPoint("TOPLEFT", f.health, "TOPLEFT", currentWidth + mineWidth + otherWidth, 0) + predOver:SetPoint("BOTTOMLEFT", f.health, "BOTTOMLEFT", currentWidth + mineWidth + otherWidth, 0) + predOver:SetWidth(overWidth) + predOver:SetHeight(f.health:GetHeight()) + predOver:Show() + else + predOver:Hide() + end + else + predOver:Hide() + end end function SFrames.Party:UpdatePowerType(unit) diff --git a/Units/Pet.lua b/Units/Pet.lua index cb3c0f8..33ddc14 100644 --- a/Units/Pet.lua +++ b/Units/Pet.lua @@ -270,11 +270,13 @@ function SFrames.Pet:Initialize() end) f:SetScript("OnEnter", function() + if SetMouseoverUnit then SetMouseoverUnit("pet") end GameTooltip_SetDefaultAnchor(GameTooltip, this) GameTooltip:SetUnit("pet") GameTooltip:Show() end) f:SetScript("OnLeave", function() + if SetMouseoverUnit then SetMouseoverUnit() end GameTooltip:Hide() end) @@ -297,6 +299,25 @@ function SFrames.Pet:Initialize() f.health.bg:SetTexture(SFrames:GetTexture()) f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) + -- Heal prediction overlay (incoming heals) + f.health.healPredMine = f.health:CreateTexture(nil, "ARTWORK") + f.health.healPredMine:SetTexture(SFrames:GetTexture()) + f.health.healPredMine:SetVertexColor(0.4, 1.0, 0.55, 0.78) + f.health.healPredMine:SetDrawLayer("ARTWORK", 2) + f.health.healPredMine:Hide() + + f.health.healPredOther = f.health:CreateTexture(nil, "ARTWORK") + f.health.healPredOther:SetTexture(SFrames:GetTexture()) + f.health.healPredOther:SetVertexColor(0.2, 0.9, 0.35, 0.5) + f.health.healPredOther:SetDrawLayer("ARTWORK", 2) + f.health.healPredOther:Hide() + + f.health.healPredOver = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOver:SetTexture(SFrames:GetTexture()) + f.health.healPredOver:SetVertexColor(1.0, 0.3, 0.3, 0.6) + f.health.healPredOver:SetDrawLayer("OVERLAY", 7) + f.health.healPredOver:Hide() + -- Power Bar f.power = SFrames:CreateStatusBar(f, "SFramesPetPower") f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -1) @@ -434,6 +455,128 @@ function SFrames.Pet:UpdateHealth() else self.frame.healthText:SetText("") end + + self:UpdateHealPrediction() +end + +function SFrames.Pet:UpdateHealPrediction() + if not (self.frame and self.frame.health and self.frame.health.healPredMine and self.frame.health.healPredOther and self.frame.health.healPredOver) then return end + local predMine = self.frame.health.healPredMine + local predOther = self.frame.health.healPredOther + local predOver = self.frame.health.healPredOver + + local function HidePredictions() + predMine:Hide() + predOther:Hide() + predOver:Hide() + end + + local hp = UnitHealth("pet") or 0 + local maxHp = UnitHealthMax("pet") or 0 + + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, realHp = pcall(UnitHealth, "pet") + if ok2 then hp = realHp or hp end + local ok3, realMaxHp = pcall(UnitHealthMax, "pet") + if ok3 then maxHp = realMaxHp or maxHp end + end + end + + if maxHp <= 0 or UnitIsDeadOrGhost("pet") then + HidePredictions() + return + end + + local totalIncoming, mineIncoming, othersIncoming = 0, 0, 0 + local ok, t, m, o = pcall(function() return SFrames:GetIncomingHeals("pet") end) + if ok then + totalIncoming, mineIncoming, othersIncoming = t or 0, m or 0, o or 0 + end + + local missing = maxHp - hp + if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then + HidePredictions() + return + end + + local mineShown = math.min(math.max(0, mineIncoming), missing) + local remaining = missing - mineShown + local otherShown = math.min(math.max(0, othersIncoming), remaining) + if mineShown <= 0 and otherShown <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then + HidePredictions() + return + end + + local barWidth = self.frame.health:GetWidth() + if barWidth <= 0 then + HidePredictions() + return + end + + local currentWidth = (hp / maxHp) * barWidth + if currentWidth < 0 then currentWidth = 0 end + if currentWidth > barWidth then currentWidth = barWidth end + + local availableWidth = barWidth - currentWidth + if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then + HidePredictions() + return + end + + local mineWidth = 0 + local otherWidth = 0 + if missing > 0 then + mineWidth = (mineShown / missing) * availableWidth + otherWidth = (otherShown / missing) * availableWidth + if mineWidth < 0 then mineWidth = 0 end + if otherWidth < 0 then otherWidth = 0 end + if mineWidth > availableWidth then mineWidth = availableWidth end + if otherWidth > (availableWidth - mineWidth) then + otherWidth = availableWidth - mineWidth + end + end + + if mineWidth > 0 then + predMine:ClearAllPoints() + predMine:SetPoint("TOPLEFT", self.frame.health, "TOPLEFT", currentWidth, 0) + predMine:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMLEFT", currentWidth, 0) + predMine:SetWidth(mineWidth) + predMine:SetHeight(self.frame.health:GetHeight()) + predMine:Show() + else + predMine:Hide() + end + + if otherWidth > 0 then + predOther:ClearAllPoints() + predOther:SetPoint("TOPLEFT", self.frame.health, "TOPLEFT", currentWidth + mineWidth, 0) + predOther:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMLEFT", currentWidth + mineWidth, 0) + predOther:SetWidth(otherWidth) + predOther:SetHeight(self.frame.health:GetHeight()) + predOther:Show() + else + predOther:Hide() + end + + local totalIncomingValue = mineIncoming + othersIncoming + local overHeal = totalIncomingValue - missing + if overHeal > 0 then + local overWidth = math.floor((overHeal / maxHp) * barWidth + 0.5) + if overWidth > 0 then + predOver:ClearAllPoints() + predOver:SetPoint("TOPLEFT", self.frame.health, "TOPRIGHT", 0, 0) + predOver:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMRIGHT", 0, 0) + predOver:SetWidth(overWidth) + predOver:SetHeight(self.frame.health:GetHeight()) + predOver:Show() + else + predOver:Hide() + end + else + predOver:Hide() + end end function SFrames.Pet:UpdatePowerType() @@ -1118,6 +1261,7 @@ function SFrames.Pet:CreateAuras() this.timer = this.timer + arg1 if this.timer >= 0.25 then SFrames.Pet:TickAuras() + SFrames.Pet:UpdateHealPrediction() this.timer = 0 end end) diff --git a/Units/Player.lua b/Units/Player.lua index 6c890c3..cb41dd5 100644 --- a/Units/Player.lua +++ b/Units/Player.lua @@ -21,31 +21,7 @@ local function GetChineseClassName(classToken, localizedClass) end local function GetIncomingHeals(unit) - if not (ShaguTweaks and ShaguTweaks.libpredict and ShaguTweaks.libpredict.UnitGetIncomingHeals) then - return 0, 0, 0 - end - - local libpredict = ShaguTweaks.libpredict - if libpredict.UnitGetIncomingHealsBreakdown then - local ok, total, mine, others = pcall(function() - return libpredict:UnitGetIncomingHealsBreakdown(unit, UnitName("player")) - end) - if ok then - total = math.max(0, tonumber(total) or 0) - mine = math.max(0, tonumber(mine) or 0) - others = math.max(0, tonumber(others) or 0) - return total, mine, others - end - end - - local ok, amount = pcall(function() - return libpredict:UnitGetIncomingHeals(unit) - end) - if not ok then return 0, 0, 0 end - - amount = tonumber(amount) or 0 - if amount < 0 then amount = 0 end - return amount, 0, amount + return SFrames:GetIncomingHeals(unit) end local function Clamp(value, minValue, maxValue) @@ -116,6 +92,15 @@ function SFrames.Player:ApplyConfig() f:SetHeight(cfg.height) f:SetAlpha(frameAlpha) + local bgA = tonumber(db.playerBgAlpha) or 0.9 + local _A = SFrames.ActiveTheme + if _A and _A.panelBg and bgA < 0.89 then + if f.SetBackdropColor then f:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + if f.healthBGFrame and f.healthBGFrame.SetBackdropColor then f.healthBGFrame:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + if f.powerBGFrame and f.powerBGFrame.SetBackdropColor then f.powerBGFrame:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + if f.portraitBG and f.portraitBG.SetBackdropColor then f.portraitBG:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + end + if showPortrait then if f.portrait then f.portrait:SetWidth(cfg.portraitWidth) @@ -295,15 +280,23 @@ function SFrames.Player:Initialize() f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) -- Heal prediction overlay (incoming heals) - f.health.healPredMine = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredMine = f.health:CreateTexture(nil, "ARTWORK") f.health.healPredMine:SetTexture(SFrames:GetTexture()) f.health.healPredMine:SetVertexColor(0.4, 1.0, 0.55, 0.78) + f.health.healPredMine:SetDrawLayer("ARTWORK", 2) f.health.healPredMine:Hide() - f.health.healPredOther = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOther = f.health:CreateTexture(nil, "ARTWORK") f.health.healPredOther:SetTexture(SFrames:GetTexture()) f.health.healPredOther:SetVertexColor(0.2, 0.9, 0.35, 0.5) + f.health.healPredOther:SetDrawLayer("ARTWORK", 2) f.health.healPredOther:Hide() + + f.health.healPredOver = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOver:SetTexture(SFrames:GetTexture()) + f.health.healPredOver:SetVertexColor(1.0, 0.3, 0.3, 0.6) + f.health.healPredOver:SetDrawLayer("OVERLAY", 7) + f.health.healPredOver:Hide() -- Power Bar f.power = SFrames:CreateStatusBar(f, "SFramesPlayerPower") @@ -524,11 +517,13 @@ function SFrames.Player:Initialize() f.unit = "player" f:SetScript("OnEnter", function() + if SetMouseoverUnit then SetMouseoverUnit(this.unit) end GameTooltip_SetDefaultAnchor(GameTooltip, this) GameTooltip:SetUnit(this.unit) GameTooltip:Show() end) f:SetScript("OnLeave", function() + if SetMouseoverUnit then SetMouseoverUnit() end GameTooltip:Hide() end) end @@ -1094,6 +1089,21 @@ end function SFrames.Player:UpdateHealth() local hp = UnitHealth("player") local maxHp = UnitHealthMax("player") + + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, realHp = pcall(UnitHealth, "player") + if ok2 then + hp = realHp or hp + end + local ok3, realMaxHp = pcall(UnitHealthMax, "player") + if ok3 then + maxHp = realMaxHp or maxHp + end + end + end + self.frame.health:SetMinMaxValues(0, maxHp) self.frame.health:SetValue(hp) @@ -1107,25 +1117,47 @@ function SFrames.Player:UpdateHealth() end function SFrames.Player:UpdateHealPrediction() - if not (self.frame and self.frame.health and self.frame.health.healPredMine and self.frame.health.healPredOther) then return end + if not (self.frame and self.frame.health and self.frame.health.healPredMine and self.frame.health.healPredOther and self.frame.health.healPredOver) then return end local predMine = self.frame.health.healPredMine local predOther = self.frame.health.healPredOther + local predOver = self.frame.health.healPredOver local function HidePredictions() predMine:Hide() predOther:Hide() + predOver:Hide() end local hp = UnitHealth("player") or 0 local maxHp = UnitHealthMax("player") or 0 - if maxHp <= 0 or hp >= maxHp or UnitIsDeadOrGhost("player") then + + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, realHp = pcall(UnitHealth, "player") + if ok2 then + hp = realHp or hp + end + local ok3, realMaxHp = pcall(UnitHealthMax, "player") + if ok3 then + maxHp = realMaxHp or maxHp + end + end + end + + if maxHp <= 0 or UnitIsDeadOrGhost("player") then HidePredictions() return end - local _, mineIncoming, othersIncoming = GetIncomingHeals("player") + local totalIncoming, mineIncoming, othersIncoming = 0, 0, 0 + + local ok, t, m, o = pcall(function() return GetIncomingHeals("player") end) + if ok then + totalIncoming, mineIncoming, othersIncoming = t or 0, m or 0, o or 0 + end local missing = maxHp - hp - if missing <= 0 then + if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end @@ -1133,34 +1165,39 @@ function SFrames.Player:UpdateHealPrediction() local mineShown = math.min(math.max(0, mineIncoming), missing) local remaining = missing - mineShown local otherShown = math.min(math.max(0, othersIncoming), remaining) - if mineShown <= 0 and otherShown <= 0 then + if mineShown <= 0 and otherShown <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end - local barWidth = self.frame.health:GetWidth() or 0 + local showPortrait = SFramesDB and SFramesDB.playerShowPortrait ~= false + local barWidth = self.frame:GetWidth() - (showPortrait and (self.frame.portrait:GetWidth() + 2) or 2) if barWidth <= 0 then HidePredictions() return end - local currentWidth = math.floor((hp / maxHp) * barWidth + 0.5) + local currentWidth = (hp / maxHp) * barWidth if currentWidth < 0 then currentWidth = 0 end if currentWidth > barWidth then currentWidth = barWidth end local availableWidth = barWidth - currentWidth - if availableWidth <= 0 then + if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end - local mineWidth = math.floor((mineShown / maxHp) * barWidth + 0.5) - local otherWidth = math.floor((otherShown / maxHp) * barWidth + 0.5) - if mineWidth < 0 then mineWidth = 0 end - if otherWidth < 0 then otherWidth = 0 end - if mineWidth > availableWidth then mineWidth = availableWidth end - if otherWidth > (availableWidth - mineWidth) then - otherWidth = availableWidth - mineWidth + local mineWidth = 0 + local otherWidth = 0 + if missing > 0 then + mineWidth = (mineShown / missing) * availableWidth + otherWidth = (otherShown / missing) * availableWidth + if mineWidth < 0 then mineWidth = 0 end + if otherWidth < 0 then otherWidth = 0 end + if mineWidth > availableWidth then mineWidth = availableWidth end + if otherWidth > (availableWidth - mineWidth) then + otherWidth = availableWidth - mineWidth + end end if mineWidth > 0 then @@ -1168,6 +1205,7 @@ function SFrames.Player:UpdateHealPrediction() predMine:SetPoint("TOPLEFT", self.frame.health, "TOPLEFT", currentWidth, 0) predMine:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMLEFT", currentWidth, 0) predMine:SetWidth(mineWidth) + predMine:SetHeight(self.frame.health:GetHeight()) predMine:Show() else predMine:Hide() @@ -1178,10 +1216,29 @@ function SFrames.Player:UpdateHealPrediction() predOther:SetPoint("TOPLEFT", self.frame.health, "TOPLEFT", currentWidth + mineWidth, 0) predOther:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMLEFT", currentWidth + mineWidth, 0) predOther:SetWidth(otherWidth) + predOther:SetHeight(self.frame.health:GetHeight()) predOther:Show() else predOther:Hide() end + + local totalIncomingValue = mineIncoming + othersIncoming + local overHeal = totalIncomingValue - missing + if overHeal > 0 then + local overWidth = math.floor((overHeal / maxHp) * barWidth + 0.5) + if overWidth > 0 then + predOver:ClearAllPoints() + predOver:SetPoint("TOPLEFT", self.frame.health, "TOPRIGHT", 0, 0) + predOver:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMRIGHT", 0, 0) + predOver:SetWidth(overWidth) + predOver:SetHeight(self.frame.health:GetHeight()) + predOver:Show() + else + predOver:Hide() + end + else + predOver:Hide() + end end function SFrames.Player:UpdatePowerType() @@ -1426,7 +1483,7 @@ end -------------------------------------------------------------------------------- function SFrames.Player:CreateAuras() - -- Create 16 Buff Slots + -- Create 32 Buff Slots self.frame.buffs = {} self.frame.debuffs = {} local size = 24 @@ -1434,7 +1491,7 @@ function SFrames.Player:CreateAuras() local rowSpacing = 1 local buffsPerRow = 9 - for i = 1, 16 do + for i = 1, 32 do local b = CreateFrame("Button", "SFramesPlayerBuff"..i, self.frame) b:SetWidth(size) b:SetHeight(size) @@ -1474,17 +1531,25 @@ end function SFrames.Player:UpdateAuras() local slotIdx = 0 + local hasGetPlayerBuffID = SFrames.superwow_active and type(GetPlayerBuffID) == "function" for i = 0, 31 do local buffIndex, untilCancelled = GetPlayerBuff(i, "HELPFUL") if buffIndex and buffIndex >= 0 then if not SFrames:IsBuffHidden(buffIndex) then slotIdx = slotIdx + 1 - if slotIdx > 16 then break end + if slotIdx > 32 then break end local b = self.frame.buffs[slotIdx] local texture = GetPlayerBuffTexture(buffIndex) if texture then b.icon:SetTexture(texture) b.buffIndex = buffIndex + -- Store aura ID when SuperWoW is available + if hasGetPlayerBuffID then + local ok, auraID = pcall(GetPlayerBuffID, buffIndex) + b.auraID = ok and auraID or nil + else + b.auraID = nil + end b:Show() local timeLeft = GetPlayerBuffTimeLeft(buffIndex) @@ -1499,8 +1564,9 @@ function SFrames.Player:UpdateAuras() end end end - for j = slotIdx + 1, 16 do + for j = slotIdx + 1, 32 do self.frame.buffs[j]:Hide() + self.frame.buffs[j].auraID = nil end end @@ -1544,6 +1610,63 @@ end -- Player Castbar -------------------------------------------------------------------------------- +local function GetLatencySeconds() + if GetNetStats then + local _, _, latency = GetNetStats() + if latency and latency > 0 then return latency / 1000 end + end + return 0 +end + +local function HSVtoRGB(h, s, v) + local i = math.floor(h * 6) + local f = h * 6 - i + local p = v * (1 - s) + local q = v * (1 - f * s) + local t = v * (1 - (1 - f) * s) + local m = math.mod(i, 6) + if m == 0 then return v, t, p + elseif m == 1 then return q, v, p + elseif m == 2 then return p, v, t + elseif m == 3 then return p, q, v + elseif m == 4 then return t, p, v + else return v, p, q end +end + +local RAINBOW_TEX_PATH = "Interface\\AddOns\\Nanami-UI\\img\\progress" +local RAINBOW_SEG_COORDS = { + {40/512, 473/512, 45/512, 169/512}, + {40/512, 473/512, 194/512, 318/512}, + {40/512, 473/512, 343/512, 467/512}, +} + +local function UpdateRainbowProgress(cb, progress) + if not cb.rainbowSegs then return end + local barWidth = cb:GetWidth() + if barWidth <= 0 then return end + local fillW = progress * barWidth + for i = 1, 3 do + local seg = cb.rainbowSegs[i] + local segL = barWidth * (i - 1) / 3 + local segR = barWidth * i / 3 + if fillW <= segL then + seg:Hide() + else + local visR = math.min(fillW, segR) + local frac = (visR - segL) / (segR - segL) + if visR >= segR and i < 3 then + visR = segR + 2 + end + seg:ClearAllPoints() + seg:SetPoint("TOPLEFT", cb, "TOPLEFT", segL, 0) + seg:SetPoint("BOTTOMRIGHT", cb, "BOTTOMLEFT", visR, 0) + local c = seg.fullCoords + seg:SetTexCoord(c[1], c[1] + (c[2] - c[1]) * frac, c[3], c[4]) + seg:Show() + end + end +end + function SFrames.Player:CreateCastbar() local cb = SFrames:CreateStatusBar(self.frame, "SFramesPlayerCastbar") cb:SetHeight(SFrames.Config.castbarHeight) @@ -1581,6 +1704,23 @@ function SFrames.Player:CreateCastbar() ibg:SetFrameLevel(cb:GetFrameLevel() - 1) SFrames:CreateUnitBackdrop(ibg) + local lagTex = cb:CreateTexture(nil, "OVERLAY") + lagTex:SetTexture(SFrames:GetTexture()) + lagTex:SetVertexColor(1, 0.2, 0.2, 0.5) + lagTex:Hide() + cb.lagTex = lagTex + + cb.rainbowSegs = {} + for i = 1, 3 do + local tex = cb:CreateTexture(nil, "ARTWORK") + tex:SetTexture(RAINBOW_TEX_PATH) + local c = RAINBOW_SEG_COORDS[i] + tex:SetTexCoord(c[1], c[2], c[3], c[4]) + tex.fullCoords = c + tex:Hide() + cb.rainbowSegs[i] = tex + end + cb:Hide() cbbg:Hide() cb.icon:Hide() @@ -1591,7 +1731,9 @@ function SFrames.Player:CreateCastbar() self.frame.castbar.ibg = ibg cb:SetScript("OnUpdate", function() SFrames.Player:CastbarOnUpdate() end) - + + self:ApplyCastbarPosition() + -- Hook events SFrames:RegisterEvent("SPELLCAST_START", function() self:CastbarStart(arg1, arg2) end) SFrames:RegisterEvent("SPELLCAST_STOP", function() self:CastbarStop() end) @@ -1603,6 +1745,60 @@ function SFrames.Player:CreateCastbar() SFrames:RegisterEvent("SPELLCAST_CHANNEL_STOP", function() self:CastbarStop() end) end +function SFrames.Player:ApplyCastbarPosition() + local cb = self.frame.castbar + if not cb then return end + local db = SFramesDB or {} + + cb:ClearAllPoints() + cb.cbbg:ClearAllPoints() + cb.icon:ClearAllPoints() + cb.ibg:ClearAllPoints() + + if db.castbarStandalone then + cb:SetParent(UIParent) + cb.cbbg:SetParent(UIParent) + cb.ibg:SetParent(UIParent) + cb:SetWidth(280) + cb:SetHeight(20) + cb:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 120) + cb:SetFrameStrata("HIGH") + cb.cbbg:SetPoint("TOPLEFT", cb, "TOPLEFT", -1, 1) + cb.cbbg:SetPoint("BOTTOMRIGHT", cb, "BOTTOMRIGHT", 1, -1) + cb.cbbg:SetFrameLevel(math.max(1, cb:GetFrameLevel() - 1)) + cb.icon:SetWidth(22) + cb.icon:SetHeight(22) + cb.icon:SetPoint("RIGHT", cb, "LEFT", -4, 0) + cb.ibg:SetPoint("TOPLEFT", cb.icon, "TOPLEFT", -1, 1) + cb.ibg:SetPoint("BOTTOMRIGHT", cb.icon, "BOTTOMRIGHT", 1, -1) + cb.ibg:SetFrameLevel(math.max(1, cb:GetFrameLevel() - 1)) + + if SFrames.Movers and SFrames.Movers.RegisterMover then + SFrames.Movers:RegisterMover("PlayerCastbar", cb, "施法条", + "BOTTOM", "UIParent", "BOTTOM", 0, 120) + end + else + cb:SetParent(self.frame) + cb.cbbg:SetParent(self.frame) + cb.ibg:SetParent(self.frame) + local cbH = SFrames.Config.castbarHeight + cb:SetWidth(0) -- 清除独立模式的显式宽度,让双锚点自动计算 + cb:SetHeight(cbH) + cb:SetPoint("BOTTOMRIGHT", self.frame, "TOPRIGHT", 0, 6) + cb:SetPoint("BOTTOMLEFT", self.frame.portrait, "TOPLEFT", cbH + 6, 6) + cb:SetFrameStrata("MEDIUM") + cb.cbbg:SetPoint("TOPLEFT", cb, "TOPLEFT", -1, 1) + cb.cbbg:SetPoint("BOTTOMRIGHT", cb, "BOTTOMRIGHT", 1, -1) + cb.cbbg:SetFrameLevel(math.max(1, cb:GetFrameLevel() - 1)) + cb.icon:SetWidth(cbH + 2) + cb.icon:SetHeight(cbH + 2) + cb.icon:SetPoint("RIGHT", cb, "LEFT", -4, 0) + cb.ibg:SetPoint("TOPLEFT", cb.icon, "TOPLEFT", -1, 1) + cb.ibg:SetPoint("BOTTOMRIGHT", cb.icon, "BOTTOMRIGHT", 1, -1) + cb.ibg:SetFrameLevel(math.max(1, cb:GetFrameLevel() - 1)) + end +end + function SFrames.Player:CastbarStart(spellName, duration) local cb = self.frame.castbar cb.casting = true @@ -1648,6 +1844,23 @@ function SFrames.Player:CastbarStart(spellName, duration) end cb:Show() cb.cbbg:Show() + + local lag = GetLatencySeconds() + if lag > 0 and cb.maxValue > 0 then + local barW = cb:GetWidth() + local lagW = math.min((lag / cb.maxValue) * barW, barW * 0.5) + if lagW >= 1 then + cb.lagTex:ClearAllPoints() + cb.lagTex:SetPoint("TOPRIGHT", cb, "TOPRIGHT", 0, 0) + cb.lagTex:SetPoint("BOTTOMRIGHT", cb, "BOTTOMRIGHT", 0, 0) + cb.lagTex:SetWidth(lagW) + cb.lagTex:Show() + else + cb.lagTex:Hide() + end + else + cb.lagTex:Hide() + end end function SFrames.Player:CastbarChannelStart(duration, spellName) @@ -1696,6 +1909,23 @@ function SFrames.Player:CastbarChannelStart(duration, spellName) end cb:Show() cb.cbbg:Show() + + local lag = GetLatencySeconds() + if lag > 0 and cb.maxValue > 0 then + local barW = cb:GetWidth() + local lagW = math.min((lag / cb.maxValue) * barW, barW * 0.5) + if lagW >= 1 then + cb.lagTex:ClearAllPoints() + cb.lagTex:SetPoint("TOPLEFT", cb, "TOPLEFT", 0, 0) + cb.lagTex:SetPoint("BOTTOMLEFT", cb, "BOTTOMLEFT", 0, 0) + cb.lagTex:SetWidth(lagW) + cb.lagTex:Show() + else + cb.lagTex:Hide() + end + else + cb.lagTex:Hide() + end end function SFrames.Player:CastbarStop() @@ -1703,7 +1933,7 @@ function SFrames.Player:CastbarStop() cb.casting = nil cb.channeling = nil cb.fadeOut = true - -- keep showing for a short fade out + if cb.lagTex then cb.lagTex:Hide() end end function SFrames.Player:CastbarDelayed(delay) @@ -1714,18 +1944,17 @@ function SFrames.Player:CastbarDelayed(delay) end end -function SFrames.Player:CastbarChannelUpdate(delay) +function SFrames.Player:CastbarChannelUpdate(remainingMs) local cb = self.frame.castbar if cb.channeling then - local add = delay / 1000 - cb.maxValue = cb.maxValue + add - cb.endTime = cb.endTime + add - cb:SetMinMaxValues(0, cb.maxValue) + cb.endTime = GetTime() + remainingMs / 1000 end end function SFrames.Player:CastbarOnUpdate() local cb = self.frame.castbar + local db = SFramesDB or {} + if cb.casting then local elapsed = GetTime() - cb.startTime if elapsed >= cb.maxValue then @@ -1736,6 +1965,19 @@ function SFrames.Player:CastbarOnUpdate() end cb:SetValue(elapsed) cb.time:SetText(string.format("%.1f", math.max(cb.maxValue - elapsed, 0))) + if db.castbarRainbow then + if not cb.rainbowActive then + cb:SetStatusBarColor(0, 0, 0, 0) + cb.rainbowActive = true + end + UpdateRainbowProgress(cb, elapsed / cb.maxValue) + elseif cb.rainbowActive then + cb:SetStatusBarColor(1, 0.7, 0) + if cb.rainbowSegs then + for i = 1, 3 do cb.rainbowSegs[i]:Hide() end + end + cb.rainbowActive = nil + end if not cb.icon:IsShown() then self:CastbarTryResolveIcon() end @@ -1749,10 +1991,28 @@ function SFrames.Player:CastbarOnUpdate() end cb:SetValue(timeRemaining) cb.time:SetText(string.format("%.1f", timeRemaining)) + if db.castbarRainbow then + if not cb.rainbowActive then + cb:SetStatusBarColor(0, 0, 0, 0) + cb.rainbowActive = true + end + UpdateRainbowProgress(cb, timeRemaining / cb.maxValue) + elseif cb.rainbowActive then + cb:SetStatusBarColor(1, 0.7, 0) + if cb.rainbowSegs then + for i = 1, 3 do cb.rainbowSegs[i]:Hide() end + end + cb.rainbowActive = nil + end if not cb.icon:IsShown() then self:CastbarTryResolveIcon() end elseif cb.fadeOut then + if cb.rainbowActive then + for i = 1, 3 do cb.rainbowSegs[i]:Hide() end + cb:SetStatusBarColor(1, 0.7, 0) + cb.rainbowActive = nil + end local alpha = cb:GetAlpha() - 0.05 if alpha > 0 then cb:SetAlpha(alpha) diff --git a/Units/Raid.lua b/Units/Raid.lua index 6aa5300..e5677af 100644 --- a/Units/Raid.lua +++ b/Units/Raid.lua @@ -71,6 +71,17 @@ function SFrames.Raid:ApplyFrameStyle(frame, metrics) frame.healthBGFrame:SetPoint("BOTTOMRIGHT", frame.health, "BOTTOMRIGHT", 1, -1) end + local bgA = (SFramesDB and type(SFramesDB.raidBgAlpha) == "number") and SFramesDB.raidBgAlpha or 0.9 + local A = SFrames.ActiveTheme + if A and A.panelBg and bgA < 0.89 then + if frame.healthBGFrame and frame.healthBGFrame.SetBackdropColor then + frame.healthBGFrame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], bgA) + end + if frame.powerBGFrame and frame.powerBGFrame.SetBackdropColor then + frame.powerBGFrame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], bgA) + end + end + if frame.power then if metrics.showPower then frame.power:Show() @@ -290,6 +301,10 @@ function SFrames.Raid:EnsureFrames() end) f:SetScript("OnClick", function() if arg1 == "LeftButton" then + if IsShiftKeyDown() and SFrames.Focus and SFrames.Focus.SetFromUnit then + pcall(SFrames.Focus.SetFromUnit, SFrames.Focus, this.unit) + return + end if TryDropCursorOnUnit(this.unit) then return end if SpellIsTargeting and SpellIsTargeting() then SpellTargetUnit(this.unit) @@ -375,10 +390,14 @@ function SFrames.Raid:EnsureFrames() end end) f:SetScript("OnEnter", function() + if SetMouseoverUnit then SetMouseoverUnit(this.unit) end GameTooltip_SetDefaultAnchor(GameTooltip, this) GameTooltip:SetUnit(this.unit) end) - f:SetScript("OnLeave", function() GameTooltip:Hide() end) + f:SetScript("OnLeave", function() + if SetMouseoverUnit then SetMouseoverUnit() end + GameTooltip:Hide() + end) f.unit = unit -- Health Bar @@ -395,16 +414,24 @@ function SFrames.Raid:EnsureFrames() f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) -- Heal prediction overlay (incoming heals) - f.health.healPredMine = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredMine = f.health:CreateTexture(nil, "ARTWORK") f.health.healPredMine:SetTexture(SFrames:GetTexture()) f.health.healPredMine:SetVertexColor(0.4, 1.0, 0.55, 0.78) + f.health.healPredMine:SetDrawLayer("ARTWORK", 2) f.health.healPredMine:Hide() - f.health.healPredOther = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOther = f.health:CreateTexture(nil, "ARTWORK") f.health.healPredOther:SetTexture(SFrames:GetTexture()) f.health.healPredOther:SetVertexColor(0.2, 0.9, 0.35, 0.5) + f.health.healPredOther:SetDrawLayer("ARTWORK", 2) f.health.healPredOther:Hide() + f.health.healPredOver = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOver:SetTexture(SFrames:GetTexture()) + f.health.healPredOver:SetVertexColor(1.0, 0.3, 0.3, 0.6) + f.health.healPredOver:SetDrawLayer("OVERLAY", 7) + f.health.healPredOver:Hide() + -- Power Bar f.power = SFrames:CreateStatusBar(f, "SFramesRaidFrame"..i.."Power") f.power:SetMinMaxValues(0, 100) @@ -793,14 +820,16 @@ function SFrames.Raid:UpdateHealPrediction(unit) if not frameData then return end local f = frameData.frame - if not (f.health and f.health.healPredMine and f.health.healPredOther) then return end + if not (f.health and f.health.healPredMine and f.health.healPredOther and f.health.healPredOver) then return end local predMine = f.health.healPredMine local predOther = f.health.healPredOther + local predOver = f.health.healPredOver local function HidePredictions() predMine:Hide() predOther:Hide() + predOver:Hide() end if not UnitExists(unit) or not UnitIsConnected(unit) then @@ -810,14 +839,39 @@ function SFrames.Raid:UpdateHealPrediction(unit) local hp = UnitHealth(unit) or 0 local maxHp = UnitHealthMax(unit) or 0 - if maxHp <= 0 or hp >= maxHp then + + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, realHp = pcall(UnitHealth, unit) + if ok2 then + hp = realHp or hp + end + local ok3, realMaxHp = pcall(UnitHealthMax, unit) + if ok3 then + maxHp = realMaxHp or maxHp + end + end + end + + if maxHp <= 0 then HidePredictions() return end local _, mineIncoming, othersIncoming = GetIncomingHeals(unit) + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, _, realMine, realOther = pcall(GetIncomingHeals, unit) + if ok2 then + mineIncoming = realMine or mineIncoming + othersIncoming = realOther or othersIncoming + end + end + end local missing = maxHp - hp - if missing <= 0 then + if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end @@ -825,41 +879,46 @@ function SFrames.Raid:UpdateHealPrediction(unit) local mineShown = math.min(math.max(0, mineIncoming), missing) local remaining = missing - mineShown local otherShown = math.min(math.max(0, othersIncoming), remaining) - if mineShown <= 0 and otherShown <= 0 then + if mineIncoming <= 0 and othersIncoming <= 0 then HidePredictions() return end - local barWidth = f.health:GetWidth() or 0 + local barWidth = f:GetWidth() - 2 if barWidth <= 0 then HidePredictions() return end - local currentWidth = math.floor((hp / maxHp) * barWidth + 0.5) - if currentWidth < 0 then currentWidth = 0 end - if currentWidth > barWidth then currentWidth = barWidth end + local currentPosition = (hp / maxHp) * barWidth + if currentPosition < 0 then currentPosition = 0 end + if currentPosition > barWidth then currentPosition = barWidth end - local availableWidth = barWidth - currentWidth - if availableWidth <= 0 then + local availableWidth = barWidth - currentPosition + if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end - local mineWidth = math.floor((mineShown / maxHp) * barWidth + 0.5) - local otherWidth = math.floor((otherShown / maxHp) * barWidth + 0.5) - if mineWidth < 0 then mineWidth = 0 end - if otherWidth < 0 then otherWidth = 0 end - if mineWidth > availableWidth then mineWidth = availableWidth end - if otherWidth > (availableWidth - mineWidth) then - otherWidth = availableWidth - mineWidth + local mineWidth = 0 + local otherWidth = 0 + if missing > 0 then + mineWidth = (mineShown / missing) * availableWidth + otherWidth = (otherShown / missing) * availableWidth + if mineWidth < 0 then mineWidth = 0 end + if otherWidth < 0 then otherWidth = 0 end + if mineWidth > availableWidth then mineWidth = availableWidth end + if otherWidth > (availableWidth - mineWidth) then + otherWidth = availableWidth - mineWidth + end end if mineWidth > 0 then predMine:ClearAllPoints() - predMine:SetPoint("TOPLEFT", f.health, "TOPLEFT", currentWidth, 0) - predMine:SetPoint("BOTTOMLEFT", f.health, "BOTTOMLEFT", currentWidth, 0) + predMine:SetPoint("TOPLEFT", f.health, "TOPLEFT", currentPosition, 0) + predMine:SetPoint("BOTTOMLEFT", f.health, "BOTTOMLEFT", currentPosition, 0) predMine:SetWidth(mineWidth) + predMine:SetHeight(f.health:GetHeight()) predMine:Show() else predMine:Hide() @@ -867,13 +926,32 @@ function SFrames.Raid:UpdateHealPrediction(unit) if otherWidth > 0 then predOther:ClearAllPoints() - predOther:SetPoint("TOPLEFT", f.health, "TOPLEFT", currentWidth + mineWidth, 0) - predOther:SetPoint("BOTTOMLEFT", f.health, "BOTTOMLEFT", currentWidth + mineWidth, 0) + predOther:SetPoint("TOPLEFT", f.health, "TOPLEFT", currentPosition + mineWidth, 0) + predOther:SetPoint("BOTTOMLEFT", f.health, "BOTTOMLEFT", currentPosition + mineWidth, 0) predOther:SetWidth(otherWidth) + predOther:SetHeight(f.health:GetHeight()) predOther:Show() else predOther:Hide() end + + local totalIncoming = mineIncoming + othersIncoming + local overHeal = totalIncoming - missing + if overHeal > 0 then + local overWidth = (overHeal / maxHp) * barWidth + if overWidth > 0 then + predOver:ClearAllPoints() + predOver:SetPoint("TOPLEFT", f.health, "TOPRIGHT", 0, 0) + predOver:SetPoint("BOTTOMLEFT", f.health, "BOTTOMRIGHT", 0, 0) + predOver:SetWidth(overWidth) + predOver:SetHeight(f.health:GetHeight()) + predOver:Show() + else + predOver:Hide() + end + else + predOver:Hide() + end end function SFrames.Raid:UpdatePower(unit) @@ -1016,15 +1094,51 @@ function SFrames.Raid:UpdateAuras(unit) return false end + -- Helper: get buff name via SuperWoW aura ID (fast) or tooltip scan (fallback) + local hasSuperWoW = SFrames.superwow_active and SpellInfo + local function GetBuffName(unit, index) + if hasSuperWoW then + local texture, auraID = UnitBuff(unit, index) + if auraID and SpellInfo then + local spellName = SpellInfo(auraID) + if spellName and spellName ~= "" then + return spellName, texture + end + end + end + -- Fallback: tooltip scan + SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") + SFrames.Tooltip:SetUnitBuff(unit, index) + local buffName = SFramesScanTooltipTextLeft1:GetText() + SFrames.Tooltip:Hide() + return buffName, UnitBuff(unit, index) + end + + local function GetDebuffName(unit, index) + if hasSuperWoW then + local texture, count, dtype, auraID = UnitDebuff(unit, index) + if auraID and SpellInfo then + local spellName = SpellInfo(auraID) + if spellName and spellName ~= "" then + return spellName, texture, count, dtype + end + end + end + -- Fallback: tooltip scan + SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") + SFrames.Tooltip:SetUnitDebuff(unit, index) + local debuffName = SFramesScanTooltipTextLeft1:GetText() + SFrames.Tooltip:Hide() + local texture, count, dtype = UnitDebuff(unit, index) + return debuffName, texture, count, dtype + end + -- Check Buffs for i = 1, 32 do local texture, applications = UnitBuff(unit, i) if not texture then break end - SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") - SFrames.Tooltip:SetUnitBuff(unit, i) - local buffName = SFramesScanTooltipTextLeft1:GetText() - SFrames.Tooltip:Hide() + local buffName = GetBuffName(unit, i) if buffName then for pos, listData in pairs(buffsNeeded) do @@ -1058,10 +1172,7 @@ function SFrames.Raid:UpdateAuras(unit) end end - SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") - SFrames.Tooltip:SetUnitDebuff(unit, i) - local debuffName = SFramesScanTooltipTextLeft1:GetText() - SFrames.Tooltip:Hide() + local debuffName = GetDebuffName(unit, i) if debuffName then for pos, listData in pairs(buffsNeeded) do diff --git a/Units/Target.lua b/Units/Target.lua index 39f671c..a51386e 100644 --- a/Units/Target.lua +++ b/Units/Target.lua @@ -186,13 +186,30 @@ local DIST_BASE_WIDTH = 80 local DIST_BASE_HEIGHT = 24 local DIST_BASE_FONTSIZE = 14 +-- Check if UnitXP SP3 distance API is available +local hasUnitXP = nil -- nil = not checked yet, true/false = cached result +local function IsUnitXPAvailable() + if hasUnitXP ~= nil then return hasUnitXP end + hasUnitXP = (type(UnitXP) == "function") and (pcall(UnitXP, "nop", "nop")) + return hasUnitXP +end + function SFrames.Target:GetDistance(unit) if not UnitExists(unit) then return nil end if UnitIsUnit(unit, "player") then return "0 码" end - -- Using multiple "scale rulers" (rungs) for better precision in 1.12 - if CheckInteractDistance(unit, 2) then return "< 8 码" -- Trade - elseif CheckInteractDistance(unit, 3) then return "8-10 码" -- Duel - elseif CheckInteractDistance(unit, 4) then return "10-28 码" -- Follow + + -- Prefer UnitXP precise distance when available + if IsUnitXPAvailable() then + local ok, dist = pcall(UnitXP, "distanceBetween", "player", unit) + if ok and type(dist) == "number" and dist >= 0 then + return string.format("%.1f 码", dist) + end + end + + -- Fallback: CheckInteractDistance rough ranges + if CheckInteractDistance(unit, 2) then return "< 8 码" + elseif CheckInteractDistance(unit, 3) then return "8-10 码" + elseif CheckInteractDistance(unit, 4) then return "10-28 码" elseif UnitIsVisible(unit) then return "28-100 码" else return "> 100 码" end end @@ -255,6 +272,15 @@ function SFrames.Target:ApplyConfig() f:SetHeight(cfg.height) f:SetAlpha(frameAlpha) + local bgA = tonumber(db.targetBgAlpha) or 0.9 + local _A = SFrames.ActiveTheme + if _A and _A.panelBg and bgA < 0.89 then + if f.SetBackdropColor then f:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + if f.healthBGFrame and f.healthBGFrame.SetBackdropColor then f.healthBGFrame:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + if f.powerBGFrame and f.powerBGFrame.SetBackdropColor then f.powerBGFrame:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + if f.portraitBG and f.portraitBG.SetBackdropColor then f.portraitBG:SetBackdropColor(_A.panelBg[1], _A.panelBg[2], _A.panelBg[3], bgA) end + end + if showPortrait then if f.portrait then f.portrait:SetWidth(cfg.portraitWidth) @@ -362,7 +388,13 @@ function SFrames.Target:ApplyDistanceScale(scale) if f.text then local fontPath = SFrames:GetFont() local outline = (SFrames.Media and SFrames.Media.fontOutline) or "OUTLINE" - local fontSize = math.max(8, math.floor(DIST_BASE_FONTSIZE * scale + 0.5)) + local customSize = SFramesDB and tonumber(SFramesDB.targetDistanceFontSize) + local fontSize + if customSize and customSize >= 8 and customSize <= 24 then + fontSize = math.floor(customSize + 0.5) + else + fontSize = math.max(8, math.floor(DIST_BASE_FONTSIZE * scale + 0.5)) + end f.text:SetFont(fontPath, fontSize, outline) end end @@ -405,6 +437,13 @@ function SFrames.Target:InitializeDistanceFrame() f.text:SetShadowColor(0, 0, 0, 1) f.text:SetShadowOffset(1, -1) + -- Behind indicator text (shown next to distance) + f.behindText = SFrames:CreateFontString(f, fontSize, "LEFT") + f.behindText:SetPoint("LEFT", f.text, "RIGHT", 4, 0) + f.behindText:SetShadowColor(0, 0, 0, 1) + f.behindText:SetShadowOffset(1, -1) + f.behindText:Hide() + SFrames.Target.distanceFrame = f f:Hide() @@ -416,6 +455,7 @@ function SFrames.Target:InitializeDistanceFrame() end if not UnitExists("target") then if this:IsShown() then this:Hide() end + if this.behindText then this.behindText:Hide() end return end this.timer = this.timer + (arg1 or 0) @@ -424,6 +464,28 @@ function SFrames.Target:InitializeDistanceFrame() local dist = SFrames.Target:GetDistance("target") this.text:SetText(dist or "---") if not this:IsShown() then this:Show() end + + -- Behind indicator + if this.behindText then + local showBehind = not SFramesDB or SFramesDB.Tweaks == nil + or SFramesDB.Tweaks.behindIndicator ~= false + if showBehind and IsUnitXPAvailable() then + local ok, isBehind = pcall(UnitXP, "behind", "player", "target") + if ok and isBehind then + this.behindText:SetText("背后") + this.behindText:SetTextColor(0.2, 1.0, 0.3) + this.behindText:Show() + elseif ok then + this.behindText:SetText("正面") + this.behindText:SetTextColor(1.0, 0.35, 0.3) + this.behindText:Show() + else + this.behindText:Hide() + end + else + this.behindText:Hide() + end + end end end) end @@ -476,7 +538,23 @@ function SFrames.Target:Initialize() f:RegisterForClicks("LeftButtonUp", "RightButtonUp") f:SetScript("OnClick", function() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] OnClick fired: " .. tostring(arg1) .. "|r") if arg1 == "LeftButton" then + -- Shift+左键 = 设为焦点 + if IsShiftKeyDown() then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] Shift+LeftButton -> SetFocus|r") + if SFrames.Focus and SFrames.Focus.SetFromTarget then + local ok, err = pcall(SFrames.Focus.SetFromTarget, SFrames.Focus) + if ok then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] Focus set OK|r") + else + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] Focus error: " .. tostring(err) .. "|r") + end + else + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] SFrames.Focus missing!|r") + end + return + end if TryDropCursorOnUnit(this.unit) then return end @@ -484,15 +562,132 @@ function SFrames.Target:Initialize() SpellTargetUnit(this.unit) end elseif arg1 == "RightButton" then - if SpellIsTargeting() then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] RightButton hit|r") + if SpellIsTargeting and SpellIsTargeting() then SpellStopTargeting() return end - HideDropDownMenu(1) - TargetFrameDropDown.unit = "target" - TargetFrameDropDown.name = UnitName("target") - TargetFrameDropDown.initialize = TargetFrameDropDown_Initialize - ToggleDropDownMenu(1, nil, TargetFrameDropDown, "SFramesTargetFrame", 120, 10) + if not UnitExists("target") then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] No target, abort|r") + return + end + if not SFrames.Target.dropDown then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] Creating dropdown...|r") + local ok1, err1 = pcall(function() + SFrames.Target.dropDown = CreateFrame("Frame", "SFramesTargetDropDown", UIParent, "UIDropDownMenuTemplate") + end) + if not ok1 then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] CreateFrame failed: " .. tostring(err1) .. "|r") + return + end + SFrames.Target.dropDown.displayMode = "MENU" + SFrames.Target.dropDown.initialize = function() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] initialize() called|r") + local dd = SFrames.Target.dropDown + local name = dd.targetName + if not name then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] initialize: no targetName|r") + return + end + + local info = {} + info.text = name + info.isTitle = 1 + info.notCheckable = 1 + UIDropDownMenu_AddButton(info) + + if UnitIsPlayer("target") and UnitIsFriend("player", "target") and not UnitIsUnit("target", "player") then + -- 悄悄话 + info = {} + info.text = "悄悄话" + info.notCheckable = 1 + info.func = function() ChatFrame_SendTell(name) end + UIDropDownMenu_AddButton(info) + + -- 组队相关(动态) + local inParty = UnitInParty("target") + local isLeader = IsPartyLeader() + if inParty and isLeader then + info = {} + info.text = "提升为队长" + info.notCheckable = 1 + info.func = function() PromoteToLeader(name) end + UIDropDownMenu_AddButton(info) + + info = {} + info.text = "取消邀请" + info.notCheckable = 1 + info.func = function() UninviteByName(name) end + UIDropDownMenu_AddButton(info) + end + + -- 观察 + info = {} + info.text = "观察" + info.notCheckable = 1 + info.func = function() InspectUnit("target") end + UIDropDownMenu_AddButton(info) + + -- 交易 + info = {} + info.text = "交易" + info.notCheckable = 1 + info.func = function() InitiateTrade("target") end + UIDropDownMenu_AddButton(info) + + -- 邀请组队(不在队伍中时显示) + if not inParty then + info = {} + info.text = "邀请组队" + info.notCheckable = 1 + info.func = function() InviteByName(name) end + UIDropDownMenu_AddButton(info) + end + + -- 跟随 + info = {} + info.text = "跟随" + info.notCheckable = 1 + info.func = function() FollowUnit("target") end + UIDropDownMenu_AddButton(info) + + -- 决斗(不在队伍中时显示,节省按钮数) + if not inParty then + info = {} + info.text = "决斗" + info.notCheckable = 1 + info.func = function() StartDuel("target") end + UIDropDownMenu_AddButton(info) + end + end + + if SFrames.Focus and SFrames.Focus.GetFocusName then + info = {} + local ok2, curFocus = pcall(SFrames.Focus.GetFocusName, SFrames.Focus) + local isSameTarget = ok2 and curFocus and curFocus == name + if isSameTarget then + info.text = "取消焦点" + info.func = function() pcall(SFrames.Focus.Clear, SFrames.Focus) end + else + info.text = "设为焦点" + info.func = function() pcall(SFrames.Focus.SetFromTarget, SFrames.Focus) end + end + info.notCheckable = 1 + UIDropDownMenu_AddButton(info) + end + + -- 取消按钮不添加,点击菜单外部即可关闭(节省按钮位) + end + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] Dropdown created OK|r") + end + SFrames.Target.dropDown.targetName = UnitName("target") + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] Calling ToggleDropDownMenu...|r") + local ok3, err3 = pcall(ToggleDropDownMenu, 1, nil, SFrames.Target.dropDown, "cursor") + if not ok3 then + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] ToggleDropDownMenu failed: " .. tostring(err3) .. "|r") + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] ToggleDropDownMenu OK|r") + end end end) f:SetScript("OnReceiveDrag", function() @@ -540,15 +735,23 @@ function SFrames.Target:Initialize() f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) -- Heal prediction overlay (incoming heals) - f.health.healPredMine = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredMine = f.health:CreateTexture(nil, "ARTWORK") f.health.healPredMine:SetTexture(SFrames:GetTexture()) f.health.healPredMine:SetVertexColor(0.4, 1.0, 0.55, 0.78) + f.health.healPredMine:SetDrawLayer("ARTWORK", 2) f.health.healPredMine:Hide() - f.health.healPredOther = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOther = f.health:CreateTexture(nil, "ARTWORK") f.health.healPredOther:SetTexture(SFrames:GetTexture()) f.health.healPredOther:SetVertexColor(0.2, 0.9, 0.35, 0.5) + f.health.healPredOther:SetDrawLayer("ARTWORK", 2) f.health.healPredOther:Hide() + + f.health.healPredOver = f.health:CreateTexture(nil, "OVERLAY") + f.health.healPredOver:SetTexture(SFrames:GetTexture()) + f.health.healPredOver:SetVertexColor(1.0, 0.3, 0.3, 0.6) + f.health.healPredOver:SetDrawLayer("OVERLAY", 7) + f.health.healPredOver:Hide() -- Power Bar f.power = SFrames:CreateStatusBar(f, "SFramesTargetPower") @@ -645,11 +848,13 @@ function SFrames.Target:Initialize() f.unit = "target" f:SetScript("OnEnter", function() + if SetMouseoverUnit then SetMouseoverUnit(this.unit) end GameTooltip_SetDefaultAnchor(GameTooltip, this) GameTooltip:SetUnit(this.unit) GameTooltip:Show() end) f:SetScript("OnLeave", function() + if SetMouseoverUnit then SetMouseoverUnit() end GameTooltip:Hide() end) @@ -1012,13 +1217,15 @@ function SFrames.Target:UpdateHealth() end function SFrames.Target:UpdateHealPrediction() - if not (self.frame and self.frame.health and self.frame.health.healPredMine and self.frame.health.healPredOther) then return end + if not (self.frame and self.frame.health and self.frame.health.healPredMine and self.frame.health.healPredOther and self.frame.health.healPredOver) then return end local predMine = self.frame.health.healPredMine local predOther = self.frame.health.healPredOther + local predOver = self.frame.health.healPredOver local function HidePredictions() predMine:Hide() predOther:Hide() + predOver:Hide() end if not UnitExists("target") then @@ -1028,14 +1235,39 @@ function SFrames.Target:UpdateHealPrediction() local hp = UnitHealth("target") or 0 local maxHp = UnitHealthMax("target") or 0 - if maxHp <= 0 or hp >= maxHp then + + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, realHp = pcall(UnitHealth, "target") + if ok2 then + hp = realHp or hp + end + local ok3, realMaxHp = pcall(UnitHealthMax, "target") + if ok3 then + maxHp = realMaxHp or maxHp + end + end + end + + if maxHp <= 0 then HidePredictions() return end local _, mineIncoming, othersIncoming = GetIncomingHeals("target") + if CheckSuperWow then + local ok, hasSW = pcall(CheckSuperWow) + if ok and hasSW then + local ok2, _, realMine, realOther = pcall(GetIncomingHeals, "target") + if ok2 then + mineIncoming = realMine or mineIncoming + othersIncoming = realOther or othersIncoming + end + end + end local missing = maxHp - hp - if missing <= 0 then + if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end @@ -1043,34 +1275,39 @@ function SFrames.Target:UpdateHealPrediction() local mineShown = math.min(math.max(0, mineIncoming), missing) local remaining = missing - mineShown local otherShown = math.min(math.max(0, othersIncoming), remaining) - if mineShown <= 0 and otherShown <= 0 then + if mineShown <= 0 and otherShown <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end - local barWidth = self.frame.health:GetWidth() or 0 + local showPortrait = SFramesDB and SFramesDB.targetShowPortrait ~= false + local barWidth = self.frame:GetWidth() - (showPortrait and (self.frame.portrait:GetWidth() + 2) or 2) if barWidth <= 0 then HidePredictions() return end - local currentWidth = math.floor((hp / maxHp) * barWidth + 0.5) + local currentWidth = (hp / maxHp) * barWidth if currentWidth < 0 then currentWidth = 0 end if currentWidth > barWidth then currentWidth = barWidth end local availableWidth = barWidth - currentWidth - if availableWidth <= 0 then + if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then HidePredictions() return end - local mineWidth = math.floor((mineShown / maxHp) * barWidth + 0.5) - local otherWidth = math.floor((otherShown / maxHp) * barWidth + 0.5) - if mineWidth < 0 then mineWidth = 0 end - if otherWidth < 0 then otherWidth = 0 end - if mineWidth > availableWidth then mineWidth = availableWidth end - if otherWidth > (availableWidth - mineWidth) then - otherWidth = availableWidth - mineWidth + local mineWidth = 0 + local otherWidth = 0 + if missing > 0 then + mineWidth = (mineShown / missing) * availableWidth + otherWidth = (otherShown / missing) * availableWidth + if mineWidth < 0 then mineWidth = 0 end + if otherWidth < 0 then otherWidth = 0 end + if mineWidth > availableWidth then mineWidth = availableWidth end + if otherWidth > (availableWidth - mineWidth) then + otherWidth = availableWidth - mineWidth + end end if mineWidth > 0 then @@ -1078,6 +1315,7 @@ function SFrames.Target:UpdateHealPrediction() predMine:SetPoint("TOPLEFT", self.frame.health, "TOPLEFT", currentWidth, 0) predMine:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMLEFT", currentWidth, 0) predMine:SetWidth(mineWidth) + predMine:SetHeight(self.frame.health:GetHeight()) predMine:Show() else predMine:Hide() @@ -1088,10 +1326,29 @@ function SFrames.Target:UpdateHealPrediction() predOther:SetPoint("TOPLEFT", self.frame.health, "TOPLEFT", currentWidth + mineWidth, 0) predOther:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMLEFT", currentWidth + mineWidth, 0) predOther:SetWidth(otherWidth) + predOther:SetHeight(self.frame.health:GetHeight()) predOther:Show() else predOther:Hide() end + + local totalIncoming = mineIncoming + othersIncoming + local overHeal = totalIncoming - missing + if overHeal > 0 then + local overWidth = math.floor((overHeal / maxHp) * barWidth + 0.5) + if overWidth > 0 then + predOver:ClearAllPoints() + predOver:SetPoint("TOPLEFT", self.frame.health, "TOPRIGHT", 0, 0) + predOver:SetPoint("BOTTOMLEFT", self.frame.health, "BOTTOMRIGHT", 0, 0) + predOver:SetWidth(overWidth) + predOver:SetHeight(self.frame.health:GetHeight()) + predOver:Show() + else + predOver:Hide() + end + else + predOver:Hide() + end end function SFrames.Target:UpdatePowerType() @@ -1155,7 +1412,7 @@ function SFrames.Target:CreateAuras() self.frame.debuffs = {} -- Target Buffs (Top left to right) - for i = 1, 16 do + for i = 1, 32 do local b = CreateFrame("Button", "SFramesTargetBuff"..i, self.frame) b:SetWidth(AURA_SIZE) b:SetHeight(AURA_SIZE) @@ -1191,7 +1448,7 @@ function SFrames.Target:CreateAuras() end -- Target Debuffs (Bottom left to right) - for i = 1, 16 do + for i = 1, 32 do local b = CreateFrame("Button", "SFramesTargetDebuff"..i, self.frame) b:SetWidth(AURA_SIZE) b:SetHeight(AURA_SIZE) @@ -1256,7 +1513,7 @@ function SFrames.Target:TickAuras() end -- Buffs - for i = 1, 16 do + for i = 1, 32 do local b = self.frame.buffs[i] if b:IsShown() and b.expirationTime then local timeLeft = b.expirationTime - timeNow @@ -1275,7 +1532,7 @@ function SFrames.Target:TickAuras() end -- Debuffs: re-query SpellDB for live-accurate timers - for i = 1, 16 do + for i = 1, 32 do local b = self.frame.debuffs[i] if b:IsShown() then local timeLeft = nil @@ -1316,14 +1573,17 @@ end function SFrames.Target:UpdateAuras() if not UnitExists("target") then return end + local hasSuperWoW = SFrames.superwow_active and SpellInfo local numBuffs = 0 -- Buffs - for i = 1, 16 do - local texture = UnitBuff("target", i) + for i = 1, 32 do + local texture, swAuraID = UnitBuff("target", i) local b = self.frame.buffs[i] b:SetID(i) -- Ensure ID is set for tooltips if texture then b.icon:SetTexture(texture) + -- Store aura ID when SuperWoW is available + b.auraID = hasSuperWoW and swAuraID or nil SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") SFrames.Tooltip:ClearLines() @@ -1364,12 +1624,14 @@ function SFrames.Target:UpdateAuras() local hasNP = NanamiPlates_SpellDB and NanamiPlates_SpellDB.UnitDebuff local npFormat = NanamiPlates_Auras and NanamiPlates_Auras.FormatTime - for i = 1, 16 do - local texture = UnitDebuff("target", i) + for i = 1, 32 do + local texture, dbCount, dbType, swDebuffAuraID = UnitDebuff("target", i) local b = self.frame.debuffs[i] b:SetID(i) if texture then b.icon:SetTexture(texture) + -- Store aura ID when SuperWoW is available + b.auraID = hasSuperWoW and swDebuffAuraID or nil local timeLeft = 0 local effectName = nil diff --git a/WorldMap.lua b/WorldMap.lua index 8b193c4..24238e0 100644 --- a/WorldMap.lua +++ b/WorldMap.lua @@ -2117,6 +2117,4 @@ function WM:Initialize() DiscoverDmfZoneIndices() self.initialized = true - local _hex = (SFrames.Theme and SFrames.Theme:GetAccentHex()) or "ffffb3d9" - DEFAULT_CHAT_FRAME:AddMessage("|c" .. _hex .. "[Nanami-UI]|r 世界地图模块已加载 (Nanami主题 | Ctrl+左键放置标记 | /nui nav 导航地图)") end diff --git a/docs/插件功能概览.md b/docs/插件功能概览.md new file mode 100644 index 0000000..54e1a8e --- /dev/null +++ b/docs/插件功能概览.md @@ -0,0 +1,333 @@ +# Nanami-UI 插件功能概览 + +本文基于当前仓库代码结构整理,目标是快速回答两个问题: + +1. 这个乌龟服插件当前做了什么。 +2. 每块功能主要落在哪些代码文件里。 + +## 1. 插件定位 + +`Nanami-UI` 是一个面向 Turtle WoW 1.12 环境的大型整合型 UI 插件。整体风格以 `SFrames` 为统一命名空间,核心能力主要包括: + +- 自定义单位框体:玩家、宠物、目标、目标的目标、队伍、团队、焦点、天赋树。 +- 动作条与按键:动作条重排、绑定管理、绑定导入导出。 +- 地图与导航:小地图/世界地图换肤、地图迷雾揭示、区域等级、队友图标、暗月马戏团辅助。 +- 聊天与社交:聊天框接管、私聊容器、社交面板重做。 +- 背包与交易:背包/银行整合、离线库存、商人、交易、邮件、拾取窗口。 +- 任务与书籍:任务对话、任务日志皮肤、书籍阅读界面。 +- 职业与养成:角色面板、装备统计、观察面板、法术书、训练师、专业/制造、野兽训练。 +- 辅助与增强:Tooltip、Tweaks、AFK 屏保、Setup Wizard、配置面板等。 + +## 2. 加载主线 + +### 2.1 入口文件 + +- [Nanami-UI.toc](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Nanami-UI.toc) + 负责定义加载顺序、依赖和 SavedVariables。 +- [Core.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Core.lua) + 定义全局表 `SFrames`、事件分发、初始化总流程、角色配置快照、斜杠命令。 +- [Config.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Config.lua) + 提供默认配置、主题预设、颜色与样式生成逻辑。 + +### 2.2 初始化方式 + +当前插件的模块大致分成两种启动方式: + +- `Core.lua` 集中初始化 + 通过 `SFrames:DoFullInitialize()` 分批启动关键模块与延迟模块,避免登录瞬间内存尖峰。 +- 模块自举初始化 + 一部分界面文件自己监听 `PLAYER_LOGIN` / `ADDON_LOADED` / 专用事件,在对应窗口可用时初始化。 + +### 2.3 Core 直接管理的模块 + +`Core.lua` 当前显式初始化的模块包括: + +- 立即加载:`Player`、`Pet`、`Target`、`ToT`、`Party`、`FloatingTooltip`、`ActionBars` +- 延迟批量加载:`Raid`、`Bags`、`Focus`、`TalentTree`、`Minimap`、`MinimapBuffs`、`MinimapButton`、`Chat`、`MapReveal`、`WorldMap`、`ZoneLevelRange`、`MapIcons`、`Tweaks`、`AFKScreen`、`LootDisplay` + +### 2.4 自举型模块 + +这类文件通常在自身内部注册事件并完成初始化: + +- [QuestUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\QuestUI.lua) +- [TrainerUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\TrainerUI.lua) +- [TradeSkillUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\TradeSkillUI.lua) +- [BeastTrainingUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\BeastTrainingUI.lua) +- [BookUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\BookUI.lua) +- [Merchant.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Merchant.lua) +- [Mail.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Mail.lua) +- [SocialUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\SocialUI.lua) +- [SpellBookUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\SpellBookUI.lua) +- [FlightMap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\FlightMap.lua) +- [QuestLogSkin.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\QuestLogSkin.lua) + +## 3. 核心框架与基础设施 + +### 3.1 核心与公共能力 + +- [Core.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Core.lua) + 事件总线、统一初始化、角色配置快照、斜杠命令、若干兼容性保护。 +- [Config.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Config.lua) + 默认配置、主题生成、颜色方案、类职业主题映射。 +- [Media.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Media.lua) + 字体与状态条材质列表,兼容 LibSharedMedia。 +- [Factory.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Factory.lua) + 通用边框、背景、按钮、下拉、输入框等 UI 工厂方法。 +- [Movers.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Movers.lua) + 布局模式、拖拽锚点、网格吸附、统一解锁/锁定框体。 +- [IconMap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\IconMap.lua) + `img/icon.tga` 的图标坐标表,供多个界面复用。 +- [Bindings.xml](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bindings.xml) + 插件按键绑定入口。 +- [ConfigUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\ConfigUI.lua) + 综合配置面板,覆盖 UI、背包、动作条、小地图、角色等多页设置。 +- [SetupWizard.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\SetupWizard.lua) + 首次安装向导,也可作为二次配置入口。 +- [GameMenu.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\GameMenu.lua) + 游戏菜单增强,提供 Nanami-UI 相关入口。 +- [MinimapButton.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\MinimapButton.lua) + 小地图按钮,用于打开主要面板或功能入口。 + +### 3.2 斜杠命令 + +[Core.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Core.lua) 中已确认的主命令入口为: + +- `/nanami` +- `/nui` + +从当前代码可见,至少包含以下子命令: + +- `unlock` / `move` +- `lock` +- `chatreset` +- `test` +- `partyh` +- `partyv` +- `partylayout` +- `focus` +- `clearfocus` +- `targetfocus` +- `fcast` +- `focushelp` + +此外还有若干模块自己的调试或快捷命令: + +- [DarkmoonGuide.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\DarkmoonGuide.lua):`/dmf`、`/darkmoon` +- [FlightMap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\FlightMap.lua):`/ftcdebug`、`/ftcexport` +- [GearScore.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\GearScore.lua):`/gsdebug` +- [BeastTrainingUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\BeastTrainingUI.lua):`/btdebug` + +## 4. 功能与代码文件对应 + +### 4.1 单位框体与战斗信息 + +- [Units\Player.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Units\Player.lua) + 玩家框体,包含血量/能量、头像、施法条、buff/debuff、移动锚点等。 +- [Units\Pet.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Units\Pet.lua) + 宠物框体。 +- [Units\Target.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Units\Target.lua) + 目标框体,含施法识别与法术图标映射。 +- [Units\ToT.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Units\ToT.lua) + 目标的目标框体。 +- [Units\Party.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Units\Party.lua) + 队伍框体,支持布局切换、拖拽物品到队友等逻辑。 +- [Units\Raid.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Units\Raid.lua) + 团队框体。 +- [Focus.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Focus.lua) + 焦点系统,支持设置焦点、目标焦点、对焦点施法。 +- [Tooltip.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Tooltip.lua) + 浮动 Tooltip、单位信息扩展。 +- [MinimapBuffs.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\MinimapBuffs.lua) + 小地图附近的 buff/debuff 重新排布。 +- [ActionBars.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\ActionBars.lua) + 主动作条、姿态条、宠物条、键位显示、范围检测、布局修正。 +- [KeyBindManager.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\KeyBindManager.lua) + 按键绑定查看、导入导出、编辑。 + +### 4.2 地图与导航 + +- [Minimap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Minimap.lua) + 小地图换肤,支持不同边框样式与职业主题皮肤。 +- [WorldMap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\WorldMap.lua) + 世界地图整体重绘与窗口化,是地图类功能的主要承载文件。 +- [MapReveal.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\MapReveal.lua) + 迷雾揭示与 overlay 数据补丁。 +- [ZoneLevelRange.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\ZoneLevelRange.lua) + 世界地图区域等级、阵营归属、副本/团队本信息提示。 +- [MapIcons.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\MapIcons.lua) + 世界地图、小地图、战场地图上的队伍/团队职业图标显示。 +- [DarkmoonGuide.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\DarkmoonGuide.lua) + 暗月马戏团 Sayge Buff 指引面板。 +- [DarkmoonMapMarker.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\DarkmoonMapMarker.lua) + 现在只保留兼容占位,实际标记逻辑已并入 `WorldMap.lua`。 +- [FlightMap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\FlightMap.lua) + 飞行点目的地列表、飞行进度条与路径可视化。 +- [FlightData.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\FlightData.lua) + 飞行路线耗时数据库。 + +### 4.3 聊天与社交 + +- [Chat.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Chat.lua) + 聊天框全面换肤与接管,保留原生聊天后端。 +- [Whisper.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Whisper.lua) + 私聊联系人和聊天记录容器。 +- [SocialUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\SocialUI.lua) + 好友、忽略、Who、公会、团队管理界面重做。 + +### 4.4 背包、银行、商人、交易、邮件、拾取 + +- [Bags\Offline.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bags\Offline.lua) + 跨角色离线背包/银行数据采集。 +- [Bags\Core.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bags\Core.lua) + 背包模块总入口。 +- [Bags\Container.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bags\Container.lua) + 背包容器 UI 与交互。 +- [Bags\Bank.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bags\Bank.lua) + 银行界面与银行容器管理。 +- [Bags\Sort.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bags\Sort.lua) + 背包整理逻辑。 +- [Bags\Features.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bags\Features.lua) + 自动卖灰、自动开包等附加功能。 +- [Merchant.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Merchant.lua) + 商人面板重做,含回购、批量购买、修理。 +- [Trade.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Trade.lua) + 交易窗口重做。 +- [Mail.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Mail.lua) + 邮件面板重做,支持收件管理与多物品发送队列。 +- [LootDisplay.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\LootDisplay.lua) + 接管原生拾取窗口视觉层,同时尽量保留原生按钮交互。 +- [SellPriceDB.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\SellPriceDB.lua) + 物品售卖价格数据库,服务于商人/背包场景。 + +### 4.5 任务、书籍、日志 + +- [QuestUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\QuestUI.lua) + NPC 任务对话、详情、进度、奖励等流程界面。 +- [QuestLogSkin.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\QuestLogSkin.lua) + 任务日志窗口换肤。 +- [BookUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\BookUI.lua) + 书籍/卷轴阅读窗口重做。 + +### 4.6 角色、法术、训练、专业 + +- [CharacterPanel.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\CharacterPanel.lua) + 角色面板核心文件,体量很大,集成装备、属性、模型、称号、套装等内容。 +- [StatSummary.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\StatSummary.lua) + 装备统计摘要与独立统计面板。 +- [InspectPanel.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\InspectPanel.lua) + 观察面板与目标装备信息展示。 +- [SpellBookUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\SpellBookUI.lua) + 法术书重做。 +- [TrainerUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\TrainerUI.lua) + 职业训练师界面重做。 +- [TradeSkillUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\TradeSkillUI.lua) + 专业制造/制作界面重做。 +- [BeastTrainingUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\BeastTrainingUI.lua) + 猎人宠物训练界面重做。 +- [PetStableSkin.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\PetStableSkin.lua) + 宠物管理员/兽栏界面皮肤化。 +- [Units\TalentTree.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Units\TalentTree.lua) + 天赋树显示模块。 +- [TalentDefaultDB.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\TalentDefaultDB.lua) + 默认天赋数据库。 +- [TradeSkillDB.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\TradeSkillDB.lua) + 专业/配方静态数据库。 +- [ClassSkillData.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\ClassSkillData.lua) + 职业训练技能数据。 +- [GearScore.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\GearScore.lua) + 装等/装备评分能力。 + +### 4.7 其他增强 + +- [Tweaks.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Tweaks.lua) + 各种杂项增强,当前能看到包括自动下马/取消变形、冷却文本、世界地图 Tooltip 处理、战斗提示等。 +- [AFKScreen.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\AFKScreen.lua) + AFK 全屏界面,显示模型、时间、世界 Buff 等信息。 +- [Roll.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Roll.lua) + 与掷骰/需求贪婪相关的界面或逻辑扩展。 + +## 5. 数据、资源与文档文件 + +### 5.1 图片与材质资源 + +- `img/` + 存放小地图底图、职业风格贴图、动作条材质、图标图集等资源。 +- [IconMap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\IconMap.lua) + 对 `img/icon.tga` 提供图标索引。 + +### 5.2 现有文档 + +- [docs\LootDisplay-技术要点.md](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\docs\LootDisplay-技术要点.md) + 记录接管拾取窗口时的关键实现思路。 +- [docs\AutoDismount-技术总结.md](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\docs\AutoDismount-技术总结.md) + 记录自动下马/取消变形的实现方案。 + +### 5.3 辅助脚本与研究资料 + +- `agent-tools/` + 看起来用于生成或整理职业训练相关数据。 +- `*.pdf` + 当前仓库中包含乌龟服属性收益研究资料,更偏设计/数值参考,不属于插件运行时代码。 + +## 6. SavedVariables 与依赖 + +### 6.1 SavedVariables + +根据 [Nanami-UI.toc](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Nanami-UI.toc): + +- `SFramesDB` + 角色级配置。 +- `SFramesGlobalDB` + 全局配置和跨角色数据。 + +### 6.2 可选依赖 + +`toc` 中声明的可选依赖包括: + +- `ShaguTweaks` +- `Blizzard_CombatLog` +- `HealComm-1.0` +- `DruidManaLib-1.0` +- `!Libs` +- `pfQuest` +- `RDbItems` + +从代码上看,这些依赖主要用于: + +- 治疗预测、Debuff/Overlay 数据读取。 +- 地图尺寸与覆盖层补全。 +- 额外物品/任务数据支持。 + +## 7. 维护视角下的重点文件 + +如果后续要继续分析或改功能,优先关注下面这些“枢纽文件”: + +- [Core.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Core.lua) + 初始化顺序、模块开关、斜杠命令、配置快照。 +- [Config.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Config.lua) + 默认值和主题系统。 +- [ConfigUI.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\ConfigUI.lua) + 用户可见设置入口。 +- [CharacterPanel.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\CharacterPanel.lua) + 当前仓库里体量最大、耦合也较高的角色面板核心。 +- [Chat.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Chat.lua) + 体量大、接管深,聊天相关问题通常会从这里开始查。 +- [WorldMap.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\WorldMap.lua) + 地图增强的主要承载点。 +- [Bags\Container.lua](e:\Game\trutle wow\Interface\AddOns\Nanami-UI\Bags\Container.lua) + 背包显示与交互核心。 + +## 8. 当前文档的边界 + +这份文档是“基础概览版”,目前更偏结构和职责映射,尚未逐项展开: + +- 每个模块的详细事件流。 +- 每个配置项对应的具体字段。 +- 模块之间的调用关系图。 +- Turtle WoW 私有 API 或兼容处理的逐点说明。 + +如果继续往下做,比较自然的下一步是: + +1. 补一份“初始化时序图”。 +2. 补一份“配置字段与模块对应表”。 +3. 针对地图、角色面板、聊天三个大模块分别拆详细设计文档。 diff --git a/img/bar/bar_elvui.tga b/img/bar/bar_elvui.tga new file mode 100644 index 0000000000000000000000000000000000000000..996b269025f7aeb0e0e182e27ac60349c92614e9 GIT binary patch literal 32812 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 A-2eap literal 0 HcmV?d00001 diff --git a/img/bar/bar_gradient.tga b/img/bar/bar_gradient.tga new file mode 100644 index 0000000000000000000000000000000000000000..6d9dc93fc8051c6b72cda48509e0c8adb18657ae GIT binary patch literal 24202 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 S`@jA3|NixF|N57Iss0aOVMKQT literal 0 HcmV?d00001 diff --git a/img/progress.tga b/img/progress.tga new file mode 100644 index 0000000000000000000000000000000000000000..0ab42d626587b55ca6581ed7d37096c0371bde1b GIT binary patch literal 1048594 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 OZ(!fRzJagW4gCKR@DjfO literal 0 HcmV?d00001