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 0000000..996b269 Binary files /dev/null and b/img/bar/bar_elvui.tga differ diff --git a/img/bar/bar_gradient.tga b/img/bar/bar_gradient.tga new file mode 100644 index 0000000..6d9dc93 Binary files /dev/null and b/img/bar/bar_gradient.tga differ diff --git a/img/bar/bar_tukui.tga b/img/bar/bar_tukui.tga new file mode 100644 index 0000000..2d99837 Binary files /dev/null and b/img/bar/bar_tukui.tga differ diff --git a/img/progress.tga b/img/progress.tga new file mode 100644 index 0000000..0ab42d6 Binary files /dev/null and b/img/progress.tga differ